From 6a786a7665a2281355cdf27508f72da279d48aa2 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Mon, 22 Jun 2026 07:17:21 +0300 Subject: [PATCH 001/351] General code nits (#8107) * General nits * compiler * cformat * gc_state * fix release compile * Fix logic error * revert bad logic error * don't inline elapsed_time --- crates/stdlib/src/pystruct.rs | 7 +- crates/vm/src/anystr.rs | 40 +++--- crates/vm/src/buffer.rs | 9 +- crates/vm/src/builtins/float.rs | 4 +- crates/vm/src/builtins/staticmethod.rs | 1 + crates/vm/src/byte.rs | 5 +- crates/vm/src/cformat.rs | 161 ++++++++++++++----------- crates/vm/src/class.rs | 24 ++-- crates/vm/src/codecs.rs | 86 ++++++++----- crates/vm/src/compiler.rs | 63 +++++----- crates/vm/src/convert/try_from.rs | 10 +- crates/vm/src/gc_state.rs | 37 +++--- crates/vm/src/recursion.rs | 10 +- crates/vm/src/suggestion.rs | 3 +- 14 files changed, 267 insertions(+), 193 deletions(-) diff --git a/crates/stdlib/src/pystruct.rs b/crates/stdlib/src/pystruct.rs index 8cf1023c8ca..c525942e35e 100644 --- a/crates/stdlib/src/pystruct.rs +++ b/crates/stdlib/src/pystruct.rs @@ -51,9 +51,8 @@ pub(crate) mod _struct { s } b @ PyBytes => { - let ascii_str = ascii::AsciiStr::from_ascii(&b).map_err(|_| { - new_struct_error(vm, "bad char in struct format".to_owned()) - })?; + let ascii_str = ascii::AsciiStr::from_ascii(&b) + .map_err(|_| new_struct_error(vm, "bad char in struct format"))?; vm.ctx.new_str(ascii_str) } other => @@ -192,7 +191,7 @@ pub(crate) mod _struct { if format_spec.size == 0 { Err(new_struct_error( vm, - "cannot iteratively unpack with a struct of length 0".to_owned(), + "cannot iteratively unpack with a struct of length 0", )) } else if !buffer.len().is_multiple_of(format_spec.size) { Err(new_struct_error( diff --git a/crates/vm/src/anystr.rs b/crates/vm/src/anystr.rs index f1c35ecd65f..3aa40eaf3a2 100644 --- a/crates/vm/src/anystr.rs +++ b/crates/vm/src/anystr.rs @@ -1,15 +1,16 @@ +use core::ops::Range; + +use icu_properties::props::{ + BinaryProperty, EnumeratedProperty, GeneralCategory, GeneralCategoryGroup, +}; +use num_traits::{cast::ToPrimitive, sign::Signed}; + use crate::{ Py, PyObject, PyObjectRef, PyResult, TryFromObject, VirtualMachine, builtins::{PyIntRef, PyTuple}, convert::TryFromBorrowedObject, function::OptionalOption, }; -use icu_properties::props::{ - BinaryProperty, EnumeratedProperty, GeneralCategory, GeneralCategoryGroup, -}; -use num_traits::{cast::ToPrimitive, sign::Signed}; - -use core::ops::Range; #[derive(FromArgs)] pub struct SplitArgs { @@ -422,6 +423,7 @@ pub(crate) trait AnyStr { } lower = true; } + lower } @@ -439,6 +441,7 @@ pub(crate) trait AnyStr { } upper = true; } + upper } @@ -461,10 +464,12 @@ pub(crate) trait AnyStr { { return false; } + if !all_cased && VALID::for_char(c) { all_cased = true; } } + all_cased } } @@ -484,18 +489,19 @@ where F: Fn(T) -> PyResult, M: Fn(&PyObject) -> String, { - match obj.try_to_value::(vm) { - Ok(single) => (predicate)(single), - Err(_) => { - let tuple: &Py = obj - .try_to_value(vm) - .map_err(|_| vm.new_type_error((message)(obj)))?; - for obj in tuple { - if single_or_tuple_any(obj, predicate, message, vm)? { - return Ok(true); - } + if let Ok(single) = obj.try_to_value::(vm) { + (predicate)(single) + } else { + let tuple: &Py = obj + .try_to_value(vm) + .map_err(|_| vm.new_type_error((message)(obj)))?; + + for obj in tuple { + if single_or_tuple_any(obj, predicate, message, vm)? { + return Ok(true); } - Ok(false) } + + Ok(false) } } diff --git a/crates/vm/src/buffer.rs b/crates/vm/src/buffer.rs index 255250485b1..a174e2fd613 100644 --- a/crates/vm/src/buffer.rs +++ b/crates/vm/src/buffer.rs @@ -5,8 +5,10 @@ use crate::{ convert::ToPyObject, function::{ArgBytesLike, ArgIntoBool, ArgIntoFloat}, }; -use alloc::fmt; -use core::{iter::Peekable, mem}; + +use rustpython_common::wtf8::Wtf8Buf; + +use core::{fmt, iter::Peekable, mem}; use half::f16; use itertools::Itertools; use malachite_bigint::BigInt; @@ -736,9 +738,8 @@ pub fn struct_error_type(vm: &VirtualMachine) -> &'static PyTypeRef { INSTANCE.get_or_init(|| vm.ctx.new_exception_type("struct", "error", None)) } -pub fn new_struct_error(vm: &VirtualMachine, msg: impl Into) -> PyBaseExceptionRef { +pub fn new_struct_error>(vm: &VirtualMachine, msg: T) -> PyBaseExceptionRef { // can't just STRUCT_ERROR.get().unwrap() cause this could be called before from buffer // machinery, independent of whether _struct was ever imported - let msg: String = msg.into(); vm.new_exception_msg(struct_error_type(vm).clone(), msg.into()) } diff --git a/crates/vm/src/builtins/float.rs b/crates/vm/src/builtins/float.rs index db6478d668d..6f9fd382b51 100644 --- a/crates/vm/src/builtins/float.rs +++ b/crates/vm/src/builtins/float.rs @@ -84,6 +84,7 @@ impl ToPyObject for f64 { vm.ctx.new_float(self).into() } } + impl ToPyObject for f32 { fn to_pyobject(self, vm: &VirtualMachine) -> PyObjectRef { vm.ctx.new_float(f64::from(self)).into() @@ -156,8 +157,7 @@ fn inner_divmod(v1: f64, v2: f64, vm: &VirtualMachine) -> PyResult<(f64, f64)> { pub(crate) fn float_pow(v1: f64, v2: f64, vm: &VirtualMachine) -> PyResult { if v1.is_zero() && v2.is_sign_negative() { - let msg = "zero to a negative power"; - Err(vm.new_zero_division_error(msg.to_owned())) + Err(vm.new_zero_division_error("zero to a negative power")) } else if v1.is_sign_negative() && (v2.floor() - v2).abs() > f64::EPSILON { let v1 = Complex64::new(v1, 0.); let v2 = Complex64::new(v2, 0.); diff --git a/crates/vm/src/builtins/staticmethod.rs b/crates/vm/src/builtins/staticmethod.rs index 3a8d451b3dc..1ab697a0a1d 100644 --- a/crates/vm/src/builtins/staticmethod.rs +++ b/crates/vm/src/builtins/staticmethod.rs @@ -68,6 +68,7 @@ impl PyStaticMethod { callable: PyMutex::new(callable), } } + #[deprecated(note = "use PyStaticMethod::new(...).into_ref() instead")] pub fn new_ref(callable: PyObjectRef, ctx: &Context) -> PyRef { Self::new(callable).into_ref(ctx) diff --git a/crates/vm/src/byte.rs b/crates/vm/src/byte.rs index d9e927cbfa5..933ddead4b9 100644 --- a/crates/vm/src/byte.rs +++ b/crates/vm/src/byte.rs @@ -1,8 +1,9 @@ //! byte operation APIs -use crate::object::AsObject; -use crate::{PyObject, PyResult, VirtualMachine}; + use num_traits::ToPrimitive; +use crate::{AsObject, PyObject, PyResult, VirtualMachine}; + pub fn bytes_from_object(vm: &VirtualMachine, obj: &PyObject) -> PyResult> { if let Ok(elements) = obj.try_bytes_like(vm, |bytes| bytes.to_vec()) { return Ok(elements); diff --git a/crates/vm/src/cformat.rs b/crates/vm/src/cformat.rs index 6bf6062c84f..3bba0e5f8e7 100644 --- a/crates/vm/src/cformat.rs +++ b/crates/vm/src/cformat.rs @@ -3,8 +3,9 @@ //! Implementation of Printf-Style string formatting //! as per the [Python Docs](https://docs.python.org/3/library/stdtypes.html#printf-style-string-formatting). -use crate::common::cformat::*; -use crate::common::wtf8::{CodePoint, Wtf8, Wtf8Buf}; +use itertools::Itertools; +use num_traits::cast::ToPrimitive; + use crate::{ AsObject, PyObject, PyObjectRef, PyResult, TryFromBorrowedObject, TryFromObject, VirtualMachine, @@ -12,12 +13,18 @@ use crate::{ PyBaseExceptionRef, PyByteArray, PyBytes, PyFloat, PyInt, PyStr, int::check_int_to_str_digits, try_f64_to_bigint, tuple, }, + common::{ + cformat::{ + CCharacterType, CConversionFlags, CFormatBytes, CFormatConversion, CFormatPart, + CFormatPrecision, CFormatQuantity, CFormatSpec, CFormatSpecKeyed, CFormatType, + CFormatWtf8, CNumberType, + }, + wtf8::{CodePoint, Wtf8, Wtf8Buf}, + }, function::ArgIntoFloat, protocol::PyBuffer, stdlib::builtins, }; -use itertools::Itertools; -use num_traits::cast::ToPrimitive; fn spec_format_bytes( vm: &VirtualMachine, @@ -39,11 +46,12 @@ fn spec_format_bytes( let bytes = vm .get_special_method(&obj, identifier!(vm, __bytes__))? .ok_or_else(|| { - vm.new_type_error(format!( + let msg = format!( "%b requires a bytes-like object, or an object that \ implements __bytes__, not '{}'", obj.class().name() - )) + ); + vm.new_type_error(msg) })? .invoke((), vm)?; let bytes = PyBytes::try_from_borrowed_object(vm, &bytes)?; @@ -71,6 +79,7 @@ fn spec_format_bytes( check_int_to_str_digits(i.as_bigint(), vm)?; return Ok(spec.format_number(i.as_bigint()).into_bytes()); } + if let Some(method) = vm.get_method(obj.clone(), identifier!(vm, __int__)) { let result = method?.call((), vm)?; if let Some(i) = result.downcast_ref::() { @@ -78,6 +87,7 @@ fn spec_format_bytes( return Ok(spec.format_number(i.as_bigint()).into_bytes()); } } + Err(vm.new_type_error(format!( "%{} format: a real number is required, not {}", spec.format_type.to_char(), @@ -301,6 +311,7 @@ fn try_update_quantity_from_tuple<'a, I: Iterator>( let Some(CFormatQuantity::FromValuesTuple) = q else { return Ok(()); }; + let element = elements.next(); f.insert(try_conversion_flag_from_tuple( vm, @@ -319,6 +330,7 @@ fn try_update_precision_from_tuple<'a, I: Iterator>( let Some(CFormatPrecision::Quantity(CFormatQuantity::FromValuesTuple)) = p else { return Ok(()); }; + let quantity = try_update_quantity_from_element(vm, elements.next().map(|v| v.as_ref()))?; *p = Some(CFormatPrecision::Quantity(quantity)); Ok(()) @@ -347,42 +359,45 @@ pub(crate) fn cformat_bytes( && !values_obj.fast_isinstance(vm.ctx.types.bytearray_type); if num_specifiers == 0 { - // literal only - return if is_mapping - || values_obj + if !is_mapping + && values_obj .downcast_ref::() - .is_some_and(|e| e.is_empty()) + .is_none_or(|e| !e.is_empty()) { - for (_, part) in format.iter_mut() { - match part { - CFormatPart::Literal(literal) => result.append(literal), - CFormatPart::Spec(_) => unreachable!(), - } + return Err(vm.new_type_error("not all arguments converted during bytes formatting")); + } + + // literal only + for (_, part) in format.iter_mut() { + if let CFormatPart::Literal(literal) = part { + result.append(literal) + } else { + unreachable!() } - Ok(result) - } else { - Err(vm.new_type_error("not all arguments converted during bytes formatting")) - }; + } + + return Ok(result); } if mapping_required { + if !is_mapping { + return Err(vm.new_type_error("format requires a mapping")); + } + // dict - return if is_mapping { - for (_, part) in format { - match part { - CFormatPart::Literal(literal) => result.extend(literal), - CFormatPart::Spec(CFormatSpecKeyed { mapping_key, spec }) => { - let key = mapping_key.unwrap(); - let value = values_obj.get_item(&key, vm)?; - let part_result = spec_format_bytes(vm, &spec, value)?; - result.extend(part_result); - } + for (_, part) in format { + match part { + CFormatPart::Literal(literal) => result.extend(literal), + CFormatPart::Spec(CFormatSpecKeyed { mapping_key, spec }) => { + let key = mapping_key.unwrap(); + let value = values_obj.get_item(&key, vm)?; + let part_result = spec_format_bytes(vm, &spec, value)?; + result.extend(part_result); } } - Ok(result) - } else { - Err(vm.new_type_error("format requires a mapping")) - }; + } + + return Ok(result); } // tuple @@ -405,18 +420,18 @@ pub(crate) fn cformat_bytes( )?; try_update_precision_from_tuple(vm, &mut value_iter, &mut spec.precision)?; - let value = match value_iter.next() { - Some(obj) => Ok(obj.clone()), - None => Err(vm.new_type_error("not enough arguments for format string")), - }?; - let part_result = spec_format_bytes(vm, &spec, value)?; + let Some(value) = value_iter.next() else { + return Err(vm.new_type_error("not enough arguments for format string")); + }; + + let part_result = spec_format_bytes(vm, &spec, value.clone())?; result.extend(part_result); } } } // check that all arguments were converted - if value_iter.next().is_some() && !is_mapping { + if !is_mapping && value_iter.next().is_some() { Err(vm.new_type_error("not all arguments converted during bytes formatting")) } else { Ok(result) @@ -441,41 +456,44 @@ pub(crate) fn cformat_string( && !values_obj.fast_isinstance(vm.ctx.types.str_type); if num_specifiers == 0 { - // literal only - return if is_mapping - || values_obj + if !is_mapping + && values_obj .downcast_ref::() - .is_some_and(|e| e.is_empty()) + .is_none_or(|e| !e.is_empty()) { - for (_, part) in format.iter() { - match part { - CFormatPart::Literal(literal) => result.push_wtf8(literal), - CFormatPart::Spec(_) => unreachable!(), - } + return Err(vm.new_type_error("not all arguments converted during string formatting")); + } + + // literal only + for (_, part) in format.iter() { + if let CFormatPart::Literal(literal) = part { + result.push_wtf8(literal) + } else { + unreachable!() } - Ok(result) - } else { - Err(vm.new_type_error("not all arguments converted during string formatting")) - }; + } + + return Ok(result); } if mapping_required { + if !is_mapping { + return Err(vm.new_type_error("format requires a mapping")); + } + // dict - return if is_mapping { - for (idx, part) in format { - match part { - CFormatPart::Literal(literal) => result.push_wtf8(&literal), - CFormatPart::Spec(CFormatSpecKeyed { mapping_key, spec }) => { - let value = values_obj.get_item(&mapping_key.unwrap(), vm)?; - let part_result = spec_format_string(vm, &spec, value, idx)?; - result.push_wtf8(&part_result); - } + for (idx, part) in format { + match part { + CFormatPart::Literal(literal) => result.push_wtf8(&literal), + CFormatPart::Spec(CFormatSpecKeyed { mapping_key, spec }) => { + let value = values_obj.get_item(&mapping_key.unwrap(), vm)?; + let part_result = spec_format_string(vm, &spec, value, idx)?; + result.push_wtf8(&part_result); } } - Ok(result) - } else { - Err(vm.new_type_error("format requires a mapping")) - }; + } + + return Ok(result); } // tuple @@ -484,6 +502,7 @@ pub(crate) fn cformat_string( } else { core::slice::from_ref(&values_obj) }; + let mut value_iter = values.iter(); for (idx, part) in format { @@ -498,18 +517,18 @@ pub(crate) fn cformat_string( )?; try_update_precision_from_tuple(vm, &mut value_iter, &mut spec.precision)?; - let value = match value_iter.next() { - Some(obj) => Ok(obj.clone()), - None => Err(vm.new_type_error("not enough arguments for format string")), - }?; - let part_result = spec_format_string(vm, &spec, value, idx)?; + let Some(value) = value_iter.next() else { + return Err(vm.new_type_error("not enough arguments for format string")); + }; + + let part_result = spec_format_string(vm, &spec, value.clone(), idx)?; result.push_wtf8(&part_result); } } } // check that all arguments were converted - if value_iter.next().is_some() && !is_mapping { + if !is_mapping && value_iter.next().is_some() { Err(vm.new_type_error("not all arguments converted during string formatting")) } else { Ok(result) diff --git a/crates/vm/src/class.rs b/crates/vm/src/class.rs index 2e8af54f974..364ae721159 100644 --- a/crates/vm/src/class.rs +++ b/crates/vm/src/class.rs @@ -66,16 +66,19 @@ pub fn add_operators(class: &'static Py, ctx: &Context) { pub trait StaticType { // Ideally, saving PyType is better than PyTypeRef fn static_cell() -> &'static static_cell::StaticCell; + #[inline] #[must_use] fn static_metaclass() -> &'static Py { PyType::static_type() } + #[inline] #[must_use] fn static_baseclass() -> &'static Py { PyBaseObject::static_type() } + #[inline] #[must_use] fn static_type() -> &'static Py { @@ -87,6 +90,7 @@ pub trait StaticType { } Self::static_cell().get().unwrap_or_else(|| fail()) } + #[must_use] fn init_manually(typ: PyTypeRef) -> &'static Py { let cell = Self::static_cell(); @@ -94,6 +98,7 @@ pub trait StaticType { .unwrap_or_else(|_| panic!("double initialization from init_manually")); cell.get().unwrap() } + #[must_use] fn init_builtin_type() -> &'static Py where @@ -105,6 +110,7 @@ pub trait StaticType { .unwrap_or_else(|_| panic!("double initialization of {}", Self::NAME)); cell.get().unwrap() } + #[must_use] fn create_static_type() -> PyTypeRef where @@ -137,14 +143,19 @@ pub trait PyClassDef { pub trait PyClassImpl: PyClassDef { const TP_FLAGS: PyTypeFlags = PyTypeFlags::DEFAULT; + const METHOD_DEFS: &'static [PyMethodDef]; + + fn impl_extend_class(ctx: &'static Context, class: &'static Py); + + fn extend_slots(slots: &mut PyTypeSlots); + fn extend_class(ctx: &'static Context, class: &'static Py) where Self: Sized, { + // NOTE: `is_created_with_flags` if only available when debug_assertions is true #[cfg(debug_assertions)] - { - assert!(class.slots.flags.is_created_with_flags()); - } + debug_assert!(class.slots.flags.is_created_with_flags()); let _ = ctx.intern_str(Self::NAME); // intern type name @@ -161,7 +172,9 @@ pub trait PyClassImpl: PyClassDef { .into(), ); } + Self::impl_extend_class(ctx, class); + if let Some(doc) = Self::DOC { // Only set __doc__ if it doesn't already exist (e.g., as a member descriptor) // This matches CPython's behavior in type_dict_set_doc @@ -170,6 +183,7 @@ pub trait PyClassImpl: PyClassDef { class.set_attr(doc_attr_name, ctx.new_str(doc).into()); } } + if let Some(module_name) = Self::MODULE_NAME { let module_key = identifier!(ctx, __module__); // Don't overwrite a getset descriptor for __module__ (e.g. TypeAliasType @@ -230,10 +244,6 @@ pub trait PyClassImpl: PyClassDef { .to_owned() } - fn impl_extend_class(ctx: &'static Context, class: &'static Py); - const METHOD_DEFS: &'static [PyMethodDef]; - fn extend_slots(slots: &mut PyTypeSlots); - fn make_slots() -> PyTypeSlots { let mut slots = PyTypeSlots { flags: Self::TP_FLAGS, diff --git a/crates/vm/src/codecs.rs b/crates/vm/src/codecs.rs index a07ffb47e77..c06caefef51 100644 --- a/crates/vm/src/codecs.rs +++ b/crates/vm/src/codecs.rs @@ -1,14 +1,19 @@ +use alloc::borrow::Cow; +use core::ops::{Deref, Range}; +use std::collections::HashMap; + use rustpython_common::{ + ascii, borrow::BorrowedValue, encodings::{ CodecContext, DecodeContext, DecodeErrorHandler, EncodeContext, EncodeErrorHandler, EncodeReplace, StrBuffer, StrSize, errors, }, + lock::{OnceCell, PyRwLock}, str::StrKind, wtf8::{CodePoint, Wtf8, Wtf8Buf}, }; -use crate::common::lock::OnceCell; use crate::{ AsObject, Context, Py, PyObject, PyObjectRef, PyResult, TryFromBorrowedObject, TryFromObject, VirtualMachine, @@ -16,13 +21,9 @@ use crate::{ PyBaseExceptionRef, PyBytes, PyBytesRef, PyStr, PyStrRef, PyTuple, PyTupleRef, PyUtf8Str, PyUtf8StrRef, }, - common::{ascii, lock::PyRwLock}, convert::ToPyObject, function::{ArgBytesLike, PyMethodDef}, }; -use alloc::borrow::Cow; -use core::ops::{self, Range}; -use std::collections::HashMap; pub struct CodecsRegistry { inner: PyRwLock, @@ -39,6 +40,7 @@ pub(crate) const DEFAULT_ENCODING: &str = "utf-8"; #[derive(Clone)] #[repr(transparent)] pub struct PyCodec(PyTupleRef); + impl PyCodec { #[inline] pub fn from_tuple(tuple: PyTupleRef) -> Result { @@ -48,10 +50,12 @@ impl PyCodec { Err(tuple) } } + #[inline] pub fn into_tuple(self) -> PyTupleRef { self.0 } + #[inline] pub fn as_tuple(&self) -> &Py { &self.0 @@ -61,6 +65,7 @@ impl PyCodec { pub fn get_encode_func(&self) -> &PyObject { &self.0[0] } + #[inline] pub fn get_decode_func(&self) -> &PyObject { &self.0[1] @@ -116,10 +121,7 @@ impl PyCodec { errors: Option, vm: &VirtualMachine, ) -> PyResult { - let args = match errors { - Some(e) => vec![e.into()], - None => vec![], - }; + let args = errors.map_or_else(Vec::new, |e| vec![e.into()]); vm.call_method(self.0.as_object(), "incrementalencoder", args) } @@ -128,10 +130,7 @@ impl PyCodec { errors: Option, vm: &VirtualMachine, ) -> PyResult { - let args = match errors { - Some(e) => vec![e.into()], - None => vec![], - }; + let args = errors.map_or_else(Vec::new, |e| vec![e.into()]); vm.call_method(self.0.as_object(), "incrementaldecoder", args) } } @@ -191,16 +190,17 @@ impl CodecsRegistry { ("namereplace", methods[5].build_function(ctx)), ("surrogatepass", methods[6].build_function(ctx)), ("surrogateescape", methods[7].build_function(ctx)), - ]; - let errors = errors - .into_iter() - .map(|(name, f)| (name.to_owned(), f.into())) - .collect(); + ] + .into_iter() + .map(|(name, f)| (name.to_owned(), f.into())) + .collect(); + let inner = RegistryInner { search_path: Vec::new(), search_cache: HashMap::new(), errors, }; + Self { inner: PyRwLock::new(inner), } @@ -210,6 +210,7 @@ impl CodecsRegistry { if !search_function.is_callable() { return Err(vm.new_type_error("argument must be callable")); } + self.inner.write().search_path.push(search_function); Ok(()) } @@ -250,6 +251,7 @@ impl CodecsRegistry { } inner.search_path.clone() }; + let encoding: PyUtf8StrRef = vm.ctx.new_utf8_str(encoding.as_ref()); for func in search_path { let res = func.call((encoding.clone(),), vm)?; @@ -264,6 +266,7 @@ impl CodecsRegistry { return Ok(codec.clone()); } } + Err(vm.new_lookup_error(format!("unknown encoding: {encoding}"))) } @@ -274,6 +277,7 @@ impl CodecsRegistry { vm: &VirtualMachine, ) -> PyResult { let codec = self.lookup(encoding, vm)?; + if codec.is_text_codec(vm)? { Ok(codec) } else { @@ -430,7 +434,7 @@ fn normalize_encoding_name(encoding: &str) -> Cow<'_, str> { out.into() } -#[derive(Eq, PartialEq)] +#[derive(Clone, Copy, Eq, PartialEq)] enum StandardEncoding { Utf8, Utf16Be, @@ -455,12 +459,14 @@ impl StandardEncoding { let encoding = encoding .strip_prefix(|c| ['-', '_'].contains(&c)) .unwrap_or(encoding); + if encoding == "8" { Some(Self::Utf8) } else if let Some(encoding) = encoding.strip_prefix("16") { if encoding.is_empty() { return Some(Self::UTF_16_NE); } + let encoding = encoding.strip_prefix(['-', '_']).unwrap_or(encoding); match encoding { "be" => Some(Self::Utf16Be), @@ -471,6 +477,7 @@ impl StandardEncoding { if encoding.is_empty() { return Some(Self::UTF_32_NE); } + let encoding = encoding.strip_prefix(['-', '_']).unwrap_or(encoding); match encoding { "be" => Some(Self::Utf32Be), @@ -504,10 +511,12 @@ impl<'a> EncodeErrorHandler> for SurrogatePass { let mut out: Vec = Vec::with_capacity(num_chars * 4); for ch in err_str.code_points() { let c = ch.to_u32(); - let 0xd800..=0xdfff = c else { + + if !(0xd800..=0xdfff).contains(&c) { // Not a surrogate, fail with original exception return Err(ctx.error_encoding(range, reason)); - }; + } + match standard_encoding { StandardEncoding::Utf8 => out.extend(ch.encode_wtf8(&mut [0; 4]).as_bytes()), StandardEncoding::Utf16Le => out.extend((c as u16).to_le_bytes()), @@ -601,7 +610,9 @@ impl<'a> PyEncodeContext<'a> { impl CodecContext for PyEncodeContext<'_> { type Error = PyBaseExceptionRef; + type StrBuf = PyStrRef; + type BytesBuf = PyBytesRef; fn string(&self, s: Wtf8Buf) -> Self::StrBuf { @@ -612,6 +623,7 @@ impl CodecContext for PyEncodeContext<'_> { self.vm.ctx.new_bytes(b) } } + impl EncodeContext for PyEncodeContext<'_> { fn full_data(&self) -> &Wtf8 { self.data.as_wtf8() @@ -690,12 +702,15 @@ pub(crate) struct PyDecodeContext<'a> { pos: usize, exception: OnceCell, } + enum PyDecodeData<'a> { Original(BorrowedValue<'a, [u8]>), Modified(PyBytesRef), } -impl ops::Deref for PyDecodeData<'_> { + +impl Deref for PyDecodeData<'_> { type Target = [u8]; + fn deref(&self) -> &Self::Target { match self { PyDecodeData::Original(data) => data, @@ -719,7 +734,9 @@ impl<'a> PyDecodeContext<'a> { impl CodecContext for PyDecodeContext<'_> { type Error = PyBaseExceptionRef; + type StrBuf = PyStrRef; + type BytesBuf = PyBytesRef; fn string(&self, s: Wtf8Buf) -> Self::StrBuf { @@ -730,6 +747,7 @@ impl CodecContext for PyDecodeContext<'_> { self.vm.ctx.new_bytes(b) } } + impl DecodeContext for PyDecodeContext<'_> { fn full_data(&self) -> &[u8] { &self.data @@ -872,17 +890,19 @@ enum ResolvedError { impl<'a> ErrorsHandler<'a> { #[inline] pub(crate) fn new(errors: Option<&'a Py>, vm: &VirtualMachine) -> Self { - match errors { - Some(errors) => Self { + if let Some(errors) = errors { + Self { errors, resolved: OnceCell::new(), - }, - None => Self { + } + } else { + Self { errors: identifier_utf8!(vm, strict), resolved: OnceCell::from(ResolvedError::Standard(StandardError::Strict)), - }, + } } } + #[inline] fn resolve(&self, vm: &VirtualMachine) -> PyResult<&ResolvedError> { if let Some(val) = self.resolved.get() { @@ -901,11 +921,13 @@ impl<'a> ErrorsHandler<'a> { Ok(self.resolved.get().unwrap()) } } + impl StrBuffer for PyStrRef { fn is_compatible_with(&self, kind: StrKind) -> bool { self.kind() <= kind } } + impl<'a> EncodeErrorHandler> for ErrorsHandler<'_> { fn handle_encode_error( &self, @@ -956,6 +978,7 @@ impl<'a> EncodeErrorHandler> for ErrorsHandler<'_> { Ok((replace, restart)) } } + impl<'a> DecodeErrorHandler> for ErrorsHandler<'_> { fn handle_decode_error( &self, @@ -1110,8 +1133,10 @@ where fn extract_unicode_error_range(err: &PyObject, vm: &VirtualMachine) -> PyResult> { let start = err.get_attr("start", vm)?; let start = start.try_into_value(vm)?; + let end = err.get_attr("end", vm)?; let end = end.try_into_value(vm)?; + Ok(Range { start, end }) } @@ -1134,10 +1159,12 @@ fn update_unicode_error_attrs( fn is_encode_err(err: &PyObject, vm: &VirtualMachine) -> bool { err.fast_isinstance(vm.ctx.exceptions.unicode_encode_error) } + #[inline] fn is_decode_err(err: &PyObject, vm: &VirtualMachine) -> bool { err.fast_isinstance(vm.ctx.exceptions.unicode_decode_error) } + #[inline] fn is_translate_err(err: &PyObject, vm: &VirtualMachine) -> bool { err.fast_isinstance(vm.ctx.exceptions.unicode_translate_error) @@ -1151,10 +1178,9 @@ fn bad_err_type(err: PyObjectRef, vm: &VirtualMachine) -> PyBaseExceptionRef { } fn strict_errors(err: PyObjectRef, vm: &VirtualMachine) -> PyResult { - let err = err + Err(err .downcast() - .unwrap_or_else(|_| vm.new_type_error("codec must pass exception instance")); - Err(err) + .unwrap_or_else(|_| vm.new_type_error("codec must pass exception instance"))) } fn ignore_errors(err: PyObjectRef, vm: &VirtualMachine) -> PyResult<(PyObjectRef, usize)> { diff --git a/crates/vm/src/compiler.rs b/crates/vm/src/compiler.rs index 25fa33302a5..9842bc0d1c7 100644 --- a/crates/vm/src/compiler.rs +++ b/crates/vm/src/compiler.rs @@ -1,45 +1,42 @@ +#[cfg(all(not(feature = "compiler"), feature = "parser", feature = "codegen",))] +compile_error!("Use --features=compiler to enable both parser and codegen"); + #[cfg(feature = "codegen")] pub use rustpython_codegen::CompileOpts; -#[cfg(feature = "compiler")] -pub use rustpython_compiler::*; - -#[cfg(not(feature = "compiler"))] -pub use rustpython_compiler_core::Mode; +cfg_select! { + feature = "compiler" => { + pub use rustpython_compiler::*; + } + _ => { + pub use ruff_python_parser as parser; -#[cfg(not(feature = "compiler"))] -pub use rustpython_compiler_core as core; + pub use rustpython_compiler_core::Mode; + pub use rustpython_compiler_core as core; + } +} #[cfg(not(feature = "compiler"))] -pub use ruff_python_parser as parser; +#[derive(Debug, thiserror::Error)] +pub enum CompileErrorType { + #[cfg(feature = "codegen")] + #[error(transparent)] + Codegen(#[from] super::codegen::error::CodegenErrorType), + #[cfg(feature = "parser")] + #[error(transparent)] + Parse(#[from] super::parser::ParseErrorType), +} #[cfg(not(feature = "compiler"))] -mod error { - #[cfg(all(feature = "parser", feature = "codegen"))] - panic!("Use --features=compiler to enable both parser and codegen"); - - #[derive(Debug, thiserror::Error)] - pub enum CompileErrorType { - #[cfg(feature = "codegen")] - #[error(transparent)] - Codegen(#[from] super::codegen::error::CodegenErrorType), - #[cfg(feature = "parser")] - #[error(transparent)] - Parse(#[from] super::parser::ParseErrorType), - } - - #[derive(Debug, thiserror::Error)] - pub enum CompileError { - #[cfg(feature = "codegen")] - #[error(transparent)] - Codegen(#[from] super::codegen::error::CodegenError), - #[cfg(feature = "parser")] - #[error(transparent)] - Parse(#[from] super::parser::ParseError), - } +#[derive(Debug, thiserror::Error)] +pub enum CompileError { + #[cfg(feature = "codegen")] + #[error(transparent)] + Codegen(#[from] super::codegen::error::CodegenError), + #[cfg(feature = "parser")] + #[error(transparent)] + Parse(#[from] super::parser::ParseError), } -#[cfg(not(feature = "compiler"))] -pub use error::{CompileError, CompileErrorType}; #[cfg(any(feature = "parser", feature = "codegen"))] impl crate::convert::ToPyException for (CompileError, Option<&str>) { diff --git a/crates/vm/src/convert/try_from.rs b/crates/vm/src/convert/try_from.rs index 85d6f5e20e3..10b1449d7eb 100644 --- a/crates/vm/src/convert/try_from.rs +++ b/crates/vm/src/convert/try_from.rs @@ -1,10 +1,11 @@ +use malachite_bigint::Sign; +use num_traits::ToPrimitive; + use crate::{ Py, VirtualMachine, builtins::PyFloat, object::{AsObject, PyObject, PyObjectRef, PyPayload, PyRef, PyResult}, }; -use malachite_bigint::Sign; -use num_traits::ToPrimitive; /// Implemented by any type that can be created from a Python object. /// @@ -62,7 +63,7 @@ impl PyObject { } } -/// Lower-cost variation of `TryFromObject` +/// Lower-cost variation of [`TryFromObject`]. pub trait TryFromBorrowedObject<'a>: Sized where Self: 'a, @@ -126,12 +127,15 @@ impl TryFromObject for core::time::Duration { fn try_from_object(vm: &VirtualMachine, obj: PyObjectRef) -> PyResult { if let Some(float) = obj.downcast_ref::() { let f = float.to_f64(); + if f.is_nan() { return Err(vm.new_value_error("Invalid value NaN (not a number)")); } + if f < 0.0 { return Err(vm.new_value_error("negative duration")); } + if !f.is_finite() || f > u64::MAX as f64 { return Err(vm.new_overflow_error("timestamp too large to convert to C PyTime_t")); } diff --git a/crates/vm/src/gc_state.rs b/crates/vm/src/gc_state.rs index ecb34fcc869..93596a9dad7 100644 --- a/crates/vm/src/gc_state.rs +++ b/crates/vm/src/gc_state.rs @@ -10,14 +10,14 @@ use core::ptr::NonNull; use core::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; use std::collections::HashSet; -#[cfg(not(target_arch = "wasm32"))] -fn elapsed_secs(start: &std::time::Instant) -> f64 { - start.elapsed().as_secs_f64() -} - -#[cfg(target_arch = "wasm32")] -fn elapsed_secs(_start: &()) -> f64 { - 0.0 +fn elapsed_secs( + #[cfg(target_arch = "wasm32")] _start: &(), + #[cfg(not(target_arch = "wasm32"))] start: &std::time::Instant, +) -> f64 { + cfg_select! { + target_arch = "wasm32" => 0.0, + _ => start.elapsed().as_secs_f64(), + } } bitflags::bitflags! { @@ -38,7 +38,7 @@ bitflags::bitflags! { } /// Result from a single collection run -#[derive(Debug, Default)] +#[derive(Clone, Copy, Debug, Default)] pub struct CollectResult { pub collected: usize, pub uncollectable: usize, @@ -47,7 +47,7 @@ pub struct CollectResult { } /// Statistics for a single generation (gc_generation_stats) -#[derive(Debug, Default)] +#[derive(Clone, Copy, Debug, Default)] pub struct GcStats { pub collections: usize, pub collected: usize, @@ -176,7 +176,7 @@ impl Default for GcState { impl GcState { #[must_use] - pub fn new() -> Self { + pub const fn new() -> Self { Self { generations: [ GcGeneration::new(2000), // young @@ -393,10 +393,10 @@ impl GcState { return CollectResult::default(); }; - #[cfg(not(target_arch = "wasm32"))] - let start_time = std::time::Instant::now(); - #[cfg(target_arch = "wasm32")] - let start_time = (); + let start_time = cfg_select! { + target_arch = "wasm32" => (), + _ => std::time::Instant::now(), + }; // Memory barrier to ensure visibility of all reference count updates // from other threads before we start analyzing the object graph. @@ -431,7 +431,9 @@ impl GcState { for i in 0..reset_end { self.generations[i].count.store(0, Ordering::SeqCst); } + let duration = elapsed_secs(&start_time); + self.generations[generation].update_stats(0, 0, 0, duration); return CollectResult { collected: 0, @@ -556,7 +558,9 @@ impl GcState { for i in 0..reset_end { self.generations[i].count.store(0, Ordering::SeqCst); } + let duration = elapsed_secs(&start_time); + self.generations[generation].update_stats(0, 0, candidates, duration); return CollectResult { collected: 0, @@ -577,7 +581,9 @@ impl GcState { for i in 0..reset_end { self.generations[i].count.store(0, Ordering::SeqCst); } + let duration = elapsed_secs(&start_time); + self.generations[generation].update_stats(0, 0, candidates, duration); return CollectResult { collected: 0, @@ -726,6 +732,7 @@ impl GcState { } let duration = elapsed_secs(&start_time); + self.generations[generation].update_stats(collected, 0, candidates, duration); CollectResult { diff --git a/crates/vm/src/recursion.rs b/crates/vm/src/recursion.rs index 7392cca4ded..dea7898c8a7 100644 --- a/crates/vm/src/recursion.rs +++ b/crates/vm/src/recursion.rs @@ -1,14 +1,15 @@ use crate::{AsObject, PyObject, VirtualMachine}; +/// A guard to protect repr methods from recursion into itself. pub struct ReprGuard<'vm> { vm: &'vm VirtualMachine, id: usize, } -/// A guard to protect repr methods from recursion into itself, impl<'vm> ReprGuard<'vm> { - /// Returns None if the guard against 'obj' is still held otherwise returns the guard. The guard - /// which is released if dropped. + /// Returns None if the guard against 'obj' is still held otherwise returns the guard. + /// + /// The guard which is released if dropped. pub fn enter(vm: &'vm VirtualMachine, obj: &PyObject) -> Option { let mut guards = vm.repr_guards.borrow_mut(); @@ -18,8 +19,9 @@ impl<'vm> ReprGuard<'vm> { if guards.contains(&id) { return None; } + guards.insert(id); - Some(ReprGuard { vm, id }) + Some(Self { vm, id }) } } diff --git a/crates/vm/src/suggestion.rs b/crates/vm/src/suggestion.rs index b48b78af755..69ce8f5f6b9 100644 --- a/crates/vm/src/suggestion.rs +++ b/crates/vm/src/suggestion.rs @@ -1,13 +1,14 @@ //! This module provides functionality to suggest similar names for attributes or variables. //! This is used during tracebacks. +use core::iter::ExactSizeIterator; + use crate::{ AsObject, Py, PyObject, PyObjectRef, VirtualMachine, builtins::{PyStr, PyStrRef}, exceptions::types::PyBaseException, sliceable::SliceableSequenceOp, }; -use core::iter::ExactSizeIterator; use rustpython_common::str::levenshtein::{MOVE_COST, levenshtein_distance}; const MAX_CANDIDATE_ITEMS: usize = 750; From 0f539cc0cb7b8b56c069c61b07bb8cc75ca5fd38 Mon Sep 17 00:00:00 2001 From: Kwak Byoung Min <67214970+doma17@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:17:55 +0900 Subject: [PATCH 002/351] Support csv writer QUOTE_STRINGS and QUOTE_NOTNULL (#8109) * Support csv quoting modes added in Python 3.12 Implement writer handling for QUOTE_STRINGS and QUOTE_NOTNULL so the exported constants no longer panic and follow CPython behavior for string, non-null, None, and quotechar validation cases. Constraint: RustPython contribution policy requires CPython-compatible behavior through Rust-side changes and regression coverage outside Lib/. Rejected: Marking CPython Lib tests or hiding the constants | would preserve a runtime panic for supported public constants. Confidence: high Scope-risk: narrow Directive: Keep QUOTE_STRINGS and QUOTE_NOTNULL writer behavior aligned with CPython's csv module when extending dialect validation. Tested: cargo check -p rustpython-stdlib; cargo run --release -- extra_tests/snippets/stdlib_csv.py; cargo run --release -- -m test test_csv; cargo clippy; pre-commit run --all-files via temporary venv/PATH shim; cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher -- --test-threads=1 Not-tested: Remote CI and PR creation were not run because remote git operations require explicit approval. Assisted-by: Codex:gpt-5.5 * Update crates/stdlib/src/csv.rs Co-authored-by: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> * Update crates/stdlib/src/csv.rs Co-authored-by: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> * Keep csv writer follow-up lint clean Apply maintainer-requested expression cleanups and rustfmt wrapping so the PR lint job can pass without changing behavior. Constraint: GitHub Actions Lint failed on rustfmt output after the review suggestion. Rejected: Changing csv writer semantics | the failing check was formatting-only and all behavior tests already passed. Confidence: high Scope-risk: narrow Directive: Keep future review suggestions rustfmt-clean before pushing. Tested: cargo fmt --check; cargo check -p rustpython-stdlib; cargo run --release -- extra_tests/snippets/stdlib_csv.py; cargo run --release -- -m test test_csv; cargo clippy; pre-commit run --all-files; cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher -- --test-threads=1 Not-tested: Remote CI has not rerun yet; this commit will trigger it after push. Assisted-by: Codex:gpt-5.5 --------- Co-authored-by: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> --- crates/stdlib/src/csv.rs | 114 ++++++++++++++++++++++++++++- extra_tests/snippets/stdlib_csv.py | 25 +++++++ 2 files changed, 136 insertions(+), 3 deletions(-) diff --git a/crates/stdlib/src/csv.rs b/crates/stdlib/src/csv.rs index f728002768d..bd80cf7eccf 100644 --- a/crates/stdlib/src/csv.rs +++ b/crates/stdlib/src/csv.rs @@ -466,8 +466,7 @@ mod _csv { QuoteStyle::All => Self::Always, QuoteStyle::Nonnumeric => Self::NonNumeric, QuoteStyle::None => Self::Never, - QuoteStyle::Strings => todo!(), - QuoteStyle::Notnull => todo!(), + QuoteStyle::Strings | QuoteStyle::Notnull => Self::Necessary, } } } @@ -661,7 +660,10 @@ mod _csv { |_| { vm.new_type_error(r#""quotechar" must be a 1-character string"#) } )?)), PyNone => { - if let Some(QuoteStyle::All) = res.quoting { + if res + .quoting + .is_some_and(|quoting| quoting != QuoteStyle::None) + { return Err(ArgumentError::Exception( vm.new_type_error("quotechar must be set if quoting enabled"), )); @@ -1114,6 +1116,55 @@ mod _csv { } } + fn write_quoted_field( + output: &mut Vec, + data: &[u8], + dialect: PyDialect, + vm: &VirtualMachine, + ) -> PyResult<()> { + let quotechar = dialect + .quotechar + .ok_or_else(|| vm.new_type_error("quotechar must be set if quoting enabled"))?; + output.push(quotechar); + for &byte in data { + if byte == quotechar { + if dialect.doublequote { + output.push(quotechar); + output.push(quotechar); + } else if let Some(escapechar) = dialect.escapechar { + output.push(escapechar); + output.push(byte); + } else { + return Err(new_csv_error(vm, "need to escape, but no escapechar set")); + } + } else { + if dialect.escapechar == Some(byte) { + output.push(byte); + } + output.push(byte); + } + } + output.push(quotechar); + Ok(()) + } + + fn field_needs_quotes(data: &[u8], dialect: PyDialect) -> bool { + data.iter().any(|&byte| { + byte == dialect.delimiter + || dialect.quotechar == Some(byte) + || matches!(byte, b'\r' | b'\n') + || matches!(dialect.lineterminator, Terminator::Any(t) if byte == t) + }) + } + + fn write_lineterminator(output: &mut Vec, terminator: Terminator) { + match terminator { + Terminator::CRLF => output.extend_from_slice(b"\r\n"), + Terminator::Any(byte) => output.push(byte), + _ => unreachable!(), + } + } + #[pyclass(flags(DISALLOW_INSTANTIATION))] impl Writer { #[pygetset(name = "dialect")] @@ -1121,8 +1172,65 @@ mod _csv { self.dialect } + fn writerow_quoted_strings(&self, row: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let _state = self.state.lock(); + let row: ArgIterable = ArgIterable::try_from_object(vm, row.clone()).map_err(|_e| { + new_csv_error( + vm, + format!("'{}' object is not iterable", row.class().name()), + ) + })?; + let fields = row.iter(vm)?.collect::>>()?; + let single_field = fields.len() == 1; + let mut output = Vec::new(); + + for (index, field) in fields.into_iter().enumerate() { + if index > 0 { + output.push(self.dialect.delimiter); + } + + let stringified; + let (data, is_str, is_none): (&[u8], bool, bool) = match_class!(match field { + ref s @ PyStr => (s.as_bytes(), true, false), + crate::builtins::PyNone => (b"", false, true), + ref obj => { + stringified = obj.str(vm)?; + (stringified.as_bytes(), false, false) + } + }); + + let should_quote = match self.dialect.quoting { + QuoteStyle::Strings => is_str || field_needs_quotes(data, self.dialect), + QuoteStyle::Notnull => !is_none, + _ => unreachable!(), + }; + if should_quote { + write_quoted_field(&mut output, data, self.dialect, vm)?; + } else if single_field && data.is_empty() { + return Err(new_csv_error( + vm, + "single empty field record must be quoted", + )); + } else { + output.extend_from_slice(data); + } + } + + write_lineterminator(&mut output, self.dialect.lineterminator); + let s = core::str::from_utf8(&output) + .map_err(|_| vm.new_unicode_decode_error("csv not utf8"))?; + self.write.call((s,), vm) + } + #[pymethod] fn writerow(&self, row: PyObjectRef, vm: &VirtualMachine) -> PyResult { + if matches!( + self.dialect.quoting, + QuoteStyle::Strings | QuoteStyle::Notnull + ) { + return self.writerow_quoted_strings(row, vm); + } + let mut state = self.state.lock(); let WriteState { buffer, writer } = &mut *state; diff --git a/extra_tests/snippets/stdlib_csv.py b/extra_tests/snippets/stdlib_csv.py index eb3461e9082..aa7b41223b6 100644 --- a/extra_tests/snippets/stdlib_csv.py +++ b/extra_tests/snippets/stdlib_csv.py @@ -1,4 +1,5 @@ import csv +import io from testutils import assert_raises @@ -47,3 +48,27 @@ def test_delim(): test_delim() + + +def test_quote_strings_and_notnull_writer(): + string_buf = io.StringIO() + csv.writer(string_buf, quoting=csv.QUOTE_STRINGS).writerow(["x", 1, None, ""]) + assert string_buf.getvalue() == '"x",1,,""\r\n' + + notnull_buf = io.StringIO() + csv.writer(notnull_buf, quoting=csv.QUOTE_NOTNULL).writerow(["x", 1, None, ""]) + assert notnull_buf.getvalue() == '"x","1",,""\r\n' + + for quoting in (csv.QUOTE_STRINGS, csv.QUOTE_NOTNULL): + buf = io.StringIO() + csv.writer(buf, quoting=quoting).writerow([None, None]) + assert buf.getvalue() == ",\r\n" + + with assert_raises(csv.Error): + csv.writer(io.StringIO(), quoting=quoting).writerow([None]) + + with assert_raises(TypeError): + csv.writer(io.StringIO(), quoting=quoting, quotechar=None) + + +test_quote_strings_and_notnull_writer() From 4b70b01ee4886f8d6b2af2a5ae186cc63aedc6e8 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:28:35 +0300 Subject: [PATCH 003/351] Skip flaky test at `test_thread.py` (#8125) --- Lib/test/test_thread.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_thread.py b/Lib/test/test_thread.py index dc55174421a..22b19ee3d30 100644 --- a/Lib/test/test_thread.py +++ b/Lib/test/test_thread.py @@ -115,7 +115,7 @@ def test_nt_and_posix_stack_size(self): thread.stack_size(0) - @unittest.skipIf(__import__("sys").platform in ("linux", "win32"), "TODO: RUSTPYTHON; Flakey on CI") + @unittest.skip("TODO: RUSTPYTHON; Flakey on CI") def test__count(self): # Test the _count() function. orig = thread._count() From 2e6ca88dc74bc177171ada004ba583d95f9c215b Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:30:23 +0300 Subject: [PATCH 004/351] Align patches at `test_types.py` (#8126) * Align patches at `test_types.py` * Add missing methods for `_io.TextIOBase` --- Lib/test/test_memoryio.py | 4 ---- Lib/test/test_types.py | 5 ++--- crates/vm/src/stdlib/_io.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_memoryio.py b/Lib/test/test_memoryio.py index 7214a377067..0ae48600529 100644 --- a/Lib/test/test_memoryio.py +++ b/Lib/test/test_memoryio.py @@ -1026,10 +1026,6 @@ def test_relative_seek(self): def test_flags(self): return super().test_flags() - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'StringIO' object has no attribute 'detach' - def test_detach(self): - return super().test_detach() - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'StringIO' object has no attribute 'newlines'. Did you mean: 'readlines'? def test_newlines_property(self): return super().test_newlines_property() diff --git a/Lib/test/test_types.py b/Lib/test/test_types.py index 01da70b4c68..63bc0803e79 100644 --- a/Lib/test/test_types.py +++ b/Lib/test/test_types.py @@ -45,7 +45,6 @@ def clear_typing_caches(): class TypesTests(unittest.TestCase): - @unittest.skipUnless(c_types, "TODO: RUSTPYTHON; requires _types module") def test_names(self): c_only_names = {'CapsuleType'} ignored = {'new_class', 'resolve_bases', 'prepare_class', @@ -636,7 +635,7 @@ def test_slot_wrapper_types(self): self.assertIsInstance(object.__lt__, types.WrapperDescriptorType) self.assertIsInstance(int.__lt__, types.WrapperDescriptorType) - @unittest.expectedFailure # TODO: RUSTPYTHON; No signature found in builtin method __get__ of 'method_descriptor' objects. + @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: no signature found for builtin PyResult { + _unsupported(vm, &zelf, "read") + } + + #[pymethod] + fn write(zelf: PyObjectRef, _b: PyObjectRef, vm: &VirtualMachine) -> PyResult { + _unsupported(vm, &zelf, "write") + } + + #[pymethod] + fn truncate(zelf: PyObjectRef, _pos: OptionalArg, vm: &VirtualMachine) -> PyResult { + _unsupported(vm, &zelf, "truncate") + } + + #[pymethod] + fn readline(zelf: PyObjectRef, vm: &VirtualMachine) -> PyResult { + _unsupported(vm, &zelf, "readline") + } + + #[pymethod] + fn detach(zelf: PyObjectRef, vm: &VirtualMachine) -> PyResult { + _unsupported(vm, &zelf, "detach") + } + #[pygetset] fn encoding(_zelf: PyObjectRef, vm: &VirtualMachine) -> PyObjectRef { vm.ctx.none() } + #[pygetset] + fn newlines(_zelf: PyObjectRef, vm: &VirtualMachine) -> PyObjectRef { + vm.ctx.none() + } + #[pygetset] fn errors(_zelf: PyObjectRef, vm: &VirtualMachine) -> PyObjectRef { vm.ctx.none() From a82660013895f257a87c81a5045167e3f9e83d25 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:30:51 +0300 Subject: [PATCH 005/351] More clippy rules (#8127) * clippy `elidable_lifetime_names` * large_types_passed_by_value * trivially_copy_pass_by_ref * type_repetition_in_bounds * copy_iterator * collection_is_never_read * same_functions_in_if_condition * unnecessary_struct_initialization * clippy on windows * ssl windows --- Cargo.toml | 8 ++++++ crates/codegen/src/compile.rs | 46 +++++++++++++++--------------- crates/codegen/src/ir.rs | 15 ++++++---- crates/common/src/format.rs | 17 +++++++---- crates/host_env/src/crt_fd.rs | 9 +++--- crates/host_env/src/fileutils.rs | 10 +++---- crates/host_env/src/signal.rs | 4 +-- crates/stdlib/src/_asyncio.rs | 2 +- crates/stdlib/src/binascii.rs | 11 +++---- crates/stdlib/src/ssl.rs | 8 +++--- crates/vm/src/builtins/float.rs | 4 +++ crates/vm/src/builtins/type.rs | 2 +- crates/vm/src/protocol/callable.rs | 6 ++-- crates/vm/src/protocol/number.rs | 22 +++++++------- crates/vm/src/protocol/sequence.rs | 2 +- crates/vm/src/stdlib/_ast.rs | 5 ++-- crates/vm/src/stdlib/_io.rs | 2 +- crates/vm/src/stdlib/_signal.rs | 4 +-- crates/vm/src/types/slot.rs | 11 ++++--- crates/vm/src/vm/thread.rs | 3 +- crates/vm/src/vm/vm_new.rs | 6 ++-- 21 files changed, 112 insertions(+), 85 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 280b64f01e5..677de28ef1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -363,6 +363,7 @@ std_instead_of_core = "warn" tests_outside_test_module = "warn" # nursery lints to enforce gradually +collection_is_never_read = "warn" debug_assert_with_mut_call = "warn" derive_partial_eq_without_eq = "warn" imprecise_flops = "warn" @@ -376,6 +377,8 @@ search_is_some = "warn" significant_drop_in_scrutinee = "warn" single_option_map = "warn" trait_duplication_in_bounds = "warn" +type_repetition_in_bounds = "warn" +unnecessary_struct_initialization = "warn" unused_peekable = "warn" unused_rounding = "warn" use_self = "warn" @@ -387,8 +390,10 @@ checked_conversions = "warn" cloned_instead_of_copied = "warn" collapsible_else_if = "warn" comparison_chain = "warn" +copy_iterator = "warn" doc_link_with_quotes = "warn" duration_suboptimal_units = "warn" +elidable_lifetime_names = "warn" enum_glob_use = "warn" explicit_deref_methods = "warn" explicit_into_iter_loop = "warn" @@ -404,6 +409,7 @@ ip_constant = "warn" iter_filter_is_ok = "warn" iter_filter_is_some = "warn" large_futures = "warn" +large_types_passed_by_value = "warn" manual_instant_elapsed = "warn" manual_is_variant_and = "warn" map_unwrap_or = "warn" @@ -421,7 +427,9 @@ range_plus_one = "warn" redundant_else = "warn" ref_option = "warn" return_self_not_must_use = "warn" +same_functions_in_if_condition = "warn" single_char_pattern = "warn" +trivially_copy_pass_by_ref = "warn" unchecked_time_subtraction = "warn" uninlined_format_args = "warn" unnecessary_box_returns = "warn" diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index 476aa35e3ef..45c7dce6096 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -2091,7 +2091,7 @@ impl Compiler { self.emit_resume_for_scope(CompilerScope::Module, 1); emit!(self, PseudoInstruction::AnnotationsPlaceholder); - let (doc, statements) = split_doc_with_range(&body.body, &self.opts); + let (doc, statements) = split_doc_with_range(&body.body, self.opts); let module_start_loc = self.module_start_location(&body.body); // Handle annotation bookkeeping before the docstring assignment, as // codegen_body() does after _PyCodegen_Module() inserts the prefix set. @@ -3075,7 +3075,7 @@ impl Compiler { } ast::Stmt::AugAssign(ast::StmtAugAssign { target, op, value, .. - }) => self.compile_augassign(target, op, value)?, + }) => self.compile_augassign(target, *op, value)?, ast::Stmt::AnnAssign(ast::StmtAnnAssign { target, annotation, @@ -4294,7 +4294,7 @@ impl Compiler { self.set_qualname(); // Handle docstring - store in co_consts[0] if present - let (doc_info, body) = split_doc_with_range(body, &self.opts); + let (doc_info, body) = split_doc_with_range(body, self.opts); let doc_str = doc_info.as_ref().map(|(doc, _)| doc); if let Some(doc) = &doc_str { // Docstring present: store in co_consts[0] and set HAS_DOCSTRING flag @@ -5137,7 +5137,7 @@ impl Compiler { self.code_stack.last_mut().unwrap().private = Some(name.to_owned()); // 2. Set up class namespace - let (doc_str, body) = split_doc_with_range(body, &self.opts); + let (doc_str, body) = split_doc_with_range(body, self.opts); let class_body_prefix_range = self.source_line_start_range(firstlineno); self.set_source_range(class_body_prefix_range); @@ -7004,7 +7004,7 @@ impl Compiler { } /// [CPython `compiler_addcompare`](https://github.com/python/cpython/blob/627894459a84be3488a1789919679c997056a03c/Python/compile.c#L2880-L2924) - fn compile_addcompare(&mut self, op: &ast::CmpOp) { + fn compile_addcompare(&mut self, op: ast::CmpOp) { match op { ast::CmpOp::Eq => emit!( self, @@ -7096,7 +7096,7 @@ impl Compiler { if mid_comparators.is_empty() { self.compile_expression(last_comparator)?; self.set_source_range(compare_range); - self.compile_addcompare(last_op); + self.compile_addcompare(*last_op); return Ok(()); } @@ -7112,7 +7112,7 @@ impl Compiler { emit!(self, Instruction::Swap { i: 2 }); emit!(self, Instruction::Copy { i: 2 }); - self.compile_addcompare(op); + self.compile_addcompare(*op); // if comparison result is false, we break with this value; if true, try the next one. emit!(self, Instruction::Copy { i: 1 }); @@ -7123,7 +7123,7 @@ impl Compiler { self.compile_expression(last_comparator)?; self.set_source_range(compare_range); - self.compile_addcompare(last_op); + self.compile_addcompare(*last_op); let end = self.new_block(); emit!(self, PseudoInstruction::JumpNoInterrupt { delta: end }); @@ -7154,7 +7154,7 @@ impl Compiler { self.compile_expression(left)?; self.compile_expression(last_comparator)?; self.set_source_range(compare_range); - self.compile_addcompare(last_op); + self.compile_addcompare(*last_op); self.emit_pop_jump_by_condition(condition, target_block); return Ok(()); } @@ -7167,14 +7167,14 @@ impl Compiler { self.set_source_range(compare_range); emit!(self, Instruction::Swap { i: 2 }); emit!(self, Instruction::Copy { i: 2 }); - self.compile_addcompare(op); + self.compile_addcompare(*op); emit!(self, Instruction::ToBool); emit!(self, Instruction::PopJumpIfFalse { delta: cleanup }); } self.compile_expression(last_comparator)?; self.set_source_range(compare_range); - self.compile_addcompare(last_op); + self.compile_addcompare(*last_op); emit!(self, Instruction::ToBool); self.emit_pop_jump_by_condition(condition, target_block); let end = self.new_block(); @@ -7446,7 +7446,7 @@ impl Compiler { fn compile_augassign( &mut self, target: &ast::Expr, - op: &ast::Operator, + op: ast::Operator, value: &ast::Expr, ) -> CompileResult<()> { let stmt_range = self.current_source_range; @@ -7559,7 +7559,7 @@ impl Compiler { Ok(()) } - fn compile_op(&mut self, op: &ast::Operator, inplace: bool) { + fn compile_op(&mut self, op: ast::Operator, inplace: bool) { let bin_op = match op { ast::Operator::Add => BinaryOperator::Add, ast::Operator::Sub => BinaryOperator::Subtract, @@ -7687,7 +7687,7 @@ impl Compiler { /// Compile a boolean operation as an expression. /// This means, that the last value remains on the stack. - fn compile_bool_op(&mut self, op: &ast::BoolOp, values: &[ast::Expr]) -> CompileResult<()> { + fn compile_bool_op(&mut self, op: ast::BoolOp, values: &[ast::Expr]) -> CompileResult<()> { let boolop_range = self.current_source_range; let after_block = self.new_block(); let (last_value, prefix_values) = values.split_last().unwrap(); @@ -7707,7 +7707,7 @@ impl Compiler { /// Emit CPython-style pseudo conditional jump for short-circuit evaluation. /// flowgraph.c lowers it to `COPY 1; TO_BOOL; POP_JUMP_IF_*`. - fn emit_short_circuit_test(&mut self, op: &ast::BoolOp, target: BlockIdx) { + fn emit_short_circuit_test(&mut self, op: ast::BoolOp, target: BlockIdx) { match op { ast::BoolOp::And => { emit!(self, PseudoInstruction::JumpIfFalse { delta: target }); @@ -7962,7 +7962,7 @@ impl Compiler { func, arguments, .. }) => self.compile_call(func, arguments)?, ast::Expr::BoolOp(ast::ExprBoolOp { op, values, .. }) => { - self.compile_bool_op(op, values)? + self.compile_bool_op(*op, values)? } ast::Expr::BinOp(ast::ExprBinOp { left, op, right, .. @@ -7972,7 +7972,7 @@ impl Compiler { // Restore full expression range before emitting the operation self.set_source_range(range); - self.compile_op(op, false); + self.compile_op(*op, false); } ast::Expr::Subscript(ast::ExprSubscript { value, slice, ctx, .. @@ -12000,10 +12000,10 @@ fn expandtabs(input: &str, tab_size: usize) -> String { expanded_str } -fn split_doc_with_range<'a>( - body: &'a [ast::Stmt], - opts: &CompileOpts, -) -> (Option<(String, TextRange)>, &'a [ast::Stmt]) { +fn split_doc_with_range( + body: &[ast::Stmt], + opts: CompileOpts, +) -> (Option<(String, TextRange)>, &[ast::Stmt]) { if let Some((ast::Stmt::Expr(expr), body_rest)) = body.split_first() { let doc_comment = match &*expr.value { ast::Expr::StringLiteral(value) => Some((&value.value, expr.value.range())), @@ -12023,7 +12023,7 @@ fn split_doc_with_range<'a>( } #[cfg(test)] -fn split_doc<'a>(body: &'a [ast::Stmt], opts: &CompileOpts) -> (Option, &'a [ast::Stmt]) { +fn split_doc(body: &[ast::Stmt], opts: CompileOpts) -> (Option, &[ast::Stmt]) { let (doc, body) = split_doc_with_range(body, opts); (doc.map(|(doc, _)| doc), body) } @@ -12452,7 +12452,7 @@ def f(x, y, z): in_async_scope: is_async, }; compiler.set_qualname(); - let (_doc_str, body) = split_doc(body, &compiler.opts); + let (_doc_str, body) = split_doc(body, compiler.opts); let start_label = compiler.use_cpython_function_start_label(); let is_gen = is_async || compiler.current_symbol_table().is_generator; let stop_iteration_block = if is_gen { diff --git a/crates/codegen/src/ir.rs b/crates/codegen/src/ir.rs index f22efe8e52d..74f55868867 100644 --- a/crates/codegen/src/ir.rs +++ b/crates/codegen/src/ir.rs @@ -3291,9 +3291,9 @@ fn optimize_lists_and_sets( const VISITED: i32 = -1; /// flowgraph.c SWAPPABLE -fn is_swappable(instr: &AnyInstruction) -> bool { +fn is_swappable(instr: AnyInstruction) -> bool { matches!( - (*instr).into(), + instr.into(), AnyOpcode::Real(Opcode::StoreFast | Opcode::PopTop) | AnyOpcode::Pseudo(PseudoOpcode::StoreFastMaybeNull) ) @@ -3315,17 +3315,22 @@ fn next_swappable_instruction(block: &Block, mut i: usize, lineno: i32) -> Optio if i >= block.instruction_used { return None; } + let info = &block.instructions[i]; let info_lineno = instruction_lineno(info); + if lineno >= 0 && info_lineno != lineno { return None; } + if matches!(info.instr, AnyInstruction::Real(Instruction::Nop)) { continue; } - if is_swappable(&info.instr) { + + if is_swappable(info.instr) { return Some(i); } + return None; } } @@ -5440,7 +5445,7 @@ fn basicblock_add_jump( } /// pycore_opcode_utils.h IS_CONDITIONAL_JUMP_OPCODE -fn is_conditional_jump_opcode(instr: &AnyInstruction) -> bool { +fn is_conditional_jump_opcode(instr: AnyInstruction) -> bool { matches!( instr.real().map(Into::into), Some( @@ -5524,7 +5529,7 @@ fn normalize_jumps_in_block( let Some(last_ins) = basicblock_last_instr(&blocks[idx]).copied() else { return Ok(()); }; - if !is_conditional_jump_opcode(&last_ins.instr) { + if !is_conditional_jump_opcode(last_ins.instr) { return Ok(()); } debug_assert!(!last_ins.instr.is_assembler()); diff --git a/crates/common/src/format.rs b/crates/common/src/format.rs index ea459886914..5510dce41d0 100644 --- a/crates/common/src/format.rs +++ b/crates/common/src/format.rs @@ -141,8 +141,8 @@ impl FormatParse for FormatGrouping { } } -impl From<&FormatGrouping> for char { - fn from(fg: &FormatGrouping) -> Self { +impl From for char { + fn from(fg: FormatGrouping) -> Self { match fg { FormatGrouping::Comma => ',', FormatGrouping::Underscore => '_', @@ -150,6 +150,12 @@ impl From<&FormatGrouping> for char { } } +impl From<&FormatGrouping> for char { + fn from(fg: &FormatGrouping) -> Self { + Self::from(*fg) + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FormatType { String, @@ -322,7 +328,7 @@ impl FormatSpec { return Err(FormatSpecError::DecimalDigitsTooMany); } let (grouping_option, text) = FormatGrouping::parse(text); - if let Some(grouping) = &grouping_option { + if let Some(grouping) = grouping_option { Self::validate_separator(grouping, text)?; } let (precision, text) = parse_precision(text)?; @@ -349,11 +355,12 @@ impl FormatSpec { }) } - fn validate_separator(grouping: &FormatGrouping, text: &Wtf8) -> Result<(), FormatSpecError> { + fn validate_separator(grouping: FormatGrouping, text: &Wtf8) -> Result<(), FormatSpecError> { let mut chars = text.code_points().peekable(); + let grouping_char = char::from(grouping); match chars.peek().and_then(|cp| CodePoint::to_char(*cp)) { Some(c) if c == ',' || c == '_' => { - if c == char::from(grouping) { + if c == grouping_char { Err(FormatSpecError::UnspecifiedFormat(c, c)) } else { Err(FormatSpecError::ExclusiveFormat(',', '_')) diff --git a/crates/host_env/src/crt_fd.rs b/crates/host_env/src/crt_fd.rs index c49d661b37a..ee21934bee2 100644 --- a/crates/host_env/src/crt_fd.rs +++ b/crates/host_env/src/crt_fd.rs @@ -112,7 +112,7 @@ mod win { } #[inline] - pub(super) fn as_raw_fd(&self) -> Raw { + pub(super) fn as_raw_fd(self) -> Raw { self.fd } } @@ -140,12 +140,13 @@ pub struct Borrowed<'fd> { inner: BorrowedInner<'fd>, } -impl<'fd> PartialEq for Borrowed<'fd> { +impl PartialEq for Borrowed<'_> { fn eq(&self, other: &Self) -> bool { self.as_raw() == other.as_raw() } } -impl<'fd> Eq for Borrowed<'fd> {} + +impl Eq for Borrowed<'_> {} impl fmt::Debug for Borrowed<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -250,7 +251,7 @@ impl IntoRawFd for Owned { } } -impl<'fd> Borrowed<'fd> { +impl Borrowed<'_> { /// Create a `crt_fd::Borrowed` from a raw file descriptor. /// /// # Safety diff --git a/crates/host_env/src/fileutils.rs b/crates/host_env/src/fileutils.rs index 79cee1cb551..c9e366e3dd5 100644 --- a/crates/host_env/src/fileutils.rs +++ b/crates/host_env/src/fileutils.rs @@ -165,8 +165,8 @@ pub mod windows { (time_out, nsec_out as _) } - fn file_time_to_time_t_nsec(in_ptr: &FILETIME) -> (libc::time_t, libc::c_int) { - let in_val: i64 = unsafe { core::mem::transmute_copy(in_ptr) }; + fn file_time_to_time_t_nsec(in_ptr: FILETIME) -> (libc::time_t, libc::c_int) { + let in_val: i64 = unsafe { core::mem::transmute_copy(&in_ptr) }; let nsec_out = (in_val % 10_000_000) * 100; // FILETIME is in units of 100 nsec. let time_out = (in_val / 10_000_000) - SECS_BETWEEN_EPOCHS; (time_out, nsec_out as _) @@ -196,10 +196,10 @@ pub mod windows { ) } else { ( - file_time_to_time_t_nsec(&info.ftCreationTime), + file_time_to_time_t_nsec(info.ftCreationTime), (0, 0), - file_time_to_time_t_nsec(&info.ftLastWriteTime), - file_time_to_time_t_nsec(&info.ftLastAccessTime), + file_time_to_time_t_nsec(info.ftLastWriteTime), + file_time_to_time_t_nsec(info.ftLastAccessTime), ) }; let st_nlink = info.nNumberOfLinks as i32; diff --git a/crates/host_env/src/signal.rs b/crates/host_env/src/signal.rs index ab1974f37df..cd11998e5ff 100644 --- a/crates/host_env/src/signal.rs +++ b/crates/host_env/src/signal.rs @@ -327,8 +327,8 @@ pub fn valid_signals(max_signum: usize) -> io::Result> { } #[cfg(unix)] -pub fn sigset_contains(mask: &libc::sigset_t, signum: i32) -> bool { - unsafe { libc::sigismember(mask, signum) == 1 } +pub fn sigset_contains(mask: libc::sigset_t, signum: i32) -> bool { + unsafe { libc::sigismember(&mask, signum) == 1 } } #[cfg(windows)] diff --git a/crates/stdlib/src/_asyncio.rs b/crates/stdlib/src/_asyncio.rs index 48be4115ed6..0c74310eaef 100644 --- a/crates/stdlib/src/_asyncio.rs +++ b/crates/stdlib/src/_asyncio.rs @@ -101,7 +101,7 @@ pub(crate) mod _asyncio { } impl FutureState { - fn as_str(&self) -> &'static str { + const fn as_str(self) -> &'static str { match self { Self::Pending => "PENDING", Self::Cancelled => "CANCELLED", diff --git a/crates/stdlib/src/binascii.rs b/crates/stdlib/src/binascii.rs index 30e9b379ad9..ac945235884 100644 --- a/crates/stdlib/src/binascii.rs +++ b/crates/stdlib/src/binascii.rs @@ -386,18 +386,18 @@ mod decl { } #[inline] - fn uu_a2b_read(c: &u8, vm: &VirtualMachine) -> PyResult { + fn uu_a2b_read(c: u8, vm: &VirtualMachine) -> PyResult { // Check the character for legality // The 64 instead of the expected 63 is because // there are a few uuencodes out there that use // '`' as zero instead of space. - if !(b' '..=(b' ' + 64)).contains(c) { - if [b'\r', b'\n'].contains(c) { + if !(b' '..=(b' ' + 64)).contains(&c) { + if [b'\r', b'\n'].contains(&c) { return Ok(0); } return Err(super::new_binascii_error("Illegal char", vm)); } - Ok((*c - b' ') & 0x3f) + Ok((c - b' ') & 0x3f) } #[derive(FromArgs)] @@ -407,6 +407,7 @@ mod decl { #[pyarg(named, default = false)] header: bool, } + #[pyfunction] fn a2b_qp(args: A2bQpArgs) -> PyResult> { let s = args.data; @@ -760,7 +761,7 @@ mod decl { let (char_a, char_b, char_c, char_d) = { let mut chunk = chunk .iter() - .map(|x| uu_a2b_read(x, vm)) + .map(|&x| uu_a2b_read(x, vm)) .collect::, _>>()?; while chunk.len() < 4 { chunk.push(0); diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 35cb1794045..edcdf18fa09 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -461,7 +461,7 @@ mod _ssl { // Generate a synthetic session ID from server name and timestamp // NOTE: This is NOT the actual TLS session ID, just a unique identifier - fn generate_session_id_from_metadata(server_name: &str, time: &SystemTime) -> Vec { + fn generate_session_id_from_metadata(server_name: &str, time: SystemTime) -> Vec { let mut hasher = Sha256::new(); hasher.update(server_name.as_bytes()); hasher.update( @@ -507,7 +507,7 @@ mod _ssl { _server_name: server_name_str.as_ref().to_string(), session_id: generate_session_id_from_metadata( server_name_str.as_ref(), - &creation_time, + creation_time, ), creation_time, lifetime: 7200, // TLS 1.2 default session lifetime @@ -551,7 +551,7 @@ mod _ssl { _server_name: server_name_str.to_string(), session_id: generate_session_id_from_metadata( server_name_str.as_ref(), - &creation_time, + creation_time, ), creation_time, lifetime: 7200, // Default TLS 1.3 ticket lifetime (Rustls uses this) @@ -2501,7 +2501,7 @@ mod _ssl { } else { // Create new session ID if not in cache let time = std::time::SystemTime::now(); - (generate_session_id_from_metadata(name, &time), time, 7200) + (generate_session_id_from_metadata(name, time), time, 7200) } } else { // No server name, use defaults diff --git a/crates/vm/src/builtins/float.rs b/crates/vm/src/builtins/float.rs index 6f9fd382b51..5a12c4cbf31 100644 --- a/crates/vm/src/builtins/float.rs +++ b/crates/vm/src/builtins/float.rs @@ -254,6 +254,10 @@ fn float_from_string(val: PyObjectRef, vm: &VirtualMachine) -> PyResult { }) } +#[expect( + clippy::trivially_copy_pass_by_ref, + reason = "Needs to comply with a signature" +)] #[pyclass( flags(BASETYPE, _MATCH_SELF), with(Comparable, Hashable, Constructor, AsNumber, Representable) diff --git a/crates/vm/src/builtins/type.rs b/crates/vm/src/builtins/type.rs index d26c81a7b76..89321189b09 100644 --- a/crates/vm/src/builtins/type.rs +++ b/crates/vm/src/builtins/type.rs @@ -370,7 +370,7 @@ unsafe impl Sync for PointerSlot {} unsafe impl Send for PointerSlot {} impl PointerSlot { - pub(crate) const unsafe fn borrow_static(&self) -> &'static T { + pub(crate) const unsafe fn borrow_static(self) -> &'static T { unsafe { self.0.as_ref() } } } diff --git a/crates/vm/src/protocol/callable.rs b/crates/vm/src/protocol/callable.rs index 42d0ac194ae..c9afbd5afb0 100644 --- a/crates/vm/src/protocol/callable.rs +++ b/crates/vm/src/protocol/callable.rs @@ -151,7 +151,7 @@ pub(crate) enum TraceEvent { impl TraceEvent { /// Whether sys.settrace receives this event. #[must_use] - const fn is_trace_event(&self) -> bool { + const fn is_trace_event(self) -> bool { matches!( self, Self::Call | Self::Return | Self::Exception | Self::Line | Self::Opcode @@ -162,7 +162,7 @@ impl TraceEvent { /// In legacy_tracing.c, profile callbacks are only registered for /// PY_RETURN, PY_UNWIND, C_CALL, C_RETURN, C_RAISE. #[must_use] - const fn is_profile_event(&self) -> bool { + const fn is_profile_event(self) -> bool { matches!( self, Self::Call | Self::Return | Self::CCall | Self::CReturn | Self::CException @@ -171,7 +171,7 @@ impl TraceEvent { /// Whether this event is dispatched only when f_trace_opcodes is set. #[must_use] - pub(crate) const fn is_opcode_event(&self) -> bool { + pub(crate) const fn is_opcode_event(self) -> bool { matches!(self, Self::Opcode) } } diff --git a/crates/vm/src/protocol/number.rs b/crates/vm/src/protocol/number.rs index 86b126538ca..301499aa115 100644 --- a/crates/vm/src/protocol/number.rs +++ b/crates/vm/src/protocol/number.rs @@ -631,18 +631,6 @@ impl Deref for PyNumber<'_> { } } -impl<'a> PyNumber<'a> { - // PyNumber_Check - slots are now inherited - #[must_use] - pub fn check(obj: &PyObject) -> bool { - let methods = &obj.class().slots.as_number; - let has_number = methods.int.load().is_some() - || methods.index.load().is_some() - || methods.float.load().is_some(); - has_number || obj.downcastable::() - } -} - impl PyNumber<'_> { // PyIndex_Check #[must_use] @@ -736,6 +724,16 @@ and may be removed in a future version of Python." } }) } + + // PyNumber_Check - slots are now inherited + #[must_use] + pub fn check(obj: &PyObject) -> bool { + let methods = &obj.class().slots.as_number; + let has_number = methods.int.load().is_some() + || methods.index.load().is_some() + || methods.float.load().is_some(); + has_number || obj.downcastable::() + } } pub fn handle_bytes_to_int_err( diff --git a/crates/vm/src/protocol/sequence.rs b/crates/vm/src/protocol/sequence.rs index dbef92c66a9..774e579ee62 100644 --- a/crates/vm/src/protocol/sequence.rs +++ b/crates/vm/src/protocol/sequence.rs @@ -286,7 +286,7 @@ impl PySequence<'_> { } fn _ass_slice( - &self, + self, start: isize, stop: isize, value: Option, diff --git a/crates/vm/src/stdlib/_ast.rs b/crates/vm/src/stdlib/_ast.rs index 38e0d546f44..6bbbbefe504 100644 --- a/crates/vm/src/stdlib/_ast.rs +++ b/crates/vm/src/stdlib/_ast.rs @@ -720,11 +720,12 @@ fn fold_expr(expr: &mut ast::Expr) { let Expr::NumberLiteral(left) = binop.left.as_ref() else { return; }; + let Expr::NumberLiteral(right) = binop.right.as_ref() else { return; }; - if let Some(number) = fold_number_binop(&left.value, &binop.op, &right.value) { + if let Some(number) = fold_number_binop(&left.value, binop.op, &right.value) { *expr = Expr::NumberLiteral(ast::ExprNumberLiteral { node_index: binop.node_index.clone(), range: binop.range, @@ -737,7 +738,7 @@ fn fold_expr(expr: &mut ast::Expr) { #[cfg(feature = "parser")] fn fold_number_binop( left: &ast::Number, - op: &ast::Operator, + op: ast::Operator, right: &ast::Number, ) -> Option { let (left_real, left_imag, left_is_complex) = number_to_complex(left)?; diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index dd01c46fda5..580add6471e 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -2340,7 +2340,7 @@ mod _io { impl Newlines { /// returns position where the new line starts if found, otherwise position at which to /// continue the search after more is read into the buffer - fn find_newline(&self, s: &Wtf8) -> Result { + fn find_newline(self, s: &Wtf8) -> Result { let len = s.len(); match self { Self::Universal | Self::Lf => s.find("\n".as_ref()).map(|p| p + 1).ok_or(len), diff --git a/crates/vm/src/stdlib/_signal.rs b/crates/vm/src/stdlib/_signal.rs index e3d12568d26..71063d8959b 100644 --- a/crates/vm/src/stdlib/_signal.rs +++ b/crates/vm/src/stdlib/_signal.rs @@ -442,7 +442,7 @@ pub(crate) mod _signal { } #[cfg(unix)] - fn sigset_to_pyset(mask: &libc::sigset_t, vm: &VirtualMachine) -> PyResult { + fn sigset_to_pyset(mask: libc::sigset_t, vm: &VirtualMachine) -> PyResult { use crate::PyPayload; use crate::builtins::PySet; let set = PySet::default().into_ref(&vm.ctx); @@ -491,7 +491,7 @@ pub(crate) mod _signal { signal::check_signals(vm)?; // Convert old mask to Python set - sigset_to_pyset(&old_mask, vm) + sigset_to_pyset(old_mask, vm) } #[cfg(any(unix, windows))] diff --git a/crates/vm/src/types/slot.rs b/crates/vm/src/types/slot.rs index a56d1c493f9..4197e1554aa 100644 --- a/crates/vm/src/types/slot.rs +++ b/crates/vm/src/types/slot.rs @@ -202,10 +202,13 @@ impl PyTypeSlots { #[must_use] pub fn heap_default() -> Self { + /* Self { - // init: AtomicCell::new(Some(init_wrapper)), + init: AtomicCell::new(Some(init_wrapper)), ..Default::default() } + */ + Self::default() } } @@ -663,7 +666,7 @@ impl PyType { // NOTE: Collect into Vec first to avoid issues during iteration let defs: Vec<_> = find_slot_defs_by_name(name.as_str()).collect(); for def in defs { - self.update_one_slot::(&def.accessor, name, ctx); + self.update_one_slot::(def.accessor, name, ctx); } // Recursively update subclasses that don't have their own definition @@ -689,7 +692,7 @@ impl PyType { // Update subclass's slots for def in find_slot_defs_by_name(name.as_str()) { - subclass.update_one_slot::(&def.accessor, name, ctx); + subclass.update_one_slot::(def.accessor, name, ctx); } // Recurse into subclass's subclasses @@ -700,7 +703,7 @@ impl PyType { /// Update a single slot fn update_one_slot( &self, - accessor: &SlotAccessor, + accessor: SlotAccessor, name: &'static PyStrInterned, ctx: &Context, ) { diff --git a/crates/vm/src/vm/thread.rs b/crates/vm/src/vm/thread.rs index 33fcec5e43e..26d8db9d764 100644 --- a/crates/vm/src/vm/thread.rs +++ b/crates/vm/src/vm/thread.rs @@ -671,8 +671,7 @@ impl VirtualMachine { #[cfg(feature = "threading")] pub fn start_thread(&self, f: F) -> std::thread::JoinHandle where - F: FnOnce(&Self) -> R, - F: Send + 'static, + F: Send + 'static + FnOnce(&Self) -> R, R: Send + 'static, { let func = self.new_thread().make_spawn_func(f); diff --git a/crates/vm/src/vm/vm_new.rs b/crates/vm/src/vm/vm_new.rs index 30b8e6b1af0..4db965855df 100644 --- a/crates/vm/src/vm/vm_new.rs +++ b/crates/vm/src/vm/vm_new.rs @@ -65,8 +65,8 @@ impl SyntaxErrorInfo { #[cfg(feature = "parser")] #[must_use] - const fn handle_expected_token(expected: &TokenKind, found: &TokenKind) -> &'static str { - match (*expected, *found) { + const fn handle_expected_token(expected: TokenKind, found: TokenKind) -> &'static str { + match (expected, found) { (TokenKind::Colon, TokenKind::Newline) => "expected ':'", (TokenKind::Lpar, _) => "expected '('", @@ -110,7 +110,7 @@ impl SyntaxErrorInfo { ParseErrorType::UnexpectedExpressionToken => format!("invalid syntax: {}", self.msg), ParseErrorType::ExpectedToken { expected, found } => { - Self::handle_expected_token(expected, found).into() + Self::handle_expected_token(*expected, *found).into() } ParseErrorType::InvalidStarredExpressionUsage => { From d748c12a37ff0e7cb1e34fe65eb0918974922d20 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:32:37 +0900 Subject: [PATCH 006/351] Bump insta from 1.47.2 to 1.48.0 (#8130) Bumps [insta](https://github.com/mitsuhiko/insta) from 1.47.2 to 1.48.0. - [Release notes](https://github.com/mitsuhiko/insta/releases) - [Changelog](https://github.com/mitsuhiko/insta/blob/master/CHANGELOG.md) - [Commits](https://github.com/mitsuhiko/insta/compare/1.47.2...1.48.0) --- updated-dependencies: - dependency-name: insta dependency-version: 1.48.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2f916ef8cdb..d49aab2d5e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,7 +100,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -111,7 +111,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1145,7 +1145,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1237,7 +1237,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1811,9 +1811,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.47.2" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ "console", "once_cell", @@ -1971,7 +1971,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "160f2eade097f30263b548aae5deb12ad349c909baa710fa24b92c9090b2e006" dependencies = [ "scopeguard", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3349,7 +3349,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3425,7 +3425,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4228,7 +4228,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4369,10 +4369,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4918,7 +4918,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] From be10ab082e414fe0854ebd55c36807f266cd5766 Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Mon, 22 Jun 2026 09:38:33 +0200 Subject: [PATCH 007/351] Add stdlib to c-api (#8133) --- Cargo.lock | 1 + crates/capi/Cargo.toml | 7 ++----- crates/capi/src/import.rs | 7 +++++++ crates/capi/src/pylifecycle.rs | 15 ++++++++++++++- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d49aab2d5e7..8c39aa02409 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3479,6 +3479,7 @@ dependencies = [ "itertools 0.14.0", "num-complex", "pyo3", + "rustpython-pylib", "rustpython-stdlib", "rustpython-vm", ] diff --git a/crates/capi/Cargo.toml b/crates/capi/Cargo.toml index 70b7fc4b8c5..601989faa1d 100644 --- a/crates/capi/Cargo.toml +++ b/crates/capi/Cargo.toml @@ -15,15 +15,12 @@ crate-type = ["cdylib", "rlib"] bitflags = { workspace = true } itertools = { workspace = true } num-complex = { workspace = true } -rustpython-vm = { workspace = true, features = ["threading", "compiler"] } +rustpython-vm = { workspace = true, features = ["threading", "compiler", "importlib", "host_env"] } rustpython-stdlib = {workspace = true, features = ["threading"] } +rustpython-pylib = { workspace = true } [dev-dependencies] pyo3 = { workspace = true, features = ["auto-initialize", "abi3"] } [lints] workspace = true - -[package.metadata.cargo-shear] -# Not a direct dependency (yet), but we need to enable threading support in the stdlib. -ignored = ["rustpython-stdlib"] diff --git a/crates/capi/src/import.rs b/crates/capi/src/import.rs index d380d1f8266..3550baa1440 100644 --- a/crates/capi/src/import.rs +++ b/crates/capi/src/import.rs @@ -19,4 +19,11 @@ mod tests { let _module = py.import("sys").unwrap(); }) } + + #[test] + fn import_stdlib() { + Python::attach(|py| { + let _module = py.import("types").unwrap(); + }) + } } diff --git a/crates/capi/src/pylifecycle.rs b/crates/capi/src/pylifecycle.rs index 6760b2822a3..df964d11753 100644 --- a/crates/capi/src/pylifecycle.rs +++ b/crates/capi/src/pylifecycle.rs @@ -2,6 +2,7 @@ use crate::get_main_interpreter; use crate::pyerrors::init_exception_statics; use crate::pystate::ensure_thread_has_vm_attached; use core::ffi::c_int; +use rustpython_vm::common::rc::PyRc; use rustpython_vm::vm::thread::ThreadedVirtualMachine; use rustpython_vm::{Context, Interpreter}; use std::sync::Mutex; @@ -32,7 +33,19 @@ pub extern "C" fn Py_InitializeEx(_initsigs: c_int) { if interp.is_none() { // Safety: Interpreter was not initialized before, so we can safely assume the statics are not used unsafe { init_exception_statics(&Context::genesis().exceptions) }; - *interp = Interpreter::with_init(Default::default(), |_vm| {}).into(); + let builder = Interpreter::builder(Default::default()); + let defs = rustpython_stdlib::stdlib_module_defs(&builder.ctx); + *interp = builder + .add_native_modules(&defs) + .init_hook(|vm| { + let state = PyRc::get_mut(&mut vm.state).unwrap(); + let path = rustpython_pylib::LIB_PATH.to_owned(); + + state.config.paths.stdlib_dir = Some(path.clone()); + state.config.paths.module_search_paths.insert(0, path); + }) + .build() + .into(); drop(interp); ensure_thread_has_vm_attached(); } From 828cf211784da500924e833aa630291022145a82 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:42:54 +0300 Subject: [PATCH 008/351] Replace `&String` -> `&str` (#8136) --- crates/vm/src/builtins/module.rs | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/vm/src/builtins/module.rs b/crates/vm/src/builtins/module.rs index 90410907276..e6296755870 100644 --- a/crates/vm/src/builtins/module.rs +++ b/crates/vm/src/builtins/module.rs @@ -231,7 +231,7 @@ impl Py { } else { // Check for uninitialized submodule let submodule_initializing = - is_uninitialized_submodule(mod_name_str.as_ref(), name, vm); + is_uninitialized_submodule(mod_name_str.as_deref(), name, vm); if submodule_initializing { Err(vm.new_attribute_error(format!( "cannot access submodule '{name}' of module '{mod_display}' \ @@ -461,29 +461,29 @@ pub(crate) fn init(context: &'static Context) { /// Check if {module_name}.{name} is an uninitialized submodule in sys.modules. fn is_uninitialized_submodule( - module_name: Option<&String>, + module_name: Option<&str>, name: &Py, vm: &VirtualMachine, ) -> bool { - let mod_name = match module_name { - Some(n) => n.as_str(), - None => return false, + let Some(mod_name) = module_name else { + return false; }; - let full_name = format!("{mod_name}.{name}"); - let sys_modules = match vm.sys_module.get_attr("modules", vm).ok() { - Some(m) => m, - None => return false, + + let Ok(sys_modules) = vm.sys_module.get_attr("modules", vm) else { + return false; }; - let sub_mod = match sys_modules.get_item(&full_name, vm).ok() { - Some(m) => m, - None => return false, + + let full_name = format!("{mod_name}.{name}"); + let Ok(sub_mod) = sys_modules.get_item(&full_name, vm) else { + return false; }; - let spec = match sub_mod.get_attr("__spec__", vm).ok() { - Some(s) if !vm.is_none(&s) => s, + + let spec = match sub_mod.get_attr("__spec__", vm) { + Ok(s) if !vm.is_none(&s) => s, _ => return false, }; + spec.get_attr("_initializing", vm) - .ok() - .and_then(|v| v.try_to_bool(vm).ok()) + .and_then(|v| v.try_to_bool(vm)) .unwrap_or(false) } From 8214c903e0fceb60a103c19837ff1b6136a5e8c1 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:44:08 +0300 Subject: [PATCH 009/351] Fix symtable type_param_block name (#8137) --- Lib/test/test_symtable.py | 32 +++++++++++++------------------ crates/codegen/src/symboltable.rs | 30 ++++++++++++++++++++++++++--- crates/vm/src/stdlib/_symtable.rs | 31 +++++++++++------------------- 3 files changed, 51 insertions(+), 42 deletions(-) diff --git a/Lib/test/test_symtable.py b/Lib/test/test_symtable.py index 1653ab4a718..f36bbcaea1f 100644 --- a/Lib/test/test_symtable.py +++ b/Lib/test/test_symtable.py @@ -190,20 +190,15 @@ class SymtableTest(unittest.TestCase): foo = find_block(top, "foo") Alias = find_block(top, "Alias") GenericAlias = find_block(top, "GenericAlias") - # XXX: RUSTPYTHON - # GenericAlias_inner = find_block(GenericAlias, "GenericAlias") + GenericAlias_inner = find_block(GenericAlias, "GenericAlias") generic_spam = find_block(top, "generic_spam") - # XXX: RUSTPYTHON - # generic_spam_inner = find_block(generic_spam, "generic_spam") + generic_spam_inner = find_block(generic_spam, "generic_spam") GenericMine = find_block(top, "GenericMine") - # XXX: RUSTPYTHON - # GenericMine_inner = find_block(GenericMine, "GenericMine") - # XXX: RUSTPYTHON - # T = find_block(GenericMine, "T") - # XXX: RUSTPYTHON - # U = find_block(GenericMine, "U") + GenericMine_inner = find_block(GenericMine, "GenericMine") + T = find_block(GenericMine, "T") + U = find_block(GenericMine, "U") - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: != 'type alias' def test_type(self): self.assertEqual(self.top.get_type(), "module") self.assertEqual(self.Mine.get_type(), "class") @@ -221,7 +216,6 @@ def test_type(self): self.assertEqual(self.T.get_type(), "type variable") self.assertEqual(self.U.get_type(), "type variable") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_id(self): self.assertGreater(self.top.get_id(), 0) self.assertGreater(self.Mine.get_id(), 0) @@ -254,7 +248,7 @@ def test_lineno(self): self.assertEqual(self.top.get_lineno(), 0) self.assertEqual(self.spam.get_lineno(), 14) - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Lists differ: [] != ['a', 'b', 'internal', 'kw', 'other_internal', 'some_var', 'var', 'x'] def test_function_info(self): func = self.spam self.assertEqual(sorted(func.get_parameters()), ["a", "b", "kw", "var"]) @@ -337,7 +331,7 @@ def test_assigned(self): self.assertTrue(self.Mine.lookup("a_method").is_assigned()) self.assertFalse(self.internal.lookup("x").is_assigned()) - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON; IndexError: list index out of range def test_annotated(self): st1 = symtable.symtable('def f():\n x: int\n', 'test', 'exec') st2 = st1.get_children()[1] @@ -375,7 +369,7 @@ def test_name(self): self.assertEqual(self.spam.lookup("x").get_name(), "x") self.assertEqual(self.Mine.get_name(), "Mine") - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Tuples differ: () != ('a_method',) def test_class_get_methods(self): deprecation_mess = ( re.escape('symtable.Class.get_methods() is deprecated ' @@ -457,7 +451,7 @@ def check_body(body, expected_methods): check_body('\n'.join((gen, func)), ('genexpr',)) check_body('\n'.join((func, gen)), ('genexpr',)) - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON; SyntaxError: name 'x' is parameter and global def test_filename_correct(self): ### Bug tickler: SyntaxError file name correct whether error raised ### while parsing or building symbol table. @@ -489,7 +483,7 @@ def test_single(self): def test_exec(self): symbols = symtable.symtable("def f(x): return x", "?", "exec") - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: Expected type 'str' but 'bytes' found. def test_bytes(self): top = symtable.symtable(TEST_CODE.encode('utf8'), "?", "exec") self.assertIsNotNone(find_block(top, "Mine")) @@ -503,7 +497,7 @@ def test_symtable_repr(self): self.assertEqual(str(self.top), "") self.assertEqual(str(self.spam), "") - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: AssertionError: "" != "" def test_symbol_repr(self): self.assertEqual(repr(self.spam.lookup("glob")), "") @@ -579,7 +573,7 @@ def test_loopvar_in_only_one_scope(self): class CommandLineTest(unittest.TestCase): maxDiff = None - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: Expected type 'str' but 'bytes' found. def test_file(self): filename = os_helper.TESTFN self.addCleanup(os_helper.unlink, filename) diff --git a/crates/codegen/src/symboltable.rs b/crates/codegen/src/symboltable.rs index cd9ee4f0a41..27ba10ebadb 100644 --- a/crates/codegen/src/symboltable.rs +++ b/crates/codegen/src/symboltable.rs @@ -184,6 +184,30 @@ pub enum SymbolScope { Cell, } +impl SymbolScope { + /// Returns the [`i32`] representation of this symbol scope. + /// + /// # See also + /// [CPython's definition](https://github.com/python/cpython/blob/v3.14.6/Include/internal/pycore_symtable.h#L180-L184) + #[must_use] + pub const fn as_i32(&self) -> i32 { + match self { + Self::Unknown => 0, + Self::Local => 1, + Self::GlobalExplicit => 2, + Self::GlobalImplicit => 3, + Self::Free => 4, + Self::Cell => 5, + } + } +} + +impl From for i32 { + fn from(scope: SymbolScope) -> Self { + scope.as_i32() + } +} + bitflags! { #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct SymbolFlags: u16 { @@ -1510,7 +1534,7 @@ impl SymbolTableBuilder { // annotation scopes are nested inside and can see type parameters. if let Some(type_params) = type_params { self.enter_type_param_block( - &format!("", name.as_str()), + name.as_str(), self.line_index_start(type_params.range), false, true, @@ -1563,7 +1587,7 @@ impl SymbolTableBuilder { let prev_class = self.class_name.take(); if let Some(type_params) = type_params { self.enter_type_param_block( - &format!("", name.as_str()), + name.as_str(), self.line_index_start(type_params.range), true, // for_class: enable selective mangling false, @@ -1876,7 +1900,7 @@ impl SymbolTableBuilder { let is_generic = type_params.is_some(); if let Some(type_params) = type_params { self.enter_type_param_block( - &format!(""), + &alias_name, self.line_index_start(type_params.range), false, false, diff --git a/crates/vm/src/stdlib/_symtable.rs b/crates/vm/src/stdlib/_symtable.rs index 4945cbeedce..4c6ec75f5ac 100644 --- a/crates/vm/src/stdlib/_symtable.rs +++ b/crates/vm/src/stdlib/_symtable.rs @@ -13,6 +13,9 @@ mod _symtable { CompilerScope, Symbol, SymbolFlags, SymbolScope, SymbolTable, }; + /// [CPython's `SCOPE_OFFSET`](https://github.com/python/cpython/blob/v3.14.6/Include/internal/pycore_symtable.h#L176) + const SCOPE_OFFSET: i32 = 12; + // Consts as defined at // https://github.com/python/cpython/blob/6cb20a219a860eaf687b2d968b41c480c7461909/Include/internal/pycore_symtable.h#L156 @@ -55,32 +58,23 @@ mod _symtable { #[pyattr] pub(super) const DEF_BOUND: i32 = DEF_LOCAL | DEF_PARAM | DEF_IMPORT; - #[pyattr] - pub(super) const SCOPE_OFFSET: i32 = 12; - #[pyattr] pub(super) const SCOPE_MASK: i32 = DEF_GLOBAL | DEF_LOCAL | DEF_PARAM | DEF_NONLOCAL; #[pyattr] - pub(super) const LOCAL: i32 = 1; - - #[pyattr] - pub(super) const GLOBAL_EXPLICIT: i32 = 2; + pub(super) const LOCAL: i32 = SymbolScope::Local.as_i32(); #[pyattr] - pub(super) const GLOBAL_IMPLICIT: i32 = 3; + pub(super) const GLOBAL_EXPLICIT: i32 = SymbolScope::GlobalExplicit.as_i32(); #[pyattr] - pub(super) const FREE: i32 = 4; + pub(super) const GLOBAL_IMPLICIT: i32 = SymbolScope::GlobalImplicit.as_i32(); #[pyattr] - pub(super) const CELL: i32 = 5; + pub(super) const FREE: i32 = SymbolScope::Free.as_i32(); #[pyattr] - pub(super) const GENERATOR: i32 = 1; - - #[pyattr] - pub(super) const GENERATOR_EXPRESSION: i32 = 2; + pub(super) const CELL: i32 = SymbolScope::Cell.as_i32(); #[pyattr] pub(super) const SCOPE_OFF: i32 = SCOPE_OFFSET; @@ -98,16 +92,13 @@ mod _symtable { pub(super) const TYPE_ANNOTATION: i32 = 3; #[pyattr] - pub(super) const TYPE_TYPE_VAR_BOUND: i32 = 4; - - #[pyattr] - pub(super) const TYPE_TYPE_ALIAS: i32 = 5; + pub(super) const TYPE_TYPE_ALIAS: i32 = 4; #[pyattr] - pub(super) const TYPE_TYPE_PARAMETERS: i32 = 6; + pub(super) const TYPE_TYPE_PARAMETERS: i32 = 5; #[pyattr] - pub(super) const TYPE_TYPE_VARIABLE: i32 = 7; + pub(super) const TYPE_TYPE_VARIABLE: i32 = 6; #[pyfunction] fn symtable( From 9bb6ba83e58615e6bdfcba5222a95816fe5f8e8f Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:46:17 +0300 Subject: [PATCH 010/351] General code nits (#8135) * General nitpicks * Move Compiler::constant_truthiness to a ConstantData method * More code cleanup * bytecode::CodeFlags -> CodeFlags * More code clenaups --- Cargo.lock | 1 + crates/codegen/src/compile.rs | 394 +++++++++++---------------- crates/codegen/src/ir.rs | 345 ++++++++--------------- crates/codegen/src/string_parser.rs | 1 + crates/compiler-core/Cargo.toml | 1 + crates/compiler-core/src/bytecode.rs | 26 ++ crates/vm/src/function/buffer.rs | 23 +- crates/vm/src/function/builtin.rs | 7 +- crates/vm/src/function/either.rs | 24 +- crates/vm/src/function/getset.rs | 4 +- crates/vm/src/function/method.rs | 1 + crates/vm/src/py_io.rs | 20 +- crates/vm/src/py_serde.rs | 5 +- crates/vm/src/readline.rs | 2 +- 14 files changed, 364 insertions(+), 490 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8c39aa02409..60080587885 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3553,6 +3553,7 @@ dependencies = [ "lz4_flex", "malachite-bigint", "num-complex", + "num-traits", "rustpython-ruff_source_file", "rustpython-wtf8", ] diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index 45c7dce6096..0ad465fb24c 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -24,13 +24,15 @@ use num_complex::Complex; use num_traits::{Num, ToPrimitive, Zero}; use ruff_python_ast as ast; use ruff_text_size::{Ranged, TextRange, TextSize}; + use rustpython_compiler_core::{ Mode, OneIndexed, PositionEncoding, SourceFile, SourceLocation, bytecode::{ self, AnyInstruction, AnyOpcode, Arg as OpArgMarker, BinaryOperator, BuildSliceArgCount, - CodeObject, ComparisonOperator, ConstantData, ConvertValueOparg, Instruction, - IntrinsicFunction1, Invert, LoadAttr, LoadSuperAttr, OpArg, OpArgType, PseudoInstruction, - SpecialMethod, UnpackExArgs, oparg, + CodeFlags, CodeObject, ComparisonOperator, ConstantData, ConvertValueOparg, Instruction, + IntrinsicFunction1, Invert, LoadAttr, LoadSuperAttr, MakeFunctionFlag, MakeFunctionFlags, + OpArg, OpArgType, Opcode, PseudoInstruction, PseudoOpcode, SpecialMethod, UnpackExArgs, + oparg, }, }; use rustpython_wtf8::Wtf8Buf; @@ -63,12 +65,9 @@ impl ExprExt for ast::Expr { fn is_constant_slice(&self) -> bool { match self { Self::Slice(s) => { - let lower_const = - s.lower.is_none() || s.lower.as_deref().is_some_and(|e| e.is_constant()); - let upper_const = - s.upper.is_none() || s.upper.as_deref().is_some_and(|e| e.is_constant()); - let step_const = - s.step.is_none() || s.step.as_deref().is_some_and(|e| e.is_constant()); + let lower_const = s.lower.as_deref().is_none_or(|e| e.is_constant()); + let upper_const = s.upper.as_deref().is_none_or(|e| e.is_constant()); + let step_const = s.step.as_deref().is_none_or(|e| e.is_constant()); lower_const && upper_const && step_const } _ => false, @@ -458,22 +457,6 @@ enum CollectionType { const STACK_USE_GUIDELINE: u32 = 30; impl Compiler { - fn constant_truthiness(constant: &ConstantData) -> bool { - match constant { - ConstantData::Tuple { elements } | ConstantData::Frozenset { elements } => { - !elements.is_empty() - } - ConstantData::Integer { value } => !value.is_zero(), - ConstantData::Float { value } => *value != 0.0, - ConstantData::Complex { value } => value.re != 0.0 || value.im != 0.0, - ConstantData::Boolean { value } => *value, - ConstantData::Str { value } => !value.is_empty(), - ConstantData::Bytes { value } => !value.is_empty(), - ConstantData::Code { .. } | ConstantData::Slice { .. } | ConstantData::Ellipsis => true, - ConstantData::None => false, - } - } - fn new(opts: CompileOpts, source_file: SourceFile, code_name: &str) -> Self { let module_code = ir::CodeInfo { // CPython convention: top-level module / interactive / @@ -484,7 +467,7 @@ impl Compiler { // empty flags. frame.rs:725-731 then binds locals to globals // for module/REPL frames whose `scope.locals` is None - the // correct semantics for `exec(code, globals)` and module init. - flags: bytecode::CodeFlags::empty(), + flags: CodeFlags::empty(), source_path: source_file.name().to_owned(), private: None, blocks: vec![ir::Block::default()], @@ -1196,13 +1179,10 @@ impl Compiler { let source_path = self.source_file.name().to_owned(); // Lookup symbol table entry using key (_PySymtable_Lookup) - let ste = match self.symbol_table_stack.get(key) { - Some(v) => v, - None => { - return Err(self.error(CodegenErrorType::SyntaxError( - "unknown symbol table entry".to_owned(), - ))); - } + let Some(ste) = self.symbol_table_stack.get(key) else { + return Err(self.error(CodegenErrorType::SyntaxError( + "unknown symbol table entry".into(), + ))); }; // Use varnames from symbol table (already collected in definition order) @@ -1273,16 +1253,21 @@ impl Compiler { .collect() }) .unwrap_or_default(); - let mut free_names: Vec<_> = ste + + let mut free_names = ste .symbols .iter() .filter(|(_, s)| { - s.scope == SymbolScope::Free - || (scope_type != CompilerScope::Class - && s.flags.contains(SymbolFlags::FREE_CLASS)) - || (scope_type == CompilerScope::Class - && s.flags.contains(SymbolFlags::FREE_CLASS) - && self.has_enclosing_non_module_code_scope()) + if s.scope == SymbolScope::Free { + return true; + } + + let has_free_class = s.flags.contains(SymbolFlags::FREE_CLASS); + if scope_type == CompilerScope::Class { + has_free_class && self.has_enclosing_non_module_code_scope() + } else { + has_free_class + } }) .filter(|(name, symbol)| { if !matches!( @@ -1294,7 +1279,8 @@ impl Compiler { !(annotation_free_names.contains(*name) && symbol.flags.is_empty()) }) .map(|(name, _)| name.clone()) - .collect(); + .collect::>(); + free_names.sort(); for name in free_names { freevar_cache.insert(name); @@ -1302,28 +1288,23 @@ impl Compiler { // Initialize u_metadata fields let (mut flags, posonlyarg_count, arg_count, kwonlyarg_count) = match scope_type { - CompilerScope::Module => (bytecode::CodeFlags::empty(), 0, 0, 0), - CompilerScope::Class => (bytecode::CodeFlags::empty(), 0, 0, 0), + CompilerScope::Module => (CodeFlags::empty(), 0, 0, 0), + CompilerScope::Class => (CodeFlags::empty(), 0, 0, 0), CompilerScope::Function | CompilerScope::AsyncFunction | CompilerScope::Lambda => ( - bytecode::CodeFlags::NEWLOCALS | bytecode::CodeFlags::OPTIMIZED, + CodeFlags::NEWLOCALS | CodeFlags::OPTIMIZED, 0, // Will be set later in enter_function 0, // Will be set later in enter_function 0, // Will be set later in enter_function ), CompilerScope::Comprehension => ( - bytecode::CodeFlags::NEWLOCALS | bytecode::CodeFlags::OPTIMIZED, + CodeFlags::NEWLOCALS | CodeFlags::OPTIMIZED, 0, 1, // comprehensions take one argument (.0) 0, ), - CompilerScope::TypeParams => ( - bytecode::CodeFlags::NEWLOCALS | bytecode::CodeFlags::OPTIMIZED, - 0, - 0, - 0, - ), + CompilerScope::TypeParams => (CodeFlags::NEWLOCALS | CodeFlags::OPTIMIZED, 0, 0, 0), CompilerScope::Annotation => ( - bytecode::CodeFlags::NEWLOCALS | bytecode::CodeFlags::OPTIMIZED, + CodeFlags::NEWLOCALS | CodeFlags::OPTIMIZED, 1, // format is positional-only 1, // annotation scope takes one argument (format) 0, @@ -1331,7 +1312,7 @@ impl Compiler { }; if ste.is_method { - flags |= bytecode::CodeFlags::METHOD; + flags |= CodeFlags::METHOD; } // CPython sets CO_NESTED from symtable's ste_nested, not merely @@ -1347,12 +1328,12 @@ impl Compiler { | CompilerScope::Annotation | CompilerScope::TypeParams ) { - flags | bytecode::CodeFlags::NESTED + flags | CodeFlags::NESTED } else { flags }; if self.future_annotations { - flags |= bytecode::CodeFlags::FUTURE_ANNOTATIONS; + flags |= CodeFlags::FUTURE_ANNOTATIONS; } // Get private name from parent scope @@ -1436,10 +1417,7 @@ impl Compiler { let except_handler = None; self.cpython_cfg_builder_addop(ir::InstructionInfo { - instr: Instruction::Resume { - context: OpArgMarker::marker(), - } - .into(), + instr: Opcode::Resume.into(), arg: OpArg::new(oparg::ResumeLocation::AtFuncStart.into()), target: BlockIdx::NULL, location, @@ -1477,9 +1455,7 @@ impl Compiler { // Preserve flags computed from the symbol-table context. info.flags = flags | (info.flags - & (bytecode::CodeFlags::NESTED - | bytecode::CodeFlags::METHOD - | bytecode::CodeFlags::FUTURE_ANNOTATIONS)); + & (CodeFlags::NESTED | CodeFlags::METHOD | CodeFlags::FUTURE_ANNOTATIONS)); info.metadata.argcount = arg_count; info.metadata.posonlyargcount = posonlyarg_count; info.metadata.kwonlyargcount = kwonlyarg_count; @@ -2074,7 +2050,7 @@ impl Compiler { if self.future_annotations { self.current_code_info() .flags - .insert(bytecode::CodeFlags::FUTURE_ANNOTATIONS); + .insert(CodeFlags::FUTURE_ANNOTATIONS); } // Module-level __conditional_annotations__ cell @@ -2145,7 +2121,7 @@ impl Compiler { if self.future_annotations { self.current_code_info() .flags - .insert(bytecode::CodeFlags::FUTURE_ANNOTATIONS); + .insert(CodeFlags::FUTURE_ANNOTATIONS); } self.symbol_table_stack.push(symbol_table); let module_start_loc = self.module_start_location(body); @@ -2485,11 +2461,13 @@ impl Compiler { let current_table = self.current_symbol_table(); if current_table.typ == CompilerScope::Class && !self.current_code_info().in_inlined_comp - && ((usage == NameUsage::Load - && (name == "__class__" - || name == "__classdict__" - || name == "__conditional_annotations__")) - || (name == "__conditional_annotations__" && usage == NameUsage::Store)) + && matches!( + (usage, name.as_ref()), + ( + NameUsage::Load, + "__class__" | "__classdict__" | "__conditional_annotations__" + ) | (NameUsage::Store, "__conditional_annotations__") + ) { Some(SymbolScope::Cell) } else { @@ -3150,7 +3128,7 @@ impl Compiler { let code = self.exit_scope(); self.ctx = prev_ctx; - self.make_closure(code, bytecode::MakeFunctionFlags::new())?; + self.make_closure(code, MakeFunctionFlags::new())?; emit!(self, Instruction::PushNull); emit!(self, Instruction::Call { argc: 0 }); } else { @@ -3228,7 +3206,7 @@ impl Compiler { } self.push_output( - bytecode::CodeFlags::NEWLOCALS | bytecode::CodeFlags::OPTIMIZED, + CodeFlags::NEWLOCALS | CodeFlags::OPTIMIZED, parameters.posonlyargs.len().to_u32(), (parameters.posonlyargs.len() + parameters.args.len()).to_u32(), parameters.kwonlyargs.len().to_u32(), @@ -3246,11 +3224,11 @@ impl Compiler { } if let Some(name) = parameters.vararg.as_deref() { - self.current_code_info().flags |= bytecode::CodeFlags::VARARGS; + self.current_code_info().flags |= CodeFlags::VARARGS; self.varname(name.name.as_str()); } if let Some(name) = parameters.kwarg.as_deref() { - self.current_code_info().flags |= bytecode::CodeFlags::VARKEYWORDS; + self.current_code_info().flags |= CodeFlags::VARKEYWORDS; self.varname(name.name.as_str()); } @@ -3333,10 +3311,7 @@ impl Compiler { self.ctx = prev_ctx; self.set_source_range(expr_range); - self.make_closure( - code, - bytecode::MakeFunctionFlags::from([bytecode::MakeFunctionFlag::Defaults]), - )?; + self.make_closure(code, MakeFunctionFlags::from([MakeFunctionFlag::Defaults]))?; Ok(()) } @@ -3376,10 +3351,7 @@ impl Compiler { let code = self.exit_scope(); self.ctx = prev_ctx; self.set_source_range(alias_range); - self.make_closure( - code, - bytecode::MakeFunctionFlags::from([bytecode::MakeFunctionFlag::Defaults]), - )?; + self.make_closure(code, MakeFunctionFlags::from([MakeFunctionFlag::Defaults]))?; Ok(()) } @@ -4206,7 +4178,7 @@ impl Compiler { parameters: &ast::Parameters, loc: TextRange, ) -> CompileResult { - let mut funcflags = bytecode::MakeFunctionFlags::new(); + let mut funcflags = MakeFunctionFlags::new(); // Handle positional defaults let defaults: Vec<_> = core::iter::empty() @@ -4227,7 +4199,7 @@ impl Compiler { count: defaults.len().to_u32() } ); - funcflags.insert(bytecode::MakeFunctionFlag::Defaults); + funcflags.insert(MakeFunctionFlag::Defaults); } // Handle keyword-only defaults @@ -4254,7 +4226,7 @@ impl Compiler { count: kw_with_defaults.len().to_u32(), } ); - funcflags.insert(bytecode::MakeFunctionFlag::KwOnlyDefaults); + funcflags.insert(MakeFunctionFlag::KwOnlyDefaults); } Ok(funcflags) @@ -4275,7 +4247,7 @@ impl Compiler { self.enter_function(name, parameters)?; self.current_code_info() .flags - .set(bytecode::CodeFlags::COROUTINE, is_async); + .set(CodeFlags::COROUTINE, is_async); // Set up context let prev_ctx = self.ctx; @@ -4304,7 +4276,7 @@ impl Compiler { .insert_full(ConstantData::Str { value: (*doc).to_string().into(), }); - self.current_code_info().flags |= bytecode::CodeFlags::HAS_DOCSTRING; + self.current_code_info().flags |= CodeFlags::HAS_DOCSTRING; } let start_label = self.use_cpython_function_start_label(); @@ -4460,7 +4432,7 @@ impl Compiler { // Make a closure from the code object self.set_source_range(func_range); - self.make_closure(annotate_code, bytecode::MakeFunctionFlags::new())?; + self.make_closure(annotate_code, MakeFunctionFlags::new())?; Ok(true) } @@ -4684,7 +4656,7 @@ impl Compiler { // Make a closure from the code object self.set_source_range(loc); - self.make_closure(annotate_code, bytecode::MakeFunctionFlags::new())?; + self.make_closure(annotate_code, MakeFunctionFlags::new())?; // Store as __annotate_func__ for classes, __annotate__ for modules let name = if parent_scope_type == CompilerScope::Class { @@ -4737,10 +4709,10 @@ impl Compiler { if is_generic { // Count args to pass to type params scope - if funcflags.contains(&bytecode::MakeFunctionFlag::Defaults) { + if funcflags.contains(&MakeFunctionFlag::Defaults) { num_typeparam_args += 1; } - if funcflags.contains(&bytecode::MakeFunctionFlag::KwOnlyDefaults) { + if funcflags.contains(&MakeFunctionFlag::KwOnlyDefaults) { num_typeparam_args += 1; } if num_typeparam_args == 2 { @@ -4750,7 +4722,7 @@ impl Compiler { // Enter type params scope let type_params_name = format!(""); self.push_output( - bytecode::CodeFlags::OPTIMIZED | bytecode::CodeFlags::NEWLOCALS, + CodeFlags::OPTIMIZED | CodeFlags::NEWLOCALS, 0, num_typeparam_args, 0, @@ -4767,13 +4739,13 @@ impl Compiler { // Add parameter names to varnames for the type params scope // These will be passed as arguments when the closure is called let current_info = self.current_code_info(); - if funcflags.contains(&bytecode::MakeFunctionFlag::Defaults) { + if funcflags.contains(&MakeFunctionFlag::Defaults) { current_info .metadata .varnames .insert(".defaults".to_owned()); } - if funcflags.contains(&bytecode::MakeFunctionFlag::KwOnlyDefaults) { + if funcflags.contains(&MakeFunctionFlag::KwOnlyDefaults) { current_info .metadata .varnames @@ -4791,9 +4763,9 @@ impl Compiler { } // Compile annotations as closure (PEP 649) - let mut annotations_flag = bytecode::MakeFunctionFlags::new(); + let mut annotations_flag = MakeFunctionFlags::new(); if self.compile_annotations_closure(name, parameters, returns, def_source_range)? { - annotations_flag.insert(bytecode::MakeFunctionFlag::Annotate); + annotations_flag.insert(MakeFunctionFlag::Annotate); } // Compile function body @@ -4834,7 +4806,7 @@ impl Compiler { self.ctx = saved_ctx; // Make closure for type params code - self.make_closure(type_params_code, bytecode::MakeFunctionFlags::new())?; + self.make_closure(type_params_code, MakeFunctionFlags::new())?; if num_typeparam_args > 0 { emit!( @@ -4873,31 +4845,36 @@ impl Compiler { /// Determines if a variable should be CELL or FREE type // = get_ref_type fn get_ref_type(&self, name: &str) -> Result { - let table = self.symbol_table_stack.last().unwrap(); + let table = self.current_symbol_table(); // Special handling for __class__, __classdict__, and __conditional_annotations__ in class scope // This should only apply when we're actually IN a class body, // not when we're in a method nested inside a class. if table.typ == CompilerScope::Class - && (name == "__class__" - || name == "__classdict__" - || name == "__conditional_annotations__") + && matches!( + name, + "__class__" | "__classdict__" | "__conditional_annotations__" + ) { return Ok(SymbolScope::Cell); } - match table.lookup(name) { - Some(symbol) => match symbol.scope { - SymbolScope::Cell => Ok(SymbolScope::Cell), - SymbolScope::Free => Ok(SymbolScope::Free), - _ if symbol.flags.contains(SymbolFlags::FREE_CLASS) => Ok(SymbolScope::Free), - _ => Err(CodegenErrorType::SyntaxError(format!( - "get_ref_type: invalid scope for '{name}'" - ))), - }, - None => Err(CodegenErrorType::SyntaxError(format!( + + let Some(symbol) = table.lookup(name) else { + return Err(CodegenErrorType::SyntaxError(format!( "get_ref_type: cannot find symbol '{name}'" - ))), - } + ))); + }; + + Ok(match symbol.scope { + SymbolScope::Cell => SymbolScope::Cell, + SymbolScope::Free => SymbolScope::Free, + _ if symbol.flags.contains(SymbolFlags::FREE_CLASS) => SymbolScope::Free, + _ => { + return Err(CodegenErrorType::SyntaxError(format!( + "get_ref_type: invalid scope for '{name}'" + ))); + } + }) } /// Loads closure variables if needed and creates a function object @@ -4990,57 +4967,57 @@ impl Compiler { emit!( self, Instruction::SetFunctionAttribute { - flag: bytecode::MakeFunctionFlag::Closure + flag: MakeFunctionFlag::Closure } ); } // Set annotations if present - if flags.contains(&bytecode::MakeFunctionFlag::Annotations) { + if flags.contains(&MakeFunctionFlag::Annotations) { emit!( self, Instruction::SetFunctionAttribute { - flag: bytecode::MakeFunctionFlag::Annotations + flag: MakeFunctionFlag::Annotations } ); } // Set __annotate__ closure if present (PEP 649) - if flags.contains(&bytecode::MakeFunctionFlag::Annotate) { + if flags.contains(&MakeFunctionFlag::Annotate) { emit!( self, Instruction::SetFunctionAttribute { - flag: bytecode::MakeFunctionFlag::Annotate + flag: MakeFunctionFlag::Annotate } ); } // Set kwdefaults if present - if flags.contains(&bytecode::MakeFunctionFlag::KwOnlyDefaults) { + if flags.contains(&MakeFunctionFlag::KwOnlyDefaults) { emit!( self, Instruction::SetFunctionAttribute { - flag: bytecode::MakeFunctionFlag::KwOnlyDefaults + flag: MakeFunctionFlag::KwOnlyDefaults } ); } // Set defaults if present - if flags.contains(&bytecode::MakeFunctionFlag::Defaults) { + if flags.contains(&MakeFunctionFlag::Defaults) { emit!( self, Instruction::SetFunctionAttribute { - flag: bytecode::MakeFunctionFlag::Defaults + flag: MakeFunctionFlag::Defaults } ); } // Set type_params if present - if flags.contains(&bytecode::MakeFunctionFlag::TypeParams) { + if flags.contains(&MakeFunctionFlag::TypeParams) { emit!( self, Instruction::SetFunctionAttribute { - flag: bytecode::MakeFunctionFlag::TypeParams + flag: MakeFunctionFlag::TypeParams } ); } @@ -5302,7 +5279,7 @@ impl Compiler { if is_generic { let type_params_name = format!(""); self.push_output( - bytecode::CodeFlags::OPTIMIZED | bytecode::CodeFlags::NEWLOCALS, + CodeFlags::OPTIMIZED | CodeFlags::NEWLOCALS, 0, 0, 0, @@ -5345,7 +5322,7 @@ impl Compiler { // Create the class body function with the .type_params closure // captured through the class code object's freevars. - self.make_closure(class_code, bytecode::MakeFunctionFlags::new())?; + self.make_closure(class_code, MakeFunctionFlags::new())?; self.emit_load_const(ConstantData::Str { value: name.into() }); // Create .generic_base after the class function and name are on the @@ -5496,7 +5473,7 @@ impl Compiler { // Execute the type params function self.set_source_range(class_source_range); - self.make_closure(type_params_code, bytecode::MakeFunctionFlags::new())?; + self.make_closure(type_params_code, MakeFunctionFlags::new())?; self.set_source_range(class_source_range); emit!(self, Instruction::PushNull); self.set_source_range(class_source_range); @@ -5507,7 +5484,7 @@ impl Compiler { emit!(self, Instruction::PushNull); // Create class function with closure - self.make_closure(class_code, bytecode::MakeFunctionFlags::new())?; + self.make_closure(class_code, MakeFunctionFlags::new())?; self.emit_load_const(ConstantData::Str { value: name.into() }); if let Some(arguments) = arguments { @@ -8225,12 +8202,12 @@ impl Compiler { } self.enter_function(&name, params)?; - let mut func_flags = bytecode::MakeFunctionFlags::new(); + let mut func_flags = MakeFunctionFlags::new(); if have_defaults { - func_flags.insert(bytecode::MakeFunctionFlag::Defaults); + func_flags.insert(MakeFunctionFlag::Defaults); } if have_kwdefaults { - func_flags.insert(bytecode::MakeFunctionFlag::KwOnlyDefaults); + func_flags.insert(MakeFunctionFlag::KwOnlyDefaults); } // Set qualname for lambda @@ -8270,12 +8247,7 @@ impl Compiler { }) => { self.compile_comprehension( "", - Some( - Instruction::BuildList { - count: OpArgMarker::marker(), - } - .into(), - ), + Some(Opcode::BuildList.into()), generators, &|compiler, collection_add_i| { compiler.compile_comprehension_element(elt)?; @@ -8303,12 +8275,7 @@ impl Compiler { }) => { self.compile_comprehension( "", - Some( - Instruction::BuildSet { - count: OpArgMarker::marker(), - } - .into(), - ), + Some(Opcode::BuildSet.into()), generators, &|compiler, collection_add_i| { compiler.compile_comprehension_element(elt)?; @@ -8337,12 +8304,7 @@ impl Compiler { }) => { self.compile_comprehension( "", - Some( - Instruction::BuildMap { - count: OpArgMarker::marker(), - } - .into(), - ), + Some(Opcode::BuildMap.into()), generators, &|compiler, collection_add_i| { // changed evaluation order for Py38 named expression PEP 572 @@ -9309,9 +9271,7 @@ impl Compiler { if let Some(info) = self.code_stack.last_mut() { info.flags = flags | (info.flags - & (bytecode::CodeFlags::NESTED - | bytecode::CodeFlags::METHOD - | bytecode::CodeFlags::FUTURE_ANNOTATIONS)); + & (CodeFlags::NESTED | CodeFlags::METHOD | CodeFlags::FUTURE_ANNOTATIONS)); info.metadata.argcount = arg_count; info.metadata.posonlyargcount = posonlyarg_count; info.metadata.kwonlyargcount = kwonlyarg_count; @@ -9395,9 +9355,9 @@ impl Compiler { in_async_scope: prev_ctx.in_async_scope || is_async, }; - let flags = bytecode::CodeFlags::NEWLOCALS | bytecode::CodeFlags::OPTIMIZED; + let flags = CodeFlags::NEWLOCALS | CodeFlags::OPTIMIZED; let flags = if is_async { - flags | bytecode::CodeFlags::COROUTINE + flags | CodeFlags::COROUTINE } else { flags }; @@ -9593,7 +9553,7 @@ impl Compiler { // Create comprehension function with closure self.set_source_range(comprehension_range); - self.make_closure(code, bytecode::MakeFunctionFlags::new())?; + self.make_closure(code, MakeFunctionFlags::new())?; // Evaluate iterated item and get its iterator. self.compile_comprehension_iter(outermost)?; @@ -10092,18 +10052,22 @@ impl Compiler { } let instr = instr.into(); let opcode = AnyOpcode::from(instr); + debug_assert!( !instr.is_assembler(), "CPython codegen_addop_* must not emit assembler-only opcodes" ); + debug_assert!( opcode.has_arg() || instr.has_target() || u32::from(arg) == 0, "CPython _PyInstructionSequence_Addop requires either OPCODE_HAS_ARG, HAS_TARGET, or oparg == 0" ); + debug_assert!( target == BlockIdx::NULL || instr.has_target(), "CPython codegen_addop_j only accepts HAS_TARGET opcodes" ); + let range = self.current_source_range; let source = self.source_file.to_source_code(); let location = source.source_location(range.start(), PositionEncoding::Utf8); @@ -10187,6 +10151,7 @@ impl Compiler { .blocks .first_mut() .expect("code unit must have an entry block"); + debug_assert!( entry .used_instructions() @@ -10200,14 +10165,11 @@ impl Compiler { }), "scope entry must start with a function-start RESUME" ); + debug_assert!( !entry.used_instructions().iter().any(|info| matches!( - info.instr.real(), - Some( - Instruction::ReturnGenerator - | Instruction::MakeCell { .. } - | Instruction::CopyFreeVars { .. } - ) + info.instr.real_opcode(), + Some(Opcode::ReturnGenerator | Opcode::MakeCell | Opcode::CopyFreeVars) )), "CPython inserts StopIteration cleanup before CFG prefix instructions" ); @@ -10555,7 +10517,7 @@ impl Compiler { } (ast::UnaryOp::Not, ConstantData::Tuple { .. }) => return Ok(None), (ast::UnaryOp::Not, value) => ConstantData::Boolean { - value: !Self::constant_truthiness(&value), + value: !value.truthiness(), }, _ => return Ok(None), } @@ -10575,9 +10537,9 @@ impl Compiler { let mut selected = first; match op { ast::BoolOp::Or => { - if !Self::constant_truthiness(&selected) { + if !selected.truthiness() { for constant in iter { - let is_truthy = Self::constant_truthiness(&constant); + let is_truthy = constant.truthiness(); selected = constant; if is_truthy { break; @@ -10586,9 +10548,9 @@ impl Compiler { } } ast::BoolOp::And => { - if Self::constant_truthiness(&selected) { + if selected.truthiness() { for constant in iter { - let is_truthy = Self::constant_truthiness(&constant); + let is_truthy = constant.truthiness(); selected = constant; if !is_truthy { break; @@ -10914,12 +10876,17 @@ impl Compiler { _ => {} } } + if !found_loop { - if is_break { - return Err(self.error_ranged(CodegenErrorType::InvalidBreak, range)); - } - return Err(self.error_ranged(CodegenErrorType::InvalidContinue, range)); + let err_type = if is_break { + CodegenErrorType::InvalidBreak + } else { + CodegenErrorType::InvalidContinue + }; + + return Err(self.error_ranged(err_type, range)); } + return Ok(()); } @@ -10956,12 +10923,7 @@ impl Compiler { } else { self.set_source_range(range); }; - self.emit_jump_label( - PseudoInstruction::Jump { - delta: OpArgMarker::marker(), - }, - target_label, - ); + self.emit_jump_label(PseudoOpcode::Jump, target_label); if unwind_loc.is_none() { self.set_no_location(); } @@ -11191,10 +11153,10 @@ impl Compiler { let is_async = self.ctx.func == FunctionContext::AsyncFunction; let flags = &mut self.current_code_info().flags; if is_async { - flags.remove(bytecode::CodeFlags::COROUTINE); - flags.insert(bytecode::CodeFlags::ASYNC_GENERATOR); + flags.remove(CodeFlags::COROUTINE); + flags.insert(CodeFlags::ASYNC_GENERATOR); } else { - flags.insert(bytecode::CodeFlags::GENERATOR); + flags.insert(CodeFlags::GENERATOR); } } @@ -12238,10 +12200,8 @@ mod tests { fn assert_scope_exit_locations(code: &CodeObject) { for (instr, (location, _)) in code.instructions.iter().zip(code.locations.iter()) { if matches!( - instr.op, - Instruction::ReturnValue - | Instruction::RaiseVarargs { .. } - | Instruction::Reraise { .. } + instr.op.into(), + Opcode::ReturnValue | Opcode::RaiseVarargs | Opcode::Reraise ) { assert!( location.line.get() > 0, @@ -12439,7 +12399,7 @@ def f(x, y, z): compiler .current_code_info() .flags - .set(bytecode::CodeFlags::COROUTINE, is_async); + .set(CodeFlags::COROUTINE, is_async); let prev_ctx = compiler.ctx; compiler.ctx = CompileContext { @@ -14757,13 +14717,13 @@ def f(): for code in [method, async_method, lambda, genexpr] { assert!( - code.flags.contains(bytecode::CodeFlags::METHOD), + code.flags.contains(CodeFlags::METHOD), "class-scope function-like code should carry CO_METHOD like CPython 3.14, got {:?}", code.flags ); } assert!( - !module_function.flags.contains(bytecode::CodeFlags::METHOD), + !module_function.flags.contains(CodeFlags::METHOD), "module-scope function must not carry CO_METHOD" ); } @@ -14782,11 +14742,11 @@ class C: let class_code = find_code(&code, "C").expect("missing class code"); let lambda = find_code(class_code, "").expect("missing lambda code"); assert!( - lambda.flags.contains(bytecode::CodeFlags::NESTED), + lambda.flags.contains(CodeFlags::NESTED), "lambda under inlined class comprehension should stay nested" ); assert!( - !lambda.flags.contains(bytecode::CodeFlags::METHOD), + !lambda.flags.contains(CodeFlags::METHOD), "CPython creates this lambda while the current symtable block is the comprehension, not the class" ); } @@ -14822,37 +14782,17 @@ async def ag(): let coroutine = find_code(&code, "c").expect("missing coroutine code"); let async_generator = find_code(&code, "ag").expect("missing async generator code"); - assert!(generator.flags.contains(bytecode::CodeFlags::GENERATOR)); - assert!(!generator.flags.contains(bytecode::CodeFlags::COROUTINE)); - assert!( - !generator - .flags - .contains(bytecode::CodeFlags::ASYNC_GENERATOR) - ); + assert!(generator.flags.contains(CodeFlags::GENERATOR)); + assert!(!generator.flags.contains(CodeFlags::COROUTINE)); + assert!(!generator.flags.contains(CodeFlags::ASYNC_GENERATOR)); - assert!(coroutine.flags.contains(bytecode::CodeFlags::COROUTINE)); - assert!(!coroutine.flags.contains(bytecode::CodeFlags::GENERATOR)); - assert!( - !coroutine - .flags - .contains(bytecode::CodeFlags::ASYNC_GENERATOR) - ); + assert!(coroutine.flags.contains(CodeFlags::COROUTINE)); + assert!(!coroutine.flags.contains(CodeFlags::GENERATOR)); + assert!(!coroutine.flags.contains(CodeFlags::ASYNC_GENERATOR)); - assert!( - async_generator - .flags - .contains(bytecode::CodeFlags::ASYNC_GENERATOR) - ); - assert!( - !async_generator - .flags - .contains(bytecode::CodeFlags::GENERATOR) - ); - assert!( - !async_generator - .flags - .contains(bytecode::CodeFlags::COROUTINE) - ); + assert!(async_generator.flags.contains(CodeFlags::ASYNC_GENERATOR)); + assert!(!async_generator.flags.contains(CodeFlags::GENERATOR)); + assert!(!async_generator.flags.contains(CodeFlags::COROUTINE)); } #[test] @@ -24093,15 +24033,11 @@ def f(): return C ", ); - assert!(code.flags.contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS)); + assert!(code.flags.contains(CodeFlags::FUTURE_ANNOTATIONS)); let f = find_code(&code, "f").expect("missing f code"); - assert!(f.flags.contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS)); + assert!(f.flags.contains(CodeFlags::FUTURE_ANNOTATIONS)); let class_code = find_code(f, "C").expect("missing C code"); - assert!( - class_code - .flags - .contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS) - ); + assert!(class_code.flags.contains(CodeFlags::FUTURE_ANNOTATIONS)); } #[test] @@ -24120,7 +24056,7 @@ def outer(): let class_annotate = find_code(class_code, "__annotate__").expect("missing class annotation code"); assert!( - !class_annotate.flags.contains(bytecode::CodeFlags::NESTED), + !class_annotate.flags.contains(CodeFlags::NESTED), "module-level class annotation scope should not be nested" ); @@ -24129,7 +24065,7 @@ def outer(): let nested_annotate = find_code(nested_class, "__annotate__").expect("missing nested annotation code"); assert!( - nested_annotate.flags.contains(bytecode::CodeFlags::NESTED), + nested_annotate.flags.contains(CodeFlags::NESTED), "annotation scope under a nested class should be nested" ); } @@ -24144,25 +24080,25 @@ type A[T] = T ); let outer_lambda = find_code(&code, "").expect("missing outer lambda code"); assert!( - !outer_lambda.flags.contains(bytecode::CodeFlags::NESTED), + !outer_lambda.flags.contains(CodeFlags::NESTED), "module-level lambda should not be nested" ); let inner_lambda = find_direct_child_code(outer_lambda, "").expect("missing inner lambda code"); assert!( - inner_lambda.flags.contains(bytecode::CodeFlags::NESTED), + inner_lambda.flags.contains(CodeFlags::NESTED), "lambda inside lambda should be nested" ); let type_params = find_code(&code, "").expect("missing type params code"); assert!( - !type_params.flags.contains(bytecode::CodeFlags::NESTED), + !type_params.flags.contains(CodeFlags::NESTED), "module-level type-parameter scope should not be nested" ); let type_alias = find_direct_child_code(type_params, "A").expect("missing type alias code"); assert!( - type_alias.flags.contains(bytecode::CodeFlags::NESTED), + type_alias.flags.contains(CodeFlags::NESTED), "type alias body inside type-parameter scope should be nested" ); } diff --git a/crates/codegen/src/ir.rs b/crates/codegen/src/ir.rs index 74f55868867..58e68b48725 100644 --- a/crates/codegen/src/ir.rs +++ b/crates/codegen/src/ir.rs @@ -9,10 +9,10 @@ use rustpython_wtf8::Wtf8Buf; use rustpython_compiler_core::{ OneIndexed, SourceLocation, bytecode::{ - AnyInstruction, AnyOpcode, Arg, CO_FAST_ARG_KW, CO_FAST_ARG_POS, CO_FAST_ARG_VAR, - CO_FAST_CELL, CO_FAST_FREE, CO_FAST_HIDDEN, CO_FAST_LOCAL, CodeFlags, CodeObject, CodeUnit, - CodeUnits, ConstantData, InstrDisplayContext, Instruction, IntrinsicFunction1, OpArg, - OpArgByte, Opcode, PseudoInstruction, PseudoOpcode, PyCodeLocationInfoKind, oparg, + AnyInstruction, AnyOpcode, CO_FAST_ARG_KW, CO_FAST_ARG_POS, CO_FAST_ARG_VAR, CO_FAST_CELL, + CO_FAST_FREE, CO_FAST_HIDDEN, CO_FAST_LOCAL, CodeFlags, CodeObject, CodeUnit, CodeUnits, + ConstantData, InstrDisplayContext, Instruction, IntrinsicFunction1, OpArg, OpArgByte, + Opcode, PseudoInstruction, PseudoOpcode, PyCodeLocationInfoKind, oparg, }, varint::{write_signed_varint, write_varint}, }; @@ -832,7 +832,7 @@ fn instr_size(instr: &InstructionInfo) -> usize { } /// pycore_opcode_metadata.h is_pseudo_target -fn is_pseudo_target(pseudo: PseudoOpcode, target: Opcode) -> bool { +const fn is_pseudo_target(pseudo: PseudoOpcode, target: Opcode) -> bool { match pseudo { PseudoOpcode::LoadClosure => matches!(target, Opcode::LoadFast), PseudoOpcode::StoreFastMaybeNull => matches!(target, Opcode::StoreFast), @@ -874,16 +874,11 @@ fn resolve_unconditional_jumps( AnyInstruction::Pseudo(PseudoInstruction::Jump { .. }) => { debug_assert!(is_pseudo_target(PseudoOpcode::Jump, Opcode::JumpForward)); debug_assert!(is_pseudo_target(PseudoOpcode::Jump, Opcode::JumpBackward)); + if is_forward { - instr.instr = Instruction::JumpForward { - delta: Arg::marker(), - } - .into(); + instr.instr = Opcode::JumpForward.into(); } else { - instr.instr = Instruction::JumpBackward { - delta: Arg::marker(), - } - .into(); + instr.instr = Opcode::JumpBackward.into(); } } AnyInstruction::Pseudo(PseudoInstruction::JumpNoInterrupt { .. }) => { @@ -896,15 +891,9 @@ fn resolve_unconditional_jumps( Opcode::JumpBackwardNoInterrupt )); if is_forward { - instr.instr = Instruction::JumpForward { - delta: Arg::marker(), - } - .into(); + instr.instr = Opcode::JumpForward.into(); } else { - instr.instr = Instruction::JumpBackwardNoInterrupt { - delta: Arg::marker(), - } - .into(); + instr.instr = Opcode::JumpBackwardNoInterrupt.into(); } } _ => { @@ -1319,7 +1308,8 @@ impl Block { &self.instructions[..self.instruction_used] } - pub(crate) fn is_empty(&self) -> bool { + #[must_use] + pub(crate) const fn is_empty(&self) -> bool { self.instruction_used == 0 } } @@ -1717,10 +1707,7 @@ impl CodeInfo { &mut self.instr_sequence, 0, InstructionInfo { - instr: PseudoInstruction::SetupCleanup { - delta: Arg::marker(), - } - .into(), + instr: PseudoOpcode::SetupCleanup.into(), arg: instruction_sequence_label_oparg(handler_label), target: BlockIdx::NULL, location: SourceLocation::default(), @@ -1851,7 +1838,6 @@ fn optimized_cfg_to_instruction_sequence( } impl CodeInfo { - #[allow(clippy::needless_range_loop)] pub fn finalize_code( mut self, opts: &crate::compile::CompileOpts, @@ -2016,7 +2002,7 @@ fn insert_prefix_instructions( entry, ncellsused, InstructionInfo { - instr: Instruction::MakeCell { i: Arg::marker() }.into(), + instr: Opcode::MakeCell.into(), arg: OpArg::new(oldindex as u32), target: BlockIdx::NULL, location: SourceLocation::default(), @@ -2034,7 +2020,7 @@ fn insert_prefix_instructions( entry, 0, InstructionInfo { - instr: Instruction::CopyFreeVars { n: Arg::marker() }.into(), + instr: Opcode::CopyFreeVars.into(), arg: OpArg::new(nfreevars as u32), target: BlockIdx::NULL, location: SourceLocation::default(), @@ -2153,7 +2139,7 @@ fn eval_const_unaryop( } (ConstantData::Boolean { .. }, Instruction::UnaryInvert, None) => None, (_, Instruction::UnaryNot, None) => Some(ConstantData::Boolean { - value: !constant_truthiness(operand), + value: !operand.truthiness(), }), ( ConstantData::Integer { value }, @@ -2183,22 +2169,6 @@ fn eval_const_unaryop( } } -fn constant_truthiness(constant: &ConstantData) -> bool { - match constant { - ConstantData::Tuple { elements } | ConstantData::Frozenset { elements } => { - !elements.is_empty() - } - ConstantData::Integer { value } => !value.is_zero(), - ConstantData::Float { value } => *value != 0.0, - ConstantData::Complex { value } => value.re != 0.0 || value.im != 0.0, - ConstantData::Boolean { value } => *value, - ConstantData::Str { value } => !value.is_empty(), - ConstantData::Bytes { value } => !value.is_empty(), - ConstantData::Code { .. } | ConstantData::Slice { .. } | ConstantData::Ellipsis => true, - ConstantData::None => false, - } -} - fn load_const_truthiness( instr: Instruction, arg: OpArg, @@ -2207,7 +2177,7 @@ fn load_const_truthiness( match instr { Instruction::LoadConst { consti } => { let constant = &metadata.consts[consti.get(arg).as_usize()]; - Some(constant_truthiness(constant)) + Some(constant.truthiness()) } Instruction::LoadSmallInt { i } => Some(i.get(arg) != 0), _ => None, @@ -2234,10 +2204,7 @@ fn instr_make_load_const( let const_idx = add_const(metadata, constant)?; instr_set_op1( instr, - Instruction::LoadConst { - consti: Arg::marker(), - } - .into(), + Opcode::LoadConst.into(), OpArg::new(const_idx as u32), ); Ok(()) @@ -2260,12 +2227,7 @@ fn fold_const_unaryop( oparg::IntrinsicFunction1::UnaryPositive ) => { - ( - Instruction::CallIntrinsic1 { - func: Arg::marker(), - }, - Some(func.get(instr.arg)), - ) + (Opcode::CallIntrinsic1.into(), Some(func.get(instr.arg))) } _ => return Ok(false), }; @@ -2337,9 +2299,10 @@ fn fold_const_binop( ) -> crate::InternalResult { use oparg::BinaryOperator as BinOp; - let Some(Instruction::BinaryOp { .. }) = block.instructions[i].instr.real() else { + let Some(Opcode::BinaryOp) = block.instructions[i].instr.real_opcode() else { return Ok(false); }; + let Some(operand_indices) = (if let Some(start) = i.checked_sub(1) { get_const_loading_instrs(block, start, 2)? } else { @@ -2347,18 +2310,22 @@ fn fold_const_binop( }) else { return Ok(false); }; + let op_raw = u32::from(block.instructions[i].arg); let Ok(op) = BinOp::try_from(op_raw) else { return Ok(false); }; + let left = get_const_value(metadata, &block.instructions[operand_indices[0]]); let right = get_const_value(metadata, &block.instructions[operand_indices[1]]); let (Some(left_val), Some(right_val)) = (left, right) else { return Ok(false); }; + let Some(result_const) = eval_const_binop(&left_val, &right_val, op) else { return Ok(false); }; + nop_out(block, &operand_indices); instr_make_load_const(metadata, &mut block.instructions[i], result_const)?; Ok(true) @@ -2366,13 +2333,13 @@ fn fold_const_binop( /// flowgraph.c loads_const fn loads_const(info: &InstructionInfo) -> bool { - info.instr.has_const() || matches!(info.instr.real(), Some(Instruction::LoadSmallInt { .. })) + info.instr.has_const() || matches!(info.instr.real_opcode(), Some(Opcode::LoadSmallInt)) } /// flowgraph.c get_const_value fn get_const_value(metadata: &CodeUnitMetadata, info: &InstructionInfo) -> Option { - match info.instr.real() { - Some(Instruction::LoadSmallInt { .. }) => { + match info.instr.real_opcode() { + Some(Opcode::LoadSmallInt) => { let v = u32::from(info.arg) as i32; Some(ConstantData::Integer { value: BigInt::from(v), @@ -3059,7 +3026,7 @@ fn fold_tuple_of_constants( block: &mut Block, i: usize, ) -> crate::InternalResult { - let Some(Instruction::BuildTuple { .. }) = block.instructions[i].instr.real() else { + let Some(Opcode::BuildTuple) = block.instructions[i].instr.real_opcode() else { return Ok(false); }; @@ -3237,16 +3204,11 @@ fn optimize_lists_and_sets( nop_out(block, &operand_indices); let build_instr = if is_list { - Instruction::BuildList { - count: Arg::marker(), - } - .into() + Opcode::BuildList } else { - Instruction::BuildSet { - count: Arg::marker(), - } - .into() - }; + Opcode::BuildSet + } + .into(); instr_set_op1(&mut block.instructions[i - 2], build_instr, OpArg::new(0)); block.instructions[i - 2].location = folded_loc; block.instructions[i - 2].end_location = end_loc; @@ -3254,10 +3216,7 @@ fn optimize_lists_and_sets( instr_set_op1( &mut block.instructions[i - 1], - Instruction::LoadConst { - consti: Arg::marker(), - } - .into(), + Opcode::LoadConst.into(), OpArg::new(const_idx as u32), ); @@ -3278,10 +3237,7 @@ fn optimize_lists_and_sets( instr_set_op1( &mut block.instructions[i], - Instruction::LoadConst { - consti: Arg::marker(), - } - .into(), + Opcode::LoadConst.into(), OpArg::new(const_idx as u32), ); Ok(true) @@ -3338,21 +3294,21 @@ fn next_swappable_instruction(block: &Block, mut i: usize, lineno: i32) -> Optio /// flowgraph.c swaptimize fn swaptimize(block: &mut Block, ix: &mut usize) -> crate::InternalResult<()> { debug_assert!(matches!( - block.instructions[*ix].instr.real(), - Some(Instruction::Swap { .. }) + block.instructions[*ix].instr.real_opcode(), + Some(Opcode::Swap) )); let mut depth = u32::from(block.instructions[*ix].arg) as usize; let mut len = 1usize; let mut more = false; let limit = block.instruction_used - *ix; while len < limit { - match block.instructions[*ix + len].instr.real() { - Some(Instruction::Swap { .. }) => { + match block.instructions[*ix + len].instr.real_opcode() { + Some(Opcode::Swap) => { depth = depth.max(u32::from(block.instructions[*ix + len].arg) as usize); more = true; len += 1; } - Some(Instruction::Nop) => { + Some(Opcode::Nop) => { len += 1; } _ => break, @@ -3377,7 +3333,7 @@ fn swaptimize(block: &mut Block, ix: &mut usize) -> crate::InternalResult<()> { i = 0; while i < len { let info = &block.instructions[*ix + i]; - if matches!(info.instr.real(), Some(Instruction::Swap { .. })) { + if matches!(info.instr.real_opcode(), Some(Opcode::Swap)) { let oparg = u32::from(info.arg) as usize; stack.swap(0, oparg - 1); } @@ -3421,15 +3377,15 @@ fn apply_static_swaps(block: &mut Block, mut i: isize) { while i >= 0 { let idx = i as usize; debug_assert!(idx < block.instruction_used); - let swap_arg = match block.instructions[idx].instr.real() { - Some(Instruction::Swap { .. }) => u32::from(block.instructions[idx].arg), - Some(Instruction::Nop | Instruction::PopTop | Instruction::StoreFast { .. }) => { + let swap_arg = match block.instructions[idx].instr.real_opcode() { + Some(Opcode::Swap) => u32::from(block.instructions[idx].arg), + Some(Opcode::Nop | Opcode::PopTop | Opcode::StoreFast) => { i -= 1; continue; } _ if matches!( - block.instructions[idx].instr.pseudo(), - Some(PseudoInstruction::StoreFastMaybeNull { .. }) + block.instructions[idx].instr.pseudo_opcode(), + Some(PseudoOpcode::StoreFastMaybeNull) ) => { i -= 1; @@ -3477,8 +3433,8 @@ fn apply_static_swaps_block(block: &mut Block) -> crate::InternalResult<()> { let mut i = 0; while i < block.instruction_used { if matches!( - block.instructions[i].instr.real(), - Some(Instruction::Swap { .. }) + block.instructions[i].instr.real_opcode(), + Some(Opcode::Swap) ) { swaptimize(block, &mut i)?; apply_static_swaps(block, i as isize); @@ -3548,11 +3504,11 @@ fn basicblock_optimize_load_const( let next_arg = next.arg; if let Some(is_true) = load_const_truthiness(const_instr, const_arg, metadata) { - let const_jump = match (next.instr.real(), next.instr.pseudo()) { - (_, Some(PseudoInstruction::JumpIfTrue { .. })) => Some((true, false)), - (_, Some(PseudoInstruction::JumpIfFalse { .. })) => Some((false, false)), - (Some(Instruction::PopJumpIfTrue { .. }), _) => Some((true, true)), - (Some(Instruction::PopJumpIfFalse { .. }), _) => Some((false, true)), + let const_jump = match (next.instr.real_opcode(), next.instr.pseudo_opcode()) { + (_, Some(PseudoOpcode::JumpIfTrue)) => Some((true, false)), + (_, Some(PseudoOpcode::JumpIfFalse)) => Some((false, false)), + (Some(Opcode::PopJumpIfTrue), _) => Some((true, true)), + (Some(Opcode::PopJumpIfFalse), _) => Some((false, true)), _ => None, }; if let Some((jump_if_true, pops_condition)) = const_jump { @@ -3560,10 +3516,7 @@ fn basicblock_optimize_load_const( set_to_nop(&mut block.instructions[i]); } if is_true == jump_if_true { - block.instructions[i + 1].instr = PseudoInstruction::Jump { - delta: Arg::marker(), - } - .into(); + block.instructions[i + 1].instr = PseudoOpcode::Jump.into(); } else { set_to_nop(&mut block.instructions[i + 1]); } @@ -3624,13 +3577,9 @@ fn basicblock_optimize_load_const( set_to_nop(&mut block.instructions[i]); set_to_nop(&mut block.instructions[i + 1]); block.instructions[jump_idx].instr = if invert { - Instruction::PopJumpIfNotNone { - delta: Arg::marker(), - } + Opcode::PopJumpIfNotNone } else { - Instruction::PopJumpIfNone { - delta: Arg::marker(), - } + Opcode::PopJumpIfNone } .into(); i = jump_idx; @@ -3648,10 +3597,7 @@ fn basicblock_optimize_load_const( set_to_nop(&mut block.instructions[i]); instr_set_op1( &mut block.instructions[i + 1], - Instruction::LoadConst { - consti: Arg::marker(), - } - .into(), + Opcode::LoadConst.into(), OpArg::new(const_idx as u32), ); i += 1; @@ -3729,8 +3675,7 @@ fn optimize_basic_block( } 2 | 3 => { set_to_nop(&mut blocks[bi].instructions[i]); - blocks[bi].instructions[i + 1].instr = - Instruction::Swap { i: Arg::marker() }.into(); + blocks[bi].instructions[i + 1].instr = Opcode::Swap.into(); i += 1; continue; } @@ -3925,7 +3870,6 @@ fn optimize_basic_block( } /// flowgraph.c remove_redundant_nops_and_pairs -#[allow(clippy::if_same_then_else, clippy::useless_let_if_seq)] #[allow(clippy::unnecessary_wraps)] fn remove_redundant_nops_and_pairs(blocks: &mut [Block]) -> crate::InternalResult<()> { let mut done = false; @@ -3947,29 +3891,21 @@ fn remove_redundant_nops_and_pairs(blocks: &mut [Block]) -> crate::InternalResul instr = Some((block_idx, instr_idx)); let instr_info = blocks[block_idx.idx()].instructions[instr_idx]; let mut prev_opcode = None; - let mut prev_oparg = 0; - if let Some((prev_block, prev_instr_idx)) = prev_instr { + let prev_oparg = if let Some((prev_block, prev_instr_idx)) = prev_instr { let prev_info = blocks[prev_block.idx()].instructions[prev_instr_idx]; - prev_opcode = prev_info.instr.real(); - prev_oparg = match prev_info.instr.real() { + prev_opcode = prev_info.instr.real_opcode(); + match prev_info.instr.real() { Some(Instruction::Copy { i }) => i.get(prev_info.arg), _ => u32::from(prev_info.arg), - }; - } - let opcode = instr_info.instr.real(); - let mut is_redundant_pair = false; - if matches!(opcode, Some(Instruction::PopTop)) { - if matches!( - prev_opcode, - Some(Instruction::LoadConst { .. } | Instruction::LoadSmallInt { .. }) - ) { - is_redundant_pair = true; - } else if matches!(prev_opcode, Some(Instruction::Copy { .. })) - && prev_oparg == 1 - { - is_redundant_pair = true; } - } + } else { + 0 + }; + + let opcode = instr_info.instr.real_opcode(); + let is_redundant_pair = matches!(opcode, Some(Opcode::PopTop)) + && (matches!(prev_opcode, Some(Opcode::LoadConst | Opcode::LoadSmallInt)) + || (prev_oparg == 1 && matches!(prev_opcode, Some(Opcode::Copy)))); if is_redundant_pair { let (prev_block, prev_instr_idx) = @@ -3980,10 +3916,10 @@ fn remove_redundant_nops_and_pairs(blocks: &mut [Block]) -> crate::InternalResul } } - let mut instr_is_jump = false; - if let Some((instr_block, instr_idx)) = instr { - instr_is_jump = is_jump(&blocks[instr_block.idx()].instructions[instr_idx]); - } + let instr_is_jump = instr.is_some_and(|(instr_block, instr_idx)| { + is_jump(&blocks[instr_block.idx()].instructions[instr_idx]) + }); + let block = &blocks[block_idx.idx()]; if instr_is_jump || !bb_has_fallthrough(block) { instr = None; @@ -4327,18 +4263,13 @@ fn optimize_load_fast(blocks: &mut [Block]) -> crate::InternalResult<()> { i += 1; continue; } - match info.instr.real() { - Some(Instruction::LoadFast { .. }) => { - info.instr = Instruction::LoadFastBorrow { - var_num: Arg::marker(), - } - .into(); + + match info.instr.real_opcode() { + Some(Opcode::LoadFast) => { + info.instr = Opcode::LoadFastBorrow.into(); } - Some(Instruction::LoadFastLoadFast { .. }) => { - info.instr = Instruction::LoadFastBorrowLoadFastBorrow { - var_nums: Arg::marker(), - } - .into(); + Some(Opcode::LoadFastLoadFast) => { + info.instr = Opcode::LoadFastBorrowLoadFastBorrow.into(); } _ => {} } @@ -4621,10 +4552,7 @@ fn insert_superinstructions(blocks: &mut [Block]) -> crate::InternalResult crate::InternalResult { @@ -4645,10 +4570,7 @@ fn insert_superinstructions(blocks: &mut [Block]) -> crate::InternalResult {} @@ -4665,6 +4587,7 @@ fn insert_superinstructions(blocks: &mut [Block]) -> crate::InternalResult crate::InternalResu let instr = block.instructions[i]; let opcode = instr.instr; if matches!( - opcode.pseudo(), - Some(PseudoInstruction::JumpIfFalse { .. } | PseudoInstruction::JumpIfTrue { .. }) + opcode.pseudo_opcode(), + Some(PseudoOpcode::JumpIfFalse | PseudoOpcode::JumpIfTrue) ) { debug_assert_eq!(i, block.instruction_used - 1); block.instructions[i].instr = - if matches!(opcode.pseudo(), Some(PseudoInstruction::JumpIfFalse { .. })) { - Instruction::PopJumpIfFalse { - delta: Arg::marker(), - } - .into() + if matches!(opcode.pseudo_opcode(), Some(PseudoOpcode::JumpIfFalse)) { + Opcode::PopJumpIfFalse } else { - Instruction::PopJumpIfTrue { - delta: Arg::marker(), - } - .into() - }; + Opcode::PopJumpIfTrue + } + .into(); let location = instr.location; let end_location = instr.end_location; let except_handler = instr.except_handler; let lineno_override = instr.lineno_override; let copy = InstructionInfo { - instr: Instruction::Copy { i: Arg::marker() }.into(), + instr: Opcode::Copy.into(), arg: OpArg::new(1), target: BlockIdx::NULL, location, @@ -5502,7 +5421,7 @@ fn convert_pseudo_conditional_jumps(blocks: &mut [Block]) -> crate::InternalResu i += 1; let to_bool = InstructionInfo { - instr: Instruction::ToBool.into(), + instr: Opcode::ToBool.into(), arg: OpArg::new(0), target: BlockIdx::NULL, location, @@ -5552,7 +5471,7 @@ fn normalize_jumps_in_block( return Ok(()); } - let reversed_opcode = match AnyOpcode::from(last_ins.instr).real() { + let reversed_opcode = match last_ins.instr.real_opcode() { Some(Opcode::PopJumpIfNotNone) => Opcode::PopJumpIfNone.into(), Some(Opcode::PopJumpIfNone) => Opcode::PopJumpIfNotNone.into(), Some(Opcode::PopJumpIfFalse) => Opcode::PopJumpIfTrue.into(), @@ -5770,10 +5689,7 @@ fn remove_redundant_nops(blocks: &mut [Block]) -> crate::InternalResult { /// flowgraph.c no_redundant_nops #[cfg(debug_assertions)] fn no_redundant_nops(blocks: &mut [Block]) -> bool { - match remove_redundant_nops(blocks) { - Ok(0) => true, - Ok(_) | Err(_) => false, - } + matches!(remove_redundant_nops(blocks), Ok(0)) } /// flowgraph.c remove_redundant_jumps @@ -6745,10 +6661,7 @@ pub(crate) fn convert_pseudo_ops(blocks: &mut [Block]) -> crate::InternalResult< PseudoOpcode::StoreFastMaybeNull, Opcode::StoreFast )); - info.instr = Instruction::StoreFast { - var_num: Arg::marker(), - } - .into(); + info.instr = Opcode::StoreFast.into(); } } block_idx = next; @@ -6972,10 +6885,7 @@ mod tests { assert!(except_stack_top(&stack, &blocks).is_none()); let setup = InstructionInfo { - instr: PseudoInstruction::SetupWith { - delta: Arg::marker(), - } - .into(), + instr: PseudoOpcode::SetupWith.into(), arg: OpArg::new(0), target: BlockIdx::new(1), location: SourceLocation::default(), @@ -7294,14 +7204,9 @@ mod tests { #[test] fn static_swaps_respect_cpython_no_location_line_boundary() { let mut block = Block::default(); - let mut swap = test_instr(Instruction::Swap { i: Arg::marker() }, 60); + let mut swap = test_instr(Opcode::Swap.into(), 60); swap.arg = OpArg::new(2); - let mut store = test_instr( - Instruction::StoreFast { - var_num: Arg::marker(), - }, - 60, - ); + let mut store = test_instr(Opcode::StoreFast.into(), 60); store.arg = OpArg::new(0); let mut pop = test_instr(Instruction::PopTop, 60); pop.lineno_override = Some(NO_LOCATION_OVERRIDE); @@ -7328,14 +7233,9 @@ mod tests { )); let mut block = Block::default(); - let mut swap = test_instr(Instruction::Swap { i: Arg::marker() }, 70); + let mut swap = test_instr(Opcode::Swap.into(), 70); swap.arg = OpArg::new(2); - let mut store = test_instr( - Instruction::StoreFast { - var_num: Arg::marker(), - }, - 70, - ); + let mut store = test_instr(Opcode::StoreFast.into(), 70); store.arg = OpArg::new(0); store.lineno_override = Some(NO_LOCATION_OVERRIDE); let pop = test_instr(Instruction::PopTop, 71); @@ -7348,32 +7248,24 @@ mod tests { // Conversely, when the first swaperand has NO_LOCATION, CPython passes // `-1` as the line filter and does not enforce a boundary. assert!(matches!( - block.instructions[0].instr.real(), - Some(Instruction::Nop) + block.instructions[0].instr.real_opcode(), + Some(Opcode::Nop) )); assert!(matches!( - block.instructions[1].instr.real(), - Some(Instruction::PopTop) + block.instructions[1].instr.real_opcode(), + Some(Opcode::PopTop) )); assert!(matches!( - block.instructions[2].instr.real(), - Some(Instruction::StoreFast { .. }) + block.instructions[2].instr.real_opcode(), + Some(Opcode::StoreFast) )); } #[test] fn optimize_load_const_tracks_cpython_copy_of_load_const() { let mut block = Block::default(); - test_block_push( - &mut block, - test_instr( - Instruction::LoadConst { - consti: Arg::marker(), - }, - 80, - ), - ); - let mut copy = test_instr(Instruction::Copy { i: Arg::marker() }, 80); + test_block_push(&mut block, test_instr(Opcode::LoadConst.into(), 80)); + let mut copy = test_instr(Opcode::Copy.into(), 80); copy.arg = OpArg::new(1); test_block_push(&mut block, copy); test_block_push(&mut block, test_instr(Instruction::ToBool, 80)); @@ -7414,17 +7306,9 @@ mod tests { #[test] fn optimize_load_fast_records_no_input_opcode_ref_at_cpython_produced_index() { let mut block = Block::default(); - test_block_push( - &mut block, - test_instr( - Instruction::LoadFast { - var_num: Arg::marker(), - }, - 10, - ), - ); + test_block_push(&mut block, test_instr(Opcode::LoadFast.into(), 10)); test_block_push(&mut block, test_instr(Instruction::GetLen, 10)); - let mut swap = test_instr(Instruction::Swap { i: Arg::marker() }, 10); + let mut swap = test_instr(Opcode::Swap.into(), 10); swap.arg = OpArg::new(2); test_block_push(&mut block, swap); test_block_push(&mut block, test_instr(Instruction::PopTop, 10)); @@ -7472,12 +7356,7 @@ mod tests { let mut mortal = test_instr(Instruction::Nop, 90); mortal.instr = Opcode::LoadConstMortal.into(); mortal.arg = OpArg::new(right as u32); - let mut build = test_instr( - Instruction::BuildTuple { - count: Arg::marker(), - }, - 90, - ); + let mut build = test_instr(Opcode::BuildTuple.into(), 90); build.arg = OpArg::new(2); let mut block = Block::default(); for info in [immortal, mortal, build] { diff --git a/crates/codegen/src/string_parser.rs b/crates/codegen/src/string_parser.rs index ee6c08a5639..0b5bcfffc9c 100644 --- a/crates/codegen/src/string_parser.rs +++ b/crates/codegen/src/string_parser.rs @@ -13,6 +13,7 @@ use rustpython_wtf8::{CodePoint, Wtf8, Wtf8Buf}; // use ruff_python_parser::{LexicalError, LexicalErrorType}; type LexicalError = Infallible; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] enum EscapedChar { Literal(CodePoint), Escape(char), diff --git a/crates/compiler-core/Cargo.toml b/crates/compiler-core/Cargo.toml index 7d6d116f530..22201597d90 100644 --- a/crates/compiler-core/Cargo.toml +++ b/crates/compiler-core/Cargo.toml @@ -18,6 +18,7 @@ bitflagset = { workspace = true } itertools = { workspace = true } malachite-bigint = { workspace = true } num-complex = { workspace = true } +num-traits = { workspace = true } lz4_flex = { workspace = true } diff --git a/crates/compiler-core/src/bytecode.rs b/crates/compiler-core/src/bytecode.rs index e5ef24815a1..ee6b6e5d96c 100644 --- a/crates/compiler-core/src/bytecode.rs +++ b/crates/compiler-core/src/bytecode.rs @@ -17,6 +17,7 @@ use core::{ use itertools::Itertools; use malachite_bigint::BigInt; use num_complex::Complex64; +use num_traits::Zero; use rustpython_wtf8::{Wtf8, Wtf8Buf}; pub use crate::bytecode::{ @@ -909,6 +910,31 @@ pub enum ConstantData { Ellipsis, } +impl ConstantData { + /// Whether or not python would return True/False for the given constant data. + /// + /// ```py + /// bool(0) # False + /// bool(1) # True + /// bool([]) # False + /// bool(...) # True + /// ``` + #[must_use] + pub fn truthiness(&self) -> bool { + match self { + Self::Tuple { elements } | Self::Frozenset { elements } => !elements.is_empty(), + Self::Integer { value } => !value.is_zero(), + Self::Float { value } => *value != 0.0, + Self::Complex { value } => value.re != 0.0 || value.im != 0.0, + Self::Boolean { value } => *value, + Self::Str { value } => !value.is_empty(), + Self::Bytes { value } => !value.is_empty(), + Self::Code { .. } | Self::Slice { .. } | Self::Ellipsis => true, + Self::None => false, + } + } +} + impl PartialEq for ConstantData { fn eq(&self, other: &Self) -> bool { match (self, other) { diff --git a/crates/vm/src/function/buffer.rs b/crates/vm/src/function/buffer.rs index 028f3633d06..213193bb9c8 100644 --- a/crates/vm/src/function/buffer.rs +++ b/crates/vm/src/function/buffer.rs @@ -13,11 +13,10 @@ use crate::{ pub struct ArgBytesLike(PyBuffer); impl PyObject { - pub fn try_bytes_like( - &self, - vm: &VirtualMachine, - f: impl FnOnce(&[u8]) -> R, - ) -> PyResult { + pub fn try_bytes_like(&self, vm: &VirtualMachine, f: F) -> PyResult + where + F: FnOnce(&[u8]) -> R, + { let buffer = PyBuffer::try_from_borrowed_object(vm, self)?; buffer .as_contiguous() @@ -25,11 +24,10 @@ impl PyObject { .ok_or_else(|| vm.new_buffer_error("non-contiguous buffer is not a bytes-like object")) } - pub fn try_rw_bytes_like( - &self, - vm: &VirtualMachine, - f: impl FnOnce(&mut [u8]) -> R, - ) -> PyResult { + pub fn try_rw_bytes_like(&self, vm: &VirtualMachine, f: F) -> PyResult + where + F: FnOnce(&mut [u8]) -> R, + { let buffer = PyBuffer::try_from_borrowed_object(vm, self)?; buffer .as_contiguous_mut() @@ -207,7 +205,10 @@ impl ArgAsciiBuffer { } #[inline] - pub fn with_ref(&self, f: impl FnOnce(&[u8]) -> R) -> R { + pub fn with_ref(&self, f: F) -> R + where + F: FnOnce(&[u8]) -> R, + { match self { Self::String(s) => f(s.as_bytes()), Self::Buffer(buffer) => buffer.with_ref(f), diff --git a/crates/vm/src/function/builtin.rs b/crates/vm/src/function/builtin.rs index 4fed5e4cf23..47cb8dfcb64 100644 --- a/crates/vm/src/function/builtin.rs +++ b/crates/vm/src/function/builtin.rs @@ -11,8 +11,9 @@ pub trait PyNativeFn: Fn(&VirtualMachine, FuncArgs) -> PyResult + PyThreadingConstraint + 'static { } -impl PyResult + PyThreadingConstraint + 'static> PyNativeFn - for F + +impl PyNativeFn for F where + F: Fn(&VirtualMachine, FuncArgs) -> PyResult + PyThreadingConstraint + 'static { } @@ -103,8 +104,10 @@ use sealed::PyNativeFnInternal; #[doc(hidden)] pub struct OwnedParam(PhantomData); + #[doc(hidden)] pub struct BorrowedParam(PhantomData); + #[doc(hidden)] pub struct RefParam(PhantomData); diff --git a/crates/vm/src/function/either.rs b/crates/vm/src/function/either.rs index 9ee7f028bd2..e7f6091b200 100644 --- a/crates/vm/src/function/either.rs +++ b/crates/vm/src/function/either.rs @@ -8,7 +8,11 @@ pub enum Either { B(B), } -impl, B: Borrow> Borrow for Either { +impl Borrow for Either +where + A: Borrow, + B: Borrow, +{ #[inline(always)] fn borrow(&self) -> &PyObject { match self { @@ -18,7 +22,11 @@ impl, B: Borrow> Borrow for Either } } -impl, B: AsRef> AsRef for Either { +impl AsRef for Either +where + A: AsRef, + B: AsRef, +{ #[inline(always)] fn as_ref(&self) -> &PyObject { match self { @@ -28,7 +36,11 @@ impl, B: AsRef> AsRef for Either { } } -impl, B: Into> From> for PyObjectRef { +impl From> for PyObjectRef +where + A: Into, + B: Into, +{ #[inline(always)] fn from(value: Either) -> Self { match value { @@ -38,7 +50,11 @@ impl, B: Into> From> for PyObjectRef { } } -impl ToPyObject for Either { +impl ToPyObject for Either +where + A: ToPyObject, + B: ToPyObject, +{ #[inline(always)] fn to_pyobject(self, vm: &VirtualMachine) -> PyObjectRef { match self { diff --git a/crates/vm/src/function/getset.rs b/crates/vm/src/function/getset.rs index bcd745f561b..a1c003dbca6 100644 --- a/crates/vm/src/function/getset.rs +++ b/crates/vm/src/function/getset.rs @@ -1,6 +1,4 @@ -/*! Python `attribute` descriptor class. (PyGetSet) - -*/ +//! Python `attribute` descriptor class. (PyGetSet) use crate::{ Py, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, VirtualMachine, convert::ToPyResult, diff --git a/crates/vm/src/function/method.rs b/crates/vm/src/function/method.rs index 12eda50c9d5..f0497fb384e 100644 --- a/crates/vm/src/function/method.rs +++ b/crates/vm/src/function/method.rs @@ -254,6 +254,7 @@ impl PyMethodDef { all_methods } + #[must_use] const fn const_copy(&self) -> Self { Self { name: self.name, diff --git a/crates/vm/src/py_io.rs b/crates/vm/src/py_io.rs index 5649463b30e..aa3fea8e545 100644 --- a/crates/vm/src/py_io.rs +++ b/crates/vm/src/py_io.rs @@ -1,14 +1,15 @@ +use core::{fmt, ops}; +use std::io; + use crate::{ PyObject, PyObjectRef, PyResult, VirtualMachine, builtins::{PyBaseExceptionRef, PyBytes, PyStr}, common::ascii, }; -use alloc::fmt; -use core::ops; -use std::io; pub trait Write { type Error; + fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> Result<(), Self::Error>; } @@ -24,10 +25,12 @@ impl IoWriter { impl ops::Deref for IoWriter { type Target = T; + fn deref(&self) -> &T { &self.0 } } + impl ops::DerefMut for IoWriter { fn deref_mut(&mut self) -> &mut T { &mut self.0 @@ -39,6 +42,7 @@ where W: io::Write, { type Error = io::Error; + fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> io::Result<()> { ::write_fmt(&mut self.0, args) } @@ -46,6 +50,7 @@ where impl Write for String { type Error = fmt::Error; + fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result { ::write_fmt(self, args) } @@ -55,8 +60,10 @@ pub struct PyWriter<'vm>(pub PyObjectRef, pub &'vm VirtualMachine); impl Write for PyWriter<'_> { type Error = PyBaseExceptionRef; + fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> Result<(), Self::Error> { - let PyWriter(obj, vm) = self; + let Self(obj, vm) = self; + vm.call_method(obj, "write", (args.to_string(),)).map(drop) } } @@ -70,6 +77,7 @@ pub fn file_readline(obj: &PyObject, size: Option, vm: &VirtualMachine) - vec![vm.ctx.new_str(ascii!("EOF when reading a line")).into()], ) }; + let ret = match_class!(match ret { s @ PyStr => { // Use as_wtf8() to handle strings with surrogates (e.g., surrogateescape) @@ -77,6 +85,7 @@ pub fn file_readline(obj: &PyObject, size: Option, vm: &VirtualMachine) - if s_wtf8.is_empty() { return Err(eof_err()); } + // '\n' is ASCII, so we can check bytes directly if s_wtf8.as_bytes().last() == Some(&b'\n') { let no_nl = &s_wtf8[..s_wtf8.len() - 1]; @@ -90,13 +99,14 @@ pub fn file_readline(obj: &PyObject, size: Option, vm: &VirtualMachine) - if buf.is_empty() { return Err(eof_err()); } + if buf.last() == Some(&b'\n') { vm.ctx.new_bytes(buf[..buf.len() - 1].to_owned()).into() } else { b.into() } } - _ => return Err(vm.new_type_error("object.readline() returned non-string".to_owned())), + _ => return Err(vm.new_type_error("object.readline() returned non-string")), }); Ok(ret) } diff --git a/crates/vm/src/py_serde.rs b/crates/vm/src/py_serde.rs index 945068113f1..50ea4422b16 100644 --- a/crates/vm/src/py_serde.rs +++ b/crates/vm/src/py_serde.rs @@ -111,8 +111,9 @@ pub struct PyObjectDeserializer<'c> { } impl<'c> PyObjectDeserializer<'c> { - pub fn new(vm: &'c VirtualMachine) -> Self { - PyObjectDeserializer { vm } + #[must_use] + pub const fn new(vm: &'c VirtualMachine) -> Self { + Self { vm } } } diff --git a/crates/vm/src/readline.rs b/crates/vm/src/readline.rs index bd0ecd73912..53475358ce6 100644 --- a/crates/vm/src/readline.rs +++ b/crates/vm/src/readline.rs @@ -12,7 +12,7 @@ pub enum ReadlineResult { Line(String), Eof, Interrupt, - Io(std::io::Error), + Io(io::Error), #[cfg(unix)] OsError(String), Other(OtherError), From 55b96f923aebdd73c52d06bfdd240271c43472e2 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:46:53 +0300 Subject: [PATCH 011/351] Run windows tests with `-u all` (#8134) * Enable all test resources on windows * Mark tests * mark wmi test --- .github/workflows/ci.yaml | 3 ++- Lib/test/test_codecmaps_kr.py | 2 +- Lib/test/test_codecmaps_tw.py | 2 +- Lib/test/test_tokenize.py | 1 + Lib/test/test_venv.py | 1 + Lib/test/test_winconsoleio.py | 1 + Lib/test/test_winsound.py | 1 + Lib/test/test_wmi.py | 1 + 8 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index abc2173c6e7..72599d28703 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -306,7 +306,8 @@ jobs: skips: [] timeout: 60 - os: windows-2025 - extra_test_args: [] # TODO: Enable '-u all' + extra_test_args: + - '-u all' env_polluting_tests: - test_set skips: [] diff --git a/Lib/test/test_codecmaps_kr.py b/Lib/test/test_codecmaps_kr.py index b8376d36615..a6409239dc5 100644 --- a/Lib/test/test_codecmaps_kr.py +++ b/Lib/test/test_codecmaps_kr.py @@ -11,7 +11,7 @@ class TestCP949Map(multibytecodec_support.TestBase_Mapping, encoding = 'cp949' mapfileurl = 'http://www.pythontest.net/unicode/CP949.TXT' - @unittest.expectedFailure # TODO: RUSTPYTHON; LookupError: unknown encoding: cp949 + @unittest.expectedFailureIf(__import__("sys").platform in ("android", "darwin", "linux"), "TODO: RUSTPYTHON; LookupError: unknown encoding: cp949") def test_mapping_file(self): return super().test_mapping_file() diff --git a/Lib/test/test_codecmaps_tw.py b/Lib/test/test_codecmaps_tw.py index 4a1359ce7be..2fcf59c9f6d 100644 --- a/Lib/test/test_codecmaps_tw.py +++ b/Lib/test/test_codecmaps_tw.py @@ -27,7 +27,7 @@ class TestCP950Map(multibytecodec_support.TestBase_Mapping, (b"\xFFxy", "replace", "\ufffdxy"), ) - @unittest.expectedFailure # TODO: RUSTPYTHON; LookupError: unknown encoding: cp950 + @unittest.expectedFailureIf(__import__("sys").platform in ("android", "darwin", "linux"), "TODO: RUSTPYTHON; LookupError: unknown encoding: cp950") def test_errorhandle(self): return super().test_errorhandle() diff --git a/Lib/test/test_tokenize.py b/Lib/test/test_tokenize.py index 394a87c3601..5ed844c34f0 100644 --- a/Lib/test/test_tokenize.py +++ b/Lib/test/test_tokenize.py @@ -2173,6 +2173,7 @@ def test_string_concatenation(self): # Two string literals on the same line self.check_roundtrip("'' ''") + @unittest.skipIf(support.is_resource_enabled("cpu") and __import__("sys").platform == "win32", "TODO: RUSTPYTHON; Timeout after 10 minutes") def test_random_files(self): # Test roundtrip on random python modules. # pass the '-ucpu' option to process the full directory. diff --git a/Lib/test/test_venv.py b/Lib/test/test_venv.py index 34757c97a4f..2ea5f502247 100644 --- a/Lib/test/test_venv.py +++ b/Lib/test/test_venv.py @@ -1094,6 +1094,7 @@ def nicer_error(self): f"**Subprocess Error**\n{err}" ) + @unittest.expectedFailureIfWindows("TODO: RUSTPYTHON; FileNotFoundError: [WinError 2] No such file or directory") @requires_venv_with_pip() @requires_resource('cpu') def test_with_pip(self): diff --git a/Lib/test/test_winconsoleio.py b/Lib/test/test_winconsoleio.py index 1bae884ed9a..516d5563218 100644 --- a/Lib/test/test_winconsoleio.py +++ b/Lib/test/test_winconsoleio.py @@ -142,6 +142,7 @@ def test_write_empty_data(self): with ConIO('CONOUT$', 'w') as f: self.assertEqual(f.write(b''), 0) + @unittest.expectedFailureIfWindows("TODO: RUSTPYTHON") @requires_resource('console') def test_write(self): testcases = [] diff --git a/Lib/test/test_winsound.py b/Lib/test/test_winsound.py index 9724d830ade..d013d8396cb 100644 --- a/Lib/test/test_winsound.py +++ b/Lib/test/test_winsound.py @@ -100,6 +100,7 @@ def test_keyword_args(self): class PlaySoundTest(unittest.TestCase): + @unittest.expectedFailureIfWindows("TODO: RUSTPYTHON; TypeError: a bytes-like object is required, not 'str'") def test_errors(self): self.assertRaises(TypeError, winsound.PlaySound) self.assertRaises(TypeError, winsound.PlaySound, "bad", "bad") diff --git a/Lib/test/test_wmi.py b/Lib/test/test_wmi.py index 90eb40439d4..e2d924c9769 100644 --- a/Lib/test/test_wmi.py +++ b/Lib/test/test_wmi.py @@ -59,6 +59,7 @@ def test_wmi_query_not_select(self): with self.assertRaises(ValueError): wmi_exec_query("not select, just in case someone tries something") + @unittest.skipIf(__import__("sys").platform == "win32", "TODO: RUSTPYTHON; Timeout after 10 minutes") @support.requires_resource('cpu') def test_wmi_query_overflow(self): # Ensure very big queries fail From 83beb3e87358c6cc26b230ee17ce300454b80f26 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:56:50 +0300 Subject: [PATCH 012/351] Use `ast::visitor::Visitor` for collection annotations (#8139) --- crates/codegen/src/compile.rs | 70 +++++++++++------------------------ 1 file changed, 21 insertions(+), 49 deletions(-) diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index 0ad465fb24c..893198d260b 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -4442,55 +4442,28 @@ impl Compiler { /// order as symbol-table construction so the annotation scope's /// `sub_tables` cursor stays aligned. fn collect_annotations(body: &[ast::Stmt]) -> Vec<&ast::StmtAnnAssign> { - fn walk<'a>(stmts: &'a [ast::Stmt], out: &mut Vec<&'a ast::StmtAnnAssign>) { - for stmt in stmts { + use ast::visitor::Visitor; + + #[derive(Default)] + struct AnnotationsVisitor<'a> { + annotations: Vec<&'a ast::StmtAnnAssign>, + } + + impl<'a> Visitor<'a> for AnnotationsVisitor<'a> { + fn visit_stmt(&mut self, stmt: &'a ast::Stmt) { match stmt { - ast::Stmt::AnnAssign(stmt) => out.push(stmt), - ast::Stmt::If(ast::StmtIf { - body, - elif_else_clauses, - .. - }) => { - walk(body, out); - for clause in elif_else_clauses { - walk(&clause.body, out); - } - } - ast::Stmt::For(ast::StmtFor { body, orelse, .. }) - | ast::Stmt::While(ast::StmtWhile { body, orelse, .. }) => { - walk(body, out); - walk(orelse, out); - } - ast::Stmt::With(ast::StmtWith { body, .. }) => walk(body, out), - ast::Stmt::Try(ast::StmtTry { - body, - handlers, - orelse, - finalbody, - .. - }) => { - walk(body, out); - for handler in handlers { - let ast::ExceptHandler::ExceptHandler( - ast::ExceptHandlerExceptHandler { body, .. }, - ) = handler; - walk(body, out); - } - walk(orelse, out); - walk(finalbody, out); - } - ast::Stmt::Match(ast::StmtMatch { cases, .. }) => { - for case in cases { - walk(&case.body, out); - } - } - _ => {} + ast::Stmt::AnnAssign(ann_assign) => self.annotations.push(ann_assign), + ast::Stmt::ClassDef(_) | ast::Stmt::FunctionDef(_) => {} + _ => ast::visitor::walk_stmt(self, stmt), } } } - let mut annotations = Vec::new(); - walk(body, &mut annotations); - annotations + + let mut visitor = AnnotationsVisitor::default(); + for stmt in body { + visitor.visit_stmt(stmt); + } + visitor.annotations } fn compile_annotation_for_symbol_cursor_only( @@ -4509,12 +4482,11 @@ impl Compiler { ) -> CompileResult { let loc = loc.unwrap_or(self.current_source_range); let annotations = Self::collect_annotations(body); - let simple_annotation_count = annotations + let has_simple_annotation = annotations .iter() - .filter(|stmt| stmt.simple && matches!(stmt.target.as_ref(), ast::Expr::Name(_))) - .count(); + .any(|stmt| stmt.simple && matches!(stmt.target.as_ref(), ast::Expr::Name(_))); - if simple_annotation_count == 0 { + if !has_simple_annotation { return Ok(false); } From 94ecb5697e77240dfb3e290d8f4277acc188170e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:57:04 +0900 Subject: [PATCH 013/351] Bump webpack-dev-server from 5.2.4 to 5.2.5 in /wasm/demo (#8140) Bumps [webpack-dev-server](https://github.com/webpack/webpack-dev-server) from 5.2.4 to 5.2.5. - [Release notes](https://github.com/webpack/webpack-dev-server/releases) - [Changelog](https://github.com/webpack/webpack-dev-server/blob/main/CHANGELOG.md) - [Commits](https://github.com/webpack/webpack-dev-server/compare/v5.2.4...v5.2.5) --- updated-dependencies: - dependency-name: webpack-dev-server dependency-version: 5.2.5 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- wasm/demo/package-lock.json | 8 ++++---- wasm/demo/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/wasm/demo/package-lock.json b/wasm/demo/package-lock.json index 21385a868a0..33be8e6016c 100644 --- a/wasm/demo/package-lock.json +++ b/wasm/demo/package-lock.json @@ -24,7 +24,7 @@ "serve": "^14.2.6", "webpack": "^5.105.0", "webpack-cli": "^6.0.1", - "webpack-dev-server": "^5.2.4" + "webpack-dev-server": "^5.2.5" } }, "node_modules/@codemirror/autocomplete": { @@ -5691,9 +5691,9 @@ } }, "node_modules/webpack-dev-server": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.4.tgz", - "integrity": "sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==", + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.5.tgz", + "integrity": "sha512-4wZtCquSuv9CKX8oybo+mqxtxZqWz47uM1Ch94lxowBztOhWCbhqvRbfC/mODOwxgV2brY+JGZpHq58/SuVFYg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/wasm/demo/package.json b/wasm/demo/package.json index 0b22c24ea50..04d7aa78c9f 100644 --- a/wasm/demo/package.json +++ b/wasm/demo/package.json @@ -19,7 +19,7 @@ "serve": "^14.2.6", "webpack": "^5.105.0", "webpack-cli": "^6.0.1", - "webpack-dev-server": "^5.2.4" + "webpack-dev-server": "^5.2.5" }, "scripts": { "dev": "webpack serve", From 73b0c9fddae91435940da955dc26d312ced0e812 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:56:35 +0300 Subject: [PATCH 014/351] Remove duplicated code in `compile.rs` (#8141) --- crates/codegen/src/compile.rs | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index 893198d260b..a6d6cadf017 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -39,7 +39,7 @@ use rustpython_wtf8::Wtf8Buf; /// Extension trait for `ast::Expr` to add constant checking methods trait ExprExt { - /// Check if an expression is a constant literal + /// Returns true if the expression is a constant literal with no side effects. fn is_constant(&self) -> bool; /// Check if a slice expression has all constant elements @@ -2782,7 +2782,7 @@ impl Compiler { // In interactive mode, always compile (to print the result). let dominated_by_interactive = self.interactive && !self.ctx.in_func() && !self.ctx.in_class; - if !dominated_by_interactive && Self::is_const_expression(value) { + if !dominated_by_interactive && value.is_constant() { emit!(self, Instruction::Nop); } else { self.compile_expression(value)?; @@ -7881,19 +7881,6 @@ impl Compiler { send_block } - /// Returns true if the expression is a constant with no side effects. - fn is_const_expression(expr: &ast::Expr) -> bool { - matches!( - expr, - ast::Expr::StringLiteral(_) - | ast::Expr::BytesLiteral(_) - | ast::Expr::NumberLiteral(_) - | ast::Expr::BooleanLiteral(_) - | ast::Expr::NoneLiteral(_) - | ast::Expr::EllipsisLiteral(_) - ) - } - fn compile_expression(&mut self, expression: &ast::Expr) -> CompileResult<()> { trace!("Compiling {expression:?}"); let range = expression.range(); From 18dc6b33b8d8cad828ac743693cb94338cf7b75a Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:57:48 +0300 Subject: [PATCH 015/351] Newtype for `[ir::Block]` (#8142) --- crates/codegen/src/compile.rs | 13 +- crates/codegen/src/ir.rs | 542 +++++++++++++++++++--------------- 2 files changed, 310 insertions(+), 245 deletions(-) diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index a6d6cadf017..a178a23bd2f 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -12,7 +12,7 @@ use crate::{ IndexMap, IndexSet, ToPythonName, error::{CodegenError, CodegenErrorType, InternalError, PatternUnreachableReason}, - ir::{self, BlockIdx}, + ir::{self, Block, BlockIdx, Blocks}, preprocess, symboltable::{self, CompilerScope, Symbol, SymbolFlags, SymbolScope, SymbolTable}, unparse::UnparseExpr, @@ -470,7 +470,7 @@ impl Compiler { flags: CodeFlags::empty(), source_path: source_file.name().to_owned(), private: None, - blocks: vec![ir::Block::default()], + blocks: Blocks::from([Block::default()]), current_block: BlockIdx::new(0), instr_sequence: ir::InstructionSequence::new(), instr_sequence_label_map: ir::InstructionSequenceLabelMap::new(), @@ -547,8 +547,9 @@ impl Compiler { saved_annotations_instr_sequence, ) = { let code = self.current_code_info(); + ( - mem::replace(&mut code.blocks, vec![ir::Block::default()]), + mem::replace(&mut code.blocks, Blocks::from([Block::default()])), mem::replace(&mut code.current_block, BlockIdx::new(0)), mem::replace(&mut code.instr_sequence, ir::InstructionSequence::new()), mem::replace( @@ -1348,7 +1349,7 @@ impl Compiler { flags, source_path, private, - blocks: vec![ir::Block::default()], + blocks: Blocks::from([Block::default()]), current_block: BlockIdx::new(0), instr_sequence: ir::InstructionSequence::new(), instr_sequence_label_map: ir::InstructionSequenceLabelMap::new(), @@ -10983,7 +10984,7 @@ impl Compiler { unwrap_internal(self, result); let code = self.current_code_info(); let idx = BlockIdx::new(code.blocks.len().to_u32()); - code.blocks.push(ir::Block::default()); + code.blocks.push(Block::default()); let result = code.push_unmapped_instr_sequence_label(); unwrap_internal(self, result); idx @@ -10998,7 +10999,7 @@ impl Compiler { unwrap_internal(self, result); let code = self.current_code_info(); let idx = BlockIdx::new(code.blocks.len().to_u32()); - code.blocks.push(ir::Block::default()); + code.blocks.push(Block::default()); let result = code.push_unlabeled_instr_sequence_block(); unwrap_internal(self, result); idx diff --git a/crates/codegen/src/ir.rs b/crates/codegen/src/ir.rs index 58e68b48725..e7b50659e8e 100644 --- a/crates/codegen/src/ir.rs +++ b/crates/codegen/src/ir.rs @@ -1,4 +1,4 @@ -use core::ops; +use core::ops::{Deref, DerefMut, Index, IndexMut}; use crate::{IndexMap, IndexSet, error::InternalError}; use malachite_bigint::BigInt; @@ -141,7 +141,7 @@ impl ConstantPool { } } -impl ops::Index for ConstantPool { +impl Index for ConstantPool { type Output = ConstantData; fn index(&self, idx: usize) -> &Self::Output { @@ -190,44 +190,34 @@ impl BlockIdx { Self(value) } - /// Returns the inner value as a [`usize`]. + /// Returns the inner [`u32`] value. #[must_use] - pub const fn idx(self) -> usize { - self.0 as usize + pub const fn as_u32(self) -> u32 { + self.0 } -} -impl From for u32 { - fn from(block_idx: BlockIdx) -> Self { - block_idx.0 - } -} - -impl ops::Index for [Block] { - type Output = Block; - - fn index(&self, idx: BlockIdx) -> &Block { - &self[idx.idx()] + /// Returns the inner value as a [`usize`]. + #[must_use] + pub const fn as_usize(self) -> usize { + self.0 as usize } -} -impl ops::IndexMut for [Block] { - fn index_mut(&mut self, idx: BlockIdx) -> &mut Block { - &mut self[idx.idx()] + /// Returns the inner value as a [`usize`]. + #[must_use] + pub const fn idx(self) -> usize { + self.as_usize() } } -impl ops::Index for Vec { - type Output = Block; - - fn index(&self, idx: BlockIdx) -> &Block { - &self[idx.idx()] +impl From for u32 { + fn from(block_idx: BlockIdx) -> Self { + block_idx.as_u32() } } -impl ops::IndexMut for Vec { - fn index_mut(&mut self, idx: BlockIdx) -> &mut Block { - &mut self[idx.idx()] +impl From for usize { + fn from(block_idx: BlockIdx) -> Self { + block_idx.as_usize() } } @@ -449,16 +439,16 @@ fn basicblock_insert_instruction( /// flowgraph.c basicblock_append_instructions fn basicblock_append_block_instructions( - blocks: &mut [Block], + blocks: &mut Blocks, to: BlockIdx, from: BlockIdx, ) -> crate::InternalResult<()> { debug_assert_ne!(to, from); - let from_len = blocks[from.idx()].instruction_used; + let from_len = blocks[from].instruction_used; for i in 0..from_len { - let info = blocks[from.idx()].instructions[i]; - let off = basicblock_next_instr(&mut blocks[to.idx()])?; - blocks[to.idx()].instructions[off] = info; + let info = blocks[from].instructions[i]; + let off = basicblock_next_instr(&mut blocks[to])?; + blocks[to].instructions[off] = info; } Ok(()) } @@ -765,7 +755,7 @@ fn instruction_sequence_apply_label_map( /// flowgraph.c _PyCfg_ToInstructionSequence fn cfg_to_instruction_sequence( - blocks: &mut [Block], + blocks: &mut Blocks, instr_sequence: &mut InstructionSequence, ) -> crate::InternalResult<()> { let mut label_id = 0; @@ -1314,6 +1304,100 @@ impl Block { } } +#[derive(Clone, Debug, Default)] +pub struct Blocks(Vec); + +impl Blocks { + pub fn try_reserve( + &mut self, + additional: usize, + ) -> Result<(), alloc::collections::TryReserveError> { + self.0.try_reserve(additional) + } + + pub fn push(&mut self, value: Block) { + self.0.push(value) + } +} + +impl From> for Blocks { + fn from(value: Vec) -> Self { + Self(value) + } +} + +impl From> for Blocks { + fn from(value: Box<[Block]>) -> Self { + Self(value.into()) + } +} + +impl From<&[Block]> for Blocks { + fn from(value: &[Block]) -> Self { + Self(value.to_vec()) + } +} + +impl From<&mut [Block]> for Blocks { + fn from(value: &mut [Block]) -> Self { + Self(value.to_vec()) + } +} + +impl From<[Block; N]> for Blocks { + fn from(value: [Block; N]) -> Self { + Self(value.into()) + } +} + +impl From<&[Block; N]> for Blocks { + fn from(value: &[Block; N]) -> Self { + Self(value.to_vec()) + } +} + +impl Deref for Blocks { + type Target = [Block]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for Blocks { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Index for Blocks { + type Output = Block; + + fn index(&self, idx: usize) -> &Self::Output { + &self.0[idx] + } +} + +impl IndexMut for Blocks { + fn index_mut(&mut self, idx: usize) -> &mut Self::Output { + &mut self.0[idx] + } +} + +impl Index for Blocks { + type Output = Block; + + fn index(&self, block_idx: BlockIdx) -> &Self::Output { + &self.0[block_idx.as_usize()] + } +} + +impl IndexMut for Blocks { + fn index_mut(&mut self, block_idx: BlockIdx) -> &mut Self::Output { + &mut self.0[block_idx.as_usize()] + } +} + pub(crate) const START_DEPTH_UNSET: i32 = i32::MIN; const CO_MAXBLOCKS: usize = 20; @@ -1541,7 +1625,7 @@ pub struct CodeInfo { pub source_path: String, pub private: Option, // For private name mangling, mostly for class - pub blocks: Vec, + pub blocks: Blocks, pub current_block: BlockIdx, pub(crate) instr_sequence: InstructionSequence, pub(crate) instr_sequence_label_map: InstructionSequenceLabelMap, @@ -1751,7 +1835,7 @@ impl CodeInfo { fn optimize_code_unit( metadata: &mut CodeUnitMetadata, - blocks: &mut Vec, + blocks: &mut Blocks, instr_sequence: InstructionSequence, nlocals: usize, nparams: usize, @@ -1776,7 +1860,7 @@ fn optimize_code_unit( fn optimize_cfg( metadata: &mut CodeUnitMetadata, - blocks: &mut Vec, + blocks: &mut Blocks, firstlineno: OneIndexed, ) -> crate::InternalResult<()> { // flowgraph.c optimize_cfg @@ -1798,7 +1882,7 @@ fn optimize_cfg( optimize_load_const(metadata, blocks)?; let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { - let next_block = blocks[block_idx.idx()].next; + let next_block = blocks[block_idx].next; optimize_basic_block(blocks, metadata, block_idx)?; block_idx = next_block; } @@ -1816,7 +1900,7 @@ fn optimize_cfg( fn optimized_cfg_to_instruction_sequence( metadata: &CodeUnitMetadata, flags: CodeFlags, - blocks: &mut Vec, + blocks: &mut Blocks, ) -> crate::InternalResult<(u32, usize, InstructionSequence)> { // Phase 2: _PyCfg_OptimizedCfgToInstructionSequence (flowgraph.c) convert_pseudo_conditional_jumps(blocks)?; @@ -1938,7 +2022,7 @@ fn is_generator(flags: CodeFlags) -> bool { /// flowgraph.c insert_prefix_instructions fn insert_prefix_instructions( metadata: &CodeUnitMetadata, - blocks: &mut [Block], + blocks: &mut Blocks, cellfixedoffsets: &[i32], nfreevars: usize, flags: CodeFlags, @@ -2036,7 +2120,7 @@ fn insert_prefix_instructions( /// flowgraph.c prepare_localsplus fn prepare_localsplus( metadata: &CodeUnitMetadata, - blocks: &mut [Block], + blocks: &mut Blocks, flags: CodeFlags, ) -> crate::InternalResult { let nlocals = metadata.varnames.len(); @@ -2060,11 +2144,11 @@ fn prepare_localsplus( } /// flowgraph.c remove_unreachable -fn remove_unreachable(blocks: &mut [Block]) -> crate::InternalResult<()> { +fn remove_unreachable(blocks: &mut Blocks) -> crate::InternalResult<()> { let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { - blocks[block_idx.idx()].predecessors = 0; - block_idx = blocks[block_idx.idx()].next; + blocks[block_idx].predecessors = 0; + block_idx = blocks[block_idx].next; } let mut stack = make_cfg_traversal_stack(blocks)?; @@ -2075,12 +2159,12 @@ fn remove_unreachable(blocks: &mut [Block]) -> crate::InternalResult<()> { let idx = current.idx(); let next = blocks[idx].next; if next != BlockIdx::NULL && bb_has_fallthrough(&blocks[idx]) { - if !blocks[next.idx()].visited { - debug_assert_eq!(blocks[next.idx()].predecessors, 0); + if !blocks[next].visited { + debug_assert_eq!(blocks[next].predecessors, 0); stack.push(next); - blocks[next.idx()].visited = true; + blocks[next].visited = true; } - blocks[next.idx()].predecessors += 1; + blocks[next].predecessors += 1; } let instr_count = blocks[idx].instruction_used; @@ -3612,7 +3696,7 @@ fn basicblock_optimize_load_const( /// flowgraph.c optimize_load_const fn optimize_load_const( metadata: &mut CodeUnitMetadata, - blocks: &mut [Block], + blocks: &mut Blocks, ) -> crate::InternalResult<()> { let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { @@ -3626,7 +3710,7 @@ fn optimize_load_const( /// flowgraph.c optimize_basic_block fn optimize_basic_block( - blocks: &mut [Block], + blocks: &mut Blocks, metadata: &mut CodeUnitMetadata, block_idx: BlockIdx, ) -> crate::InternalResult<()> { @@ -3871,7 +3955,7 @@ fn optimize_basic_block( /// flowgraph.c remove_redundant_nops_and_pairs #[allow(clippy::unnecessary_wraps)] -fn remove_redundant_nops_and_pairs(blocks: &mut [Block]) -> crate::InternalResult<()> { +fn remove_redundant_nops_and_pairs(blocks: &mut Blocks) -> crate::InternalResult<()> { let mut done = false; while !done { @@ -3910,17 +3994,17 @@ fn remove_redundant_nops_and_pairs(blocks: &mut [Block]) -> crate::InternalResul if is_redundant_pair { let (prev_block, prev_instr_idx) = prev_instr.expect("redundant pair has previous"); - set_to_nop(&mut blocks[prev_block.idx()].instructions[prev_instr_idx]); - set_to_nop(&mut blocks[block_idx.idx()].instructions[instr_idx]); + set_to_nop(&mut blocks[prev_block].instructions[prev_instr_idx]); + set_to_nop(&mut blocks[block_idx].instructions[instr_idx]); done = false; } } let instr_is_jump = instr.is_some_and(|(instr_block, instr_idx)| { - is_jump(&blocks[instr_block.idx()].instructions[instr_idx]) + is_jump(&blocks[instr_block].instructions[instr_idx]) }); - let block = &blocks[block_idx.idx()]; + let block = &blocks[block_idx]; if instr_is_jump || !bb_has_fallthrough(block) { instr = None; } @@ -3933,7 +4017,7 @@ fn remove_redundant_nops_and_pairs(blocks: &mut [Block]) -> crate::InternalResul /// flowgraph.c remove_unused_consts #[allow(clippy::needless_range_loop)] fn remove_unused_consts( - blocks: &mut [Block], + blocks: &mut Blocks, consts: &mut ConstantPool, ) -> crate::InternalResult<()> { let nconsts = consts.len(); @@ -4031,7 +4115,7 @@ fn remove_unused_consts( Ok(()) } -fn optimize_load_fast(blocks: &mut [Block]) -> crate::InternalResult<()> { +fn optimize_load_fast(blocks: &mut Blocks) -> crate::InternalResult<()> { let mut max_instrs = 0; let mut current = BlockIdx(0); while current != BlockIdx::NULL { @@ -4280,7 +4364,7 @@ fn optimize_load_fast(blocks: &mut [Block]) -> crate::InternalResult<()> { } /// flowgraph.c calculate_stackdepth -fn calculate_stackdepth(blocks: &mut [Block]) -> crate::InternalResult { +fn calculate_stackdepth(blocks: &mut Blocks) -> crate::InternalResult { let mut current = BlockIdx(0); while current != BlockIdx::NULL { blocks[current.idx()].start_depth = START_DEPTH_UNSET; @@ -4415,7 +4499,7 @@ impl CodeInfo { )); let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { - let next_block = self.blocks[block_idx.idx()].next; + let next_block = self.blocks[block_idx].next; optimize_basic_block(&mut self.blocks, &mut self.metadata, block_idx)?; block_idx = next_block; } @@ -4536,7 +4620,7 @@ fn make_super_instruction( } /// flowgraph.c insert_superinstructions -fn insert_superinstructions(blocks: &mut [Block]) -> crate::InternalResult { +fn insert_superinstructions(blocks: &mut Blocks) -> crate::InternalResult { let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { let next_block = blocks[block_idx.idx()].next; @@ -4685,22 +4769,22 @@ fn local_as_ref_local(local: usize) -> isize { /// flowgraph.c load_fast_push_block fn load_fast_push_block( worklist: &mut CfgTraversalStack, - blocks: &mut [Block], + blocks: &mut Blocks, target: BlockIdx, start_depth: usize, ) { debug_assert!(target != BlockIdx::NULL); - debug_assert!(blocks[target.idx()].start_depth >= 0); - debug_assert_eq!(blocks[target.idx()].start_depth as usize, start_depth,); - if !blocks[target.idx()].visited { - blocks[target.idx()].visited = true; + debug_assert!(blocks[target].start_depth >= 0); + debug_assert_eq!(blocks[target].start_depth as usize, start_depth,); + if !blocks[target].visited { + blocks[target].visited = true; worklist.push(target); } } fn stackdepth_push( stack: &mut CfgTraversalStack, - blocks: &mut [Block], + blocks: &mut Blocks, target: BlockIdx, depth: i32, ) -> crate::InternalResult<()> { @@ -5060,25 +5144,25 @@ fn assemble_exception_table( /// Mark exception handler target blocks. /// flowgraph.c mark_except_handlers #[allow(clippy::unnecessary_wraps)] -pub(crate) fn mark_except_handlers(blocks: &mut [Block]) -> crate::InternalResult<()> { +pub(crate) fn mark_except_handlers(blocks: &mut Blocks) -> crate::InternalResult<()> { #[cfg(debug_assertions)] { let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { - assert!(!blocks[block_idx.idx()].except_handler); - block_idx = blocks[block_idx.idx()].next; + assert!(!blocks[block_idx].except_handler); + block_idx = blocks[block_idx].next; } } let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { - let next = blocks[block_idx.idx()].next; - let instr_count = blocks[block_idx.idx()].instruction_used; + let next = blocks[block_idx].next; + let instr_count = blocks[block_idx].instruction_used; for i in 0..instr_count { - let instr = blocks[block_idx.idx()].instructions[i]; + let instr = blocks[block_idx].instructions[i]; if is_block_push(&instr) { debug_assert!(instr.target != BlockIdx::NULL); - blocks[instr.target.idx()].except_handler = true; + blocks[instr.target].except_handler = true; } } block_idx = next; @@ -5103,7 +5187,7 @@ pub(crate) fn mark_except_handlers(blocks: &mut [Block]) -> crate::InternalResul /// optimize_cfg). This matches CPython's behavior and is necessary for /// optimize_load_fast to terminate fall-through at those placeholders. /// flowgraph.c mark_warm -fn mark_warm(blocks: &mut [Block]) -> crate::InternalResult<()> { +fn mark_warm(blocks: &mut Blocks) -> crate::InternalResult<()> { let mut stack = make_cfg_traversal_stack(blocks)?; stack.push(BlockIdx(0)); blocks[0].visited = true; @@ -5113,8 +5197,7 @@ fn mark_warm(blocks: &mut [Block]) -> crate::InternalResult<()> { blocks[idx].warm = true; let next = blocks[idx].next; - if next != BlockIdx::NULL && bb_has_fallthrough(&blocks[idx]) && !blocks[next.idx()].visited - { + if next != BlockIdx::NULL && bb_has_fallthrough(&blocks[idx]) && !blocks[next].visited { stack.push(next); blocks[next.idx()].visited = true; } @@ -5135,7 +5218,7 @@ fn mark_warm(blocks: &mut [Block]) -> crate::InternalResult<()> { Ok(()) } -fn mark_cold(blocks: &mut [Block]) -> crate::InternalResult<()> { +fn mark_cold(blocks: &mut Blocks) -> crate::InternalResult<()> { let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { let block = &mut blocks[block_idx.idx()]; @@ -5189,7 +5272,7 @@ fn mark_cold(blocks: &mut [Block]) -> crate::InternalResult<()> { } /// flowgraph.c push_cold_blocks_to_end -fn push_cold_blocks_to_end(blocks: &mut Vec) -> crate::InternalResult<()> { +fn push_cold_blocks_to_end(blocks: &mut Blocks) -> crate::InternalResult<()> { if blocks[0].next == BlockIdx::NULL { return Ok(()); } @@ -5200,21 +5283,21 @@ fn push_cold_blocks_to_end(blocks: &mut Vec) -> crate::InternalResult<()> // If a cold block falls through to a warm block, add an explicit jump let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { - let next = blocks[block_idx.idx()].next; - if blocks[block_idx.idx()].cold - && bb_has_fallthrough(&blocks[block_idx.idx()]) + let next = blocks[block_idx].next; + if blocks[block_idx].cold + && bb_has_fallthrough(&blocks[block_idx]) && next != BlockIdx::NULL - && blocks[next.idx()].warm + && blocks[next].warm { let explicit_jump = blocks_new_block(blocks)?; - if !is_label(blocks[next.idx()].cpython_label) { - blocks[next.idx()].cpython_label = InstructionSequenceLabel::from_index(next_label); + if !is_label(blocks[next].cpython_label) { + blocks[next].cpython_label = InstructionSequenceLabel::from_index(next_label); next_label += 1; } - let jump_label = blocks[next.idx()].cpython_label; + let jump_label = blocks[next].cpython_label; debug_assert!(is_label(jump_label)); basicblock_addop( - &mut blocks[explicit_jump.idx()], + &mut blocks[explicit_jump], InstructionInfo { instr: PseudoOpcode::JumpNoInterrupt.into(), arg: instruction_sequence_label_oparg(jump_label), @@ -5225,16 +5308,16 @@ fn push_cold_blocks_to_end(blocks: &mut Vec) -> crate::InternalResult<()> lineno_override: Some(NO_LOCATION_OVERRIDE), }, )?; - blocks[explicit_jump.idx()].cold = true; - blocks[explicit_jump.idx()].next = next; - blocks[explicit_jump.idx()].predecessors = 1; - blocks[block_idx.idx()].next = explicit_jump; - let target = blocks[explicit_jump.idx()].next; - let last = basicblock_last_instr_mut(&mut blocks[explicit_jump.idx()]) + blocks[explicit_jump].cold = true; + blocks[explicit_jump].next = next; + blocks[explicit_jump].predecessors = 1; + blocks[block_idx].next = explicit_jump; + let target = blocks[explicit_jump].next; + let last = basicblock_last_instr_mut(&mut blocks[explicit_jump]) .expect("missing explicit jump"); last.target = target; } - block_idx = blocks[block_idx.idx()].next; + block_idx = blocks[block_idx].next; } assert!(!blocks[0].cold); @@ -5242,45 +5325,41 @@ fn push_cold_blocks_to_end(blocks: &mut Vec) -> crate::InternalResult<()> let mut cold_blocks_tail: BlockIdx = BlockIdx::NULL; let mut block_idx = BlockIdx(0); - while blocks[block_idx.idx()].next != BlockIdx::NULL { - debug_assert!(!blocks[block_idx.idx()].cold); - while blocks[block_idx.idx()].next != BlockIdx::NULL - && !blocks[blocks[block_idx.idx()].next.idx()].cold - { - block_idx = blocks[block_idx.idx()].next; + while blocks[block_idx].next != BlockIdx::NULL { + debug_assert!(!blocks[block_idx].cold); + while blocks[block_idx].next != BlockIdx::NULL && !blocks[blocks[block_idx].next].cold { + block_idx = blocks[block_idx].next; } - if blocks[block_idx.idx()].next == BlockIdx::NULL { + if blocks[block_idx].next == BlockIdx::NULL { break; } - debug_assert!(!blocks[block_idx.idx()].cold); - debug_assert!(blocks[blocks[block_idx.idx()].next.idx()].cold); + debug_assert!(!blocks[block_idx].cold); + debug_assert!(blocks[blocks[block_idx].next].cold); - let mut block_end = blocks[block_idx.idx()].next; - while blocks[block_end.idx()].next != BlockIdx::NULL - && blocks[blocks[block_end.idx()].next.idx()].cold - { - block_end = blocks[block_end.idx()].next; + let mut block_end = blocks[block_idx].next; + while blocks[block_end].next != BlockIdx::NULL && blocks[blocks[block_end].next].cold { + block_end = blocks[block_end].next; } - debug_assert!(blocks[block_end.idx()].cold); + debug_assert!(blocks[block_end].cold); debug_assert!( - blocks[block_end.idx()].next == BlockIdx::NULL - || !blocks[blocks[block_end.idx()].next.idx()].cold + blocks[block_end].next == BlockIdx::NULL || !blocks[blocks[block_end].next].cold ); if cold_blocks == BlockIdx::NULL { - cold_blocks = blocks[block_idx.idx()].next; + cold_blocks = blocks[block_idx].next; } else { - blocks[cold_blocks_tail.idx()].next = blocks[block_idx.idx()].next; + blocks[cold_blocks_tail].next = blocks[block_idx].next; } + cold_blocks_tail = block_end; - blocks[block_idx.idx()].next = blocks[block_end.idx()].next; - blocks[block_end.idx()].next = BlockIdx::NULL; + blocks[block_idx].next = blocks[block_end].next; + blocks[block_end].next = BlockIdx::NULL; } - debug_assert!(blocks[block_idx.idx()].next == BlockIdx::NULL); - blocks[block_idx.idx()].next = cold_blocks; + debug_assert!(blocks[block_idx].next == BlockIdx::NULL); + blocks[block_idx].next = cold_blocks; if cold_blocks != BlockIdx::NULL { remove_redundant_nops_and_jumps(blocks)?; @@ -5289,10 +5368,10 @@ fn push_cold_blocks_to_end(blocks: &mut Vec) -> crate::InternalResult<()> } /// flowgraph.c check_cfg -fn check_cfg(blocks: &[Block]) -> crate::InternalResult<()> { +fn check_cfg(blocks: &Blocks) -> crate::InternalResult<()> { let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { - let block = &blocks[block_idx.idx()]; + let block = &blocks[block_idx]; for i in 0..block.instruction_used { let opcode = block.instructions[i].instr; debug_assert!(!opcode.is_assembler()); @@ -5307,7 +5386,7 @@ fn check_cfg(blocks: &[Block]) -> crate::InternalResult<()> { /// flowgraph.c jump_thread fn jump_thread( - blocks: &mut [Block], + blocks: &mut Blocks, block_idx: BlockIdx, instr_idx: usize, target: &InstructionInfo, @@ -5328,7 +5407,7 @@ fn jump_thread( /// flowgraph.c basicblock_add_jump fn basicblock_add_jump( - blocks: &mut [Block], + blocks: &mut Blocks, block_idx: BlockIdx, instr: AnyInstruction, target: BlockIdx, @@ -5382,7 +5461,7 @@ fn is_conditional_jump_opcode(instr: AnyInstruction) -> bool { } /// flowgraph.c convert_pseudo_conditional_jumps -fn convert_pseudo_conditional_jumps(blocks: &mut [Block]) -> crate::InternalResult<()> { +fn convert_pseudo_conditional_jumps(blocks: &mut Blocks) -> crate::InternalResult<()> { let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { let next = blocks[block_idx.idx()].next; @@ -5440,10 +5519,7 @@ fn convert_pseudo_conditional_jumps(blocks: &mut [Block]) -> crate::InternalResu } /// flowgraph.c normalize_jumps_in_block -fn normalize_jumps_in_block( - blocks: &mut Vec, - block_idx: BlockIdx, -) -> crate::InternalResult<()> { +fn normalize_jumps_in_block(blocks: &mut Blocks, block_idx: BlockIdx) -> crate::InternalResult<()> { let idx = block_idx.idx(); let Some(last_ins) = basicblock_last_instr(&blocks[idx]).copied() else { return Ok(()); @@ -5521,7 +5597,7 @@ fn normalize_jumps_in_block( } /// flowgraph.c normalize_jumps -fn normalize_jumps(blocks: &mut Vec) -> crate::InternalResult<()> { +fn normalize_jumps(blocks: &mut Blocks) -> crate::InternalResult<()> { let mut current = BlockIdx(0); while current != BlockIdx::NULL { blocks[current.idx()].visited = false; @@ -5540,10 +5616,10 @@ fn normalize_jumps(blocks: &mut Vec) -> crate::InternalResult<()> { /// flowgraph.c basicblock_inline_small_or_no_lineno_blocks fn basicblock_inline_small_or_no_lineno_blocks( - blocks: &mut [Block], + blocks: &mut Blocks, block_idx: BlockIdx, ) -> crate::InternalResult { - let Some(last) = basicblock_last_instr(&blocks[block_idx.idx()]).copied() else { + let Some(last) = basicblock_last_instr(&blocks[block_idx]).copied() else { return Ok(false); }; if !last.instr.is_unconditional_jump() { @@ -5552,19 +5628,19 @@ fn basicblock_inline_small_or_no_lineno_blocks( let target = last.target; debug_assert!(target != BlockIdx::NULL); - let small_exit_block = basicblock_exits_scope(&blocks[target.idx()]) - && blocks[target.idx()].instruction_used <= MAX_COPY_SIZE; - let no_lineno_no_fallthrough = basicblock_has_no_lineno(&blocks[target.idx()]) - && !bb_has_fallthrough(&blocks[target.idx()]); + let small_exit_block = + basicblock_exits_scope(&blocks[target]) && blocks[target].instruction_used <= MAX_COPY_SIZE; + let no_lineno_no_fallthrough = + basicblock_has_no_lineno(&blocks[target]) && !bb_has_fallthrough(&blocks[target]); if small_exit_block || no_lineno_no_fallthrough { debug_assert!(is_jump(&last)); let removed_jump_opcode = last.instr; - let last = basicblock_last_instr_mut(&mut blocks[block_idx.idx()]) + let last = basicblock_last_instr_mut(&mut blocks[block_idx]) .expect("non-empty block has last instruction"); set_to_nop(last); basicblock_append_block_instructions(blocks, block_idx, target)?; if no_lineno_no_fallthrough { - let last = basicblock_last_instr_mut(&mut blocks[block_idx.idx()]).unwrap(); + let last = basicblock_last_instr_mut(&mut blocks[block_idx]).unwrap(); if last.instr.is_unconditional_jump() && matches!( removed_jump_opcode.into(), @@ -5574,14 +5650,14 @@ fn basicblock_inline_small_or_no_lineno_blocks( last.instr = PseudoOpcode::Jump.into(); } } - blocks[target.idx()].predecessors -= 1; + blocks[target].predecessors -= 1; return Ok(true); } Ok(false) } /// flowgraph.c inline_small_or_no_lineno_blocks -fn inline_small_or_no_lineno_blocks(blocks: &mut [Block]) -> crate::InternalResult { +fn inline_small_or_no_lineno_blocks(blocks: &mut Blocks) -> crate::InternalResult { loop { let mut changes = false; let mut current = BlockIdx(0); @@ -5603,7 +5679,7 @@ fn inline_small_or_no_lineno_blocks(blocks: &mut [Block]) -> crate::InternalResu /// flowgraph.c basicblock_remove_redundant_nops #[allow(clippy::unnecessary_wraps)] fn basicblock_remove_redundant_nops( - blocks: &mut [Block], + blocks: &mut Blocks, block_idx: BlockIdx, ) -> crate::InternalResult { let bi = block_idx.idx(); @@ -5674,7 +5750,7 @@ fn basicblock_remove_redundant_nops( /// flowgraph.c remove_redundant_nops #[allow(clippy::unnecessary_wraps)] -fn remove_redundant_nops(blocks: &mut [Block]) -> crate::InternalResult { +fn remove_redundant_nops(blocks: &mut Blocks) -> crate::InternalResult { let mut changes = 0; let mut current = BlockIdx(0); while current != BlockIdx::NULL { @@ -5688,12 +5764,12 @@ fn remove_redundant_nops(blocks: &mut [Block]) -> crate::InternalResult { /// flowgraph.c no_redundant_nops #[cfg(debug_assertions)] -fn no_redundant_nops(blocks: &mut [Block]) -> bool { +fn no_redundant_nops(blocks: &mut Blocks) -> bool { matches!(remove_redundant_nops(blocks), Ok(0)) } /// flowgraph.c remove_redundant_jumps -fn remove_redundant_jumps(blocks: &mut [Block]) -> crate::InternalResult { +fn remove_redundant_jumps(blocks: &mut Blocks) -> crate::InternalResult { let mut changes = 0; let mut current = BlockIdx(0); while current != BlockIdx::NULL { @@ -5722,7 +5798,7 @@ fn remove_redundant_jumps(blocks: &mut [Block]) -> crate::InternalResult /// flowgraph.c no_redundant_jumps #[cfg(debug_assertions)] -fn no_redundant_jumps(blocks: &[Block]) -> bool { +fn no_redundant_jumps(blocks: &Blocks) -> bool { let mut current = BlockIdx(0); while current != BlockIdx::NULL { let block = &blocks[current.idx()]; @@ -5750,7 +5826,7 @@ fn no_redundant_jumps(blocks: &[Block]) -> bool { true } -fn remove_redundant_nops_and_jumps(blocks: &mut [Block]) -> crate::InternalResult<()> { +fn remove_redundant_nops_and_jumps(blocks: &mut Blocks) -> crate::InternalResult<()> { loop { // Convergence is guaranteed because the number of redundant jumps and // nops only decreases. @@ -5764,7 +5840,7 @@ fn remove_redundant_nops_and_jumps(blocks: &mut [Block]) -> crate::InternalResul } /// flowgraph.c make_cfg_traversal_stack -fn make_cfg_traversal_stack(blocks: &mut [Block]) -> crate::InternalResult { +fn make_cfg_traversal_stack(blocks: &mut Blocks) -> crate::InternalResult { debug_assert!(!blocks.is_empty()); let mut nblocks = 0; let mut current = BlockIdx(0); @@ -5784,7 +5860,7 @@ fn make_cfg_traversal_stack(blocks: &mut [Block]) -> crate::InternalResult) -> crate::InternalResult { +fn blocks_new_block(blocks: &mut Blocks) -> crate::InternalResult { blocks .try_reserve(1) .map_err(|_| InternalError::MalformedControlFlowGraph)?; @@ -5800,7 +5876,7 @@ fn blocks_new_block(blocks: &mut Vec) -> crate::InternalResult /// flowgraph.c struct _PyCfgBuilder struct CfgBuilder { - blocks: Vec, + blocks: Blocks, entry: BlockIdx, block_list: BlockIdx, current: BlockIdx, @@ -5837,7 +5913,7 @@ fn init_cfg_builder(g: &mut CfgBuilder) -> crate::InternalResult<()> { /// flowgraph.c _PyCfgBuilder_New fn cfg_builder_new() -> crate::InternalResult { let mut builder = CfgBuilder { - blocks: Vec::new(), + blocks: Blocks::default(), entry: BlockIdx::NULL, block_list: BlockIdx::NULL, current: BlockIdx::NULL, @@ -5887,7 +5963,7 @@ fn cfg_builder_use_label( /// flowgraph.c _PyCfgBuilder_Addop fn cfg_builder_addop(g: &mut CfgBuilder, info: InstructionInfo) -> crate::InternalResult<()> { cfg_builder_maybe_start_new_block(g)?; - basicblock_addop(&mut g.blocks[g.current.idx()], info) + basicblock_addop(&mut g.blocks[g.current], info) } /// flowgraph.c cfg_builder_check @@ -5935,7 +6011,7 @@ fn cfg_builder_check_size(g: &CfgBuilder) -> crate::InternalResult<()> { } /// flowgraph.c translate_jump_labels_to_targets -fn translate_jump_labels_to_targets(blocks: &mut [Block]) -> crate::InternalResult<()> { +fn translate_jump_labels_to_targets(blocks: &mut Blocks) -> crate::InternalResult<()> { let max_label = get_max_label(blocks); let label_count = (max_label + 1) as usize; if label_count > usize::MAX / core::mem::size_of::() { @@ -5947,7 +6023,7 @@ fn translate_jump_labels_to_targets(blocks: &mut [Block]) -> crate::InternalResu let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { - let block = &blocks[block_idx.idx()]; + let block = &blocks[block_idx]; if is_label(block.cpython_label) { let label_id = block.cpython_label; debug_assert!(label_id.0 <= max_label); @@ -5958,20 +6034,17 @@ fn translate_jump_labels_to_targets(blocks: &mut [Block]) -> crate::InternalResu block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { - let next = blocks[block_idx.idx()].next; - for i in 0..blocks[block_idx.idx()].instruction_used { - let info = &mut blocks[block_idx.idx()].instructions[i]; + let next = blocks[block_idx].next; + for i in 0..blocks[block_idx].instruction_used { + let info = &mut blocks[block_idx].instructions[i]; debug_assert_eq!(info.target, BlockIdx::NULL); if info.instr.has_target() { let lbl = u32::from(info.arg) as i32; debug_assert!(lbl >= 0 && lbl <= max_label); let target = label_to_block[lbl as usize]; debug_assert!(target != BlockIdx::NULL); - debug_assert_eq!( - blocks[target.idx()].cpython_label, - InstructionSequenceLabel(lbl) - ); info.target = target; + debug_assert_eq!(blocks[target].cpython_label, InstructionSequenceLabel(lbl)); } } block_idx = next; @@ -5982,7 +6055,7 @@ fn translate_jump_labels_to_targets(blocks: &mut [Block]) -> crate::InternalResu /// flowgraph.c _PyCfg_FromInstructionSequence fn cfg_from_instruction_sequence( mut instr_sequence: InstructionSequence, -) -> crate::InternalResult> { +) -> crate::InternalResult { instruction_sequence_apply_label_map(&mut instr_sequence)?; let mut builder = cfg_builder_new()?; @@ -6061,7 +6134,7 @@ fn cfg_from_instruction_sequence( /// flowgraph.c maybe_push fn maybe_push( - blocks: &mut [Block], + blocks: &mut Blocks, worklist: &mut CfgTraversalStack, block: BlockIdx, unsafe_mask: u64, @@ -6081,7 +6154,7 @@ fn maybe_push( /// flowgraph.c scan_block_for_locals fn scan_block_for_locals( - blocks: &mut [Block], + blocks: &mut Blocks, block_idx: BlockIdx, worklist: &mut CfgTraversalStack, ) { @@ -6150,7 +6223,7 @@ fn scan_block_for_locals( } /// flowgraph.c fast_scan_many_locals -fn fast_scan_many_locals(blocks: &mut [Block], nlocals: usize) -> crate::InternalResult<()> { +fn fast_scan_many_locals(blocks: &mut Blocks, nlocals: usize) -> crate::InternalResult<()> { debug_assert!(nlocals > LOCAL_UNSAFE_MASK_BITS); let mut states = Vec::new(); states @@ -6198,7 +6271,7 @@ fn fast_scan_many_locals(blocks: &mut [Block], nlocals: usize) -> crate::Interna /// flowgraph.c add_checks_for_loads_of_uninitialized_variables fn add_checks_for_loads_of_uninitialized_variables( - blocks: &mut [Block], + blocks: &mut Blocks, mut nlocals: usize, nparams: usize, ) -> crate::InternalResult<()> { @@ -6232,9 +6305,9 @@ fn add_checks_for_loads_of_uninitialized_variables( } /// Follow chain of empty blocks to find first non-empty block. -fn next_nonempty_block(blocks: &[Block], mut idx: BlockIdx) -> BlockIdx { - while idx != BlockIdx::NULL && blocks[idx.idx()].instruction_used == 0 { - idx = blocks[idx.idx()].next; +fn next_nonempty_block(blocks: &Blocks, mut idx: BlockIdx) -> BlockIdx { + while idx != BlockIdx::NULL && blocks[idx].instruction_used == 0 { + idx = blocks[idx].next; } idx } @@ -6333,10 +6406,7 @@ fn basicblock_has_no_lineno(block: &Block) -> bool { } /// flowgraph.c copy_basicblock -fn copy_basicblock( - blocks: &mut Vec, - block_idx: BlockIdx, -) -> crate::InternalResult { +fn copy_basicblock(blocks: &mut Blocks, block_idx: BlockIdx) -> crate::InternalResult { debug_assert!(bb_no_fallthrough(&blocks[block_idx.idx()])); let result = blocks_new_block(blocks)?; basicblock_append_block_instructions(blocks, result, block_idx)?; @@ -6344,7 +6414,7 @@ fn copy_basicblock( } /// flowgraph.c get_max_label -fn get_max_label(blocks: &[Block]) -> i32 { +fn get_max_label(blocks: &Blocks) -> i32 { let mut lbl = -1; let mut current = BlockIdx(0); while current != BlockIdx::NULL { @@ -6355,61 +6425,58 @@ fn get_max_label(blocks: &[Block]) -> i32 { lbl } -fn duplicate_exits_without_lineno(blocks: &mut Vec) -> crate::InternalResult<()> { +fn duplicate_exits_without_lineno(blocks: &mut Blocks) -> crate::InternalResult<()> { let mut next_lbl = get_max_label(blocks) + 1; let entryblock = BlockIdx(0); let mut b = entryblock; while b != BlockIdx::NULL { - let Some(last) = basicblock_last_instr(&blocks[b.idx()]).copied() else { - b = blocks[b.idx()].next; + let Some(last) = basicblock_last_instr(&blocks[b]).copied() else { + b = blocks[b].next; continue; }; if is_jump(&last) { debug_assert!(last.target != BlockIdx::NULL); let target = next_nonempty_block(blocks, last.target); debug_assert!(target != BlockIdx::NULL); - if is_exit_or_eval_check_without_lineno(&blocks[target.idx()]) - && blocks[target.idx()].predecessors > 1 + if is_exit_or_eval_check_without_lineno(&blocks[target]) + && blocks[target].predecessors > 1 { let new_target = copy_basicblock(blocks, target)?; instr_set_location( - &mut blocks[new_target.idx()].instructions[0], + &mut blocks[new_target].instructions[0], instr_location(&last), ); - let last_mut = basicblock_last_instr_mut(&mut blocks[b.idx()]).unwrap(); + let last_mut = basicblock_last_instr_mut(&mut blocks[b]).unwrap(); last_mut.target = new_target; - blocks[target.idx()].predecessors -= 1; - blocks[new_target.idx()].predecessors = 1; - blocks[new_target.idx()].next = blocks[target.idx()].next; - blocks[new_target.idx()].cpython_label = InstructionSequenceLabel(next_lbl); + blocks[target].predecessors -= 1; + blocks[new_target].predecessors = 1; + blocks[new_target].next = blocks[target].next; + blocks[new_target].cpython_label = InstructionSequenceLabel(next_lbl); next_lbl += 1; - blocks[target.idx()].next = new_target; + blocks[target].next = new_target; } } - b = blocks[b.idx()].next; + b = blocks[b].next; } b = entryblock; while b != BlockIdx::NULL { - let next = blocks[b.idx()].next; - if bb_has_fallthrough(&blocks[b.idx()]) + let next = blocks[b].next; + if bb_has_fallthrough(&blocks[b]) && next != BlockIdx::NULL - && blocks[b.idx()].instruction_used != 0 - && is_exit_or_eval_check_without_lineno(&blocks[next.idx()]) + && blocks[b].instruction_used != 0 + && is_exit_or_eval_check_without_lineno(&blocks[next]) { - let last = *basicblock_last_instr(&blocks[b.idx()]).expect("block has instructions"); - instr_set_location( - &mut blocks[next.idx()].instructions[0], - instr_location(&last), - ); + let last = *basicblock_last_instr(&blocks[b]).expect("block has instructions"); + instr_set_location(&mut blocks[next].instructions[0], instr_location(&last)); } - b = blocks[b.idx()].next; + b = blocks[b].next; } Ok(()) } -fn propagate_line_numbers(blocks: &mut [Block]) { +fn propagate_line_numbers(blocks: &mut Blocks) { let mut current = BlockIdx(0); while current != BlockIdx::NULL { let idx = current.idx(); @@ -6431,30 +6498,30 @@ fn propagate_line_numbers(blocks: &mut [Block]) { if bb_has_fallthrough(&blocks[idx]) { debug_assert!(next != BlockIdx::NULL); if next != BlockIdx::NULL - && blocks[next.idx()].predecessors == 1 - && blocks[next.idx()].instruction_used != 0 - && instruction_is_no_location(&blocks[next.idx()].instructions[0]) + && blocks[next].predecessors == 1 + && blocks[next].instruction_used != 0 + && instruction_is_no_location(&blocks[next].instructions[0]) { - instr_set_location(&mut blocks[next.idx()].instructions[0], prev_location); + instr_set_location(&mut blocks[next].instructions[0], prev_location); } } if is_jump(&last) { let target = last.target; debug_assert!(target != BlockIdx::NULL); - if blocks[target.idx()].predecessors == 1 { - let instr = basicblock_raw_first_instr_mut(&mut blocks[target.idx()]); + if blocks[target].predecessors == 1 { + let instr = basicblock_raw_first_instr_mut(&mut blocks[target]); if instruction_is_no_location(instr) { instr_set_location(instr, prev_location); } } } - current = blocks[current.idx()].next; + current = blocks[current].next; } } fn resolve_line_numbers( - blocks: &mut Vec, + blocks: &mut Blocks, _firstlineno: OneIndexed, ) -> crate::InternalResult<()> { duplicate_exits_without_lineno(blocks)?; @@ -6481,7 +6548,7 @@ fn copy_except_stack(stack: &CfgExceptStack) -> crate::InternalResult Option { +fn except_stack_top(stack: &CfgExceptStack, blocks: &Blocks) -> Option { debug_assert!(stack.depth <= CO_MAXBLOCKS + 1); let handler_block = stack.handlers[stack.depth]; if handler_block == BlockIdx::NULL { @@ -6489,7 +6556,7 @@ fn except_stack_top(stack: &CfgExceptStack, blocks: &[Block]) -> Option Option Option { debug_assert!(is_block_push(&setup)); let instr = setup.instr; @@ -6507,7 +6574,7 @@ fn push_except_block( instr.pseudo(), Some(PseudoInstruction::SetupWith { .. } | PseudoInstruction::SetupCleanup { .. }) ) { - blocks[target.idx()].preserve_lasti = true; + blocks[target].preserve_lasti = true; } debug_assert!(stack.depth <= CO_MAXBLOCKS); stack.depth += 1; @@ -6517,14 +6584,14 @@ fn push_except_block( } /// flowgraph.c pop_except_block -fn pop_except_block(stack: &mut CfgExceptStack, blocks: &[Block]) -> Option { +fn pop_except_block(stack: &mut CfgExceptStack, blocks: &Blocks) -> Option { debug_assert!(stack.depth > 0); stack.depth -= 1; debug_assert!(stack.depth <= CO_MAXBLOCKS); except_stack_top(stack, blocks) } -pub(crate) fn label_exception_targets(blocks: &mut [Block]) -> crate::InternalResult<()> { +pub(crate) fn label_exception_targets(blocks: &mut Blocks) -> crate::InternalResult<()> { let mut todo = make_cfg_traversal_stack(blocks)?; todo.push(BlockIdx(0)); @@ -6553,12 +6620,12 @@ pub(crate) fn label_exception_targets(blocks: &mut [Block]) -> crate::InternalRe if is_block_push(&info) { debug_assert!(target != BlockIdx::NULL); - if !blocks[target.idx()].visited { - blocks[target.idx()].except_stack = Some(copy_except_stack( + if !blocks[target].visited { + blocks[target].except_stack = Some(copy_except_stack( stack.as_ref().expect("active exception stack"), )?); todo.push(target); - blocks[target.idx()].visited = true; + blocks[target].visited = true; } handler = push_except_block( stack.as_mut().expect("active exception stack"), @@ -6576,20 +6643,20 @@ pub(crate) fn label_exception_targets(blocks: &mut [Block]) -> crate::InternalRe // when this block can also fall through, otherwise transfer it // to the jump target. debug_assert!(target != BlockIdx::NULL); - if !blocks[target.idx()].visited { + if !blocks[target].visited { if bb_has_fallthrough(&blocks[bi]) { - blocks[target.idx()].except_stack = Some(copy_except_stack( + blocks[target].except_stack = Some(copy_except_stack( stack.as_ref().expect("active exception stack"), )?); } else { - blocks[target.idx()].except_stack = stack.take(); + blocks[target].except_stack = stack.take(); stack_transferred = true; todo.push(target); - blocks[target.idx()].visited = true; + blocks[target].visited = true; break; } todo.push(target); - blocks[target.idx()].visited = true; + blocks[target].visited = true; } } else if matches!(instr.real(), Some(Instruction::YieldValue { .. })) { blocks[bi].instructions[i].except_handler = handler; @@ -6614,10 +6681,10 @@ pub(crate) fn label_exception_targets(blocks: &mut [Block]) -> crate::InternalRe let next = blocks[bi].next; if !stack_transferred && bb_has_fallthrough(&blocks[bi]) { debug_assert!(next != BlockIdx::NULL); - if next != BlockIdx::NULL && !blocks[next.idx()].visited { - blocks[next.idx()].except_stack = stack.take(); + if next != BlockIdx::NULL && !blocks[next].visited { + blocks[next].except_stack = stack.take(); todo.push(next); - blocks[next.idx()].visited = true; + blocks[next].visited = true; } } } @@ -6625,7 +6692,7 @@ pub(crate) fn label_exception_targets(blocks: &mut [Block]) -> crate::InternalRe { let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { - let block = &blocks[block_idx.idx()]; + let block = &blocks[block_idx]; debug_assert!(block.except_stack.is_none()); block_idx = block.next; } @@ -6635,7 +6702,7 @@ pub(crate) fn label_exception_targets(blocks: &mut [Block]) -> crate::InternalRe /// Convert remaining pseudo ops to real instructions or NOP. /// flowgraph.c convert_pseudo_ops -pub(crate) fn convert_pseudo_ops(blocks: &mut [Block]) -> crate::InternalResult<()> { +pub(crate) fn convert_pseudo_ops(blocks: &mut Blocks) -> crate::InternalResult<()> { let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { let next = blocks[block_idx.idx()].next; @@ -6703,7 +6770,7 @@ pub(crate) fn build_cellfixedoffsets( #[allow(clippy::needless_range_loop)] pub(crate) fn fix_cell_offsets( metadata: &CodeUnitMetadata, - blocks: &mut [Block], + blocks: &mut Blocks, cellfixedoffsets: &mut [i32], ) -> usize { let nlocals = metadata.varnames.len(); @@ -6802,7 +6869,7 @@ mod tests { flags: CodeFlags::empty(), source_path: "source_path".to_owned(), private: None, - blocks: vec![block], + blocks: Blocks::from([block]), current_block: BlockIdx::new(0), instr_sequence: instruction_sequence_new(), instr_sequence_label_map: InstructionSequenceLabelMap::new(), @@ -6881,7 +6948,7 @@ mod tests { assert_eq!(stack.handlers.len(), CO_MAXBLOCKS + 2); assert_eq!(stack.handlers[0], BlockIdx::NULL); - let mut blocks = vec![Block::default(), Block::default()]; + let mut blocks = Blocks::from([Block::default(), Block::default()]); assert!(except_stack_top(&stack, &blocks).is_none()); let setup = InstructionInfo { @@ -6942,7 +7009,7 @@ mod tests { #[test] fn cfg_traversal_stack_resets_visited_and_allocates_for_blocks() { - let mut blocks = vec![Block::default(), Block::default()]; + let mut blocks = Blocks::from([Block::default(), Block::default()]); blocks[0].next = BlockIdx::new(1); blocks[0].visited = true; blocks[1].visited = true; @@ -7156,7 +7223,7 @@ mod tests { handler_block: BlockIdx::new(5), preserve_lasti: false, }; - let mut blocks = vec![Block::default(), Block::default()]; + let mut blocks = Blocks::from([Block::default(), Block::default()]); let mut stale = test_instr(Instruction::Nop, 41); stale.except_handler = Some(handler); test_block_push(&mut blocks[0], stale); @@ -7180,7 +7247,7 @@ mod tests { assert_eq!(info.target, BlockIdx::new(1)); - let mut blocks = vec![Block::default(), Block::default()]; + let mut blocks = Blocks::from([Block::default(), Block::default()]); test_block_push(&mut blocks[0], info); blocks[0].next = BlockIdx::new(1); @@ -7195,7 +7262,7 @@ mod tests { fn cfg_to_instruction_sequence_requires_target_for_target_opcodes() { let mut block = Block::default(); test_block_push(&mut block, test_jump(BlockIdx::NULL, 51)); - let mut blocks = vec![block]; + let mut blocks = Blocks::from([block]); let mut instr_sequence = instruction_sequence_new(); let _ = cfg_to_instruction_sequence(&mut blocks, &mut instr_sequence); @@ -7393,7 +7460,7 @@ mod tests { #[test] fn resolve_line_numbers_duplicates_exit_blocks_like_cpython() { let exit = BlockIdx::new(2); - let mut blocks = vec![Block::default(), Block::default(), Block::default()]; + let mut blocks = Blocks::from([Block::default(), Block::default(), Block::default()]); blocks[0].cpython_label = InstructionSequenceLabel::from_index(0); blocks[1].cpython_label = InstructionSequenceLabel::from_index(1); blocks[2].cpython_label = InstructionSequenceLabel::from_index(2); @@ -7412,15 +7479,12 @@ mod tests { let duplicate = blocks[0].instructions[0].target; assert_ne!(duplicate, exit); assert_eq!( - blocks[duplicate.idx()].cpython_label, + blocks[duplicate].cpython_label, InstructionSequenceLabel::from_index(3) ); - assert_eq!( - instruction_lineno(&blocks[duplicate.idx()].instructions[0]), - 10 - ); + assert_eq!(instruction_lineno(&blocks[duplicate].instructions[0]), 10); assert_eq!(blocks[1].instructions[0].target, exit); - assert_eq!(instruction_lineno(&blocks[exit.idx()].instructions[0]), 20); + assert_eq!(instruction_lineno(&blocks[exit].instructions[0]), 20); } #[test] @@ -7431,7 +7495,7 @@ mod tests { block.instructions[1].lineno_override = Some(NEXT_LOCATION_OVERRIDE); test_block_push(&mut block, test_instr(Instruction::ReturnValue, 30)); block.instructions[2].lineno_override = Some(NO_LOCATION_OVERRIDE); - let mut blocks = vec![block]; + let mut blocks = [block].into(); remove_unreachable(&mut blocks).expect("remove_unreachable succeeds"); propagate_line_numbers(&mut blocks); @@ -7452,7 +7516,7 @@ mod tests { #[test] fn propagate_line_numbers_updates_empty_jump_target_raw_slot_like_cpython() { - let mut blocks = vec![Block::default(), Block::default(), Block::default()]; + let mut blocks = Blocks::from([Block::default(), Block::default(), Block::default()]); blocks[0].next = BlockIdx::new(2); test_block_push(&mut blocks[0], test_cond_jump(BlockIdx::new(1), 10)); test_block_push(&mut blocks[1], test_instr(Instruction::Nop, 20)); @@ -7486,12 +7550,12 @@ mod tests { #[test] fn jump_threading_rechecks_new_jump_like_cpython() { - let mut blocks = vec![ + let mut blocks = Blocks::from([ Block::default(), Block::default(), Block::default(), Block::default(), - ]; + ]); for (i, block) in blocks.iter_mut().enumerate() { block.cpython_label = InstructionSequenceLabel::from_index(i as i32); } From af6b517767d4b7204821e74c08fa15cedc3b0292 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:47:10 +0300 Subject: [PATCH 016/351] Implement `collections.defaultdict` in rust (#8132) --- Lib/collections/__init__.py | 3 +- Lib/collections/_defaultdict.py | 62 -------- Lib/test/test_dataclasses/__init__.py | 2 - crates/vm/src/builtins/dict.rs | 4 +- crates/vm/src/stdlib/_collections.rs | 201 +++++++++++++++++++++++++- 5 files changed, 199 insertions(+), 73 deletions(-) delete mode 100644 Lib/collections/_defaultdict.py diff --git a/Lib/collections/__init__.py b/Lib/collections/__init__.py index 3d3bbd7a39a..803de0c6792 100644 --- a/Lib/collections/__init__.py +++ b/Lib/collections/__init__.py @@ -57,8 +57,7 @@ try: from _collections import defaultdict except ImportError: - # TODO: RUSTPYTHON - implement defaultdict in Rust - from ._defaultdict import defaultdict + pass heapq = None # Lazily imported diff --git a/Lib/collections/_defaultdict.py b/Lib/collections/_defaultdict.py deleted file mode 100644 index cb9d403c8ad..00000000000 --- a/Lib/collections/_defaultdict.py +++ /dev/null @@ -1,62 +0,0 @@ -from reprlib import recursive_repr as _recursive_repr - -class defaultdict(dict): - def __init__(self, *args, **kwargs): - if len(args) >= 1: - default_factory = args[0] - if default_factory is not None and not callable(default_factory): - raise TypeError("first argument must be callable or None") - args = args[1:] - else: - default_factory = None - super().__init__(*args, **kwargs) - self.default_factory = default_factory - - def __missing__(self, key): - if self.default_factory is not None: - val = self.default_factory() - else: - raise KeyError(key) - # CPython parity: a recursive __missing__ via factory() may have - # already populated key; preserve that value instead of overwriting. - if key in self: - return self[key] - self[key] = val - return val - - @_recursive_repr() - def __repr_factory(factory): - return repr(factory) - - def __repr__(self): - return f"{type(self).__name__}({defaultdict.__repr_factory(self.default_factory)}, {dict.__repr__(self)})" - - def copy(self): - return type(self)(self.default_factory, self) - - __copy__ = copy - - def __reduce__(self): - if self.default_factory is not None: - args = self.default_factory, - else: - args = () - return type(self), args, None, None, iter(self.items()) - - def __or__(self, other): - if not isinstance(other, dict): - return NotImplemented - - new = defaultdict(self.default_factory, self) - new.update(other) - return new - - def __ror__(self, other): - if not isinstance(other, dict): - return NotImplemented - - new = defaultdict(self.default_factory, other) - new.update(self) - return new - -defaultdict.__module__ = 'collections' diff --git a/Lib/test/test_dataclasses/__init__.py b/Lib/test/test_dataclasses/__init__.py index 1a6dc850ab9..96f42183296 100644 --- a/Lib/test/test_dataclasses/__init__.py +++ b/Lib/test/test_dataclasses/__init__.py @@ -1795,7 +1795,6 @@ class C: self.assertIsNot(d['f'], t) self.assertEqual(d['f'].my_a(), 6) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_helper_asdict_defaultdict(self): # Ensure asdict() does not throw exceptions when a # defaultdict is a member of a dataclass @@ -1938,7 +1937,6 @@ class C: t = astuple(c, tuple_factory=list) self.assertEqual(t, ['outer', T(1, ['inner', T(11, 12, 13)], 2)]) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_helper_astuple_defaultdict(self): # Ensure astuple() does not throw exceptions when a # defaultdict is a member of a dataclass diff --git a/crates/vm/src/builtins/dict.rs b/crates/vm/src/builtins/dict.rs index db97045f07c..06791410edf 100644 --- a/crates/vm/src/builtins/dict.rs +++ b/crates/vm/src/builtins/dict.rs @@ -387,7 +387,7 @@ impl PyDict { } #[pymethod] - fn setdefault( + pub(crate) fn setdefault( &self, key: PyObjectRef, default: OptionalArg, @@ -406,7 +406,7 @@ impl PyDict { } #[pymethod] - fn update( + pub(crate) fn update( &self, dict_obj: OptionalArg, kwargs: KwArgs, diff --git a/crates/vm/src/stdlib/_collections.rs b/crates/vm/src/stdlib/_collections.rs index 4f580ab5dff..c7cce5c735a 100644 --- a/crates/vm/src/stdlib/_collections.rs +++ b/crates/vm/src/stdlib/_collections.rs @@ -7,18 +7,19 @@ mod _collections { atomic_func, builtins::{ IterStatus::{Active, Exhausted}, - PositionIterInternal, PyGenericAlias, PyInt, PyStr, PyType, PyTypeRef, + PositionIterInternal, PyDict, PyGenericAlias, PyInt, PyStr, PyType, PyTypeRef, }, common::lock::{PyMutex, PyRwLock, PyRwLockReadGuard, PyRwLockWriteGuard}, - function::{KwArgs, OptionalArg, PyComparisonValue}, + convert::ToPyObject, + function::{FuncArgs, KwArgs, OptionalArg, PyComparisonValue}, iter::PyExactSizeIterator, - protocol::{PyIterReturn, PyNumberMethods, PySequenceMethods}, + protocol::{PyIterReturn, PyMappingMethods, PyNumberMethods, PySequenceMethods}, recursion::ReprGuard, sequence::{MutObjectSequenceOp, OptionalRangeArgs}, sliceable::SequenceIndexOp, types::{ - AsNumber, AsSequence, Comparable, Constructor, DefaultConstructor, Initializer, - IterNext, Iterable, PyComparisonOp, Representable, SelfIter, + AsMapping, AsNumber, AsSequence, Comparable, Constructor, DefaultConstructor, + Initializer, IterNext, Iterable, PyComparisonOp, Representable, SelfIter, }, utils::collection_repr, }; @@ -746,4 +747,194 @@ mod _collections { }) } } + + #[pyattr] + #[pyclass( + module = "collections", + name = "defaultdict", + base = PyDict, + unhashable = true + )] + #[derive(Debug, Default)] + struct PyDefaultDict { + dict: PyDict, + default_factory: PyRwLock>, + } + + #[pyclass( + with(AsMapping, AsNumber, Constructor, Initializer, Representable), + flags(BASETYPE, MAPPING, HAS_DICT) + )] + impl PyDefaultDict { + #[pygetset] + fn default_factory(&self) -> Option { + self.default_factory.read().clone() + } + + #[pygetset(name = "default_factory", setter)] + fn default_factory_setter(&self, value: PyObjectRef, vm: &VirtualMachine) { + *self.default_factory.write() = if value.is(&vm.ctx.none()) { + None + } else { + Some(value) + }; + } + + #[pymethod] + fn __missing__(&self, key: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let factory = self.default_factory(); + + if let Some(f) = factory { + let value = f.call((), vm)?; + self.dict.setdefault(key, value.into(), vm) + } else { + Err(vm.new_key_error(key)) + } + } + + #[pymethod] + #[pymethod(name = "__copy__")] + fn copy(&self) -> Self { + let default_factory = self.default_factory(); + + Self { + dict: self.dict.copy(), + default_factory: PyRwLock::new(default_factory), + } + } + + #[pymethod] + fn __reduce__(zelf: PyRef, vm: &VirtualMachine) -> PyResult { + let cls = zelf.class().to_owned(); + + let default_factory = zelf.default_factory(); + let factory_tuple_elements = + default_factory.map_or_else(Vec::new, |factory| vec![factory]); + let factory_tuple = vm.ctx.new_tuple(factory_tuple_elements); + + let items_fn = zelf.as_object().get_attr("items", vm)?; + let items_iter = items_fn.call((), vm)?; + let iter = items_iter.get_iter(vm)?; + let none = vm.ctx.none(); + + Ok(vm + .ctx + .new_tuple(vec![ + cls.into(), + factory_tuple.into(), + none.clone(), + none, + iter.into(), + ]) + .into()) + } + } + + impl PyDefaultDict { + fn __or__(lhs: PyObjectRef, rhs: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let not_implemented = || Ok(vm.ctx.not_implemented.clone().into()); + + let (default_factory, dict) = if let Some(zelf) = lhs.downcast_ref::() { + if !rhs.fast_isinstance(vm.ctx.types.dict_type) { + return not_implemented(); + } + + (zelf.default_factory(), zelf.dict.copy()) + } else if let Some(zelf) = rhs.downcast_ref::() { + let Some(dict) = lhs.downcast_ref::() else { + return not_implemented(); + }; + + (zelf.default_factory(), dict.copy()) + } else { + return Err(vm.new_type_error(format!( + "unsupported operand type(s) for |: '{}' and '{}'", + lhs.class().name(), + rhs.class().name() + ))); + }; + + dict.update(rhs.into(), KwArgs::default(), vm)?; + + Ok(Self { + dict, + default_factory: PyRwLock::new(default_factory), + } + .to_pyobject(vm)) + } + } + + impl DefaultConstructor for PyDefaultDict {} + + impl Initializer for PyDefaultDict { + type Args = FuncArgs; + + fn init(zelf: PyRef, mut args: Self::Args, vm: &VirtualMachine) -> PyResult<()> { + let default_factory = args.take_positional().map_or(Ok(None), |factory| { + let is_none = factory.is(&vm.ctx.none()); + + if !is_none && !factory.is_callable() { + Err(vm.new_type_error("first argument must be callable or None")) + } else if is_none { + Ok(None) + } else { + Ok(Some(factory)) + } + })?; + + *zelf.default_factory.write() = default_factory; + + zelf.dict.update( + OptionalArg::from_option(args.take_positional()), + args.kwargs, + vm, + )?; + + Ok(()) + } + } + + impl Representable for PyDefaultDict { + fn repr_str(zelf: &Py, vm: &VirtualMachine) -> PyResult { + let default_factory = zelf.default_factory.read(); + + let factory_repr = match default_factory.as_ref() { + Some(factory) => { + if let Some(_guard) = ReprGuard::enter(vm, factory) { + factory.repr(vm)?.to_string() + } else { + String::from("...") + } + } + None => String::from("None"), + }; + + let dict_repr = Representable::repr(&zelf.dict.copy().into_ref(&vm.ctx), vm)?; + + Ok(format!( + "{}({}, {})", + zelf.class().name(), + factory_repr, + dict_repr + )) + } + } + + impl AsMapping for PyDefaultDict { + fn as_mapping() -> &'static PyMappingMethods { + PyDict::as_mapping() + } + } + + impl AsNumber for PyDefaultDict { + fn as_number() -> &'static PyNumberMethods { + static AS_NUMBER: PyNumberMethods = PyNumberMethods { + or: Some(|a, b, vm| { + PyDefaultDict::__or__(a.to_pyobject(vm), b.to_pyobject(vm), vm) + }), + ..PyNumberMethods::NOT_IMPLEMENTED + }; + &AS_NUMBER + } + } } From efa585b85ba313e283f0b4d44bc5ab0aacbae170 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:47:28 +0900 Subject: [PATCH 017/351] Bump https://github.com/astral-sh/ruff-pre-commit (#8143) Bumps [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit) from v0.15.16 to 0.15.17. - [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases) - [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.16...v0.15.17) --- updated-dependencies: - dependency-name: https://github.com/astral-sh/ruff-pre-commit dependency-version: 0.15.17 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 39481edaa9f..6e71920d5f4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: priority: 0 - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.16 + rev: v0.15.17 hooks: - id: ruff-format priority: 0 From 2e40f78bf1141eeac7da8f236c82376ea7ce63e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:47:34 +0900 Subject: [PATCH 018/351] Bump github/gh-aw from 0.77.5 to 0.79.9 (#8144) Bumps [github/gh-aw](https://github.com/github/gh-aw) from 0.77.5 to 0.79.9. - [Release notes](https://github.com/github/gh-aw/releases) - [Changelog](https://github.com/github/gh-aw/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/gh-aw/compare/f990bbb7eb83981a203d4b5eccdc24f677e950c7...54ad1f83a833db4de127cf278b00438e19a103a0) --- updated-dependencies: - dependency-name: github/gh-aw dependency-version: 0.79.9 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/upgrade-pylib.lock.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/upgrade-pylib.lock.yml b/.github/workflows/upgrade-pylib.lock.yml index 1a028f728b1..17361719a6d 100644 --- a/.github/workflows/upgrade-pylib.lock.yml +++ b/.github/workflows/upgrade-pylib.lock.yml @@ -58,7 +58,7 @@ jobs: comment_repo: "" steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@f990bbb7eb83981a203d4b5eccdc24f677e950c7 # v0.77.5 + uses: github/gh-aw/actions/setup@54ad1f83a833db4de127cf278b00438e19a103a0 # v0.79.9 with: destination: /opt/gh-aw/actions - name: Check workflow file timestamps @@ -99,7 +99,7 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@f990bbb7eb83981a203d4b5eccdc24f677e950c7 # v0.77.5 + uses: github/gh-aw/actions/setup@54ad1f83a833db4de127cf278b00438e19a103a0 # v0.79.9 with: destination: /opt/gh-aw/actions - name: Checkout repository @@ -806,7 +806,7 @@ jobs: total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@f990bbb7eb83981a203d4b5eccdc24f677e950c7 # v0.77.5 + uses: github/gh-aw/actions/setup@54ad1f83a833db4de127cf278b00438e19a103a0 # v0.79.9 with: destination: /opt/gh-aw/actions - name: Download agent output artifact @@ -927,7 +927,7 @@ jobs: success: ${{ steps.parse_results.outputs.success }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@f990bbb7eb83981a203d4b5eccdc24f677e950c7 # v0.77.5 + uses: github/gh-aw/actions/setup@54ad1f83a833db4de127cf278b00438e19a103a0 # v0.79.9 with: destination: /opt/gh-aw/actions - name: Download agent artifacts @@ -1039,7 +1039,7 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@f990bbb7eb83981a203d4b5eccdc24f677e950c7 # v0.77.5 + uses: github/gh-aw/actions/setup@54ad1f83a833db4de127cf278b00438e19a103a0 # v0.79.9 with: destination: /opt/gh-aw/actions - name: Download agent output artifact From 05ff78e324fa2df9afc9a75d03d67d1dfb51bddc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:47:46 +0900 Subject: [PATCH 019/351] Bump https://github.com/rbubley/mirrors-prettier from v3.8.3 to 3.8.4 (#8145) Bumps [https://github.com/rbubley/mirrors-prettier](https://github.com/rbubley/mirrors-prettier) from v3.8.3 to 3.8.4. - [Commits](https://github.com/rbubley/mirrors-prettier/compare/v3.8.3...v3.8.4) --- updated-dependencies: - dependency-name: https://github.com/rbubley/mirrors-prettier dependency-version: 3.8.4 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6e71920d5f4..07edb8a052f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -77,7 +77,7 @@ repos: priority: 0 - repo: https://github.com/rbubley/mirrors-prettier - rev: v3.8.3 + rev: v3.8.4 hooks: - id: prettier files: '^wasm/.*$' From 83e33997ea6ecf160e37a570b6828c9a8606106f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:47:52 +0900 Subject: [PATCH 020/351] Bump the malachite group across 1 directory with 3 updates (#8146) Bumps the malachite group with 2 updates in the / directory: [malachite-bigint](https://github.com/mhogrefe/malachite) and [malachite-q](https://github.com/mhogrefe/malachite). Updates `malachite-bigint` from 0.9.1 to 0.9.2 - [Release notes](https://github.com/mhogrefe/malachite/releases) - [Commits](https://github.com/mhogrefe/malachite/compare/v0.9.1...v0.9.2) Updates `malachite-q` from 0.9.1 to 0.9.2 - [Release notes](https://github.com/mhogrefe/malachite/releases) - [Commits](https://github.com/mhogrefe/malachite/compare/v0.9.1...v0.9.2) Updates `malachite-base` from 0.9.1 to 0.9.2 - [Release notes](https://github.com/mhogrefe/malachite/releases) - [Commits](https://github.com/mhogrefe/malachite/compare/v0.9.1...v0.9.2) --- updated-dependencies: - dependency-name: malachite-bigint dependency-version: 0.9.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: malachite - dependency-name: malachite-q dependency-version: 0.9.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: malachite - dependency-name: malachite-base dependency-version: 0.9.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: malachite ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 60080587885..c13822218e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2240,9 +2240,9 @@ dependencies = [ [[package]] name = "malachite-base" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8b6f86fdbb1eb9955946be91775239dfcb0acdb1a51bb07d5fc9b8c854f5ccd" +checksum = "a4f44099731f17094b07825c88ccb5fbd1bfa1f82fafff7daa33e8b8652db16e" dependencies = [ "hashbrown 0.16.1", "itertools 0.14.0", @@ -2252,9 +2252,9 @@ dependencies = [ [[package]] name = "malachite-bigint" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67fcd6e504ffc67db2b3c6d5e90e08054646e2b04f42115a5460bf1c1e37d3bc" +checksum = "cc58206ba15e9c406e20c95c5f86efa07b12f94080945908e910b3a0faa23fef" dependencies = [ "malachite-base", "malachite-nz", @@ -2265,9 +2265,9 @@ dependencies = [ [[package]] name = "malachite-nz" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0197a2f5cfee19d59178e282985c6ca79a9233e26a2adcf40acb693896aa09f6" +checksum = "a137660cdba20f136c8a223125f08088adb4e0b72fbb8466f08c43e31cc0427d" dependencies = [ "itertools 0.14.0", "libm", @@ -2277,11 +2277,12 @@ dependencies = [ [[package]] name = "malachite-q" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2add95162aede090c48f0ee51bea7d328847ce3180aa44588111f846cc116b" +checksum = "5ffcbeed95e34c0fcc3864ccd146e129cbbf7de1513d3afbcfb47c7674c82d94" dependencies = [ "itertools 0.14.0", + "libm", "malachite-base", "malachite-nz", ] From 838b0c45a0d68cb535a2fd2206c57586197bd4d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:48:02 +0900 Subject: [PATCH 021/351] Bump getrandom in the random group across 1 directory (#8149) Bumps the random group with 1 update in the / directory: [getrandom](https://github.com/rust-random/getrandom). Updates `getrandom` from 0.4.2 to 0.4.3 - [Changelog](https://github.com/rust-random/getrandom/blob/master/CHANGELOG.md) - [Commits](https://github.com/rust-random/getrandom/compare/v0.4.2...v0.4.3) --- updated-dependencies: - dependency-name: getrandom dependency-version: 0.4.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: random ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 186 +++-------------------------------------------------- 1 file changed, 9 insertions(+), 177 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c13822218e0..6c88e96bb02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1322,12 +1322,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -1457,17 +1451,15 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", "wasm-bindgen", ] @@ -1510,22 +1502,13 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -1534,7 +1517,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" dependencies = [ - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -1781,12 +1764,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "indexmap" version = "2.14.0" @@ -1795,8 +1772,6 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.0", - "serde", - "serde_core", ] [[package]] @@ -1996,12 +1971,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "lexical-parse-float" version = "1.0.6" @@ -3103,7 +3072,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -3514,7 +3483,7 @@ version = "0.5.0" dependencies = [ "ascii", "bitflags 2.13.0", - "getrandom 0.4.2", + "getrandom 0.4.3", "itertools 0.14.0", "libc", "lock_api", @@ -3602,7 +3571,7 @@ name = "rustpython-host_env" version = "0.5.0" dependencies = [ "bitflags 2.13.0", - "getrandom 0.4.2", + "getrandom 0.4.3", "junction", "libc", "libffi", @@ -4605,12 +4574,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "unicode_names2" version = "1.3.0" @@ -4684,7 +4647,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "atomic", - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -4723,16 +4686,7 @@ version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -4790,40 +4744,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "wasmtime-internal-core" version = "45.0.1" @@ -5161,100 +5081,12 @@ dependencies = [ "version_check", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.13.0", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "write16" version = "1.0.0" From 83799fcad5de6822e32a42b5fa67fa1cf2975964 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:48:10 +0900 Subject: [PATCH 022/351] Bump webpki-roots in the webpki-root group across 1 directory (#8150) Bumps the webpki-root group with 1 update in the / directory: [webpki-roots](https://github.com/rustls/webpki-roots). Updates `webpki-roots` from 1.0.7 to 1.0.8 - [Release notes](https://github.com/rustls/webpki-roots/releases) - [Commits](https://github.com/rustls/webpki-roots/compare/v/1.0.7...v/1.0.8) --- updated-dependencies: - dependency-name: webpki-roots dependency-version: 1.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: webpki-root ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6c88e96bb02..6de30d4ad02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4787,9 +4787,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] From e62985731c16ec90a4c6d7eec0d63073c641114f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:48:17 +0900 Subject: [PATCH 023/351] Bump which from 8.0.3 to 8.0.4 (#8151) Bumps [which](https://github.com/harryfei/which-rs) from 8.0.3 to 8.0.4. - [Release notes](https://github.com/harryfei/which-rs/releases) - [Changelog](https://github.com/harryfei/which-rs/blob/master/CHANGELOG.md) - [Commits](https://github.com/harryfei/which-rs/compare/8.0.3...8.0.4) --- updated-dependencies: - dependency-name: which dependency-version: 8.0.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6de30d4ad02..743e293f161 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4796,9 +4796,9 @@ dependencies = [ [[package]] name = "which" -version = "8.0.3" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e" +checksum = "48d7cd18d4acb58fb3cdfe9ea54e6cd96a4e7d4cc45c56338b236e82dad47248" dependencies = [ "libc", ] From 15a754fb0bc2aba948754eab780c7166eab159f9 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:49:58 +0300 Subject: [PATCH 024/351] Use `stable` toolchain when updating caches (#8153) --- .github/workflows/update-caches.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-caches.yml b/.github/workflows/update-caches.yml index fc524fa738e..5e99abac694 100644 --- a/.github/workflows/update-caches.yml +++ b/.github/workflows/update-caches.yml @@ -46,7 +46,7 @@ jobs: persist-credentials: false - name: Setup Rust - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 + uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ matrix.toolchain }} target: ${{ matrix.target }} From 8905b80af816f19ab9101a56822e4e5ceaea1f73 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:50:53 +0300 Subject: [PATCH 025/351] Move some of free standing functions to be `ir::Blocks` method (#8154) --- crates/codegen/src/ir.rs | 6044 +++++++++++++++++++------------------- 1 file changed, 3041 insertions(+), 3003 deletions(-) diff --git a/crates/codegen/src/ir.rs b/crates/codegen/src/ir.rs index e7b50659e8e..5728ddfbefe 100644 --- a/crates/codegen/src/ir.rs +++ b/crates/codegen/src/ir.rs @@ -437,22 +437,6 @@ fn basicblock_insert_instruction( Ok(()) } -/// flowgraph.c basicblock_append_instructions -fn basicblock_append_block_instructions( - blocks: &mut Blocks, - to: BlockIdx, - from: BlockIdx, -) -> crate::InternalResult<()> { - debug_assert_ne!(to, from); - let from_len = blocks[from].instruction_used; - for i in 0..from_len { - let info = blocks[from].instructions[i]; - let off = basicblock_next_instr(&mut blocks[to])?; - blocks[to].instructions[off] = info; - } - Ok(()) -} - /// flowgraph.c direct `b_iused = 0` fn basicblock_clear(block: &mut Block) { block.instruction_used = 0; @@ -753,60 +737,6 @@ fn instruction_sequence_apply_label_map( Ok(()) } -/// flowgraph.c _PyCfg_ToInstructionSequence -fn cfg_to_instruction_sequence( - blocks: &mut Blocks, - instr_sequence: &mut InstructionSequence, -) -> crate::InternalResult<()> { - let mut label_id = 0; - let mut block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - blocks[block_idx.idx()].cpython_label = InstructionSequenceLabel::from_index(label_id); - label_id += 1; - block_idx = blocks[block_idx.idx()].next; - } - - block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - let block_label = blocks[block_idx.idx()].cpython_label; - debug_assert!(is_label(block_label)); - instruction_sequence_use_label(instr_sequence, block_label)?; - - let instr_count = blocks[block_idx.idx()].instruction_used; - for i in 0..instr_count { - if blocks[block_idx.idx()].instructions[i].instr.has_target() { - let target_block = blocks[block_idx.idx()].instructions[i].target; - debug_assert!(target_block != BlockIdx::NULL); - let lbl = blocks[target_block.idx()].cpython_label; - debug_assert!(is_label(lbl)); - blocks[block_idx.idx()].instructions[i].arg = OpArg::new(lbl.0 as u32); - } - - let mut info = blocks[block_idx.idx()].instructions[i]; - info.target = BlockIdx::NULL; - let except_handler = info.except_handler.take(); - let entry = instruction_sequence_addop(instr_sequence, info)?; - let hi = &mut entry.except_handler; - if let Some(handler) = except_handler { - debug_assert!(handler.handler_block != BlockIdx::NULL); - let lbl = blocks[handler.handler_block.idx()].cpython_label; - debug_assert!(is_label(lbl)); - let start_depth = blocks[handler.handler_block.idx()].start_depth; - debug_assert!(start_depth >= 0); - hi.h_label = lbl.0; - hi.start_depth = start_depth; - hi.preserve_lasti = i32::from(handler.preserve_lasti); - } else { - hi.h_label = NO_EXCEPTION_HANDLER_LABEL; - } - } - block_idx = blocks[block_idx.idx()].next; - } - - instruction_sequence_apply_label_map(instr_sequence)?; - Ok(()) -} - /// assemble.c instr_size fn instr_size(instr: &InstructionInfo) -> usize { let opcode = instr.instr.expect_real(); @@ -1307,6 +1237,7 @@ impl Block { #[derive(Clone, Debug, Default)] pub struct Blocks(Vec); +// Vec like methods impl Blocks { pub fn try_reserve( &mut self, @@ -1320,3097 +1251,3389 @@ impl Blocks { } } -impl From> for Blocks { - fn from(value: Vec) -> Self { - Self(value) - } -} +// CPython functions -impl From> for Blocks { - fn from(value: Box<[Block]>) -> Self { - Self(value.into()) - } -} +impl Blocks { + /// # See also + /// [CPython's remove_unreachable](https://github.com/python/cpython/blob/v3.14.6/Python/flowgraph.c#L995-L1041) + pub fn remove_unreachable(&mut self) -> crate::InternalResult<()> { + let mut block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + self[block_idx].predecessors = 0; + block_idx = self[block_idx].next; + } + + let mut stack = self.make_cfg_traversal_stack()?; + self[0].predecessors = 1; + stack.push(BlockIdx(0)); + self[0].visited = true; + while let Some(current) = stack.pop() { + let idx = current.idx(); + let next = self[idx].next; + if next != BlockIdx::NULL && bb_has_fallthrough(&self[idx]) { + if !self[next].visited { + debug_assert_eq!(self[next].predecessors, 0); + stack.push(next); + self[next].visited = true; + } + self[next].predecessors += 1; + } -impl From<&[Block]> for Blocks { - fn from(value: &[Block]) -> Self { - Self(value.to_vec()) - } -} + let instr_count = self[idx].instruction_used; + for i in 0..instr_count { + let instr = self[idx].instructions[i]; + if is_jump(&instr) || is_block_push(&instr) { + let target = instr.target; + debug_assert!(target != BlockIdx::NULL); + let target_idx = target.idx(); + if !self[target_idx].visited { + stack.push(target); + self[target_idx].visited = true; + } + self[target_idx].predecessors += 1; + } + } + } -impl From<&mut [Block]> for Blocks { - fn from(value: &mut [Block]) -> Self { - Self(value.to_vec()) + block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + let next = self[block_idx].next; + if self[block_idx].predecessors == 0 { + let block = &mut self[block_idx]; + basicblock_clear(block); + block.except_handler = false; + } + block_idx = next; + } + Ok(()) } -} -impl From<[Block; N]> for Blocks { - fn from(value: [Block; N]) -> Self { - Self(value.into()) - } -} + /// flowgraph.c basicblock_append_instructions + fn basicblock_append_block_instructions( + &mut self, + to: BlockIdx, + from: BlockIdx, + ) -> crate::InternalResult<()> { + debug_assert_ne!(to, from); -impl From<&[Block; N]> for Blocks { - fn from(value: &[Block; N]) -> Self { - Self(value.to_vec()) + let from_len = self[from].instruction_used; + for i in 0..from_len { + let info = self[from].instructions[i]; + let off = basicblock_next_instr(&mut self[to])?; + self[to].instructions[off] = info; + } + + Ok(()) } -} -impl Deref for Blocks { - type Target = [Block]; + /// flowgraph.c copy_basicblock + fn copy_basicblock(&mut self, block_idx: BlockIdx) -> crate::InternalResult { + debug_assert!(bb_no_fallthrough(&self[block_idx])); - fn deref(&self) -> &Self::Target { - &self.0 + let result = blocks_new_block(self)?; + self.basicblock_append_block_instructions(result, block_idx)?; + Ok(result) } -} -impl DerefMut for Blocks { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} + fn duplicate_exits_without_lineno(&mut self) -> crate::InternalResult<()> { + let mut next_lbl = get_max_label(self) + 1; -impl Index for Blocks { - type Output = Block; + let entryblock = BlockIdx(0); + let mut b = entryblock; + while b != BlockIdx::NULL { + let Some(last) = basicblock_last_instr(&self[b]).copied() else { + b = self[b].next; + continue; + }; - fn index(&self, idx: usize) -> &Self::Output { - &self.0[idx] - } -} + if is_jump(&last) { + debug_assert!(last.target != BlockIdx::NULL); -impl IndexMut for Blocks { - fn index_mut(&mut self, idx: usize) -> &mut Self::Output { - &mut self.0[idx] - } -} + let target = next_nonempty_block(self, last.target); -impl Index for Blocks { - type Output = Block; + debug_assert!(target != BlockIdx::NULL); - fn index(&self, block_idx: BlockIdx) -> &Self::Output { - &self.0[block_idx.as_usize()] + if is_exit_or_eval_check_without_lineno(&self[target]) + && self[target].predecessors > 1 + { + let new_target = self.copy_basicblock(target)?; + instr_set_location( + &mut self[new_target].instructions[0], + instr_location(&last), + ); + let last_mut = basicblock_last_instr_mut(&mut self[b]).unwrap(); + last_mut.target = new_target; + self[target].predecessors -= 1; + self[new_target].predecessors = 1; + self[new_target].next = self[target].next; + self[new_target].cpython_label = InstructionSequenceLabel(next_lbl); + next_lbl += 1; + self[target].next = new_target; + } + } + b = self[b].next; + } + + b = entryblock; + while b != BlockIdx::NULL { + let next = self[b].next; + if bb_has_fallthrough(&self[b]) + && next != BlockIdx::NULL + && self[b].instruction_used != 0 + && is_exit_or_eval_check_without_lineno(&self[next]) + { + let last = *basicblock_last_instr(&self[b]).expect("block has instructions"); + instr_set_location(&mut self[next].instructions[0], instr_location(&last)); + } + b = self[b].next; + } + + Ok(()) } -} -impl IndexMut for Blocks { - fn index_mut(&mut self, block_idx: BlockIdx) -> &mut Self::Output { - &mut self.0[block_idx.as_usize()] + fn resolve_line_numbers(&mut self, _firstlineno: OneIndexed) -> crate::InternalResult<()> { + self.duplicate_exits_without_lineno()?; + self.propagate_line_numbers(); + Ok(()) } -} -pub(crate) const START_DEPTH_UNSET: i32 = i32::MIN; -const CO_MAXBLOCKS: usize = 20; + /// flowgraph.c optimize_basic_block + fn optimize_basic_block( + &mut self, + metadata: &mut CodeUnitMetadata, + block_idx: BlockIdx, + ) -> crate::InternalResult<()> { + let mut nop = InstructionInfo { + instr: Instruction::Nop.into(), + arg: OpArg::NULL, + target: BlockIdx::NULL, + location: SourceLocation::default(), + end_location: SourceLocation::default(), + except_handler: None, + lineno_override: None, + }; + instr_set_op0(&mut nop, Instruction::Nop.into()); + let mut i = 0; + while i < self[block_idx].instruction_used { + let inst = self[block_idx].instructions[i]; + debug_assert!(!inst.instr.is_assembler()); + let target = if inst.instr.has_target() { + let target = inst.target; + debug_assert!(target != BlockIdx::NULL); + debug_assert!(self[target.idx()].instruction_used != 0); + debug_assert!(!self[target.idx()].instructions[0].instr.is_assembler()); + self[target.idx()].instructions[0] + } else { + nop + }; -/// flowgraph.c struct _PyCfgExceptStack -#[derive(Clone, Debug)] -struct CfgExceptStack { - handlers: [BlockIdx; CO_MAXBLOCKS + 2], - depth: usize, -} + let nextop = self[block_idx] + .instructions + .get(i + 1) + .and_then(|next| next.instr.real()); -/// flowgraph.c `basicblock **stack` -#[derive(Clone, Debug)] -struct CfgTraversalStack { - stack: Vec, - sp: usize, -} + match inst.instr { + AnyInstruction::Real(Instruction::BuildTuple { .. }) => { + let oparg = u32::from(inst.arg); + if matches!(nextop, Some(Instruction::UnpackSequence { .. })) + && u32::from(self[block_idx].instructions[i + 1].arg) == oparg + { + match oparg { + 1 => { + set_to_nop(&mut self[block_idx].instructions[i]); + set_to_nop(&mut self[block_idx].instructions[i + 1]); + i += 1; + continue; + } + 2 | 3 => { + set_to_nop(&mut self[block_idx].instructions[i]); + self[block_idx].instructions[i + 1].instr = Opcode::Swap.into(); + i += 1; + continue; + } + _ => {} + } + } + fold_tuple_of_constants(metadata, &mut self[block_idx], i)?; + } + AnyInstruction::Real( + Instruction::BuildList { .. } | Instruction::BuildSet { .. }, + ) => { + optimize_lists_and_sets(metadata, &mut self[block_idx], i, nextop)?; + } + AnyInstruction::Real( + Instruction::PopJumpIfNotNone { .. } | Instruction::PopJumpIfNone { .. }, + ) if matches!(target.instr.into(), AnyOpcode::Pseudo(PseudoOpcode::Jump)) + && jump_thread(self, block_idx, i, &target, inst.instr)? => + { + continue; + } + AnyInstruction::Real(Instruction::PopJumpIfFalse { .. }) + if matches!(target.instr.into(), AnyOpcode::Pseudo(PseudoOpcode::Jump)) + && jump_thread(self, block_idx, i, &target, inst.instr)? => + { + continue; + } + AnyInstruction::Real(Instruction::PopJumpIfTrue { .. }) + if matches!(target.instr.into(), AnyOpcode::Pseudo(PseudoOpcode::Jump)) + && jump_thread(self, block_idx, i, &target, inst.instr)? => + { + continue; + } + AnyInstruction::Pseudo( + pseudo @ (PseudoInstruction::JumpIfFalse { .. } + | PseudoInstruction::JumpIfTrue { .. }), + ) => { + let opcode = pseudo.into(); + match target.instr.pseudo().map(Into::into) { + Some(PseudoOpcode::Jump) + if jump_thread(self, block_idx, i, &target, opcode)? => + { + continue; + } + Some(PseudoOpcode::JumpIfFalse) + if matches!( + opcode, + AnyInstruction::Pseudo(PseudoInstruction::JumpIfFalse { .. }) + ) && jump_thread(self, block_idx, i, &target, opcode)? => + { + continue; + } + Some(PseudoOpcode::JumpIfTrue) + if matches!( + opcode, + AnyInstruction::Pseudo(PseudoInstruction::JumpIfTrue { .. }) + ) && jump_thread(self, block_idx, i, &target, opcode)? => + { + continue; + } + Some(PseudoOpcode::JumpIfFalse | PseudoOpcode::JumpIfTrue) => { + let next = self[inst.target.idx()].next; + debug_assert!(next != BlockIdx::NULL); + debug_assert!(next != inst.target); + self[block_idx].instructions[i].target = next; + continue; + } + _ => {} + } + } + AnyInstruction::Pseudo( + PseudoInstruction::Jump { .. } | PseudoInstruction::JumpNoInterrupt { .. }, + ) => match target.instr.into() { + AnyOpcode::Pseudo(PseudoOpcode::Jump) + if jump_thread(self, block_idx, i, &target, PseudoOpcode::Jump.into())? => + { + continue; + } + AnyOpcode::Pseudo(PseudoOpcode::JumpNoInterrupt) + if jump_thread(self, block_idx, i, &target, inst.instr)? => + { + continue; + } + _ => {} + }, + // CPython leaves FOR_ITER jump threading disabled. + AnyInstruction::Real(Instruction::ForIter { .. }) => {} + AnyInstruction::Real(Instruction::StoreFast { .. }) + if matches!(nextop, Some(Instruction::StoreFast { .. })) + && u32::from(inst.arg) + == u32::from(self[block_idx].instructions[i + 1].arg) + && instruction_lineno(&self[block_idx].instructions[i]) + == instruction_lineno(&self[block_idx].instructions[i + 1]) => + { + self[block_idx].instructions[i].instr = Instruction::PopTop.into(); + self[block_idx].instructions[i].arg = OpArg::NULL; + } + AnyInstruction::Real(Instruction::Swap { .. }) if u32::from(inst.arg) == 1 => { + set_to_nop(&mut self[block_idx].instructions[i]); + } + AnyInstruction::Real(Instruction::LoadGlobal { .. }) + if matches!(nextop, Some(Instruction::PushNull)) + && (u32::from(inst.arg) & 1) == 0 => + { + instr_set_op1( + &mut self[block_idx].instructions[i], + inst.instr, + OpArg::new(u32::from(inst.arg) | 1), + ); + set_to_nop(&mut self[block_idx].instructions[i + 1]); + } + AnyInstruction::Real(Instruction::CompareOp { .. }) + if matches!(nextop, Some(Instruction::ToBool)) => + { + set_to_nop(&mut self[block_idx].instructions[i]); + instr_set_op1( + &mut self[block_idx].instructions[i + 1], + inst.instr, + OpArg::new(u32::from(inst.arg) | oparg::COMPARE_OP_BOOL_MASK), + ); + i += 1; + continue; + } + AnyInstruction::Real(Instruction::ContainsOp { .. } | Instruction::IsOp { .. }) + if matches!(nextop, Some(Instruction::ToBool)) => + { + set_to_nop(&mut self[block_idx].instructions[i]); + instr_set_op1( + &mut self[block_idx].instructions[i + 1], + inst.instr, + inst.arg, + ); + i += 1; + continue; + } + AnyInstruction::Real(Instruction::ContainsOp { .. } | Instruction::IsOp { .. }) + if matches!(nextop, Some(Instruction::UnaryNot)) => + { + set_to_nop(&mut self[block_idx].instructions[i]); + let inverted = u32::from(inst.arg) ^ 1; + debug_assert!(inverted == 0 || inverted == 1); + instr_set_op1( + &mut self[block_idx].instructions[i + 1], + inst.instr, + OpArg::new(inverted), + ); + i += 1; + continue; + } + AnyInstruction::Real(Instruction::ToBool) + if matches!(nextop, Some(Instruction::ToBool)) => + { + set_to_nop(&mut self[block_idx].instructions[i]); + i += 1; + continue; + } + AnyInstruction::Real(Instruction::UnaryNot) => { + if matches!(nextop, Some(Instruction::ToBool)) { + set_to_nop(&mut self[block_idx].instructions[i]); + instr_set_op0(&mut self[block_idx].instructions[i + 1], inst.instr); + i += 1; + continue; + } + if matches!(nextop, Some(Instruction::UnaryNot)) { + set_to_nop(&mut self[block_idx].instructions[i]); + set_to_nop(&mut self[block_idx].instructions[i + 1]); + i += 1; + continue; + } + fold_const_unaryop(metadata, &mut self[block_idx], i)?; + } + AnyInstruction::Real(Instruction::UnaryInvert | Instruction::UnaryNegative) => { + fold_const_unaryop(metadata, &mut self[block_idx], i)?; + } + AnyInstruction::Real(Instruction::CallIntrinsic1 { func }) => { + match func.get(inst.arg) { + IntrinsicFunction1::ListToTuple => { + if matches!(nextop, Some(Instruction::GetIter)) { + set_to_nop(&mut self[block_idx].instructions[i]); + } else { + fold_constant_intrinsic_list_to_tuple( + metadata, + &mut self[block_idx], + i, + )?; + } + } + IntrinsicFunction1::UnaryPositive => { + fold_const_unaryop(metadata, &mut self[block_idx], i)?; + } + _ => {} + } + } + AnyInstruction::Real(Instruction::BinaryOp { .. }) => { + fold_const_binop(metadata, &mut self[block_idx], i)?; + } + _ => {} + } -impl CfgTraversalStack { - fn push(&mut self, block: BlockIdx) { - debug_assert!(self.sp < self.stack.len()); - self.stack[self.sp] = block; - self.sp += 1; + i += 1; + } + apply_static_swaps_block(&mut self[block_idx])?; + Ok(()) } - fn pop(&mut self) -> Option { - if self.sp == 0 { - return None; + /// flowgraph.c _PyCfg_ToInstructionSequence + fn cfg_to_instruction_sequence( + &mut self, + instr_sequence: &mut InstructionSequence, + ) -> crate::InternalResult<()> { + let mut label_id = 0; + let mut block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + self[block_idx].cpython_label = InstructionSequenceLabel::from_index(label_id); + label_id += 1; + block_idx = self[block_idx].next; } - self.sp -= 1; - Some(self.stack[self.sp]) - } - fn capacity(&self) -> usize { - self.stack.len() - } -} - -#[derive(Clone, Debug)] -pub(crate) struct InstructionSequenceLabelMap { - block_labels: Vec, - /// Codegen-side shadow of CPython's instruction-sequence label map. - /// - /// `_PyInstructionSequence_UseLabel()` can map multiple labels to the same - /// instruction offset before `_PyCfg_FromInstructionSequence()` materializes - /// CFG blocks. The codegen CFG path keeps the same aliasing by resolving - /// those labels to the block that owns the shared offset. - cpython_block_by_label: Vec, -} + block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + let block_label = self[block_idx].cpython_label; + debug_assert!(is_label(block_label)); + instruction_sequence_use_label(instr_sequence, block_label)?; + + let instr_count = self[block_idx].instruction_used; + for i in 0..instr_count { + if self[block_idx].instructions[i].instr.has_target() { + let target_block = self[block_idx].instructions[i].target; + debug_assert!(target_block != BlockIdx::NULL); + let lbl = self[target_block].cpython_label; + debug_assert!(is_label(lbl)); + self[block_idx].instructions[i].arg = OpArg::new(lbl.0 as u32); + } -fn instruction_sequence_label_map_register_label( - map: &mut InstructionSequenceLabelMap, - label: InstructionSequenceLabel, -) -> crate::InternalResult<()> { - debug_assert!(is_label(label)); - let old_size = map.cpython_block_by_label.len(); - let new_allocation = c_array_ensure_capacity::( - old_size, - label.idx(), - INITIAL_INSTR_SEQUENCE_LABELS_MAP_SIZE, - )?; - if new_allocation > old_size { - if new_allocation > map.cpython_block_by_label.capacity() { - map.cpython_block_by_label - .try_reserve_exact(new_allocation - map.cpython_block_by_label.capacity()) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - } - map.cpython_block_by_label - .resize(new_allocation, BlockIdx::NULL); - for i in old_size..map.cpython_block_by_label.len() { - map.cpython_block_by_label[i] = BlockIdx::NULL; + let mut info = self[block_idx].instructions[i]; + info.target = BlockIdx::NULL; + let except_handler = info.except_handler.take(); + let entry = instruction_sequence_addop(instr_sequence, info)?; + let hi = &mut entry.except_handler; + if let Some(handler) = except_handler { + debug_assert!(handler.handler_block != BlockIdx::NULL); + let lbl = self[handler.handler_block].cpython_label; + debug_assert!(is_label(lbl)); + let start_depth = self[handler.handler_block].start_depth; + debug_assert!(start_depth >= 0); + hi.h_label = lbl.0; + hi.start_depth = start_depth; + hi.preserve_lasti = i32::from(handler.preserve_lasti); + } else { + hi.h_label = NO_EXCEPTION_HANDLER_LABEL; + } + } + block_idx = self[block_idx].next; } - } - debug_assert!(map.cpython_block_by_label.len() > label.idx()); - Ok(()) -} -fn instruction_sequence_label_map_ensure_label_for_block( - map: &mut InstructionSequenceLabelMap, - seq: &mut InstructionSequence, - block: BlockIdx, -) -> crate::InternalResult { - debug_assert_ne!(block, BlockIdx::NULL); - let block_label = map.block_labels[block.idx()]; - if is_label(block_label) { - return Ok(block_label); + instruction_sequence_apply_label_map(instr_sequence)?; + Ok(()) } - let label = instruction_sequence_new_label(seq); - debug_assert_eq!(label.0, seq.next_free_label); - instruction_sequence_label_map_register_label(map, label)?; - map.cpython_block_by_label[label.idx()] = block; - map.block_labels[block.idx()] = label; - Ok(label) -} -fn instruction_sequence_label_map_label_for_block( - map: &InstructionSequenceLabelMap, - block: BlockIdx, -) -> InstructionSequenceLabel { - debug_assert_ne!(block, BlockIdx::NULL); - map.block_labels - .get(block.idx()) - .copied() - .unwrap_or(InstructionSequenceLabel::NO_LABEL) -} + fn optimize_load_fast(&mut self) -> crate::InternalResult<()> { + let mut max_instrs = 0; + let mut current = BlockIdx(0); + while current != BlockIdx::NULL { + max_instrs = max_instrs.max(self[current].instruction_used); + current = self[current].next; + } -fn instruction_sequence_label_map_block_for_label( - map: &InstructionSequenceLabelMap, - label: InstructionSequenceLabel, -) -> Option { - if !is_label(label) { - return None; - } - map.cpython_block_by_label - .get(label.idx()) - .copied() - .filter(|&block| block != BlockIdx::NULL) -} + let mut instr_flags = Vec::new(); + instr_flags + .try_reserve_exact(max_instrs) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + instr_flags.resize(max_instrs, 0u8); + let mut refs = RefStack { + refs: Vec::new(), + size: 0, + capacity: 0, + }; + let mut worklist = self.make_cfg_traversal_stack()?; + worklist.push(BlockIdx(0)); + self[0].start_depth = 0; + self[0].visited = true; + while let Some(block_idx) = worklist.pop() { + let instr_count = self[block_idx].instruction_used; + instr_flags[..instr_count].fill(0); + debug_assert!(self[block_idx].start_depth >= 0); + let start_depth = self[block_idx].start_depth as usize; + ref_stack_clear(&mut refs); + for _ in 0..start_depth { + push_ref(&mut refs, DUMMY_INSTR, NOT_LOCAL)?; + } -fn instruction_sequence_label_map_resolve_label( - map: &InstructionSequenceLabelMap, - block: BlockIdx, -) -> BlockIdx { - if block == BlockIdx::NULL { - return BlockIdx::NULL; - } - let label = instruction_sequence_label_map_label_for_block(map, block); - if !is_label(label) { - return block; - } - instruction_sequence_label_map_block_for_label(map, label).unwrap_or_else(|| { - debug_assert!( - false, - "CPython instruction-sequence label must map to a codegen CFG block" - ); - BlockIdx::NULL - }) -} + for i in 0..instr_count { + let info = self[block_idx].instructions[i]; + let instr = info.instr; + let arg_u32 = u32::from(info.arg); + debug_assert!(!matches!(instr.real(), Some(Instruction::ExtendedArg))); + + match instr { + AnyInstruction::Real(Instruction::DeleteFast { var_num }) => { + kill_local( + &mut instr_flags, + &refs, + local_as_ref_local(usize::from(var_num.get(info.arg))), + ); + } + AnyInstruction::Real(Instruction::LoadFast { var_num }) => { + push_ref( + &mut refs, + i as isize, + local_as_ref_local(usize::from(var_num.get(info.arg))), + )?; + } + AnyInstruction::Real(Instruction::LoadFastAndClear { var_num }) => { + let local = local_as_ref_local(usize::from(var_num.get(info.arg))); + kill_local(&mut instr_flags, &refs, local); + push_ref(&mut refs, i as isize, local)?; + } + AnyInstruction::Real(Instruction::LoadFastLoadFast { .. }) => { + let local1 = (arg_u32 >> 4) as isize; + let local2 = (arg_u32 & 15) as isize; + push_ref(&mut refs, i as isize, local1)?; + push_ref(&mut refs, i as isize, local2)?; + } + AnyInstruction::Real(Instruction::StoreFast { var_num }) => { + let r = ref_stack_pop(&mut refs); + store_local( + &mut instr_flags, + &refs, + local_as_ref_local(usize::from(var_num.get(info.arg))), + r, + ); + } + AnyInstruction::Real(Instruction::StoreFastLoadFast { .. }) => { + let r = ref_stack_pop(&mut refs); + store_local(&mut instr_flags, &refs, (arg_u32 >> 4) as isize, r); + push_ref(&mut refs, i as isize, (arg_u32 & 15) as isize)?; + } + AnyInstruction::Real(Instruction::StoreFastStoreFast { .. }) => { + let r1 = ref_stack_pop(&mut refs); + store_local(&mut instr_flags, &refs, (arg_u32 >> 4) as isize, r1); + let r2 = ref_stack_pop(&mut refs); + store_local(&mut instr_flags, &refs, (arg_u32 & 15) as isize, r2); + } + AnyInstruction::Real(Instruction::Copy { i: _ }) => { + let depth = arg_u32 as usize; + assert!(depth > 0); + assert!(refs.size >= depth); + let r = ref_stack_at(&refs, refs.size - depth); + push_ref(&mut refs, r.instr, r.local)?; + } + AnyInstruction::Real(Instruction::Swap { i: _ }) => { + let depth = arg_u32 as usize; + assert!(depth >= 2); + assert!(refs.size >= depth); + ref_stack_swap_top(&mut refs, depth); + } + AnyInstruction::Real( + Instruction::FormatSimple + | Instruction::GetAnext + | Instruction::GetLen + | Instruction::GetYieldFromIter + | Instruction::ImportFrom { .. } + | Instruction::MatchKeys + | Instruction::MatchMapping + | Instruction::MatchSequence + | Instruction::WithExceptStart, + ) => { + let effect = instr.stack_effect_info(arg_u32); + let net_pushed = effect.pushed() as isize - effect.popped() as isize; + debug_assert!(net_pushed >= 0); + // CPython optimize_load_fast() shadows the outer + // instruction index in this produced-value loop. + for produced in 0..net_pushed { + push_ref(&mut refs, produced, NOT_LOCAL)?; + } + } + AnyInstruction::Real( + Instruction::DictMerge { .. } + | Instruction::DictUpdate { .. } + | Instruction::ListAppend { .. } + | Instruction::ListExtend { .. } + | Instruction::MapAdd { .. } + | Instruction::Reraise { .. } + | Instruction::SetAdd { .. } + | Instruction::SetUpdate { .. }, + ) => { + let effect = instr.stack_effect_info(arg_u32); + let net_popped = effect.popped() as isize - effect.pushed() as isize; + debug_assert!(net_popped > 0); + for _ in 0..net_popped { + let _ = ref_stack_pop(&mut refs); + } + } + AnyInstruction::Real( + Instruction::EndSend | Instruction::SetFunctionAttribute { .. }, + ) => { + let effect = instr.stack_effect_info(arg_u32); + debug_assert_eq!(effect.popped(), 2); + debug_assert_eq!(effect.pushed(), 1); + let tos = ref_stack_pop(&mut refs); + let _ = ref_stack_pop(&mut refs); + push_ref(&mut refs, tos.instr, tos.local)?; + } + AnyInstruction::Real(Instruction::CheckExcMatch) => { + let _ = ref_stack_pop(&mut refs); + push_ref(&mut refs, i as isize, NOT_LOCAL)?; + } + AnyInstruction::Real(Instruction::ForIter { .. }) => { + let target = info.target; + debug_assert!(target != BlockIdx::NULL); + load_fast_push_block(&mut worklist, self, target, refs.size + 1); + push_ref(&mut refs, i as isize, NOT_LOCAL)?; + } + AnyInstruction::Real( + Instruction::LoadAttr { .. } | Instruction::LoadSuperAttr { .. }, + ) => { + let self_ref = ref_stack_pop(&mut refs); + if matches!(instr.real(), Some(Instruction::LoadSuperAttr { .. })) { + let _ = ref_stack_pop(&mut refs); + let _ = ref_stack_pop(&mut refs); + } + push_ref(&mut refs, i as isize, NOT_LOCAL)?; + if arg_u32 & 1 != 0 { + push_ref(&mut refs, self_ref.instr, self_ref.local)?; + } + } + AnyInstruction::Real( + Instruction::LoadSpecial { .. } | Instruction::PushExcInfo, + ) => { + let tos = ref_stack_pop(&mut refs); + push_ref(&mut refs, i as isize, NOT_LOCAL)?; + push_ref(&mut refs, tos.instr, tos.local)?; + } + AnyInstruction::Real(Instruction::Send { .. }) => { + let target = info.target; + debug_assert!(target != BlockIdx::NULL); + load_fast_push_block(&mut worklist, self, target, refs.size); + let _ = ref_stack_pop(&mut refs); + push_ref(&mut refs, i as isize, NOT_LOCAL)?; + } + _ => { + let effect = instr.stack_effect_info(arg_u32); + let num_popped = effect.popped() as usize; + let num_pushed = effect.pushed() as usize; + let target = info.target; + if instr.has_target() { + debug_assert!(target != BlockIdx::NULL); + debug_assert!(refs.size >= num_popped); + let target_depth = refs.size - num_popped + num_pushed; + load_fast_push_block(&mut worklist, self, target, target_depth); + } + if !is_block_push(&info) { + for _ in 0..num_popped { + let _ = ref_stack_pop(&mut refs); + } + for _ in 0..num_pushed { + push_ref(&mut refs, i as isize, NOT_LOCAL)?; + } + } + } + } + } -fn instruction_sequence_label_map_resolve_label_to_block( - map: &InstructionSequenceLabelMap, - label: InstructionSequenceLabel, -) -> BlockIdx { - if !is_label(label) { - return BlockIdx::NULL; - } - instruction_sequence_label_map_block_for_label(map, label).unwrap_or_else(|| { - debug_assert!( - false, - "CPython instruction-sequence label must map to a codegen CFG block" - ); - BlockIdx::NULL - }) -} + let fallthrough = self[block_idx].next; + let term = basicblock_last_instr(&self[block_idx]).copied(); + if let Some(term) = term + && fallthrough != BlockIdx::NULL + && !term.instr.is_unconditional_jump() + && !term.instr.is_scope_exit() + { + debug_assert!(bb_has_fallthrough(&self[block_idx])); + load_fast_push_block(&mut worklist, self, fallthrough, refs.size); + } -fn instruction_sequence_label_oparg(label: InstructionSequenceLabel) -> OpArg { - debug_assert!(is_label(label)); - OpArg::new(label.idx() as u32) -} + for i in 0..refs.size { + let r = ref_stack_at(&refs, i); + if r.instr != DUMMY_INSTR { + instr_flags[r.instr as usize] |= LoadFastInstrFlag::RefUnconsumed as u8; + } + } -fn instruction_sequence_label_map_use_label_at_block( - map: &mut InstructionSequenceLabelMap, - seq: &mut InstructionSequence, - from: BlockIdx, - to: BlockIdx, -) -> crate::InternalResult<()> { - if from == BlockIdx::NULL || from == to { - return Ok(()); - } - let from_label = instruction_sequence_label_map_ensure_label_for_block(map, seq, from)?; - debug_assert!(map.cpython_block_by_label.len() > from_label.idx()); - let to_block = instruction_sequence_label_map_resolve_label(map, to); - if to_block == BlockIdx::NULL { - debug_assert!( - false, - "CPython label target must map to a codegen CFG block" - ); - return Ok(()); + let block = &mut self[block_idx]; + let iused = block.instruction_used; + let mut i = 0; + while i < iused { + let info = &mut block.instructions[i]; + if instr_flags[i] != 0 { + i += 1; + continue; + } + + match info.instr.real_opcode() { + Some(Opcode::LoadFast) => { + info.instr = Opcode::LoadFastBorrow.into(); + } + Some(Opcode::LoadFastLoadFast) => { + info.instr = Opcode::LoadFastBorrowLoadFastBorrow.into(); + } + _ => {} + } + i += 1; + } + } + + Ok(()) } - map.cpython_block_by_label[from_label.idx()] = to_block; - Ok(()) -} -fn instruction_sequence_label_map_push_unlabeled_block( - map: &mut InstructionSequenceLabelMap, -) -> crate::InternalResult<()> { - map.block_labels - .try_reserve(1) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - map.block_labels.push(InstructionSequenceLabel::NO_LABEL); - Ok(()) -} + fn propagate_line_numbers(&mut self) { + let mut current = BlockIdx(0); + while current != BlockIdx::NULL { + let Some(last) = basicblock_last_instr(&self[current]).copied() else { + current = self[current].next; + continue; + }; -fn instruction_sequence_label_map_push_unmapped_label( - map: &mut InstructionSequenceLabelMap, - seq: &mut InstructionSequence, -) -> crate::InternalResult<()> { - let label = instruction_sequence_new_label(seq); - debug_assert_eq!(label.0, seq.next_free_label); - instruction_sequence_label_map_register_label(map, label)?; - let block = BlockIdx( - map.block_labels - .len() - .to_u32() - .ok_or(InternalError::MalformedControlFlowGraph)?, - ); - map.cpython_block_by_label[label.idx()] = block; - map.block_labels - .try_reserve(1) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - map.block_labels.push(label); - Ok(()) -} + let mut prev_location = no_instruction_location(); + for i in 0..self[current].instruction_used { + if instruction_is_no_location(&self[current].instructions[i]) { + instr_set_location(&mut self[current].instructions[i], prev_location); + } else { + prev_location = instr_location(&self[current].instructions[i]); + } + } -impl InstructionSequenceLabelMap { - pub(crate) fn new() -> Self { - Self { - block_labels: vec![InstructionSequenceLabel::NO_LABEL], - cpython_block_by_label: Vec::new(), + let next = self[current].next; + if bb_has_fallthrough(&self[current]) { + debug_assert!(next != BlockIdx::NULL); + if next != BlockIdx::NULL + && self[next].predecessors == 1 + && self[next].instruction_used != 0 + && instruction_is_no_location(&self[next].instructions[0]) + { + instr_set_location(&mut self[next].instructions[0], prev_location); + } + } + + if is_jump(&last) { + let target = last.target; + debug_assert!(target != BlockIdx::NULL); + if self[target].predecessors == 1 { + let instr = basicblock_raw_first_instr_mut(&mut self[target]); + if instruction_is_no_location(instr) { + instr_set_location(instr, prev_location); + } + } + } + current = self[current].next; } } -} -pub struct CodeInfo { - pub flags: CodeFlags, - pub source_path: String, - pub private: Option, // For private name mangling, mostly for class + /// flowgraph.c remove_redundant_nops_and_pairs + fn remove_redundant_nops_and_pairs(&mut self) -> crate::InternalResult<()> { + let mut done = false; - pub blocks: Blocks, - pub current_block: BlockIdx, - pub(crate) instr_sequence: InstructionSequence, - pub(crate) instr_sequence_label_map: InstructionSequenceLabelMap, - pub(crate) annotations_instr_sequence: Option, + while !done { + done = true; + let mut instr: Option<(BlockIdx, usize)> = None; + let mut block_idx = BlockIdx::new(0); - pub metadata: CodeUnitMetadata, + while block_idx != BlockIdx::NULL { + basicblock_remove_redundant_nops(self, block_idx)?; + if is_label(self[block_idx].cpython_label) { + instr = None; + } - // For class scopes: attributes accessed via self.X - pub static_attributes: Option>, + let len = self[block_idx].instruction_used; + for instr_idx in 0..len { + let prev_instr = instr; + instr = Some((block_idx, instr_idx)); + let instr_info = self[block_idx].instructions[instr_idx]; + let mut prev_opcode = None; + let prev_oparg = if let Some((prev_block, prev_instr_idx)) = prev_instr { + let prev_info = self[prev_block].instructions[prev_instr_idx]; + prev_opcode = prev_info.instr.real_opcode(); + match prev_info.instr.real() { + Some(Instruction::Copy { i }) => i.get(prev_info.arg), + _ => u32::from(prev_info.arg), + } + } else { + 0 + }; - // True if compiling an inlined comprehension - pub in_inlined_comp: bool, + let opcode = instr_info.instr.real_opcode(); + let is_redundant_pair = matches!(opcode, Some(Opcode::PopTop)) + && (matches!(prev_opcode, Some(Opcode::LoadConst | Opcode::LoadSmallInt)) + || (prev_oparg == 1 && matches!(prev_opcode, Some(Opcode::Copy)))); + + if is_redundant_pair { + let (prev_block, prev_instr_idx) = + prev_instr.expect("redundant pair has previous"); + set_to_nop(&mut self[prev_block].instructions[prev_instr_idx]); + set_to_nop(&mut self[block_idx].instructions[instr_idx]); + done = false; + } + } - // Block stack for tracking nested control structures - pub fblock: Vec, + let instr_is_jump = instr.is_some_and(|(instr_block, instr_idx)| { + is_jump(&self[instr_block].instructions[instr_idx]) + }); - // Reference to the symbol table for this scope - pub symbol_table_index: usize, - // CPython compile.c uses PyList_GET_SIZE(u->u_ste->ste_varnames) - // when calling flowgraph.c _PyCfg_OptimizeCodeUnit(). - pub nparams: usize, + let block = &self[block_idx]; + if instr_is_jump || !bb_has_fallthrough(block) { + instr = None; + } + block_idx = block.next; + } + } + Ok(()) + } - // PEP 649: Track nesting depth inside conditional blocks (if/for/while/etc.) - // u_in_conditional_block - pub in_conditional_block: u32, + /// flowgraph.c calculate_stackdepth + fn calculate_stackdepth(&mut self) -> crate::InternalResult { + let mut current = BlockIdx(0); + while current != BlockIdx::NULL { + self[current.idx()].start_depth = START_DEPTH_UNSET; + current = self[current.idx()].next; + } + let mut stack = self.make_cfg_traversal_stack()?; + let mut maxdepth = 0i32; + stackdepth_push(&mut stack, self, BlockIdx(0), 0)?; + while let Some(block_idx) = stack.pop() { + let mut depth = self[block_idx].start_depth; + debug_assert!(depth >= 0); + let mut next = self[block_idx].next; + let instr_count = self[block_idx].instruction_used; + for i in 0..instr_count { + let ins = self[block_idx].instructions[i]; + let instr = &ins.instr; + let effects = get_stack_effects(*instr, ins.arg, 0)?; + let new_depth = depth + effects.net; + if new_depth < 0 { + return Err(InternalError::StackUnderflow); + } + maxdepth = maxdepth.max(depth); + if instr.has_target() && !matches!(instr.real(), Some(Instruction::EndAsyncFor)) { + debug_assert!(ins.target != BlockIdx::NULL); + let effects = get_stack_effects(*instr, ins.arg, 1)?; + let target_depth = depth + effects.net; + debug_assert!(target_depth >= 0); + maxdepth = maxdepth.max(depth); + stackdepth_push(&mut stack, self, ins.target, target_depth)?; + } + depth = new_depth; + debug_assert!(!instr.is_assembler()); + if instr.is_unconditional_jump() || instr.is_scope_exit() { + next = BlockIdx::NULL; + break; + } + } - // PEP 649: Next index for conditional annotation tracking - // u_next_conditional_annotation_index - pub next_conditional_annotation_index: u32, -} + if next != BlockIdx::NULL { + debug_assert!(bb_has_fallthrough(&self[block_idx])); + stackdepth_push(&mut stack, self, next, depth)?; + } + } -impl CodeInfo { - pub(crate) fn addop_to_instr_sequence( - &mut self, - mut info: InstructionInfo, - ) -> crate::InternalResult<()> { - if info.instr.has_target() && info.target != BlockIdx::NULL { - let label = instruction_sequence_label_map_ensure_label_for_block( - &mut self.instr_sequence_label_map, - &mut self.instr_sequence, - info.target, - )?; - info.arg = instruction_sequence_label_oparg(label); - info.target = BlockIdx::NULL; + let stackdepth = maxdepth; + Ok(stackdepth as u32) + } + + /// flowgraph.c make_cfg_traversal_stack + fn make_cfg_traversal_stack(&mut self) -> crate::InternalResult { + debug_assert!(!self.is_empty()); + + let mut nblocks = 0; + let mut current = BlockIdx(0); + while current != BlockIdx::NULL { + self[current].visited = false; + nblocks += 1; + current = self[current].next; } - instruction_sequence_addop(&mut self.instr_sequence, info)?; - Ok(()) + debug_assert!(nblocks > 0); + let mut stack = Vec::new(); + stack + .try_reserve_exact(nblocks) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + stack.resize(nblocks, BlockIdx::NULL); + let stack = CfgTraversalStack { stack, sp: 0 }; + debug_assert_eq!(stack.capacity(), nblocks); + Ok(stack) } - pub(crate) fn addop_to_instr_sequence_with_target_label( - &mut self, - mut info: InstructionInfo, - target_label: InstructionSequenceLabel, - ) -> crate::InternalResult<()> { - if !info.instr.has_target() { - return Err(InternalError::MalformedControlFlowGraph); + /// flowgraph.c normalize_jumps + fn normalize_jumps(&mut self) -> crate::InternalResult<()> { + let mut current = BlockIdx(0); + while current != BlockIdx::NULL { + self[current].visited = false; + current = self[current].next; } - info.arg = instruction_sequence_label_oparg(target_label); - info.target = BlockIdx::NULL; - instruction_sequence_addop(&mut self.instr_sequence, info)?; + + let mut current = BlockIdx(0); + while current != BlockIdx::NULL { + self[current].visited = true; + normalize_jumps_in_block(self, current)?; + current = self[current].next; + } + Ok(()) } - pub(crate) fn addop_to_current_block( - &mut self, - info: InstructionInfo, - ) -> crate::InternalResult<()> { - basicblock_addop(&mut self.blocks[self.current_block.idx()], info) - } + /// flowgraph.c remove_unused_consts + #[allow(clippy::needless_range_loop)] + fn remove_unused_consts(&mut self, consts: &mut ConstantPool) -> crate::InternalResult<()> { + let nconsts = consts.len(); + if nconsts == 0 { + return Ok(()); + } - pub(crate) fn last_current_block_instr_mut(&mut self) -> Option<&mut InstructionInfo> { - basicblock_last_instr_mut(&mut self.blocks[self.current_block.idx()]) - } + let mut index_map = Vec::new(); + index_map + .try_reserve_exact(nconsts) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + index_map.resize(nconsts, 0isize); + for i in 1..nconsts { + index_map[i] = -1; + } + // The first constant may be docstring; keep it always. + index_map[0] = 0; - pub(crate) fn set_last_instr_sequence_lineno_override(&mut self, lineno_override: i32) { - if let Some(last) = instruction_sequence_last_info_mut(&mut self.instr_sequence) { - last.lineno_override = Some(lineno_override); + // Mark used consts. + let mut block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + let block = &self[block_idx]; + for i in 0..block.instruction_used { + let instr = &block.instructions[i]; + if instr.instr.has_const() { + let index = u32::from(instr.arg) as usize; + debug_assert!(index < nconsts); + index_map[index] = index as isize; + } + } + block_idx = block.next; } - } - pub(crate) fn use_instr_sequence_label( - &mut self, - block: BlockIdx, - ) -> crate::InternalResult<()> { - let label = instruction_sequence_label_map_ensure_label_for_block( - &mut self.instr_sequence_label_map, - &mut self.instr_sequence, - block, - )?; - instruction_sequence_use_label(&mut self.instr_sequence, label) - } + // Now index_map[i] == i if consts[i] is used, -1 otherwise. + // Condense consts. + let mut n_used_consts = 0; + for i in 0..nconsts { + if index_map[i] != -1 { + debug_assert_eq!(index_map[i], i as isize); + index_map[n_used_consts] = index_map[i]; + n_used_consts += 1; + } + } - pub(crate) fn new_instr_sequence_label(&mut self) -> InstructionSequenceLabel { - instruction_sequence_new_label(&mut self.instr_sequence) - } + if n_used_consts == nconsts { + return Ok(()); + } - pub(crate) fn use_raw_instr_sequence_label( - &mut self, - label: InstructionSequenceLabel, - ) -> crate::InternalResult<()> { - instruction_sequence_use_label(&mut self.instr_sequence, label) - } + // Move all used consts to the beginning of the consts list. + debug_assert!(n_used_consts < nconsts); + for i in 0..n_used_consts { + let old_index = index_map[i] as usize; + debug_assert!(i <= old_index && old_index < nconsts); + if i != old_index { + let value = consts.constants[old_index].clone(); + consts.constants[i] = value; + } + } - pub(crate) fn mark_cpython_cfg_label(&mut self, block: BlockIdx) -> crate::InternalResult<()> { - let label = instruction_sequence_label_map_ensure_label_for_block( - &mut self.instr_sequence_label_map, - &mut self.instr_sequence, - block, - )?; - self.blocks[block.idx()].cpython_label = label; + // Truncate the consts list at its new size. + consts.constants.truncate(n_used_consts); + + // Adjust const indices in the bytecode. + let mut reverse_index_map = Vec::new(); + reverse_index_map + .try_reserve_exact(nconsts) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + reverse_index_map.resize(nconsts, 0isize); + for i in 0..nconsts { + reverse_index_map[i] = -1; + } + for i in 0..n_used_consts { + let old_index = index_map[i]; + debug_assert!(old_index != -1); + let old_index = old_index as usize; + debug_assert_eq!(reverse_index_map[old_index], -1); + reverse_index_map[old_index] = i as isize; + } + + block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + let next_block = self[block_idx.idx()].next; + let block = &mut self[block_idx]; + for i in 0..block.instruction_used { + let instr = &mut block.instructions[i]; + if instr.instr.has_const() { + let index = u32::from(instr.arg) as usize; + debug_assert!(reverse_index_map[index] >= 0); + debug_assert!(reverse_index_map[index] < n_used_consts as isize); + instr.arg = OpArg::new(reverse_index_map[index] as u32); + } + } + block_idx = next_block; + } Ok(()) } - pub(crate) fn resolve_instr_sequence_label(&self, block: BlockIdx) -> BlockIdx { - instruction_sequence_label_map_resolve_label(&self.instr_sequence_label_map, block) - } + /// flowgraph.c insert_superinstructions + fn insert_superinstructions(&mut self) -> crate::InternalResult { + let mut block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + let next_block = self[block_idx].next; + let block = &mut self[block_idx]; + for i in 0..block.instruction_used { + let nextop = (i + 1 < block.instruction_used) + .then(|| block.instructions[i + 1].instr.real_opcode()) + .flatten(); + + match (block.instructions[i].instr.real_opcode(), nextop) { + (Some(Opcode::LoadFast), _) => { + if matches!(nextop, Some(Opcode::LoadFast)) { + let (inst1, rest) = block.instructions[i..].split_at_mut(1); + make_super_instruction( + &mut inst1[0], + &mut rest[0], + Opcode::LoadFastLoadFast.into(), + ); + } + } - pub(crate) fn block_for_instr_sequence_label( - &self, - label: InstructionSequenceLabel, - ) -> BlockIdx { - instruction_sequence_label_map_resolve_label_to_block(&self.instr_sequence_label_map, label) - } + (Some(Opcode::StoreFast), Some(Opcode::LoadFast)) => { + let (inst1, rest) = block.instructions[i..].split_at_mut(1); + make_super_instruction( + &mut inst1[0], + &mut rest[0], + Opcode::StoreFastLoadFast.into(), + ); + } - pub(crate) fn use_instr_sequence_label_at_block( - &mut self, - from: BlockIdx, - to: BlockIdx, - ) -> crate::InternalResult<()> { - instruction_sequence_label_map_use_label_at_block( - &mut self.instr_sequence_label_map, - &mut self.instr_sequence, - from, - to, - ) - } + (Some(Opcode::StoreFast), Some(Opcode::StoreFast)) => { + let (inst1, rest) = block.instructions[i..].split_at_mut(1); + make_super_instruction( + &mut inst1[0], + &mut rest[0], + Opcode::StoreFastStoreFast.into(), + ); + } - pub(crate) fn instr_sequence_label_for_block( - &mut self, - block: BlockIdx, - ) -> crate::InternalResult { - if block == BlockIdx::NULL { - Ok(InstructionSequenceLabel::NO_LABEL) - } else { - instruction_sequence_label_map_ensure_label_for_block( - &mut self.instr_sequence_label_map, - &mut self.instr_sequence, - block, - ) + (_, _) => {} + } + } + + block_idx = next_block; } + + let res = remove_redundant_nops(self)?; + + #[cfg(debug_assertions)] + assert!(no_redundant_nops(self)); + + Ok(res) } +} - pub(crate) fn insert_start_setup_cleanup( - &mut self, - handler_block: BlockIdx, - ) -> crate::InternalResult<()> { - let handler_label = instruction_sequence_label_map_ensure_label_for_block( - &mut self.instr_sequence_label_map, - &mut self.instr_sequence, - handler_block, - )?; - instruction_sequence_insert_instruction( - &mut self.instr_sequence, - 0, - InstructionInfo { - instr: PseudoOpcode::SetupCleanup.into(), - arg: instruction_sequence_label_oparg(handler_label), - target: BlockIdx::NULL, - location: SourceLocation::default(), - end_location: SourceLocation::default(), - except_handler: None, - lineno_override: Some(NO_LOCATION_OVERRIDE), - }, - ) +impl From> for Blocks { + fn from(value: Vec) -> Self { + Self(value) } +} - pub(crate) fn push_unmapped_instr_sequence_label(&mut self) -> crate::InternalResult<()> { - instruction_sequence_label_map_push_unmapped_label( - &mut self.instr_sequence_label_map, - &mut self.instr_sequence, - ) +impl From> for Blocks { + fn from(value: Box<[Block]>) -> Self { + Self(value.into()) } +} - pub(crate) fn push_unlabeled_instr_sequence_block(&mut self) -> crate::InternalResult<()> { - instruction_sequence_label_map_push_unlabeled_block(&mut self.instr_sequence_label_map) +impl From<&[Block]> for Blocks { + fn from(value: &[Block]) -> Self { + Self(value.to_vec()) } +} - fn take_recorded_instr_sequence(&mut self) -> crate::InternalResult { - let mut instr_sequence = - core::mem::replace(&mut self.instr_sequence, instruction_sequence_new()); - if let Some(mut annotations_instr_sequence) = self.annotations_instr_sequence.take() { - instruction_sequence_apply_label_map(&mut annotations_instr_sequence)?; - instruction_sequence_set_annotations_code( - &mut instr_sequence, - Some(Box::new(annotations_instr_sequence)), - ); - } - Ok(instr_sequence) +impl From<&mut [Block]> for Blocks { + fn from(value: &mut [Block]) -> Self { + Self(value.to_vec()) } +} - fn prepare_cfg_from_codegen(&mut self) -> crate::InternalResult { - // CPython compile.c optimize_and_assemble_code_unit passes - // u_instr_sequence directly into flowgraph.c _PyCfg_FromInstructionSequence(). - self.take_recorded_instr_sequence() +impl From<[Block; N]> for Blocks { + fn from(value: [Block; N]) -> Self { + Self(value.into()) } } -fn optimize_code_unit( - metadata: &mut CodeUnitMetadata, - blocks: &mut Blocks, - instr_sequence: InstructionSequence, - nlocals: usize, - nparams: usize, -) -> crate::InternalResult<()> { - // Phase 1: _PyCfg_OptimizeCodeUnit (flowgraph.c) - *blocks = cfg_from_instruction_sequence(instr_sequence)?; - translate_jump_labels_to_targets(blocks)?; - mark_except_handlers(blocks)?; - label_exception_targets(blocks)?; - optimize_cfg(metadata, blocks, metadata.firstlineno)?; - remove_unused_consts(blocks, &mut metadata.consts)?; - add_checks_for_loads_of_uninitialized_variables(blocks, nlocals, nparams)?; - // CPython inserts superinstructions in _PyCfg_OptimizeCodeUnit, before - // later jump normalization / block reordering can create adjacencies - // that never exist at this stage in flowgraph.c. - insert_superinstructions(blocks)?; - push_cold_blocks_to_end(blocks)?; - // CPython resolves line numbers again after cold-block extraction. - resolve_line_numbers(blocks, metadata.firstlineno)?; - Ok(()) +impl From<&[Block; N]> for Blocks { + fn from(value: &[Block; N]) -> Self { + Self(value.to_vec()) + } } -fn optimize_cfg( - metadata: &mut CodeUnitMetadata, - blocks: &mut Blocks, - firstlineno: OneIndexed, -) -> crate::InternalResult<()> { - // flowgraph.c optimize_cfg - // CPython optimize_cfg() starts with check_cfg() and raises - // SystemError if a jump or scope exit is not the last instruction in - // its block. - check_cfg(blocks)?; - inline_small_or_no_lineno_blocks(blocks)?; - // CPython does not re-run instruction-sequence label-map/CFG conversion - // after this point. Unreferenced label blocks left by jump inlining - // remain block boundaries and can preserve line-marker NOPs. - remove_unreachable(blocks)?; - // CPython optimize_cfg resolves line numbers before local checks and - // superinstruction insertion, so fusion decisions see propagated - // source locations. - resolve_line_numbers(blocks, firstlineno)?; - // CPython optimize_cfg() runs optimize_load_const() and then - // optimize_basic_block() after line numbers are resolved. - optimize_load_const(metadata, blocks)?; - let mut block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - let next_block = blocks[block_idx].next; - optimize_basic_block(blocks, metadata, block_idx)?; - block_idx = next_block; +impl Deref for Blocks { + type Target = [Block]; + + fn deref(&self) -> &Self::Target { + &self.0 } - remove_redundant_nops_and_pairs(blocks)?; - // CPython optimize_cfg() removes newly-unreachable blocks and - // redundant NOP/jump chains before _PyCfg_OptimizeCodeUnit() prunes - // unused constants. - remove_unreachable(blocks)?; - remove_redundant_nops_and_jumps(blocks)?; - #[cfg(debug_assertions)] - assert!(no_redundant_jumps(blocks)); - Ok(()) } -fn optimized_cfg_to_instruction_sequence( - metadata: &CodeUnitMetadata, - flags: CodeFlags, - blocks: &mut Blocks, -) -> crate::InternalResult<(u32, usize, InstructionSequence)> { - // Phase 2: _PyCfg_OptimizedCfgToInstructionSequence (flowgraph.c) - convert_pseudo_conditional_jumps(blocks)?; - let max_stackdepth = calculate_stackdepth(blocks)?; - debug_assert!(!is_generator(flags) || max_stackdepth != 0); - let nlocalsplus = prepare_localsplus(metadata, blocks, flags)?; - // Match CPython order: pseudo ops are lowered after stackdepth and - // localsplus preparation, before normalize_jumps. - convert_pseudo_ops(blocks)?; - normalize_jumps(blocks)?; - #[cfg(debug_assertions)] - assert!(no_redundant_jumps(blocks)); - // optimize_load_fast: after normalize_jumps - optimize_load_fast(blocks)?; +impl DerefMut for Blocks { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} - let mut instr_sequence = instruction_sequence_new(); - cfg_to_instruction_sequence(blocks, &mut instr_sequence)?; - Ok((max_stackdepth, nlocalsplus, instr_sequence)) +impl Index for Blocks { + type Output = Block; + + fn index(&self, idx: usize) -> &Self::Output { + &self.0[idx] + } } -impl CodeInfo { - pub fn finalize_code( - mut self, - opts: &crate::compile::CompileOpts, - ) -> crate::InternalResult { - let instr_sequence = self.prepare_cfg_from_codegen()?; - let nlocals = self.metadata.varnames.len(); - let nparams = self.nparams; - optimize_code_unit( - &mut self.metadata, - &mut self.blocks, - instr_sequence, - nlocals, - nparams, - )?; - let (max_stackdepth, nlocalsplus, mut instr_sequence) = - optimized_cfg_to_instruction_sequence(&self.metadata, self.flags, &mut self.blocks)?; - let localsplusinfo = compute_localsplus_info(&self.metadata, nlocalsplus, self.flags)?; +impl IndexMut for Blocks { + fn index_mut(&mut self, idx: usize) -> &mut Self::Output { + &mut self.0[idx] + } +} - let Self { - flags, - source_path, - private: _, // private is only used during compilation +impl Index for Blocks { + type Output = Block; - blocks: _, - current_block: _, - instr_sequence: _, - instr_sequence_label_map: _, - annotations_instr_sequence: _, - metadata, - static_attributes: _, - in_inlined_comp: _, - fblock: _, - symbol_table_index: _, - nparams: _, - in_conditional_block: _, - next_conditional_annotation_index: _, - } = self; + fn index(&self, block_idx: BlockIdx) -> &Self::Output { + &self.0[block_idx.as_usize()] + } +} - let CodeUnitMetadata { - name: obj_name, - qualname, - consts: constants, - names: name_cache, - varnames: varname_cache, - cellvars: _, - freevars: freevar_cache, - fast_hidden: _, - fast_hidden_final: _, - argcount: arg_count, - posonlyargcount: posonlyarg_count, - kwonlyargcount: kwonlyarg_count, - firstlineno: first_line_number, - } = metadata; +impl IndexMut for Blocks { + fn index_mut(&mut self, block_idx: BlockIdx) -> &mut Self::Output { + &mut self.0[block_idx.as_usize()] + } +} - resolve_unconditional_jumps(&mut instr_sequence)?; - resolve_jump_offsets(&mut instr_sequence)?; - let assembled = assemble_emit( - &mut instr_sequence, - first_line_number.get() as i32, - opts.debug_ranges, - )?; - let locations = rustpython_compiler_core::marshal::linetable_to_locations( - &assembled.linetable, - first_line_number.get() as i32, - assembled.instructions.len(), - ); +pub(crate) const START_DEPTH_UNSET: i32 = i32::MIN; +const CO_MAXBLOCKS: usize = 20; - Ok(CodeObject { - flags, - posonlyarg_count, - arg_count, - kwonlyarg_count, - source_path, - first_line_number: Some(first_line_number), - obj_name: obj_name.clone(), - qualname: qualname.unwrap_or(obj_name), +/// flowgraph.c struct _PyCfgExceptStack +#[derive(Clone, Debug)] +struct CfgExceptStack { + handlers: [BlockIdx; CO_MAXBLOCKS + 2], + depth: usize, +} - max_stackdepth, - instructions: CodeUnits::from(assembled.instructions), - locations, - constants: constants.into_iter().collect(), - names: name_cache.into_iter().collect(), - varnames: varname_cache.into_iter().collect(), - cellvars: localsplusinfo.cellvars, - freevars: freevar_cache.into_iter().collect(), - localspluskinds: localsplusinfo.kinds, - linetable: assembled.linetable, - exceptiontable: assembled.exceptiontable, - }) +/// flowgraph.c `basicblock **stack` +#[derive(Clone, Debug)] +struct CfgTraversalStack { + stack: Vec, + sp: usize, +} + +impl CfgTraversalStack { + fn push(&mut self, block: BlockIdx) { + debug_assert!(self.sp < self.stack.len()); + self.stack[self.sp] = block; + self.sp += 1; + } + + fn pop(&mut self) -> Option { + if self.sp == 0 { + return None; + } + self.sp -= 1; + Some(self.stack[self.sp]) + } + + fn capacity(&self) -> usize { + self.stack.len() } } -/// flowgraph.c IS_GENERATOR -fn is_generator(flags: CodeFlags) -> bool { - flags.intersects(CodeFlags::GENERATOR | CodeFlags::COROUTINE | CodeFlags::ASYNC_GENERATOR) +#[derive(Clone, Debug)] +pub(crate) struct InstructionSequenceLabelMap { + block_labels: Vec, + /// Codegen-side shadow of CPython's instruction-sequence label map. + /// + /// `_PyInstructionSequence_UseLabel()` can map multiple labels to the same + /// instruction offset before `_PyCfg_FromInstructionSequence()` materializes + /// CFG blocks. The codegen CFG path keeps the same aliasing by resolving + /// those labels to the block that owns the shared offset. + cpython_block_by_label: Vec, } -/// flowgraph.c insert_prefix_instructions -fn insert_prefix_instructions( - metadata: &CodeUnitMetadata, - blocks: &mut Blocks, - cellfixedoffsets: &[i32], - nfreevars: usize, - flags: CodeFlags, +fn instruction_sequence_label_map_register_label( + map: &mut InstructionSequenceLabelMap, + label: InstructionSequenceLabel, ) -> crate::InternalResult<()> { - debug_assert!(!blocks.is_empty()); - let entry = &mut blocks[0]; - let ncellvars = metadata.cellvars.len(); - let firstlineno = metadata.firstlineno; - debug_assert!(firstlineno.get() > 0); - - if is_generator(flags) { - let location = SourceLocation { - line: firstlineno, - character_offset: OneIndexed::MIN, - }; - basicblock_insert_instruction( - entry, - 0, - InstructionInfo { - instr: Instruction::ReturnGenerator.into(), - arg: OpArg::new(0), - target: BlockIdx::NULL, - location, - end_location: location, - except_handler: None, - lineno_override: Some(LINE_ONLY_LOCATION_OVERRIDE), - }, - )?; - basicblock_insert_instruction( - entry, - 1, - InstructionInfo { - instr: Instruction::PopTop.into(), - arg: OpArg::new(0), - target: BlockIdx::NULL, - location, - end_location: location, - except_handler: None, - lineno_override: Some(LINE_ONLY_LOCATION_OVERRIDE), - }, - )?; - } - - if ncellvars > 0 { - let nvars = metadata.varnames.len() + ncellvars; - let mut sorted = Vec::new(); - vec_try_reserve_exact(&mut sorted, nvars)?; - sorted.resize(nvars, 0i32); - for i in 0..ncellvars { - sorted[cellfixedoffsets[i] as usize] = i as i32 + 1; + debug_assert!(is_label(label)); + let old_size = map.cpython_block_by_label.len(); + let new_allocation = c_array_ensure_capacity::( + old_size, + label.idx(), + INITIAL_INSTR_SEQUENCE_LABELS_MAP_SIZE, + )?; + if new_allocation > old_size { + if new_allocation > map.cpython_block_by_label.capacity() { + map.cpython_block_by_label + .try_reserve_exact(new_allocation - map.cpython_block_by_label.capacity()) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; } - let mut ncellsused = 0; - let mut i = 0; - while ncellsused < ncellvars { - let oldindex = sorted[i] - 1; - i += 1; - if oldindex == -1 { - continue; - } - basicblock_insert_instruction( - entry, - ncellsused, - InstructionInfo { - instr: Opcode::MakeCell.into(), - arg: OpArg::new(oldindex as u32), - target: BlockIdx::NULL, - location: SourceLocation::default(), - end_location: SourceLocation::default(), - except_handler: None, - lineno_override: Some(NO_LOCATION_OVERRIDE), - }, - )?; - ncellsused += 1; + map.cpython_block_by_label + .resize(new_allocation, BlockIdx::NULL); + for i in old_size..map.cpython_block_by_label.len() { + map.cpython_block_by_label[i] = BlockIdx::NULL; } } + debug_assert!(map.cpython_block_by_label.len() > label.idx()); + Ok(()) +} - if nfreevars > 0 { - basicblock_insert_instruction( - entry, - 0, - InstructionInfo { - instr: Opcode::CopyFreeVars.into(), - arg: OpArg::new(nfreevars as u32), - target: BlockIdx::NULL, - location: SourceLocation::default(), - end_location: SourceLocation::default(), - except_handler: None, - lineno_override: Some(NO_LOCATION_OVERRIDE), - }, - )?; +fn instruction_sequence_label_map_ensure_label_for_block( + map: &mut InstructionSequenceLabelMap, + seq: &mut InstructionSequence, + block: BlockIdx, +) -> crate::InternalResult { + debug_assert_ne!(block, BlockIdx::NULL); + let block_label = map.block_labels[block.idx()]; + if is_label(block_label) { + return Ok(block_label); } - Ok(()) + let label = instruction_sequence_new_label(seq); + debug_assert_eq!(label.0, seq.next_free_label); + instruction_sequence_label_map_register_label(map, label)?; + map.cpython_block_by_label[label.idx()] = block; + map.block_labels[block.idx()] = label; + Ok(label) } -/// flowgraph.c prepare_localsplus -fn prepare_localsplus( - metadata: &CodeUnitMetadata, - blocks: &mut Blocks, - flags: CodeFlags, -) -> crate::InternalResult { - let nlocals = metadata.varnames.len(); - let ncellvars = metadata.cellvars.len(); - let nfreevars = metadata.freevars.len(); - let int_max = i32::MAX as usize; - debug_assert!(nlocals < int_max); - debug_assert!(ncellvars < int_max); - debug_assert!(nfreevars < int_max); - debug_assert!(int_max - nlocals - ncellvars > 0); - debug_assert!(int_max - nlocals - ncellvars - nfreevars > 0); - let mut nlocalsplus = nlocals + ncellvars + nfreevars; - let mut cellfixedoffsets = build_cellfixedoffsets(metadata)?; +fn instruction_sequence_label_map_label_for_block( + map: &InstructionSequenceLabelMap, + block: BlockIdx, +) -> InstructionSequenceLabel { + debug_assert_ne!(block, BlockIdx::NULL); + map.block_labels + .get(block.idx()) + .copied() + .unwrap_or(InstructionSequenceLabel::NO_LABEL) +} - // This must be called before fix_cell_offsets(). - insert_prefix_instructions(metadata, blocks, &cellfixedoffsets, nfreevars, flags)?; +fn instruction_sequence_label_map_block_for_label( + map: &InstructionSequenceLabelMap, + label: InstructionSequenceLabel, +) -> Option { + if !is_label(label) { + return None; + } + map.cpython_block_by_label + .get(label.idx()) + .copied() + .filter(|&block| block != BlockIdx::NULL) +} - let numdropped = fix_cell_offsets(metadata, blocks, &mut cellfixedoffsets); - nlocalsplus -= numdropped; - Ok(nlocalsplus) +fn instruction_sequence_label_map_resolve_label( + map: &InstructionSequenceLabelMap, + block: BlockIdx, +) -> BlockIdx { + if block == BlockIdx::NULL { + return BlockIdx::NULL; + } + let label = instruction_sequence_label_map_label_for_block(map, block); + if !is_label(label) { + return block; + } + instruction_sequence_label_map_block_for_label(map, label).unwrap_or_else(|| { + debug_assert!( + false, + "CPython instruction-sequence label must map to a codegen CFG block" + ); + BlockIdx::NULL + }) } -/// flowgraph.c remove_unreachable -fn remove_unreachable(blocks: &mut Blocks) -> crate::InternalResult<()> { - let mut block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - blocks[block_idx].predecessors = 0; - block_idx = blocks[block_idx].next; +fn instruction_sequence_label_map_resolve_label_to_block( + map: &InstructionSequenceLabelMap, + label: InstructionSequenceLabel, +) -> BlockIdx { + if !is_label(label) { + return BlockIdx::NULL; } + instruction_sequence_label_map_block_for_label(map, label).unwrap_or_else(|| { + debug_assert!( + false, + "CPython instruction-sequence label must map to a codegen CFG block" + ); + BlockIdx::NULL + }) +} - let mut stack = make_cfg_traversal_stack(blocks)?; - blocks[0].predecessors = 1; - stack.push(BlockIdx(0)); - blocks[0].visited = true; - while let Some(current) = stack.pop() { - let idx = current.idx(); - let next = blocks[idx].next; - if next != BlockIdx::NULL && bb_has_fallthrough(&blocks[idx]) { - if !blocks[next].visited { - debug_assert_eq!(blocks[next].predecessors, 0); - stack.push(next); - blocks[next].visited = true; - } - blocks[next].predecessors += 1; - } +fn instruction_sequence_label_oparg(label: InstructionSequenceLabel) -> OpArg { + debug_assert!(is_label(label)); + OpArg::new(label.idx() as u32) +} - let instr_count = blocks[idx].instruction_used; - for i in 0..instr_count { - let instr = blocks[idx].instructions[i]; - if is_jump(&instr) || is_block_push(&instr) { - let target = instr.target; - debug_assert!(target != BlockIdx::NULL); - let target_idx = target.idx(); - if !blocks[target_idx].visited { - stack.push(target); - blocks[target_idx].visited = true; - } - blocks[target_idx].predecessors += 1; - } - } +fn instruction_sequence_label_map_use_label_at_block( + map: &mut InstructionSequenceLabelMap, + seq: &mut InstructionSequence, + from: BlockIdx, + to: BlockIdx, +) -> crate::InternalResult<()> { + if from == BlockIdx::NULL || from == to { + return Ok(()); } - - block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - let i = block_idx.idx(); - let next = blocks[i].next; - if blocks[i].predecessors == 0 { - let block = &mut blocks[i]; - basicblock_clear(block); - block.except_handler = false; - } - block_idx = next; + let from_label = instruction_sequence_label_map_ensure_label_for_block(map, seq, from)?; + debug_assert!(map.cpython_block_by_label.len() > from_label.idx()); + let to_block = instruction_sequence_label_map_resolve_label(map, to); + if to_block == BlockIdx::NULL { + debug_assert!( + false, + "CPython label target must map to a codegen CFG block" + ); + return Ok(()); } + map.cpython_block_by_label[from_label.idx()] = to_block; Ok(()) } -/// flowgraph.c eval_const_unaryop -fn eval_const_unaryop( - operand: &ConstantData, - op: Instruction, - intrinsic: Option, -) -> Option { - match (operand, op, intrinsic) { - (ConstantData::Integer { value }, Instruction::UnaryNegative, None) => { - Some(ConstantData::Integer { value: -value }) - } - (ConstantData::Float { value }, Instruction::UnaryNegative, None) => { - Some(ConstantData::Float { value: -value }) - } - (ConstantData::Complex { value }, Instruction::UnaryNegative, None) => { - Some(ConstantData::Complex { value: -value }) - } - (ConstantData::Boolean { value }, Instruction::UnaryNegative, None) => { - Some(ConstantData::Integer { - value: BigInt::from(-i32::from(*value)), - }) - } - (ConstantData::Integer { value }, Instruction::UnaryInvert, None) => { - Some(ConstantData::Integer { value: !value }) - } - (ConstantData::Boolean { .. }, Instruction::UnaryInvert, None) => None, - (_, Instruction::UnaryNot, None) => Some(ConstantData::Boolean { - value: !operand.truthiness(), - }), - ( - ConstantData::Integer { value }, - Instruction::CallIntrinsic1 { .. }, - Some(oparg::IntrinsicFunction1::UnaryPositive), - ) => Some(ConstantData::Integer { - value: value.clone(), - }), - ( - ConstantData::Float { value }, - Instruction::CallIntrinsic1 { .. }, - Some(oparg::IntrinsicFunction1::UnaryPositive), - ) => Some(ConstantData::Float { value: *value }), - ( - ConstantData::Boolean { value }, - Instruction::CallIntrinsic1 { .. }, - Some(oparg::IntrinsicFunction1::UnaryPositive), - ) => Some(ConstantData::Integer { - value: BigInt::from(i32::from(*value)), - }), - ( - ConstantData::Complex { value }, - Instruction::CallIntrinsic1 { .. }, - Some(oparg::IntrinsicFunction1::UnaryPositive), - ) => Some(ConstantData::Complex { value: *value }), - _ => None, - } -} - -fn load_const_truthiness( - instr: Instruction, - arg: OpArg, - metadata: &CodeUnitMetadata, -) -> Option { - match instr { - Instruction::LoadConst { consti } => { - let constant = &metadata.consts[consti.get(arg).as_usize()]; - Some(constant.truthiness()) - } - Instruction::LoadSmallInt { i } => Some(i.get(arg) != 0), - _ => None, - } -} - -/// flowgraph.c add_const -fn add_const( - metadata: &mut CodeUnitMetadata, - constant: ConstantData, -) -> crate::InternalResult { - Ok(metadata.consts.try_insert_full(constant)?.0) +fn instruction_sequence_label_map_push_unlabeled_block( + map: &mut InstructionSequenceLabelMap, +) -> crate::InternalResult<()> { + map.block_labels + .try_reserve(1) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + map.block_labels.push(InstructionSequenceLabel::NO_LABEL); + Ok(()) } -fn instr_make_load_const( - metadata: &mut CodeUnitMetadata, - instr: &mut InstructionInfo, - constant: ConstantData, +fn instruction_sequence_label_map_push_unmapped_label( + map: &mut InstructionSequenceLabelMap, + seq: &mut InstructionSequence, ) -> crate::InternalResult<()> { - if maybe_instr_make_load_smallint(instr, &constant) { - return Ok(()); - } - - let const_idx = add_const(metadata, constant)?; - instr_set_op1( - instr, - Opcode::LoadConst.into(), - OpArg::new(const_idx as u32), + let label = instruction_sequence_new_label(seq); + debug_assert_eq!(label.0, seq.next_free_label); + instruction_sequence_label_map_register_label(map, label)?; + let block = BlockIdx( + map.block_labels + .len() + .to_u32() + .ok_or(InternalError::MalformedControlFlowGraph)?, ); + map.cpython_block_by_label[label.idx()] = block; + map.block_labels + .try_reserve(1) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + map.block_labels.push(label); Ok(()) } -/// flowgraph.c fold_const_unaryop -fn fold_const_unaryop( - metadata: &mut CodeUnitMetadata, - block: &mut Block, - i: usize, -) -> crate::InternalResult { - let instr = &block.instructions[i]; - let (op, intrinsic) = match instr.instr.real() { - Some(Instruction::UnaryNegative) => (Instruction::UnaryNegative, None), - Some(Instruction::UnaryInvert) => (Instruction::UnaryInvert, None), - Some(Instruction::UnaryNot) => (Instruction::UnaryNot, None), - Some(Instruction::CallIntrinsic1 { func }) - if matches!( - func.get(instr.arg), - oparg::IntrinsicFunction1::UnaryPositive - ) => - { - (Opcode::CallIntrinsic1.into(), Some(func.get(instr.arg))) - } - _ => return Ok(false), - }; - let Some(operand_index) = (if let Some(start) = i.checked_sub(1) { - get_const_loading_instrs(block, start, 1)? - } else { - None - }) - .and_then(|indices| indices.into_iter().next()) else { - return Ok(false); - }; - let operand = get_const_value(metadata, &block.instructions[operand_index]); - let Some(operand) = operand else { - return Ok(false); - }; - let Some(folded_const) = eval_const_unaryop(&operand, op, intrinsic) else { - return Ok(false); - }; - nop_out(block, &[operand_index]); - instr_make_load_const(metadata, &mut block.instructions[i], folded_const)?; - Ok(true) -} - -/// flowgraph.c get_const_loading_instrs -fn get_const_loading_instrs( - block: &Block, - mut start: usize, - size: usize, -) -> crate::InternalResult>> { - let mut indices = Vec::new(); - indices - .try_reserve_exact(size) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - loop { - if start >= block.instruction_used { - return Ok(None); - } - let instr = &block.instructions[start]; - if !matches!(instr.instr.real(), Some(Instruction::Nop)) { - if !loads_const(instr) { - return Ok(None); - } - indices.push(start); - if indices.len() == size { - break; - } +impl InstructionSequenceLabelMap { + pub(crate) fn new() -> Self { + Self { + block_labels: vec![InstructionSequenceLabel::NO_LABEL], + cpython_block_by_label: Vec::new(), } - let Some(prev) = start.checked_sub(1) else { - return Ok(None); - }; - start = prev; } - indices.reverse(); - Ok(Some(indices)) } -/// flowgraph.c nop_out -fn nop_out(block: &mut Block, instrs: &[usize]) { - for &i in instrs { - nop_out_no_location(&mut block.instructions[i]); - } -} +pub struct CodeInfo { + pub flags: CodeFlags, + pub source_path: String, + pub private: Option, // For private name mangling, mostly for class -/// flowgraph.c fold_const_binop -fn fold_const_binop( - metadata: &mut CodeUnitMetadata, - block: &mut Block, - i: usize, -) -> crate::InternalResult { - use oparg::BinaryOperator as BinOp; + pub blocks: Blocks, + pub current_block: BlockIdx, + pub(crate) instr_sequence: InstructionSequence, + pub(crate) instr_sequence_label_map: InstructionSequenceLabelMap, + pub(crate) annotations_instr_sequence: Option, - let Some(Opcode::BinaryOp) = block.instructions[i].instr.real_opcode() else { - return Ok(false); - }; + pub metadata: CodeUnitMetadata, - let Some(operand_indices) = (if let Some(start) = i.checked_sub(1) { - get_const_loading_instrs(block, start, 2)? - } else { - None - }) else { - return Ok(false); - }; + // For class scopes: attributes accessed via self.X + pub static_attributes: Option>, - let op_raw = u32::from(block.instructions[i].arg); - let Ok(op) = BinOp::try_from(op_raw) else { - return Ok(false); - }; + // True if compiling an inlined comprehension + pub in_inlined_comp: bool, - let left = get_const_value(metadata, &block.instructions[operand_indices[0]]); - let right = get_const_value(metadata, &block.instructions[operand_indices[1]]); - let (Some(left_val), Some(right_val)) = (left, right) else { - return Ok(false); - }; + // Block stack for tracking nested control structures + pub fblock: Vec, - let Some(result_const) = eval_const_binop(&left_val, &right_val, op) else { - return Ok(false); - }; + // Reference to the symbol table for this scope + pub symbol_table_index: usize, + // CPython compile.c uses PyList_GET_SIZE(u->u_ste->ste_varnames) + // when calling flowgraph.c _PyCfg_OptimizeCodeUnit(). + pub nparams: usize, - nop_out(block, &operand_indices); - instr_make_load_const(metadata, &mut block.instructions[i], result_const)?; - Ok(true) -} + // PEP 649: Track nesting depth inside conditional blocks (if/for/while/etc.) + // u_in_conditional_block + pub in_conditional_block: u32, -/// flowgraph.c loads_const -fn loads_const(info: &InstructionInfo) -> bool { - info.instr.has_const() || matches!(info.instr.real_opcode(), Some(Opcode::LoadSmallInt)) + // PEP 649: Next index for conditional annotation tracking + // u_next_conditional_annotation_index + pub next_conditional_annotation_index: u32, } -/// flowgraph.c get_const_value -fn get_const_value(metadata: &CodeUnitMetadata, info: &InstructionInfo) -> Option { - match info.instr.real_opcode() { - Some(Opcode::LoadSmallInt) => { - let v = u32::from(info.arg) as i32; - Some(ConstantData::Integer { - value: BigInt::from(v), - }) - } - _ if info.instr.has_const() => { - let idx = u32::from(info.arg) as usize; - metadata.consts.get_index(idx).cloned() +impl CodeInfo { + pub(crate) fn addop_to_instr_sequence( + &mut self, + mut info: InstructionInfo, + ) -> crate::InternalResult<()> { + if info.instr.has_target() && info.target != BlockIdx::NULL { + let label = instruction_sequence_label_map_ensure_label_for_block( + &mut self.instr_sequence_label_map, + &mut self.instr_sequence, + info.target, + )?; + info.arg = instruction_sequence_label_oparg(label); + info.target = BlockIdx::NULL; } - _ => None, + instruction_sequence_addop(&mut self.instr_sequence, info)?; + Ok(()) } -} -/// flowgraph.c const_folding_check_complexity -fn const_folding_check_complexity(obj: &ConstantData, mut limit: isize) -> Option { - if let ConstantData::Tuple { elements } = obj { - limit -= isize::try_from(elements.len()).ok()?; - if limit < 0 { - return None; - } - for element in elements { - limit = const_folding_check_complexity(element, limit)?; + pub(crate) fn addop_to_instr_sequence_with_target_label( + &mut self, + mut info: InstructionInfo, + target_label: InstructionSequenceLabel, + ) -> crate::InternalResult<()> { + if !info.instr.has_target() { + return Err(InternalError::MalformedControlFlowGraph); } + info.arg = instruction_sequence_label_oparg(target_label); + info.target = BlockIdx::NULL; + instruction_sequence_addop(&mut self.instr_sequence, info)?; + Ok(()) } - Some(limit) -} -fn repeat_wtf8(value: &Wtf8Buf, n: usize) -> Option { - let mut result = Wtf8Buf::new(); - result.try_reserve_exact(value.len().checked_mul(n)?).ok()?; - for _ in 0..n { - result.push_wtf8(value); + pub(crate) fn addop_to_current_block( + &mut self, + info: InstructionInfo, + ) -> crate::InternalResult<()> { + basicblock_addop(&mut self.blocks[self.current_block.idx()], info) } - Some(result) -} -fn checked_repeat_count(n: &BigInt, item_size: usize) -> Option { - let n = n.to_isize()?; - if item_size != 0 && (n < 0 || n as usize > MAX_STR_SIZE / item_size) { - return None; + pub(crate) fn last_current_block_instr_mut(&mut self) -> Option<&mut InstructionInfo> { + basicblock_last_instr_mut(&mut self.blocks[self.current_block.idx()]) } - Some(n.max(0) as usize) -} -/// flowgraph.c const_folding_safe_multiply -fn const_folding_safe_multiply(left: &ConstantData, right: &ConstantData) -> Option { - match (left, right) { - (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) => { - if !l.is_zero() && !r.is_zero() && l.bits() + r.bits() > MAX_INT_SIZE { - return None; - } - Some(ConstantData::Integer { value: l * r }) - } - (ConstantData::Float { value: l }, ConstantData::Float { value: r }) => { - Some(ConstantData::Float { value: l * r }) - } - (ConstantData::Str { value: s }, ConstantData::Integer { value: n }) => { - let n = checked_repeat_count(n, s.code_points().count())?; - Some(ConstantData::Str { - value: repeat_wtf8(s, n)?, - }) - } - (ConstantData::Integer { .. }, ConstantData::Str { .. }) => { - const_folding_safe_multiply(right, left) - } - (ConstantData::Bytes { value: b }, ConstantData::Integer { value: n }) => { - let n = checked_repeat_count(n, b.len())?; - let mut value = Vec::new(); - value.try_reserve_exact(b.len().checked_mul(n)?).ok()?; - for _ in 0..n { - value.extend_from_slice(b); - } - Some(ConstantData::Bytes { value }) - } - (ConstantData::Integer { .. }, ConstantData::Bytes { .. }) => { - const_folding_safe_multiply(right, left) - } - (ConstantData::Tuple { elements }, ConstantData::Integer { value: n }) => { - let n = n.to_usize()?; - if n != 0 && !elements.is_empty() { - if n > MAX_COLLECTION_SIZE / elements.len() { - return None; - } - const_folding_check_complexity( - &ConstantData::Tuple { - elements: elements.clone(), - }, - MAX_TOTAL_ITEMS / isize::try_from(n).ok()?, - )?; - } - let mut result = Vec::new(); - result - .try_reserve_exact(elements.len().checked_mul(n)?) - .ok()?; - for _ in 0..n { - result.extend(elements.iter().cloned()); - } - Some(ConstantData::Tuple { elements: result }) - } - (ConstantData::Integer { .. }, ConstantData::Tuple { .. }) => { - const_folding_safe_multiply(right, left) + pub(crate) fn set_last_instr_sequence_lineno_override(&mut self, lineno_override: i32) { + if let Some(last) = instruction_sequence_last_info_mut(&mut self.instr_sequence) { + last.lineno_override = Some(lineno_override); } - _ => None, } -} -/// flowgraph.c const_folding_safe_power -fn const_folding_safe_power(left: &ConstantData, right: &ConstantData) -> Option { - match (left, right) { - (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) => { - if r < &BigInt::from(0) { - if l.is_zero() { - return None; - } - let base = l.to_f64()?; - if !base.is_finite() { - return None; - } - let result = if let Some(exp) = r.to_i32() { - base.powi(exp) - } else { - base.powf(r.to_f64()?) - }; - if !result.is_finite() { - return None; - } - return Some(ConstantData::Float { value: result }); - } - let exp: u64 = r.try_into().ok()?; - let exp_usize = usize::try_from(exp).ok()?; - if !l.is_zero() && exp > 0 && l.bits() > MAX_INT_SIZE / exp { - return None; - } - Some(ConstantData::Integer { - value: num_traits::pow::pow(l.clone(), exp_usize), - }) - } - (ConstantData::Float { value: l }, ConstantData::Float { value: r }) => { - let result = l.powf(*r); - result - .is_finite() - .then_some(ConstantData::Float { value: result }) - } - _ => None, + pub(crate) fn use_instr_sequence_label( + &mut self, + block: BlockIdx, + ) -> crate::InternalResult<()> { + let label = instruction_sequence_label_map_ensure_label_for_block( + &mut self.instr_sequence_label_map, + &mut self.instr_sequence, + block, + )?; + instruction_sequence_use_label(&mut self.instr_sequence, label) } -} -/// flowgraph.c const_folding_safe_lshift -fn const_folding_safe_lshift(left: &ConstantData, right: &ConstantData) -> Option { - let (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) = (left, right) - else { - return None; - }; - let shift: u64 = r.try_into().ok()?; - let shift_usize = usize::try_from(shift).ok()?; - if shift > MAX_INT_SIZE || (!l.is_zero() && l.bits() > MAX_INT_SIZE - shift) { - return None; + pub(crate) fn new_instr_sequence_label(&mut self) -> InstructionSequenceLabel { + instruction_sequence_new_label(&mut self.instr_sequence) } - Some(ConstantData::Integer { - value: l << shift_usize, - }) -} -/// flowgraph.c const_folding_safe_mod -fn const_folding_safe_mod(left: &ConstantData, right: &ConstantData) -> Option { - if matches!(left, ConstantData::Str { .. } | ConstantData::Bytes { .. }) { - return None; + pub(crate) fn use_raw_instr_sequence_label( + &mut self, + label: InstructionSequenceLabel, + ) -> crate::InternalResult<()> { + instruction_sequence_use_label(&mut self.instr_sequence, label) } - match (left, right) { - (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) => { - if r.is_zero() { - return None; - } - let rem = l.clone() % r.clone(); - let value = if !rem.is_zero() && (rem < BigInt::from(0)) != (*r < BigInt::from(0)) { - rem + r - } else { - rem - }; - Some(ConstantData::Integer { value }) - } - (ConstantData::Float { value: l }, ConstantData::Float { value: r }) => { - let (_, modulo) = float_div_mod(*l, *r)?; - Some(ConstantData::Float { value: modulo }) - } - _ => None, + pub(crate) fn mark_cpython_cfg_label(&mut self, block: BlockIdx) -> crate::InternalResult<()> { + let label = instruction_sequence_label_map_ensure_label_for_block( + &mut self.instr_sequence_label_map, + &mut self.instr_sequence, + block, + )?; + self.blocks[block.idx()].cpython_label = label; + Ok(()) } -} -fn float_div_mod(left: f64, right: f64) -> Option<(f64, f64)> { - if right == 0.0 { - return None; + pub(crate) fn resolve_instr_sequence_label(&self, block: BlockIdx) -> BlockIdx { + instruction_sequence_label_map_resolve_label(&self.instr_sequence_label_map, block) } - let mut modulo = left % right; - let div = (left - modulo) / right; - let floordiv = if modulo != 0.0 { - let div = if (right < 0.0) != (modulo < 0.0) { - modulo += right; - div - 1.0 - } else { - div - }; - let mut floordiv = div.floor(); - if div - floordiv > 0.5 { - floordiv += 1.0; - } - floordiv - } else { - modulo = 0.0f64.copysign(right); - 0.0f64.copysign(left / right) - }; + pub(crate) fn block_for_instr_sequence_label( + &self, + label: InstructionSequenceLabel, + ) -> BlockIdx { + instruction_sequence_label_map_resolve_label_to_block(&self.instr_sequence_label_map, label) + } - Some((floordiv, modulo)) -} - -/// flowgraph.c eval_const_binop complex result construction -fn eval_const_complex_const(value: Complex) -> Option { - (value.re.is_finite() && value.im.is_finite()).then_some(ConstantData::Complex { value }) -} - -/// flowgraph.c eval_const_binop complex operations -fn eval_const_complex_binop( - left: Complex, - right: Complex, - op: oparg::BinaryOperator, -) -> Option { - use oparg::BinaryOperator as BinOp; - - let value = match op { - BinOp::Add => left + right, - BinOp::Subtract => { - let re = left.re - right.re; - // Preserve CPython's signed-zero behavior for real-zero - // minus zero-complex expressions such as `0 - 0j`. - let im = if left.re == 0.0 - && left.im == 0.0 - && right.re == 0.0 - && right.im == 0.0 - && !right.im.is_sign_negative() - { - -0.0 - } else { - left.im - right.im - }; - Complex::new(re, im) - } - BinOp::Multiply => left * right, - BinOp::TrueDivide => { - if right == Complex::new(0.0, 0.0) { - return None; - } - left / right - } - BinOp::Power => { - if left == Complex::new(0.0, 0.0) { - if right.im != 0.0 || right.re < 0.0 { - return None; - } - - return eval_const_complex_const(if right.re == 0.0 { - Complex::new(1.0, 0.0) - } else { - Complex::new(0.0, 0.0) - }); - } + pub(crate) fn use_instr_sequence_label_at_block( + &mut self, + from: BlockIdx, + to: BlockIdx, + ) -> crate::InternalResult<()> { + instruction_sequence_label_map_use_label_at_block( + &mut self.instr_sequence_label_map, + &mut self.instr_sequence, + from, + to, + ) + } - if right.im == 0.0 - && right.re.fract() == 0.0 - && right.re >= f64::from(i32::MIN) - && right.re <= f64::from(i32::MAX) - { - left.powi(right.re as i32) - } else { - left.powc(right) - } + pub(crate) fn instr_sequence_label_for_block( + &mut self, + block: BlockIdx, + ) -> crate::InternalResult { + if block == BlockIdx::NULL { + Ok(InstructionSequenceLabel::NO_LABEL) + } else { + instruction_sequence_label_map_ensure_label_for_block( + &mut self.instr_sequence_label_map, + &mut self.instr_sequence, + block, + ) } - _ => return None, - }; - eval_const_complex_const(value) -} + } -/// flowgraph.c eval_const_binop subscript index conversion -fn constant_as_index(value: &ConstantData) -> Option { - match value { - ConstantData::Integer { value } => value.to_i64().or_else(|| { - if value < &BigInt::from(0) { - Some(i64::MIN) - } else { - Some(i64::MAX) - } - }), - ConstantData::Boolean { value } => Some(i64::from(*value)), - _ => None, + pub(crate) fn insert_start_setup_cleanup( + &mut self, + handler_block: BlockIdx, + ) -> crate::InternalResult<()> { + let handler_label = instruction_sequence_label_map_ensure_label_for_block( + &mut self.instr_sequence_label_map, + &mut self.instr_sequence, + handler_block, + )?; + instruction_sequence_insert_instruction( + &mut self.instr_sequence, + 0, + InstructionInfo { + instr: PseudoOpcode::SetupCleanup.into(), + arg: instruction_sequence_label_oparg(handler_label), + target: BlockIdx::NULL, + location: SourceLocation::default(), + end_location: SourceLocation::default(), + except_handler: None, + lineno_override: Some(NO_LOCATION_OVERRIDE), + }, + ) } -} -/// flowgraph.c eval_const_binop subscript slice bound conversion -fn slice_bound(value: &ConstantData) -> Option> { - match value { - ConstantData::None => Some(None), - _ => constant_as_index(value).map(Some), + pub(crate) fn push_unmapped_instr_sequence_label(&mut self) -> crate::InternalResult<()> { + instruction_sequence_label_map_push_unmapped_label( + &mut self.instr_sequence_label_map, + &mut self.instr_sequence, + ) } -} -/// flowgraph.c eval_const_binop subscript slice index adjustment -fn adjusted_slice_indices(len: usize, slice: &[ConstantData; 3]) -> Option> { - let len = i64::try_from(len).ok()?; - let start = slice_bound(&slice[0])?; - let stop = slice_bound(&slice[1])?; - let step = slice_bound(&slice[2])?.unwrap_or(1); - if step == 0 || step == i64::MIN { - return None; + pub(crate) fn push_unlabeled_instr_sequence_block(&mut self) -> crate::InternalResult<()> { + instruction_sequence_label_map_push_unlabeled_block(&mut self.instr_sequence_label_map) } - let step_is_negative = step < 0; - let lower = if step_is_negative { -1 } else { 0 }; - let upper = if step_is_negative { len - 1 } else { len }; - let adjust = |value: Option, default: i64| { - let mut value = value.unwrap_or(default); - if value < 0 { - value = value.saturating_add(len); - if value < 0 { - value = lower; - } - } else if value >= len { - value = upper; + fn take_recorded_instr_sequence(&mut self) -> crate::InternalResult { + let mut instr_sequence = + core::mem::replace(&mut self.instr_sequence, instruction_sequence_new()); + if let Some(mut annotations_instr_sequence) = self.annotations_instr_sequence.take() { + instruction_sequence_apply_label_map(&mut annotations_instr_sequence)?; + instruction_sequence_set_annotations_code( + &mut instr_sequence, + Some(Box::new(annotations_instr_sequence)), + ); } - value - }; - let start = adjust(start, if step_is_negative { upper } else { lower }); - let stop = adjust(stop, if step_is_negative { lower } else { upper }); + Ok(instr_sequence) + } - let mut index = i128::from(start); - let stop = i128::from(stop); - let step = i128::from(step); - let slice_len = if step > 0 { - if index < stop { - usize::try_from((stop - index - 1) / step + 1).ok()? - } else { - 0 - } - } else if index > stop { - usize::try_from((index - stop - 1) / -step + 1).ok()? - } else { - 0 - }; - let mut indices = Vec::new(); - indices.try_reserve_exact(slice_len).ok()?; - if step > 0 { - while index < stop { - indices.push(usize::try_from(index).ok()?); - index += step; - } - } else { - while index > stop { - indices.push(usize::try_from(index).ok()?); - index += step; - } + fn prepare_cfg_from_codegen(&mut self) -> crate::InternalResult { + // CPython compile.c optimize_and_assemble_code_unit passes + // u_instr_sequence directly into flowgraph.c _PyCfg_FromInstructionSequence(). + self.take_recorded_instr_sequence() } - Some(indices) } -/// flowgraph.c eval_const_binop subscript index adjustment -fn adjusted_const_index(len: usize, index: &ConstantData) -> Option { - let len = i64::try_from(len).ok()?; - let index = constant_as_index(index)?; - let index = if index < 0 { - index.saturating_add(len) - } else { - index - }; - if index < 0 || index >= len { - return None; - } - usize::try_from(index).ok() +fn optimize_code_unit( + metadata: &mut CodeUnitMetadata, + blocks: &mut Blocks, + instr_sequence: InstructionSequence, + nlocals: usize, + nparams: usize, +) -> crate::InternalResult<()> { + // Phase 1: _PyCfg_OptimizeCodeUnit (flowgraph.c) + *blocks = cfg_from_instruction_sequence(instr_sequence)?; + translate_jump_labels_to_targets(blocks)?; + mark_except_handlers(blocks)?; + label_exception_targets(blocks)?; + optimize_cfg(metadata, blocks, metadata.firstlineno)?; + blocks.remove_unused_consts(&mut metadata.consts)?; + add_checks_for_loads_of_uninitialized_variables(blocks, nlocals, nparams)?; + // CPython inserts superinstructions in _PyCfg_OptimizeCodeUnit, before + // later jump normalization / block reordering can create adjacencies + // that never exist at this stage in flowgraph.c. + blocks.insert_superinstructions()?; + push_cold_blocks_to_end(blocks)?; + // CPython resolves line numbers again after cold-block extraction. + blocks.resolve_line_numbers(metadata.firstlineno)?; + Ok(()) } -/// flowgraph.c eval_const_binop NB_SUBSCR -fn eval_const_subscript(container: &ConstantData, index: &ConstantData) -> Option { - match (container, index) { - ( - ConstantData::Str { value }, - ConstantData::Integer { .. } | ConstantData::Boolean { .. }, - ) => { - let string = value.to_string(); - if string.contains(char::REPLACEMENT_CHARACTER) { - return None; - } - let mut chars = Vec::new(); - chars.try_reserve_exact(string.chars().count()).ok()?; - chars.extend(string.chars()); - let index = adjusted_const_index(chars.len(), index)?; - Some(ConstantData::Str { - value: chars[index].to_string().into(), - }) - } - (ConstantData::Str { value }, ConstantData::Slice { elements }) => { - let string = value.to_string(); - if string.contains(char::REPLACEMENT_CHARACTER) { - return None; - } - let mut chars = Vec::new(); - chars.try_reserve_exact(string.chars().count()).ok()?; - chars.extend(string.chars()); - let indices = adjusted_slice_indices(chars.len(), elements)?; - let capacity = indices.iter().try_fold(0usize, |capacity, &index| { - capacity.checked_add(chars[index].len_utf8()) - })?; - let mut result = String::new(); - result.try_reserve_exact(capacity).ok()?; - for index in indices { - result.push(chars[index]); +fn optimize_cfg( + metadata: &mut CodeUnitMetadata, + blocks: &mut Blocks, + firstlineno: OneIndexed, +) -> crate::InternalResult<()> { + // flowgraph.c optimize_cfg + // CPython optimize_cfg() starts with check_cfg() and raises + // SystemError if a jump or scope exit is not the last instruction in + // its block. + check_cfg(blocks)?; + inline_small_or_no_lineno_blocks(blocks)?; + // CPython does not re-run instruction-sequence label-map/CFG conversion + // after this point. Unreferenced label blocks left by jump inlining + // remain block boundaries and can preserve line-marker NOPs. + blocks.remove_unreachable()?; + // CPython optimize_cfg resolves line numbers before local checks and + // superinstruction insertion, so fusion decisions see propagated + // source locations. + blocks.resolve_line_numbers(firstlineno)?; + // CPython optimize_cfg() runs optimize_load_const() and then + // optimize_basic_block() after line numbers are resolved. + optimize_load_const(metadata, blocks)?; + let mut block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + let next_block = blocks[block_idx].next; + blocks.optimize_basic_block(metadata, block_idx)?; + block_idx = next_block; + } + blocks.remove_redundant_nops_and_pairs()?; + // CPython optimize_cfg() removes newly-unreachable blocks and + // redundant NOP/jump chains before _PyCfg_OptimizeCodeUnit() prunes + // unused constants. + blocks.remove_unreachable()?; + remove_redundant_nops_and_jumps(blocks)?; + #[cfg(debug_assertions)] + assert!(no_redundant_jumps(blocks)); + Ok(()) +} + +fn optimized_cfg_to_instruction_sequence( + metadata: &CodeUnitMetadata, + flags: CodeFlags, + blocks: &mut Blocks, +) -> crate::InternalResult<(u32, usize, InstructionSequence)> { + // Phase 2: _PyCfg_OptimizedCfgToInstructionSequence (flowgraph.c) + convert_pseudo_conditional_jumps(blocks)?; + let max_stackdepth = blocks.calculate_stackdepth()?; + debug_assert!(!is_generator(flags) || max_stackdepth != 0); + let nlocalsplus = prepare_localsplus(metadata, blocks, flags)?; + // Match CPython order: pseudo ops are lowered after stackdepth and + // localsplus preparation, before normalize_jumps. + convert_pseudo_ops(blocks)?; + blocks.normalize_jumps()?; + #[cfg(debug_assertions)] + assert!(no_redundant_jumps(blocks)); + // optimize_load_fast: after normalize_jumps + blocks.optimize_load_fast()?; + + let mut instr_sequence = instruction_sequence_new(); + blocks.cfg_to_instruction_sequence(&mut instr_sequence)?; + Ok((max_stackdepth, nlocalsplus, instr_sequence)) +} + +impl CodeInfo { + pub fn finalize_code( + mut self, + opts: &crate::compile::CompileOpts, + ) -> crate::InternalResult { + let instr_sequence = self.prepare_cfg_from_codegen()?; + let nlocals = self.metadata.varnames.len(); + let nparams = self.nparams; + optimize_code_unit( + &mut self.metadata, + &mut self.blocks, + instr_sequence, + nlocals, + nparams, + )?; + let (max_stackdepth, nlocalsplus, mut instr_sequence) = + optimized_cfg_to_instruction_sequence(&self.metadata, self.flags, &mut self.blocks)?; + let localsplusinfo = compute_localsplus_info(&self.metadata, nlocalsplus, self.flags)?; + + let Self { + flags, + source_path, + private: _, // private is only used during compilation + + blocks: _, + current_block: _, + instr_sequence: _, + instr_sequence_label_map: _, + annotations_instr_sequence: _, + metadata, + static_attributes: _, + in_inlined_comp: _, + fblock: _, + symbol_table_index: _, + nparams: _, + in_conditional_block: _, + next_conditional_annotation_index: _, + } = self; + + let CodeUnitMetadata { + name: obj_name, + qualname, + consts: constants, + names: name_cache, + varnames: varname_cache, + cellvars: _, + freevars: freevar_cache, + fast_hidden: _, + fast_hidden_final: _, + argcount: arg_count, + posonlyargcount: posonlyarg_count, + kwonlyargcount: kwonlyarg_count, + firstlineno: first_line_number, + } = metadata; + + resolve_unconditional_jumps(&mut instr_sequence)?; + resolve_jump_offsets(&mut instr_sequence)?; + let assembled = assemble_emit( + &mut instr_sequence, + first_line_number.get() as i32, + opts.debug_ranges, + )?; + let locations = rustpython_compiler_core::marshal::linetable_to_locations( + &assembled.linetable, + first_line_number.get() as i32, + assembled.instructions.len(), + ); + + Ok(CodeObject { + flags, + posonlyarg_count, + arg_count, + kwonlyarg_count, + source_path, + first_line_number: Some(first_line_number), + obj_name: obj_name.clone(), + qualname: qualname.unwrap_or(obj_name), + + max_stackdepth, + instructions: CodeUnits::from(assembled.instructions), + locations, + constants: constants.into_iter().collect(), + names: name_cache.into_iter().collect(), + varnames: varname_cache.into_iter().collect(), + cellvars: localsplusinfo.cellvars, + freevars: freevar_cache.into_iter().collect(), + localspluskinds: localsplusinfo.kinds, + linetable: assembled.linetable, + exceptiontable: assembled.exceptiontable, + }) + } +} + +/// flowgraph.c IS_GENERATOR +fn is_generator(flags: CodeFlags) -> bool { + flags.intersects(CodeFlags::GENERATOR | CodeFlags::COROUTINE | CodeFlags::ASYNC_GENERATOR) +} + +/// flowgraph.c insert_prefix_instructions +fn insert_prefix_instructions( + metadata: &CodeUnitMetadata, + blocks: &mut Blocks, + cellfixedoffsets: &[i32], + nfreevars: usize, + flags: CodeFlags, +) -> crate::InternalResult<()> { + debug_assert!(!blocks.is_empty()); + let entry = &mut blocks[0]; + let ncellvars = metadata.cellvars.len(); + let firstlineno = metadata.firstlineno; + debug_assert!(firstlineno.get() > 0); + + if is_generator(flags) { + let location = SourceLocation { + line: firstlineno, + character_offset: OneIndexed::MIN, + }; + basicblock_insert_instruction( + entry, + 0, + InstructionInfo { + instr: Instruction::ReturnGenerator.into(), + arg: OpArg::new(0), + target: BlockIdx::NULL, + location, + end_location: location, + except_handler: None, + lineno_override: Some(LINE_ONLY_LOCATION_OVERRIDE), + }, + )?; + basicblock_insert_instruction( + entry, + 1, + InstructionInfo { + instr: Instruction::PopTop.into(), + arg: OpArg::new(0), + target: BlockIdx::NULL, + location, + end_location: location, + except_handler: None, + lineno_override: Some(LINE_ONLY_LOCATION_OVERRIDE), + }, + )?; + } + + if ncellvars > 0 { + let nvars = metadata.varnames.len() + ncellvars; + let mut sorted = Vec::new(); + vec_try_reserve_exact(&mut sorted, nvars)?; + sorted.resize(nvars, 0i32); + for i in 0..ncellvars { + sorted[cellfixedoffsets[i] as usize] = i as i32 + 1; + } + let mut ncellsused = 0; + let mut i = 0; + while ncellsused < ncellvars { + let oldindex = sorted[i] - 1; + i += 1; + if oldindex == -1 { + continue; } - Some(ConstantData::Str { - value: result.into(), - }) + basicblock_insert_instruction( + entry, + ncellsused, + InstructionInfo { + instr: Opcode::MakeCell.into(), + arg: OpArg::new(oldindex as u32), + target: BlockIdx::NULL, + location: SourceLocation::default(), + end_location: SourceLocation::default(), + except_handler: None, + lineno_override: Some(NO_LOCATION_OVERRIDE), + }, + )?; + ncellsused += 1; } - ( - ConstantData::Bytes { value }, - ConstantData::Integer { .. } | ConstantData::Boolean { .. }, - ) => { - let index = adjusted_const_index(value.len(), index)?; + } + + if nfreevars > 0 { + basicblock_insert_instruction( + entry, + 0, + InstructionInfo { + instr: Opcode::CopyFreeVars.into(), + arg: OpArg::new(nfreevars as u32), + target: BlockIdx::NULL, + location: SourceLocation::default(), + end_location: SourceLocation::default(), + except_handler: None, + lineno_override: Some(NO_LOCATION_OVERRIDE), + }, + )?; + } + Ok(()) +} + +/// flowgraph.c prepare_localsplus +fn prepare_localsplus( + metadata: &CodeUnitMetadata, + blocks: &mut Blocks, + flags: CodeFlags, +) -> crate::InternalResult { + let nlocals = metadata.varnames.len(); + let ncellvars = metadata.cellvars.len(); + let nfreevars = metadata.freevars.len(); + let int_max = i32::MAX as usize; + debug_assert!(nlocals < int_max); + debug_assert!(ncellvars < int_max); + debug_assert!(nfreevars < int_max); + debug_assert!(int_max - nlocals - ncellvars > 0); + debug_assert!(int_max - nlocals - ncellvars - nfreevars > 0); + let mut nlocalsplus = nlocals + ncellvars + nfreevars; + let mut cellfixedoffsets = build_cellfixedoffsets(metadata)?; + + // This must be called before fix_cell_offsets(). + insert_prefix_instructions(metadata, blocks, &cellfixedoffsets, nfreevars, flags)?; + + let numdropped = fix_cell_offsets(metadata, blocks, &mut cellfixedoffsets); + nlocalsplus -= numdropped; + Ok(nlocalsplus) +} + +/// flowgraph.c eval_const_unaryop +fn eval_const_unaryop( + operand: &ConstantData, + op: Instruction, + intrinsic: Option, +) -> Option { + match (operand, op, intrinsic) { + (ConstantData::Integer { value }, Instruction::UnaryNegative, None) => { + Some(ConstantData::Integer { value: -value }) + } + (ConstantData::Float { value }, Instruction::UnaryNegative, None) => { + Some(ConstantData::Float { value: -value }) + } + (ConstantData::Complex { value }, Instruction::UnaryNegative, None) => { + Some(ConstantData::Complex { value: -value }) + } + (ConstantData::Boolean { value }, Instruction::UnaryNegative, None) => { Some(ConstantData::Integer { - value: BigInt::from(value[index]), + value: BigInt::from(-i32::from(*value)), }) } - (ConstantData::Bytes { value }, ConstantData::Slice { elements }) => { - let indices = adjusted_slice_indices(value.len(), elements)?; - let mut result = Vec::new(); - result.try_reserve_exact(indices.len()).ok()?; - for index in indices { - result.push(value[index]); - } - Some(ConstantData::Bytes { value: result }) + (ConstantData::Integer { value }, Instruction::UnaryInvert, None) => { + Some(ConstantData::Integer { value: !value }) } + (ConstantData::Boolean { .. }, Instruction::UnaryInvert, None) => None, + (_, Instruction::UnaryNot, None) => Some(ConstantData::Boolean { + value: !operand.truthiness(), + }), ( - ConstantData::Tuple { elements }, - ConstantData::Integer { .. } | ConstantData::Boolean { .. }, - ) => { - let index = adjusted_const_index(elements.len(), index)?; - Some(elements[index].clone()) - } - (ConstantData::Tuple { elements }, ConstantData::Slice { elements: slice }) => { - let indices = adjusted_slice_indices(elements.len(), slice)?; - let mut result = Vec::new(); - result.try_reserve_exact(indices.len()).ok()?; - for index in indices { - result.push(elements[index].clone()); - } - Some(ConstantData::Tuple { elements: result }) - } + ConstantData::Integer { value }, + Instruction::CallIntrinsic1 { .. }, + Some(oparg::IntrinsicFunction1::UnaryPositive), + ) => Some(ConstantData::Integer { + value: value.clone(), + }), + ( + ConstantData::Float { value }, + Instruction::CallIntrinsic1 { .. }, + Some(oparg::IntrinsicFunction1::UnaryPositive), + ) => Some(ConstantData::Float { value: *value }), + ( + ConstantData::Boolean { value }, + Instruction::CallIntrinsic1 { .. }, + Some(oparg::IntrinsicFunction1::UnaryPositive), + ) => Some(ConstantData::Integer { + value: BigInt::from(i32::from(*value)), + }), + ( + ConstantData::Complex { value }, + Instruction::CallIntrinsic1 { .. }, + Some(oparg::IntrinsicFunction1::UnaryPositive), + ) => Some(ConstantData::Complex { value: *value }), _ => None, } } -/// flowgraph.c eval_const_binop bool/int coercion -fn constant_as_int(value: &ConstantData) -> Option<(BigInt, bool)> { - match value { - ConstantData::Boolean { value } => Some((BigInt::from(u8::from(*value)), true)), - ConstantData::Integer { value } => Some((value.clone(), false)), +fn load_const_truthiness( + instr: Instruction, + arg: OpArg, + metadata: &CodeUnitMetadata, +) -> Option { + match instr { + Instruction::LoadConst { consti } => { + let constant = &metadata.consts[consti.get(arg).as_usize()]; + Some(constant.truthiness()) + } + Instruction::LoadSmallInt { i } => Some(i.get(arg) != 0), _ => None, } } -/// flowgraph.c eval_const_binop -fn eval_const_binop( - left: &ConstantData, - right: &ConstantData, - op: oparg::BinaryOperator, -) -> Option { - use oparg::BinaryOperator as BinOp; - - if matches!(op, BinOp::Subscr) { - return eval_const_subscript(left, right); - } - - if let (Some((left_int, left_is_bool)), Some((right_int, right_is_bool))) = - (constant_as_int(left), constant_as_int(right)) - && (left_is_bool || right_is_bool) - { - if left_is_bool && right_is_bool { - match op { - BinOp::And => { - return Some(ConstantData::Boolean { - value: !left_int.is_zero() & !right_int.is_zero(), - }); - } - BinOp::Or => { - return Some(ConstantData::Boolean { - value: !left_int.is_zero() | !right_int.is_zero(), - }); - } - BinOp::Xor => { - return Some(ConstantData::Boolean { - value: !left_int.is_zero() ^ !right_int.is_zero(), - }); - } - _ => {} - } - } +/// flowgraph.c add_const +fn add_const( + metadata: &mut CodeUnitMetadata, + constant: ConstantData, +) -> crate::InternalResult { + Ok(metadata.consts.try_insert_full(constant)?.0) +} - return eval_const_binop( - &ConstantData::Integer { value: left_int }, - &ConstantData::Integer { value: right_int }, - op, - ); +fn instr_make_load_const( + metadata: &mut CodeUnitMetadata, + instr: &mut InstructionInfo, + constant: ConstantData, +) -> crate::InternalResult<()> { + if maybe_instr_make_load_smallint(instr, &constant) { + return Ok(()); } - match (left, right) { - (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) => { - let result = match op { - BinOp::Add => l + r, - BinOp::Subtract => l - r, - BinOp::Multiply => { - return const_folding_safe_multiply(left, right); - } - BinOp::TrueDivide => { - if r.is_zero() { - return None; - } - let l_f = l.to_f64()?; - let r_f = r.to_f64()?; - let result = l_f / r_f; - if !result.is_finite() { - return None; - } - return Some(ConstantData::Float { value: result }); - } - BinOp::FloorDivide => { - if r.is_zero() { - return None; - } - // Python floor division: round towards negative infinity - let (q, rem) = (l.clone() / r.clone(), l.clone() % r.clone()); - if !rem.is_zero() && (rem < BigInt::from(0)) != (*r < BigInt::from(0)) { - q - 1 - } else { - q - } - } - BinOp::Remainder => return const_folding_safe_mod(left, right), - BinOp::Power => return const_folding_safe_power(left, right), - BinOp::Lshift => return const_folding_safe_lshift(left, right), - BinOp::Rshift => { - let shift: u32 = r.try_into().ok()?; - l >> (shift as usize) - } - BinOp::And => l & r, - BinOp::Or => l | r, - BinOp::Xor => l ^ r, - _ => return None, - }; - Some(ConstantData::Integer { value: result }) - } - (ConstantData::Float { value: l }, ConstantData::Float { value: r }) => { - let result = match op { - BinOp::Add => l + r, - BinOp::Subtract => l - r, - BinOp::Multiply => return const_folding_safe_multiply(left, right), - BinOp::TrueDivide => { - if *r == 0.0 { - return None; - } - l / r - } - BinOp::FloorDivide => { - let (floordiv, _) = float_div_mod(*l, *r)?; - floordiv - } - BinOp::Remainder => return const_folding_safe_mod(left, right), - BinOp::Power => return const_folding_safe_power(left, right), - _ => return None, - }; - if matches!(op, BinOp::Power) && !result.is_finite() { - return None; - } - Some(ConstantData::Float { value: result }) - } - // Int op Float or Float op Int → Float - (ConstantData::Integer { value: l }, ConstantData::Float { value: r }) => { - let l_f = l.to_f64()?; - eval_const_binop( - &ConstantData::Float { value: l_f }, - &ConstantData::Float { value: *r }, - op, - ) - } - (ConstantData::Float { value: l }, ConstantData::Integer { value: r }) => { - let r_f = r.to_f64()?; - eval_const_binop( - &ConstantData::Float { value: *l }, - &ConstantData::Float { value: r_f }, - op, - ) - } - (ConstantData::Integer { value: l }, ConstantData::Complex { value: r }) => { - eval_const_complex_binop(Complex::new(l.to_f64()?, 0.0), *r, op) - } - (ConstantData::Complex { value: l }, ConstantData::Integer { value: r }) => { - eval_const_complex_binop(*l, Complex::new(r.to_f64()?, 0.0), op) - } - (ConstantData::Float { value: l }, ConstantData::Complex { value: r }) => { - eval_const_complex_binop(Complex::new(*l, 0.0), *r, op) - } - (ConstantData::Complex { value: l }, ConstantData::Float { value: r }) => { - eval_const_complex_binop(*l, Complex::new(*r, 0.0), op) - } - (ConstantData::Complex { value: l }, ConstantData::Complex { value: r }) => { - eval_const_complex_binop(*l, *r, op) - } - // String concatenation and repetition - (ConstantData::Str { value: l }, ConstantData::Str { value: r }) - if matches!(op, BinOp::Add) => - { - let mut result = Wtf8Buf::new(); - result - .try_reserve_exact(l.len().checked_add(r.len())?) - .ok()?; - result.push_wtf8(l); - result.push_wtf8(r); - Some(ConstantData::Str { value: result }) - } - (ConstantData::Str { .. }, ConstantData::Integer { .. }) - if matches!(op, BinOp::Multiply) => - { - const_folding_safe_multiply(left, right) - } - (ConstantData::Tuple { elements: l }, ConstantData::Tuple { elements: r }) - if matches!(op, BinOp::Add) => - { - let mut result = Vec::new(); - result - .try_reserve_exact(l.len().checked_add(r.len())?) - .ok()?; - result.extend(l.iter().cloned()); - result.extend(r.iter().cloned()); - Some(ConstantData::Tuple { elements: result }) - } - (ConstantData::Tuple { .. }, ConstantData::Integer { .. }) - if matches!(op, BinOp::Multiply) => - { - const_folding_safe_multiply(left, right) - } - (ConstantData::Integer { .. }, ConstantData::Tuple { .. }) - if matches!(op, BinOp::Multiply) => - { - const_folding_safe_multiply(left, right) - } - (ConstantData::Integer { .. }, ConstantData::Str { .. }) - if matches!(op, BinOp::Multiply) => - { - const_folding_safe_multiply(left, right) - } - (ConstantData::Bytes { value: l }, ConstantData::Bytes { value: r }) - if matches!(op, BinOp::Add) => - { - let mut result = Vec::new(); - result - .try_reserve_exact(l.len().checked_add(r.len())?) - .ok()?; - result.extend_from_slice(l); - result.extend_from_slice(r); - Some(ConstantData::Bytes { value: result }) - } - (ConstantData::Bytes { .. }, ConstantData::Integer { .. }) - if matches!(op, BinOp::Multiply) => - { - const_folding_safe_multiply(left, right) - } - (ConstantData::Integer { .. }, ConstantData::Bytes { .. }) - if matches!(op, BinOp::Multiply) => - { - const_folding_safe_multiply(left, right) - } - _ => None, - } + let const_idx = add_const(metadata, constant)?; + instr_set_op1( + instr, + Opcode::LoadConst.into(), + OpArg::new(const_idx as u32), + ); + Ok(()) } -/// flowgraph.c fold_tuple_of_constants -fn fold_tuple_of_constants( +/// flowgraph.c fold_const_unaryop +fn fold_const_unaryop( metadata: &mut CodeUnitMetadata, block: &mut Block, i: usize, ) -> crate::InternalResult { - let Some(Opcode::BuildTuple) = block.instructions[i].instr.real_opcode() else { - return Ok(false); + let instr = &block.instructions[i]; + let (op, intrinsic) = match instr.instr.real() { + Some(Instruction::UnaryNegative) => (Instruction::UnaryNegative, None), + Some(Instruction::UnaryInvert) => (Instruction::UnaryInvert, None), + Some(Instruction::UnaryNot) => (Instruction::UnaryNot, None), + Some(Instruction::CallIntrinsic1 { func }) + if matches!( + func.get(instr.arg), + oparg::IntrinsicFunction1::UnaryPositive + ) => + { + (Opcode::CallIntrinsic1.into(), Some(func.get(instr.arg))) + } + _ => return Ok(false), }; - - let tuple_size = u32::from(block.instructions[i].arg) as usize; - if tuple_size > STACK_USE_GUIDELINE { - return Ok(false); - } - - let Some(operand_indices) = (if tuple_size == 0 { - Some(Vec::new()) - } else if let Some(start) = i.checked_sub(1) { - get_const_loading_instrs(block, start, tuple_size)? + let Some(operand_index) = (if let Some(start) = i.checked_sub(1) { + get_const_loading_instrs(block, start, 1)? } else { None - }) else { + }) + .and_then(|indices| indices.into_iter().next()) else { + return Ok(false); + }; + let operand = get_const_value(metadata, &block.instructions[operand_index]); + let Some(operand) = operand else { + return Ok(false); + }; + let Some(folded_const) = eval_const_unaryop(&operand, op, intrinsic) else { return Ok(false); }; + nop_out(block, &[operand_index]); + instr_make_load_const(metadata, &mut block.instructions[i], folded_const)?; + Ok(true) +} - let mut elements = Vec::new(); - elements - .try_reserve_exact(tuple_size) +/// flowgraph.c get_const_loading_instrs +fn get_const_loading_instrs( + block: &Block, + mut start: usize, + size: usize, +) -> crate::InternalResult>> { + let mut indices = Vec::new(); + indices + .try_reserve_exact(size) .map_err(|_| InternalError::MalformedControlFlowGraph)?; - for &j in &operand_indices { - let Some(element) = get_const_value(metadata, &block.instructions[j]) else { - return Ok(false); + loop { + if start >= block.instruction_used { + return Ok(None); + } + let instr = &block.instructions[start]; + if !matches!(instr.instr.real(), Some(Instruction::Nop)) { + if !loads_const(instr) { + return Ok(None); + } + indices.push(start); + if indices.len() == size { + break; + } + } + let Some(prev) = start.checked_sub(1) else { + return Ok(None); }; - elements.push(element); + start = prev; } + indices.reverse(); + Ok(Some(indices)) +} - nop_out(block, &operand_indices); - instr_make_load_const( - metadata, - &mut block.instructions[i], - ConstantData::Tuple { elements }, - )?; - Ok(true) +/// flowgraph.c nop_out +fn nop_out(block: &mut Block, instrs: &[usize]) { + for &i in instrs { + nop_out_no_location(&mut block.instructions[i]); + } } -fn fold_constant_intrinsic_list_to_tuple( +/// flowgraph.c fold_const_binop +fn fold_const_binop( metadata: &mut CodeUnitMetadata, block: &mut Block, i: usize, ) -> crate::InternalResult { - let Some(Instruction::CallIntrinsic1 { func }) = block.instructions[i].instr.real() else { + use oparg::BinaryOperator as BinOp; + + let Some(Opcode::BinaryOp) = block.instructions[i].instr.real_opcode() else { return Ok(false); }; - if func.get(block.instructions[i].arg) != IntrinsicFunction1::ListToTuple { + + let Some(operand_indices) = (if let Some(start) = i.checked_sub(1) { + get_const_loading_instrs(block, start, 2)? + } else { + None + }) else { + return Ok(false); + }; + + let op_raw = u32::from(block.instructions[i].arg); + let Ok(op) = BinOp::try_from(op_raw) else { + return Ok(false); + }; + + let left = get_const_value(metadata, &block.instructions[operand_indices[0]]); + let right = get_const_value(metadata, &block.instructions[operand_indices[1]]); + let (Some(left_val), Some(right_val)) = (left, right) else { + return Ok(false); + }; + + let Some(result_const) = eval_const_binop(&left_val, &right_val, op) else { return Ok(false); + }; + + nop_out(block, &operand_indices); + instr_make_load_const(metadata, &mut block.instructions[i], result_const)?; + Ok(true) +} + +/// flowgraph.c loads_const +fn loads_const(info: &InstructionInfo) -> bool { + info.instr.has_const() || matches!(info.instr.real_opcode(), Some(Opcode::LoadSmallInt)) +} + +/// flowgraph.c get_const_value +fn get_const_value(metadata: &CodeUnitMetadata, info: &InstructionInfo) -> Option { + match info.instr.real_opcode() { + Some(Opcode::LoadSmallInt) => { + let v = u32::from(info.arg) as i32; + Some(ConstantData::Integer { + value: BigInt::from(v), + }) + } + _ if info.instr.has_const() => { + let idx = u32::from(info.arg) as usize; + metadata.consts.get_index(idx).cloned() + } + _ => None, } +} - let mut consts_found = 0usize; - let mut expect_append = true; - let mut pos = i; - while let Some(prev) = pos.checked_sub(1) { - pos = prev; - let instr = &block.instructions[pos]; - if matches!(instr.instr.real(), Some(Instruction::Nop)) { - continue; +/// flowgraph.c const_folding_check_complexity +fn const_folding_check_complexity(obj: &ConstantData, mut limit: isize) -> Option { + if let ConstantData::Tuple { elements } = obj { + limit -= isize::try_from(elements.len()).ok()?; + if limit < 0 { + return None; + } + for element in elements { + limit = const_folding_check_complexity(element, limit)?; } + } + Some(limit) +} - if matches!(instr.instr.real(), Some(Instruction::BuildList { .. })) - && u32::from(instr.arg) == 0 - { - if !expect_append { - return Ok(false); - } +fn repeat_wtf8(value: &Wtf8Buf, n: usize) -> Option { + let mut result = Wtf8Buf::new(); + result.try_reserve_exact(value.len().checked_mul(n)?).ok()?; + for _ in 0..n { + result.push_wtf8(value); + } + Some(result) +} - let mut elements = Vec::new(); - elements - .try_reserve_exact(consts_found) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - for idx in (pos..i).rev() { - if matches!(block.instructions[idx].instr.real(), Some(Instruction::Nop)) { - continue; - } - if loads_const(&block.instructions[idx]) { - let Some(value) = get_const_value(metadata, &block.instructions[idx]) else { - return Ok(false); - }; - elements.push(value); +fn checked_repeat_count(n: &BigInt, item_size: usize) -> Option { + let n = n.to_isize()?; + if item_size != 0 && (n < 0 || n as usize > MAX_STR_SIZE / item_size) { + return None; + } + Some(n.max(0) as usize) +} + +/// flowgraph.c const_folding_safe_multiply +fn const_folding_safe_multiply(left: &ConstantData, right: &ConstantData) -> Option { + match (left, right) { + (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) => { + if !l.is_zero() && !r.is_zero() && l.bits() + r.bits() > MAX_INT_SIZE { + return None; + } + Some(ConstantData::Integer { value: l * r }) + } + (ConstantData::Float { value: l }, ConstantData::Float { value: r }) => { + Some(ConstantData::Float { value: l * r }) + } + (ConstantData::Str { value: s }, ConstantData::Integer { value: n }) => { + let n = checked_repeat_count(n, s.code_points().count())?; + Some(ConstantData::Str { + value: repeat_wtf8(s, n)?, + }) + } + (ConstantData::Integer { .. }, ConstantData::Str { .. }) => { + const_folding_safe_multiply(right, left) + } + (ConstantData::Bytes { value: b }, ConstantData::Integer { value: n }) => { + let n = checked_repeat_count(n, b.len())?; + let mut value = Vec::new(); + value.try_reserve_exact(b.len().checked_mul(n)?).ok()?; + for _ in 0..n { + value.extend_from_slice(b); + } + Some(ConstantData::Bytes { value }) + } + (ConstantData::Integer { .. }, ConstantData::Bytes { .. }) => { + const_folding_safe_multiply(right, left) + } + (ConstantData::Tuple { elements }, ConstantData::Integer { value: n }) => { + let n = n.to_usize()?; + if n != 0 && !elements.is_empty() { + if n > MAX_COLLECTION_SIZE / elements.len() { + return None; } - nop_out_no_location(&mut block.instructions[idx]); + const_folding_check_complexity( + &ConstantData::Tuple { + elements: elements.clone(), + }, + MAX_TOTAL_ITEMS / isize::try_from(n).ok()?, + )?; } - debug_assert_eq!(elements.len(), consts_found); - elements.reverse(); - instr_make_load_const( - metadata, - &mut block.instructions[i], - ConstantData::Tuple { elements }, - )?; - return Ok(true); + let mut result = Vec::new(); + result + .try_reserve_exact(elements.len().checked_mul(n)?) + .ok()?; + for _ in 0..n { + result.extend(elements.iter().cloned()); + } + Some(ConstantData::Tuple { elements: result }) + } + (ConstantData::Integer { .. }, ConstantData::Tuple { .. }) => { + const_folding_safe_multiply(right, left) } + _ => None, + } +} - if expect_append { - if !matches!(instr.instr.real(), Some(Instruction::ListAppend { .. })) - || u32::from(instr.arg) != 1 - { - return Ok(false); +/// flowgraph.c const_folding_safe_power +fn const_folding_safe_power(left: &ConstantData, right: &ConstantData) -> Option { + match (left, right) { + (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) => { + if r < &BigInt::from(0) { + if l.is_zero() { + return None; + } + let base = l.to_f64()?; + if !base.is_finite() { + return None; + } + let result = if let Some(exp) = r.to_i32() { + base.powi(exp) + } else { + base.powf(r.to_f64()?) + }; + if !result.is_finite() { + return None; + } + return Some(ConstantData::Float { value: result }); } - } else { - if !loads_const(instr) { - return Ok(false); + let exp: u64 = r.try_into().ok()?; + let exp_usize = usize::try_from(exp).ok()?; + if !l.is_zero() && exp > 0 && l.bits() > MAX_INT_SIZE / exp { + return None; } - consts_found += 1; + Some(ConstantData::Integer { + value: num_traits::pow::pow(l.clone(), exp_usize), + }) } - expect_append = !expect_append; + (ConstantData::Float { value: l }, ConstantData::Float { value: r }) => { + let result = l.powf(*r); + result + .is_finite() + .then_some(ConstantData::Float { value: result }) + } + _ => None, } - - Ok(false) } -/// Port of CPython's flowgraph.c optimize_lists_and_sets(). -fn optimize_lists_and_sets( - metadata: &mut CodeUnitMetadata, - block: &mut Block, - i: usize, - nextop: Option, -) -> crate::InternalResult { - let Some(instr) = block.instructions[i].instr.real() else { - return Ok(false); +/// flowgraph.c const_folding_safe_lshift +fn const_folding_safe_lshift(left: &ConstantData, right: &ConstantData) -> Option { + let (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) = (left, right) + else { + return None; }; - let is_list = matches!(instr, Instruction::BuildList { .. }); - let is_set = matches!(instr, Instruction::BuildSet { .. }); - if !is_list && !is_set { - return Ok(false); + let shift: u64 = r.try_into().ok()?; + let shift_usize = usize::try_from(shift).ok()?; + if shift > MAX_INT_SIZE || (!l.is_zero() && l.bits() > MAX_INT_SIZE - shift) { + return None; } + Some(ConstantData::Integer { + value: l << shift_usize, + }) +} - let contains_or_iter = matches!( - nextop, - Some(Instruction::GetIter | Instruction::ContainsOp { .. }) - ); - let seq_size = u32::from(block.instructions[i].arg) as usize; - if seq_size > STACK_USE_GUIDELINE || (seq_size < MIN_CONST_SEQUENCE_SIZE && !contains_or_iter) { - return Ok(false); +/// flowgraph.c const_folding_safe_mod +fn const_folding_safe_mod(left: &ConstantData, right: &ConstantData) -> Option { + if matches!(left, ConstantData::Str { .. } | ConstantData::Bytes { .. }) { + return None; } - let Some(operand_indices) = (if seq_size == 0 { - Some(Vec::new()) - } else if let Some(start) = i.checked_sub(1) { - get_const_loading_instrs(block, start, seq_size)? - } else { - None - }) else { - if contains_or_iter && is_list { - let arg = block.instructions[i].arg; - instr_set_op1(&mut block.instructions[i], Opcode::BuildTuple.into(), arg); - return Ok(true); + match (left, right) { + (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) => { + if r.is_zero() { + return None; + } + let rem = l.clone() % r.clone(); + let value = if !rem.is_zero() && (rem < BigInt::from(0)) != (*r < BigInt::from(0)) { + rem + r + } else { + rem + }; + Some(ConstantData::Integer { value }) } - return Ok(false); - }; - - let mut elements = Vec::new(); - elements - .try_reserve_exact(seq_size) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - for &j in &operand_indices { - let Some(element) = get_const_value(metadata, &block.instructions[j]) else { - return Ok(false); - }; - elements.push(element); + (ConstantData::Float { value: l }, ConstantData::Float { value: r }) => { + let (_, modulo) = float_div_mod(*l, *r)?; + Some(ConstantData::Float { value: modulo }) + } + _ => None, } +} - let const_data = if is_list { - ConstantData::Tuple { elements } - } else { - ConstantData::Frozenset { elements } - }; - let const_idx = add_const(metadata, const_data)?; - - if !contains_or_iter { - debug_assert!(i >= 2); - let folded_loc = block.instructions[i].location; - let end_loc = block.instructions[i].end_location; - - nop_out(block, &operand_indices); +fn float_div_mod(left: f64, right: f64) -> Option<(f64, f64)> { + if right == 0.0 { + return None; + } - let build_instr = if is_list { - Opcode::BuildList + let mut modulo = left % right; + let div = (left - modulo) / right; + let floordiv = if modulo != 0.0 { + let div = if (right < 0.0) != (modulo < 0.0) { + modulo += right; + div - 1.0 } else { - Opcode::BuildSet + div + }; + let mut floordiv = div.floor(); + if div - floordiv > 0.5 { + floordiv += 1.0; } - .into(); - instr_set_op1(&mut block.instructions[i - 2], build_instr, OpArg::new(0)); - block.instructions[i - 2].location = folded_loc; - block.instructions[i - 2].end_location = end_loc; - block.instructions[i - 2].lineno_override = None; + floordiv + } else { + modulo = 0.0f64.copysign(right); + 0.0f64.copysign(left / right) + }; - instr_set_op1( - &mut block.instructions[i - 1], - Opcode::LoadConst.into(), - OpArg::new(const_idx as u32), - ); + Some((floordiv, modulo)) +} - let extend_instr = if is_list { - Opcode::ListExtend - } else { - Opcode::SetUpdate - }; - instr_set_op1( - &mut block.instructions[i], - extend_instr.into(), - OpArg::new(1), - ); - return Ok(true); - } +/// flowgraph.c eval_const_binop complex result construction +fn eval_const_complex_const(value: Complex) -> Option { + (value.re.is_finite() && value.im.is_finite()).then_some(ConstantData::Complex { value }) +} - nop_out(block, &operand_indices); +/// flowgraph.c eval_const_binop complex operations +fn eval_const_complex_binop( + left: Complex, + right: Complex, + op: oparg::BinaryOperator, +) -> Option { + use oparg::BinaryOperator as BinOp; - instr_set_op1( - &mut block.instructions[i], - Opcode::LoadConst.into(), - OpArg::new(const_idx as u32), - ); - Ok(true) -} + let value = match op { + BinOp::Add => left + right, + BinOp::Subtract => { + let re = left.re - right.re; + // Preserve CPython's signed-zero behavior for real-zero + // minus zero-complex expressions such as `0 - 0j`. + let im = if left.re == 0.0 + && left.im == 0.0 + && right.re == 0.0 + && right.im == 0.0 + && !right.im.is_sign_negative() + { + -0.0 + } else { + left.im - right.im + }; + Complex::new(re, im) + } + BinOp::Multiply => left * right, + BinOp::TrueDivide => { + if right == Complex::new(0.0, 0.0) { + return None; + } + left / right + } + BinOp::Power => { + if left == Complex::new(0.0, 0.0) { + if right.im != 0.0 || right.re < 0.0 { + return None; + } -/// flowgraph.c VISITED -const VISITED: i32 = -1; + return eval_const_complex_const(if right.re == 0.0 { + Complex::new(1.0, 0.0) + } else { + Complex::new(0.0, 0.0) + }); + } -/// flowgraph.c SWAPPABLE -fn is_swappable(instr: AnyInstruction) -> bool { - matches!( - instr.into(), - AnyOpcode::Real(Opcode::StoreFast | Opcode::PopTop) - | AnyOpcode::Pseudo(PseudoOpcode::StoreFastMaybeNull) - ) + if right.im == 0.0 + && right.re.fract() == 0.0 + && right.re >= f64::from(i32::MIN) + && right.re <= f64::from(i32::MAX) + { + left.powi(right.re as i32) + } else { + left.powc(right) + } + } + _ => return None, + }; + eval_const_complex_const(value) } -/// flowgraph.c STORES_TO -fn stores_to(info: &InstructionInfo) -> i32 { - match info.instr.into() { - AnyOpcode::Real(Opcode::StoreFast) - | AnyOpcode::Pseudo(PseudoOpcode::StoreFastMaybeNull) => u32::from(info.arg) as i32, - _ => -1, +/// flowgraph.c eval_const_binop subscript index conversion +fn constant_as_index(value: &ConstantData) -> Option { + match value { + ConstantData::Integer { value } => value.to_i64().or_else(|| { + if value < &BigInt::from(0) { + Some(i64::MIN) + } else { + Some(i64::MAX) + } + }), + ConstantData::Boolean { value } => Some(i64::from(*value)), + _ => None, } } -/// flowgraph.c next_swappable_instruction -fn next_swappable_instruction(block: &Block, mut i: usize, lineno: i32) -> Option { - loop { - i += 1; - if i >= block.instruction_used { - return None; - } +/// flowgraph.c eval_const_binop subscript slice bound conversion +fn slice_bound(value: &ConstantData) -> Option> { + match value { + ConstantData::None => Some(None), + _ => constant_as_index(value).map(Some), + } +} - let info = &block.instructions[i]; - let info_lineno = instruction_lineno(info); +/// flowgraph.c eval_const_binop subscript slice index adjustment +fn adjusted_slice_indices(len: usize, slice: &[ConstantData; 3]) -> Option> { + let len = i64::try_from(len).ok()?; + let start = slice_bound(&slice[0])?; + let stop = slice_bound(&slice[1])?; + let step = slice_bound(&slice[2])?.unwrap_or(1); + if step == 0 || step == i64::MIN { + return None; + } - if lineno >= 0 && info_lineno != lineno { - return None; + let step_is_negative = step < 0; + let lower = if step_is_negative { -1 } else { 0 }; + let upper = if step_is_negative { len - 1 } else { len }; + let adjust = |value: Option, default: i64| { + let mut value = value.unwrap_or(default); + if value < 0 { + value = value.saturating_add(len); + if value < 0 { + value = lower; + } + } else if value >= len { + value = upper; } + value + }; + let start = adjust(start, if step_is_negative { upper } else { lower }); + let stop = adjust(stop, if step_is_negative { lower } else { upper }); - if matches!(info.instr, AnyInstruction::Real(Instruction::Nop)) { - continue; + let mut index = i128::from(start); + let stop = i128::from(stop); + let step = i128::from(step); + let slice_len = if step > 0 { + if index < stop { + usize::try_from((stop - index - 1) / step + 1).ok()? + } else { + 0 } - - if is_swappable(info.instr) { - return Some(i); + } else if index > stop { + usize::try_from((index - stop - 1) / -step + 1).ok()? + } else { + 0 + }; + let mut indices = Vec::new(); + indices.try_reserve_exact(slice_len).ok()?; + if step > 0 { + while index < stop { + indices.push(usize::try_from(index).ok()?); + index += step; + } + } else { + while index > stop { + indices.push(usize::try_from(index).ok()?); + index += step; } + } + Some(indices) +} +/// flowgraph.c eval_const_binop subscript index adjustment +fn adjusted_const_index(len: usize, index: &ConstantData) -> Option { + let len = i64::try_from(len).ok()?; + let index = constant_as_index(index)?; + let index = if index < 0 { + index.saturating_add(len) + } else { + index + }; + if index < 0 || index >= len { return None; } + usize::try_from(index).ok() } -/// flowgraph.c swaptimize -fn swaptimize(block: &mut Block, ix: &mut usize) -> crate::InternalResult<()> { - debug_assert!(matches!( - block.instructions[*ix].instr.real_opcode(), - Some(Opcode::Swap) - )); - let mut depth = u32::from(block.instructions[*ix].arg) as usize; - let mut len = 1usize; - let mut more = false; - let limit = block.instruction_used - *ix; - while len < limit { - match block.instructions[*ix + len].instr.real_opcode() { - Some(Opcode::Swap) => { - depth = depth.max(u32::from(block.instructions[*ix + len].arg) as usize); - more = true; - len += 1; - } - Some(Opcode::Nop) => { - len += 1; +/// flowgraph.c eval_const_binop NB_SUBSCR +fn eval_const_subscript(container: &ConstantData, index: &ConstantData) -> Option { + match (container, index) { + ( + ConstantData::Str { value }, + ConstantData::Integer { .. } | ConstantData::Boolean { .. }, + ) => { + let string = value.to_string(); + if string.contains(char::REPLACEMENT_CHARACTER) { + return None; } - _ => break, + let mut chars = Vec::new(); + chars.try_reserve_exact(string.chars().count()).ok()?; + chars.extend(string.chars()); + let index = adjusted_const_index(chars.len(), index)?; + Some(ConstantData::Str { + value: chars[index].to_string().into(), + }) } - } - - if !more { - return Ok(()); - } - - let mut stack = Vec::new(); - stack - .try_reserve_exact(depth) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - stack.resize(depth, 0); - let mut i = 0; - while i < depth { - stack[i] = i as i32; - i += 1; - } - - i = 0; - while i < len { - let info = &block.instructions[*ix + i]; - if matches!(info.instr.real_opcode(), Some(Opcode::Swap)) { - let oparg = u32::from(info.arg) as usize; - stack.swap(0, oparg - 1); + (ConstantData::Str { value }, ConstantData::Slice { elements }) => { + let string = value.to_string(); + if string.contains(char::REPLACEMENT_CHARACTER) { + return None; + } + let mut chars = Vec::new(); + chars.try_reserve_exact(string.chars().count()).ok()?; + chars.extend(string.chars()); + let indices = adjusted_slice_indices(chars.len(), elements)?; + let capacity = indices.iter().try_fold(0usize, |capacity, &index| { + capacity.checked_add(chars[index].len_utf8()) + })?; + let mut result = String::new(); + result.try_reserve_exact(capacity).ok()?; + for index in indices { + result.push(chars[index]); + } + Some(ConstantData::Str { + value: result.into(), + }) } - i += 1; - } - - let mut current = len as isize - 1; - for i in 0..depth { - if stack[i] == VISITED || stack[i] == i as i32 { - continue; + ( + ConstantData::Bytes { value }, + ConstantData::Integer { .. } | ConstantData::Boolean { .. }, + ) => { + let index = adjusted_const_index(value.len(), index)?; + Some(ConstantData::Integer { + value: BigInt::from(value[index]), + }) } - let mut j = i; - loop { - if j != 0 { - debug_assert!(current >= 0); - let out = &mut block.instructions[*ix + current as usize]; - out.instr = Opcode::Swap.into(); - out.arg = OpArg::new((j + 1) as u32); - current -= 1; + (ConstantData::Bytes { value }, ConstantData::Slice { elements }) => { + let indices = adjusted_slice_indices(value.len(), elements)?; + let mut result = Vec::new(); + result.try_reserve_exact(indices.len()).ok()?; + for index in indices { + result.push(value[index]); } - if stack[j] == VISITED { - debug_assert_eq!(j, i); - break; + Some(ConstantData::Bytes { value: result }) + } + ( + ConstantData::Tuple { elements }, + ConstantData::Integer { .. } | ConstantData::Boolean { .. }, + ) => { + let index = adjusted_const_index(elements.len(), index)?; + Some(elements[index].clone()) + } + (ConstantData::Tuple { elements }, ConstantData::Slice { elements: slice }) => { + let indices = adjusted_slice_indices(elements.len(), slice)?; + let mut result = Vec::new(); + result.try_reserve_exact(indices.len()).ok()?; + for index in indices { + result.push(elements[index].clone()); } - let next_j = stack[j] as usize; - stack[j] = VISITED; - j = next_j; + Some(ConstantData::Tuple { elements: result }) } + _ => None, } +} - while current >= 0 { - set_to_nop(&mut block.instructions[*ix + current as usize]); - current -= 1; +/// flowgraph.c eval_const_binop bool/int coercion +fn constant_as_int(value: &ConstantData) -> Option<(BigInt, bool)> { + match value { + ConstantData::Boolean { value } => Some((BigInt::from(u8::from(*value)), true)), + ConstantData::Integer { value } => Some((value.clone(), false)), + _ => None, } - *ix += len - 1; - Ok(()) } -/// flowgraph.c apply_static_swaps -fn apply_static_swaps(block: &mut Block, mut i: isize) { - while i >= 0 { - let idx = i as usize; - debug_assert!(idx < block.instruction_used); - let swap_arg = match block.instructions[idx].instr.real_opcode() { - Some(Opcode::Swap) => u32::from(block.instructions[idx].arg), - Some(Opcode::Nop | Opcode::PopTop | Opcode::StoreFast) => { - i -= 1; - continue; - } - _ if matches!( - block.instructions[idx].instr.pseudo_opcode(), - Some(PseudoOpcode::StoreFastMaybeNull) - ) => - { - i -= 1; - continue; +/// flowgraph.c eval_const_binop +fn eval_const_binop( + left: &ConstantData, + right: &ConstantData, + op: oparg::BinaryOperator, +) -> Option { + use oparg::BinaryOperator as BinOp; + + if matches!(op, BinOp::Subscr) { + return eval_const_subscript(left, right); + } + + if let (Some((left_int, left_is_bool)), Some((right_int, right_is_bool))) = + (constant_as_int(left), constant_as_int(right)) + && (left_is_bool || right_is_bool) + { + if left_is_bool && right_is_bool { + match op { + BinOp::And => { + return Some(ConstantData::Boolean { + value: !left_int.is_zero() & !right_int.is_zero(), + }); + } + BinOp::Or => { + return Some(ConstantData::Boolean { + value: !left_int.is_zero() | !right_int.is_zero(), + }); + } + BinOp::Xor => { + return Some(ConstantData::Boolean { + value: !left_int.is_zero() ^ !right_int.is_zero(), + }); + } + _ => {} } - _ => return, - }; + } - let Some(j) = next_swappable_instruction(block, idx, -1) else { - return; - }; - let lineno = instruction_lineno(&block.instructions[j]); - let mut k = j; - for _ in 1..swap_arg { - let Some(next) = next_swappable_instruction(block, k, lineno) else { - return; + return eval_const_binop( + &ConstantData::Integer { value: left_int }, + &ConstantData::Integer { value: right_int }, + op, + ); + } + + match (left, right) { + (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) => { + let result = match op { + BinOp::Add => l + r, + BinOp::Subtract => l - r, + BinOp::Multiply => { + return const_folding_safe_multiply(left, right); + } + BinOp::TrueDivide => { + if r.is_zero() { + return None; + } + let l_f = l.to_f64()?; + let r_f = r.to_f64()?; + let result = l_f / r_f; + if !result.is_finite() { + return None; + } + return Some(ConstantData::Float { value: result }); + } + BinOp::FloorDivide => { + if r.is_zero() { + return None; + } + // Python floor division: round towards negative infinity + let (q, rem) = (l.clone() / r.clone(), l.clone() % r.clone()); + if !rem.is_zero() && (rem < BigInt::from(0)) != (*r < BigInt::from(0)) { + q - 1 + } else { + q + } + } + BinOp::Remainder => return const_folding_safe_mod(left, right), + BinOp::Power => return const_folding_safe_power(left, right), + BinOp::Lshift => return const_folding_safe_lshift(left, right), + BinOp::Rshift => { + let shift: u32 = r.try_into().ok()?; + l >> (shift as usize) + } + BinOp::And => l & r, + BinOp::Or => l | r, + BinOp::Xor => l ^ r, + _ => return None, }; - k = next; + Some(ConstantData::Integer { value: result }) } - - let store_j = stores_to(&block.instructions[j]); - let store_k = stores_to(&block.instructions[k]); - if store_j >= 0 || store_k >= 0 { - if store_j == store_k { - return; - } - let mut idx = j + 1; - while idx < k { - let store_idx = stores_to(&block.instructions[idx]); - if store_idx >= 0 && (store_idx == store_j || store_idx == store_k) { - return; + (ConstantData::Float { value: l }, ConstantData::Float { value: r }) => { + let result = match op { + BinOp::Add => l + r, + BinOp::Subtract => l - r, + BinOp::Multiply => return const_folding_safe_multiply(left, right), + BinOp::TrueDivide => { + if *r == 0.0 { + return None; + } + l / r } - idx += 1; + BinOp::FloorDivide => { + let (floordiv, _) = float_div_mod(*l, *r)?; + floordiv + } + BinOp::Remainder => return const_folding_safe_mod(left, right), + BinOp::Power => return const_folding_safe_power(left, right), + _ => return None, + }; + if matches!(op, BinOp::Power) && !result.is_finite() { + return None; } + Some(ConstantData::Float { value: result }) + } + // Int op Float or Float op Int → Float + (ConstantData::Integer { value: l }, ConstantData::Float { value: r }) => { + let l_f = l.to_f64()?; + eval_const_binop( + &ConstantData::Float { value: l_f }, + &ConstantData::Float { value: *r }, + op, + ) + } + (ConstantData::Float { value: l }, ConstantData::Integer { value: r }) => { + let r_f = r.to_f64()?; + eval_const_binop( + &ConstantData::Float { value: *l }, + &ConstantData::Float { value: r_f }, + op, + ) + } + (ConstantData::Integer { value: l }, ConstantData::Complex { value: r }) => { + eval_const_complex_binop(Complex::new(l.to_f64()?, 0.0), *r, op) + } + (ConstantData::Complex { value: l }, ConstantData::Integer { value: r }) => { + eval_const_complex_binop(*l, Complex::new(r.to_f64()?, 0.0), op) + } + (ConstantData::Float { value: l }, ConstantData::Complex { value: r }) => { + eval_const_complex_binop(Complex::new(*l, 0.0), *r, op) + } + (ConstantData::Complex { value: l }, ConstantData::Float { value: r }) => { + eval_const_complex_binop(*l, Complex::new(*r, 0.0), op) + } + (ConstantData::Complex { value: l }, ConstantData::Complex { value: r }) => { + eval_const_complex_binop(*l, *r, op) + } + // String concatenation and repetition + (ConstantData::Str { value: l }, ConstantData::Str { value: r }) + if matches!(op, BinOp::Add) => + { + let mut result = Wtf8Buf::new(); + result + .try_reserve_exact(l.len().checked_add(r.len())?) + .ok()?; + result.push_wtf8(l); + result.push_wtf8(r); + Some(ConstantData::Str { value: result }) + } + (ConstantData::Str { .. }, ConstantData::Integer { .. }) + if matches!(op, BinOp::Multiply) => + { + const_folding_safe_multiply(left, right) + } + (ConstantData::Tuple { elements: l }, ConstantData::Tuple { elements: r }) + if matches!(op, BinOp::Add) => + { + let mut result = Vec::new(); + result + .try_reserve_exact(l.len().checked_add(r.len())?) + .ok()?; + result.extend(l.iter().cloned()); + result.extend(r.iter().cloned()); + Some(ConstantData::Tuple { elements: result }) + } + (ConstantData::Tuple { .. }, ConstantData::Integer { .. }) + if matches!(op, BinOp::Multiply) => + { + const_folding_safe_multiply(left, right) + } + (ConstantData::Integer { .. }, ConstantData::Tuple { .. }) + if matches!(op, BinOp::Multiply) => + { + const_folding_safe_multiply(left, right) + } + (ConstantData::Integer { .. }, ConstantData::Str { .. }) + if matches!(op, BinOp::Multiply) => + { + const_folding_safe_multiply(left, right) + } + (ConstantData::Bytes { value: l }, ConstantData::Bytes { value: r }) + if matches!(op, BinOp::Add) => + { + let mut result = Vec::new(); + result + .try_reserve_exact(l.len().checked_add(r.len())?) + .ok()?; + result.extend_from_slice(l); + result.extend_from_slice(r); + Some(ConstantData::Bytes { value: result }) } - - set_to_nop(&mut block.instructions[idx]); - block.instructions.swap(j, k); - i -= 1; - } -} - -/// flowgraph.c optimize_basic_block swap pass -fn apply_static_swaps_block(block: &mut Block) -> crate::InternalResult<()> { - let mut i = 0; - while i < block.instruction_used { - if matches!( - block.instructions[i].instr.real_opcode(), - Some(Opcode::Swap) - ) { - swaptimize(block, &mut i)?; - apply_static_swaps(block, i as isize); + (ConstantData::Bytes { .. }, ConstantData::Integer { .. }) + if matches!(op, BinOp::Multiply) => + { + const_folding_safe_multiply(left, right) } - i += 1; - } - Ok(()) -} - -/// flowgraph.c maybe_instr_make_load_smallint -fn maybe_instr_make_load_smallint(instr: &mut InstructionInfo, constant: &ConstantData) -> bool { - if let ConstantData::Integer { value } = constant - && let Some(small) = value.to_i32().filter(|v| (0..=255).contains(v)) - { - instr_set_op1(instr, Opcode::LoadSmallInt.into(), OpArg::new(small as u32)); - return true; + (ConstantData::Integer { .. }, ConstantData::Bytes { .. }) + if matches!(op, BinOp::Multiply) => + { + const_folding_safe_multiply(left, right) + } + _ => None, } - false } -/// flowgraph.c basicblock_optimize_load_const -fn basicblock_optimize_load_const( +/// flowgraph.c fold_tuple_of_constants +fn fold_tuple_of_constants( metadata: &mut CodeUnitMetadata, block: &mut Block, -) -> crate::InternalResult<()> { - let mut i = 0; - let mut effective_opcode = None; - let mut effective_oparg = OpArg::new(0); - while i < block.instruction_used { - if matches!( - block.instructions[i].instr.real(), - Some(Instruction::LoadConst { .. }) - ) && let Some(constant) = get_const_value(metadata, &block.instructions[i]) - { - maybe_instr_make_load_smallint(&mut block.instructions[i], &constant); - } + i: usize, +) -> crate::InternalResult { + let Some(Opcode::BuildTuple) = block.instructions[i].instr.real_opcode() else { + return Ok(false); + }; - let curr = block.instructions[i]; - let curr_arg = curr.arg; + let tuple_size = u32::from(block.instructions[i].arg) as usize; + if tuple_size > STACK_USE_GUIDELINE { + return Ok(false); + } - // Only combine if the source is a real instruction. - let Some(curr_instr) = curr.instr.real() else { - i += 1; - continue; - }; + let Some(operand_indices) = (if tuple_size == 0 { + Some(Vec::new()) + } else if let Some(start) = i.checked_sub(1) { + get_const_loading_instrs(block, start, tuple_size)? + } else { + None + }) else { + return Ok(false); + }; - let is_copy_of_load_const = matches!( - (effective_opcode, curr_instr), - (Some(Instruction::LoadConst { .. }), Instruction::Copy { i }) if i.get(curr_arg) == 1 - ); - if !is_copy_of_load_const { - effective_opcode = Some(curr_instr); - effective_oparg = curr_arg; - } - let Some(const_instr) = effective_opcode else { - i += 1; - continue; + let mut elements = Vec::new(); + elements + .try_reserve_exact(tuple_size) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + for &j in &operand_indices { + let Some(element) = get_const_value(metadata, &block.instructions[j]) else { + return Ok(false); }; - let const_arg = effective_oparg; + elements.push(element); + } - if i + 1 >= block.instruction_used { - i += 1; + nop_out(block, &operand_indices); + instr_make_load_const( + metadata, + &mut block.instructions[i], + ConstantData::Tuple { elements }, + )?; + Ok(true) +} + +fn fold_constant_intrinsic_list_to_tuple( + metadata: &mut CodeUnitMetadata, + block: &mut Block, + i: usize, +) -> crate::InternalResult { + let Some(Instruction::CallIntrinsic1 { func }) = block.instructions[i].instr.real() else { + return Ok(false); + }; + if func.get(block.instructions[i].arg) != IntrinsicFunction1::ListToTuple { + return Ok(false); + } + + let mut consts_found = 0usize; + let mut expect_append = true; + let mut pos = i; + while let Some(prev) = pos.checked_sub(1) { + pos = prev; + let instr = &block.instructions[pos]; + if matches!(instr.instr.real(), Some(Instruction::Nop)) { continue; } - let next = block.instructions[i + 1]; - let next_arg = next.arg; + if matches!(instr.instr.real(), Some(Instruction::BuildList { .. })) + && u32::from(instr.arg) == 0 + { + if !expect_append { + return Ok(false); + } - if let Some(is_true) = load_const_truthiness(const_instr, const_arg, metadata) { - let const_jump = match (next.instr.real_opcode(), next.instr.pseudo_opcode()) { - (_, Some(PseudoOpcode::JumpIfTrue)) => Some((true, false)), - (_, Some(PseudoOpcode::JumpIfFalse)) => Some((false, false)), - (Some(Opcode::PopJumpIfTrue), _) => Some((true, true)), - (Some(Opcode::PopJumpIfFalse), _) => Some((false, true)), - _ => None, - }; - if let Some((jump_if_true, pops_condition)) = const_jump { - if pops_condition { - set_to_nop(&mut block.instructions[i]); + let mut elements = Vec::new(); + elements + .try_reserve_exact(consts_found) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + for idx in (pos..i).rev() { + if matches!(block.instructions[idx].instr.real(), Some(Instruction::Nop)) { + continue; } - if is_true == jump_if_true { - block.instructions[i + 1].instr = PseudoOpcode::Jump.into(); - } else { - set_to_nop(&mut block.instructions[i + 1]); + if loads_const(&block.instructions[idx]) { + let Some(value) = get_const_value(metadata, &block.instructions[idx]) else { + return Ok(false); + }; + elements.push(value); } - i += 1; - continue; + nop_out_no_location(&mut block.instructions[idx]); } + debug_assert_eq!(elements.len(), consts_found); + elements.reverse(); + instr_make_load_const( + metadata, + &mut block.instructions[i], + ConstantData::Tuple { elements }, + )?; + return Ok(true); } - // The remaining combinations require both instructions to be real. - let Some(next_instr) = next.instr.real() else { - i += 1; - continue; - }; - - if let Instruction::LoadConst { consti } = const_instr { - let constant = &metadata.consts[consti.get(const_arg).as_usize()]; - if matches!(constant, ConstantData::None) - && let Instruction::IsOp { invert } = next_instr + if expect_append { + if !matches!(instr.instr.real(), Some(Instruction::ListAppend { .. })) + || u32::from(instr.arg) != 1 { - let mut jump_idx = i + 2; - if jump_idx >= block.instruction_used { - i += 1; - continue; - } + return Ok(false); + } + } else { + if !loads_const(instr) { + return Ok(false); + } + consts_found += 1; + } + expect_append = !expect_append; + } + + Ok(false) +} + +/// Port of CPython's flowgraph.c optimize_lists_and_sets(). +fn optimize_lists_and_sets( + metadata: &mut CodeUnitMetadata, + block: &mut Block, + i: usize, + nextop: Option, +) -> crate::InternalResult { + let Some(instr) = block.instructions[i].instr.real() else { + return Ok(false); + }; + let is_list = matches!(instr, Instruction::BuildList { .. }); + let is_set = matches!(instr, Instruction::BuildSet { .. }); + if !is_list && !is_set { + return Ok(false); + } + + let contains_or_iter = matches!( + nextop, + Some(Instruction::GetIter | Instruction::ContainsOp { .. }) + ); + let seq_size = u32::from(block.instructions[i].arg) as usize; + if seq_size > STACK_USE_GUIDELINE || (seq_size < MIN_CONST_SEQUENCE_SIZE && !contains_or_iter) { + return Ok(false); + } + + let Some(operand_indices) = (if seq_size == 0 { + Some(Vec::new()) + } else if let Some(start) = i.checked_sub(1) { + get_const_loading_instrs(block, start, seq_size)? + } else { + None + }) else { + if contains_or_iter && is_list { + let arg = block.instructions[i].arg; + instr_set_op1(&mut block.instructions[i], Opcode::BuildTuple.into(), arg); + return Ok(true); + } + return Ok(false); + }; + + let mut elements = Vec::new(); + elements + .try_reserve_exact(seq_size) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + for &j in &operand_indices { + let Some(element) = get_const_value(metadata, &block.instructions[j]) else { + return Ok(false); + }; + elements.push(element); + } - if matches!( - block.instructions[jump_idx].instr.real(), - Some(Instruction::ToBool) - ) { - set_to_nop(&mut block.instructions[jump_idx]); - jump_idx += 1; - if jump_idx >= block.instruction_used { - i += 1; - continue; - } - } + let const_data = if is_list { + ConstantData::Tuple { elements } + } else { + ConstantData::Frozenset { elements } + }; + let const_idx = add_const(metadata, const_data)?; - let Some(jump_instr) = block.instructions[jump_idx].instr.real() else { - i += 1; - continue; - }; + if !contains_or_iter { + debug_assert!(i >= 2); + let folded_loc = block.instructions[i].location; + let end_loc = block.instructions[i].end_location; - let mut invert = matches!( - invert.get(next_arg), - rustpython_compiler_core::bytecode::Invert::Yes - ); - match jump_instr { - Instruction::PopJumpIfFalse { .. } => { - invert = !invert; - } - Instruction::PopJumpIfTrue { .. } => {} - _ => { - i += 1; - continue; - } - }; + nop_out(block, &operand_indices); - set_to_nop(&mut block.instructions[i]); - set_to_nop(&mut block.instructions[i + 1]); - block.instructions[jump_idx].instr = if invert { - Opcode::PopJumpIfNotNone - } else { - Opcode::PopJumpIfNone - } - .into(); - i = jump_idx; - continue; - } + let build_instr = if is_list { + Opcode::BuildList + } else { + Opcode::BuildSet } + .into(); + instr_set_op1(&mut block.instructions[i - 2], build_instr, OpArg::new(0)); + block.instructions[i - 2].location = folded_loc; + block.instructions[i - 2].end_location = end_loc; + block.instructions[i - 2].lineno_override = None; - if matches!( - const_instr, - Instruction::LoadConst { .. } | Instruction::LoadSmallInt { .. } - ) && matches!(next_instr, Instruction::ToBool) - && let Some(value) = load_const_truthiness(const_instr, const_arg, metadata) - { - let const_idx = add_const(metadata, ConstantData::Boolean { value })?; - set_to_nop(&mut block.instructions[i]); - instr_set_op1( - &mut block.instructions[i + 1], - Opcode::LoadConst.into(), - OpArg::new(const_idx as u32), - ); - i += 1; - continue; - } + instr_set_op1( + &mut block.instructions[i - 1], + Opcode::LoadConst.into(), + OpArg::new(const_idx as u32), + ); - i += 1; + let extend_instr = if is_list { + Opcode::ListExtend + } else { + Opcode::SetUpdate + }; + instr_set_op1( + &mut block.instructions[i], + extend_instr.into(), + OpArg::new(1), + ); + return Ok(true); } - Ok(()) + + nop_out(block, &operand_indices); + + instr_set_op1( + &mut block.instructions[i], + Opcode::LoadConst.into(), + OpArg::new(const_idx as u32), + ); + Ok(true) } -/// flowgraph.c optimize_load_const -fn optimize_load_const( - metadata: &mut CodeUnitMetadata, - blocks: &mut Blocks, -) -> crate::InternalResult<()> { - let mut block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - let next_block = blocks[block_idx.idx()].next; - let block = &mut blocks[block_idx]; - basicblock_optimize_load_const(metadata, block)?; - block_idx = next_block; +/// flowgraph.c VISITED +const VISITED: i32 = -1; + +/// flowgraph.c SWAPPABLE +fn is_swappable(instr: AnyInstruction) -> bool { + matches!( + instr.into(), + AnyOpcode::Real(Opcode::StoreFast | Opcode::PopTop) + | AnyOpcode::Pseudo(PseudoOpcode::StoreFastMaybeNull) + ) +} + +/// flowgraph.c STORES_TO +fn stores_to(info: &InstructionInfo) -> i32 { + match info.instr.into() { + AnyOpcode::Real(Opcode::StoreFast) + | AnyOpcode::Pseudo(PseudoOpcode::StoreFastMaybeNull) => u32::from(info.arg) as i32, + _ => -1, } - Ok(()) } -/// flowgraph.c optimize_basic_block -fn optimize_basic_block( - blocks: &mut Blocks, - metadata: &mut CodeUnitMetadata, - block_idx: BlockIdx, -) -> crate::InternalResult<()> { - let bi = block_idx.idx(); - let mut nop = InstructionInfo { - instr: Instruction::Nop.into(), - arg: OpArg::NULL, - target: BlockIdx::NULL, - location: SourceLocation::default(), - end_location: SourceLocation::default(), - except_handler: None, - lineno_override: None, - }; - instr_set_op0(&mut nop, Instruction::Nop.into()); - let mut i = 0; - while i < blocks[bi].instruction_used { - let inst = blocks[bi].instructions[i]; - debug_assert!(!inst.instr.is_assembler()); - let target = if inst.instr.has_target() { - let target = inst.target; - debug_assert!(target != BlockIdx::NULL); - debug_assert!(blocks[target.idx()].instruction_used != 0); - debug_assert!(!blocks[target.idx()].instructions[0].instr.is_assembler()); - blocks[target.idx()].instructions[0] - } else { - nop - }; +/// flowgraph.c next_swappable_instruction +fn next_swappable_instruction(block: &Block, mut i: usize, lineno: i32) -> Option { + loop { + i += 1; + if i >= block.instruction_used { + return None; + } - let nextop = blocks[bi] - .instructions - .get(i + 1) - .and_then(|next| next.instr.real()); + let info = &block.instructions[i]; + let info_lineno = instruction_lineno(info); - match inst.instr { - AnyInstruction::Real(Instruction::BuildTuple { .. }) => { - let oparg = u32::from(inst.arg); - if matches!(nextop, Some(Instruction::UnpackSequence { .. })) - && u32::from(blocks[bi].instructions[i + 1].arg) == oparg - { - match oparg { - 1 => { - set_to_nop(&mut blocks[bi].instructions[i]); - set_to_nop(&mut blocks[bi].instructions[i + 1]); - i += 1; - continue; - } - 2 | 3 => { - set_to_nop(&mut blocks[bi].instructions[i]); - blocks[bi].instructions[i + 1].instr = Opcode::Swap.into(); - i += 1; - continue; - } - _ => {} - } - } - fold_tuple_of_constants(metadata, &mut blocks[bi], i)?; - } - AnyInstruction::Real(Instruction::BuildList { .. } | Instruction::BuildSet { .. }) => { - optimize_lists_and_sets(metadata, &mut blocks[bi], i, nextop)?; - } - AnyInstruction::Real( - Instruction::PopJumpIfNotNone { .. } | Instruction::PopJumpIfNone { .. }, - ) if matches!(target.instr.into(), AnyOpcode::Pseudo(PseudoOpcode::Jump)) - && jump_thread(blocks, block_idx, i, &target, inst.instr)? => - { - continue; - } - AnyInstruction::Real(Instruction::PopJumpIfFalse { .. }) - if matches!(target.instr.into(), AnyOpcode::Pseudo(PseudoOpcode::Jump)) - && jump_thread(blocks, block_idx, i, &target, inst.instr)? => - { - continue; - } - AnyInstruction::Real(Instruction::PopJumpIfTrue { .. }) - if matches!(target.instr.into(), AnyOpcode::Pseudo(PseudoOpcode::Jump)) - && jump_thread(blocks, block_idx, i, &target, inst.instr)? => - { - continue; - } - AnyInstruction::Pseudo( - pseudo @ (PseudoInstruction::JumpIfFalse { .. } - | PseudoInstruction::JumpIfTrue { .. }), - ) => { - let opcode = pseudo.into(); - match target.instr.pseudo().map(Into::into) { - Some(PseudoOpcode::Jump) - if jump_thread(blocks, block_idx, i, &target, opcode)? => - { - continue; - } - Some(PseudoOpcode::JumpIfFalse) - if matches!( - opcode, - AnyInstruction::Pseudo(PseudoInstruction::JumpIfFalse { .. }) - ) && jump_thread(blocks, block_idx, i, &target, opcode)? => - { - continue; - } - Some(PseudoOpcode::JumpIfTrue) - if matches!( - opcode, - AnyInstruction::Pseudo(PseudoInstruction::JumpIfTrue { .. }) - ) && jump_thread(blocks, block_idx, i, &target, opcode)? => - { - continue; - } - Some(PseudoOpcode::JumpIfFalse | PseudoOpcode::JumpIfTrue) => { - let next = blocks[inst.target.idx()].next; - debug_assert!(next != BlockIdx::NULL); - debug_assert!(next != inst.target); - blocks[bi].instructions[i].target = next; - continue; - } - _ => {} - } - } - AnyInstruction::Pseudo( - PseudoInstruction::Jump { .. } | PseudoInstruction::JumpNoInterrupt { .. }, - ) => match target.instr.into() { - AnyOpcode::Pseudo(PseudoOpcode::Jump) - if jump_thread(blocks, block_idx, i, &target, PseudoOpcode::Jump.into())? => - { - continue; - } - AnyOpcode::Pseudo(PseudoOpcode::JumpNoInterrupt) - if jump_thread(blocks, block_idx, i, &target, inst.instr)? => - { - continue; - } - _ => {} - }, - // CPython leaves FOR_ITER jump threading disabled. - AnyInstruction::Real(Instruction::ForIter { .. }) => {} - AnyInstruction::Real(Instruction::StoreFast { .. }) - if matches!(nextop, Some(Instruction::StoreFast { .. })) - && u32::from(inst.arg) == u32::from(blocks[bi].instructions[i + 1].arg) - && instruction_lineno(&blocks[bi].instructions[i]) - == instruction_lineno(&blocks[bi].instructions[i + 1]) => - { - blocks[bi].instructions[i].instr = Instruction::PopTop.into(); - blocks[bi].instructions[i].arg = OpArg::NULL; - } - AnyInstruction::Real(Instruction::Swap { .. }) if u32::from(inst.arg) == 1 => { - set_to_nop(&mut blocks[bi].instructions[i]); - } - AnyInstruction::Real(Instruction::LoadGlobal { .. }) - if matches!(nextop, Some(Instruction::PushNull)) - && (u32::from(inst.arg) & 1) == 0 => - { - instr_set_op1( - &mut blocks[bi].instructions[i], - inst.instr, - OpArg::new(u32::from(inst.arg) | 1), - ); - set_to_nop(&mut blocks[bi].instructions[i + 1]); - } - AnyInstruction::Real(Instruction::CompareOp { .. }) - if matches!(nextop, Some(Instruction::ToBool)) => - { - set_to_nop(&mut blocks[bi].instructions[i]); - instr_set_op1( - &mut blocks[bi].instructions[i + 1], - inst.instr, - OpArg::new(u32::from(inst.arg) | oparg::COMPARE_OP_BOOL_MASK), - ); - i += 1; - continue; - } - AnyInstruction::Real(Instruction::ContainsOp { .. } | Instruction::IsOp { .. }) - if matches!(nextop, Some(Instruction::ToBool)) => - { - set_to_nop(&mut blocks[bi].instructions[i]); - instr_set_op1(&mut blocks[bi].instructions[i + 1], inst.instr, inst.arg); - i += 1; - continue; - } - AnyInstruction::Real(Instruction::ContainsOp { .. } | Instruction::IsOp { .. }) - if matches!(nextop, Some(Instruction::UnaryNot)) => - { - set_to_nop(&mut blocks[bi].instructions[i]); - let inverted = u32::from(inst.arg) ^ 1; - debug_assert!(inverted == 0 || inverted == 1); - instr_set_op1( - &mut blocks[bi].instructions[i + 1], - inst.instr, - OpArg::new(inverted), - ); - i += 1; - continue; - } - AnyInstruction::Real(Instruction::ToBool) - if matches!(nextop, Some(Instruction::ToBool)) => - { - set_to_nop(&mut blocks[bi].instructions[i]); - i += 1; - continue; - } - AnyInstruction::Real(Instruction::UnaryNot) => { - if matches!(nextop, Some(Instruction::ToBool)) { - set_to_nop(&mut blocks[bi].instructions[i]); - instr_set_op0(&mut blocks[bi].instructions[i + 1], inst.instr); - i += 1; - continue; - } - if matches!(nextop, Some(Instruction::UnaryNot)) { - set_to_nop(&mut blocks[bi].instructions[i]); - set_to_nop(&mut blocks[bi].instructions[i + 1]); - i += 1; - continue; - } - fold_const_unaryop(metadata, &mut blocks[bi], i)?; - } - AnyInstruction::Real(Instruction::UnaryInvert | Instruction::UnaryNegative) => { - fold_const_unaryop(metadata, &mut blocks[bi], i)?; - } - AnyInstruction::Real(Instruction::CallIntrinsic1 { func }) => { - match func.get(inst.arg) { - IntrinsicFunction1::ListToTuple => { - if matches!(nextop, Some(Instruction::GetIter)) { - set_to_nop(&mut blocks[bi].instructions[i]); - } else { - fold_constant_intrinsic_list_to_tuple(metadata, &mut blocks[bi], i)?; - } - } - IntrinsicFunction1::UnaryPositive => { - fold_const_unaryop(metadata, &mut blocks[bi], i)?; - } - _ => {} - } - } - AnyInstruction::Real(Instruction::BinaryOp { .. }) => { - fold_const_binop(metadata, &mut blocks[bi], i)?; - } - _ => {} + if lineno >= 0 && info_lineno != lineno { + return None; + } + + if matches!(info.instr, AnyInstruction::Real(Instruction::Nop)) { + continue; + } + + if is_swappable(info.instr) { + return Some(i); } - i += 1; + return None; } - apply_static_swaps_block(&mut blocks[block_idx])?; - Ok(()) } -/// flowgraph.c remove_redundant_nops_and_pairs -#[allow(clippy::unnecessary_wraps)] -fn remove_redundant_nops_and_pairs(blocks: &mut Blocks) -> crate::InternalResult<()> { - let mut done = false; - - while !done { - done = true; - let mut instr: Option<(BlockIdx, usize)> = None; - let mut block_idx = BlockIdx::new(0); - - while block_idx != BlockIdx::NULL { - basicblock_remove_redundant_nops(blocks, block_idx)?; - if is_label(blocks[block_idx.idx()].cpython_label) { - instr = None; - } - - let len = blocks[block_idx.idx()].instruction_used; - for instr_idx in 0..len { - let prev_instr = instr; - instr = Some((block_idx, instr_idx)); - let instr_info = blocks[block_idx.idx()].instructions[instr_idx]; - let mut prev_opcode = None; - let prev_oparg = if let Some((prev_block, prev_instr_idx)) = prev_instr { - let prev_info = blocks[prev_block.idx()].instructions[prev_instr_idx]; - prev_opcode = prev_info.instr.real_opcode(); - match prev_info.instr.real() { - Some(Instruction::Copy { i }) => i.get(prev_info.arg), - _ => u32::from(prev_info.arg), - } - } else { - 0 - }; - - let opcode = instr_info.instr.real_opcode(); - let is_redundant_pair = matches!(opcode, Some(Opcode::PopTop)) - && (matches!(prev_opcode, Some(Opcode::LoadConst | Opcode::LoadSmallInt)) - || (prev_oparg == 1 && matches!(prev_opcode, Some(Opcode::Copy)))); - - if is_redundant_pair { - let (prev_block, prev_instr_idx) = - prev_instr.expect("redundant pair has previous"); - set_to_nop(&mut blocks[prev_block].instructions[prev_instr_idx]); - set_to_nop(&mut blocks[block_idx].instructions[instr_idx]); - done = false; - } +/// flowgraph.c swaptimize +fn swaptimize(block: &mut Block, ix: &mut usize) -> crate::InternalResult<()> { + debug_assert!(matches!( + block.instructions[*ix].instr.real_opcode(), + Some(Opcode::Swap) + )); + let mut depth = u32::from(block.instructions[*ix].arg) as usize; + let mut len = 1usize; + let mut more = false; + let limit = block.instruction_used - *ix; + while len < limit { + match block.instructions[*ix + len].instr.real_opcode() { + Some(Opcode::Swap) => { + depth = depth.max(u32::from(block.instructions[*ix + len].arg) as usize); + more = true; + len += 1; } - - let instr_is_jump = instr.is_some_and(|(instr_block, instr_idx)| { - is_jump(&blocks[instr_block].instructions[instr_idx]) - }); - - let block = &blocks[block_idx]; - if instr_is_jump || !bb_has_fallthrough(block) { - instr = None; + Some(Opcode::Nop) => { + len += 1; } - block_idx = block.next; + _ => break, } } - Ok(()) -} -/// flowgraph.c remove_unused_consts -#[allow(clippy::needless_range_loop)] -fn remove_unused_consts( - blocks: &mut Blocks, - consts: &mut ConstantPool, -) -> crate::InternalResult<()> { - let nconsts = consts.len(); - if nconsts == 0 { + if !more { return Ok(()); } - let mut index_map = Vec::new(); - index_map - .try_reserve_exact(nconsts) + let mut stack = Vec::new(); + stack + .try_reserve_exact(depth) .map_err(|_| InternalError::MalformedControlFlowGraph)?; - index_map.resize(nconsts, 0isize); - for i in 1..nconsts { - index_map[i] = -1; + stack.resize(depth, 0); + let mut i = 0; + while i < depth { + stack[i] = i as i32; + i += 1; } - // The first constant may be docstring; keep it always. - index_map[0] = 0; - // Mark used consts. - let mut block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - let block = &blocks[block_idx]; - for i in 0..block.instruction_used { - let instr = &block.instructions[i]; - if instr.instr.has_const() { - let index = u32::from(instr.arg) as usize; - debug_assert!(index < nconsts); - index_map[index] = index as isize; - } + i = 0; + while i < len { + let info = &block.instructions[*ix + i]; + if matches!(info.instr.real_opcode(), Some(Opcode::Swap)) { + let oparg = u32::from(info.arg) as usize; + stack.swap(0, oparg - 1); } - block_idx = block.next; + i += 1; } - // Now index_map[i] == i if consts[i] is used, -1 otherwise. - // Condense consts. - let mut n_used_consts = 0; - for i in 0..nconsts { - if index_map[i] != -1 { - debug_assert_eq!(index_map[i], i as isize); - index_map[n_used_consts] = index_map[i]; - n_used_consts += 1; + let mut current = len as isize - 1; + for i in 0..depth { + if stack[i] == VISITED || stack[i] == i as i32 { + continue; + } + let mut j = i; + loop { + if j != 0 { + debug_assert!(current >= 0); + let out = &mut block.instructions[*ix + current as usize]; + out.instr = Opcode::Swap.into(); + out.arg = OpArg::new((j + 1) as u32); + current -= 1; + } + if stack[j] == VISITED { + debug_assert_eq!(j, i); + break; + } + let next_j = stack[j] as usize; + stack[j] = VISITED; + j = next_j; } } - if n_used_consts == nconsts { - return Ok(()); + while current >= 0 { + set_to_nop(&mut block.instructions[*ix + current as usize]); + current -= 1; } + *ix += len - 1; + Ok(()) +} + +/// flowgraph.c apply_static_swaps +fn apply_static_swaps(block: &mut Block, mut i: isize) { + while i >= 0 { + let idx = i as usize; + debug_assert!(idx < block.instruction_used); + let swap_arg = match block.instructions[idx].instr.real_opcode() { + Some(Opcode::Swap) => u32::from(block.instructions[idx].arg), + Some(Opcode::Nop | Opcode::PopTop | Opcode::StoreFast) => { + i -= 1; + continue; + } + _ if matches!( + block.instructions[idx].instr.pseudo_opcode(), + Some(PseudoOpcode::StoreFastMaybeNull) + ) => + { + i -= 1; + continue; + } + _ => return, + }; - // Move all used consts to the beginning of the consts list. - debug_assert!(n_used_consts < nconsts); - for i in 0..n_used_consts { - let old_index = index_map[i] as usize; - debug_assert!(i <= old_index && old_index < nconsts); - if i != old_index { - let value = consts.constants[old_index].clone(); - consts.constants[i] = value; + let Some(j) = next_swappable_instruction(block, idx, -1) else { + return; + }; + let lineno = instruction_lineno(&block.instructions[j]); + let mut k = j; + for _ in 1..swap_arg { + let Some(next) = next_swappable_instruction(block, k, lineno) else { + return; + }; + k = next; } - } - // Truncate the consts list at its new size. - consts.constants.truncate(n_used_consts); + let store_j = stores_to(&block.instructions[j]); + let store_k = stores_to(&block.instructions[k]); + if store_j >= 0 || store_k >= 0 { + if store_j == store_k { + return; + } + let mut idx = j + 1; + while idx < k { + let store_idx = stores_to(&block.instructions[idx]); + if store_idx >= 0 && (store_idx == store_j || store_idx == store_k) { + return; + } + idx += 1; + } + } - // Adjust const indices in the bytecode. - let mut reverse_index_map = Vec::new(); - reverse_index_map - .try_reserve_exact(nconsts) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - reverse_index_map.resize(nconsts, 0isize); - for i in 0..nconsts { - reverse_index_map[i] = -1; - } - for i in 0..n_used_consts { - let old_index = index_map[i]; - debug_assert!(old_index != -1); - let old_index = old_index as usize; - debug_assert_eq!(reverse_index_map[old_index], -1); - reverse_index_map[old_index] = i as isize; + set_to_nop(&mut block.instructions[idx]); + block.instructions.swap(j, k); + i -= 1; } +} - block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - let next_block = blocks[block_idx.idx()].next; - let block = &mut blocks[block_idx]; - for i in 0..block.instruction_used { - let instr = &mut block.instructions[i]; - if instr.instr.has_const() { - let index = u32::from(instr.arg) as usize; - debug_assert!(reverse_index_map[index] >= 0); - debug_assert!(reverse_index_map[index] < n_used_consts as isize); - instr.arg = OpArg::new(reverse_index_map[index] as u32); - } +/// flowgraph.c optimize_basic_block swap pass +fn apply_static_swaps_block(block: &mut Block) -> crate::InternalResult<()> { + let mut i = 0; + while i < block.instruction_used { + if matches!( + block.instructions[i].instr.real_opcode(), + Some(Opcode::Swap) + ) { + swaptimize(block, &mut i)?; + apply_static_swaps(block, i as isize); } - block_idx = next_block; + i += 1; } Ok(()) } -fn optimize_load_fast(blocks: &mut Blocks) -> crate::InternalResult<()> { - let mut max_instrs = 0; - let mut current = BlockIdx(0); - while current != BlockIdx::NULL { - max_instrs = max_instrs.max(blocks[current.idx()].instruction_used); - current = blocks[current.idx()].next; +/// flowgraph.c maybe_instr_make_load_smallint +fn maybe_instr_make_load_smallint(instr: &mut InstructionInfo, constant: &ConstantData) -> bool { + if let ConstantData::Integer { value } = constant + && let Some(small) = value.to_i32().filter(|v| (0..=255).contains(v)) + { + instr_set_op1(instr, Opcode::LoadSmallInt.into(), OpArg::new(small as u32)); + return true; } - let mut instr_flags = Vec::new(); - instr_flags - .try_reserve_exact(max_instrs) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - instr_flags.resize(max_instrs, 0u8); - let mut refs = RefStack { - refs: Vec::new(), - size: 0, - capacity: 0, - }; - let mut worklist = make_cfg_traversal_stack(blocks)?; - worklist.push(BlockIdx(0)); - blocks[0].start_depth = 0; - blocks[0].visited = true; - while let Some(block_idx) = worklist.pop() { - let block_i = block_idx.idx(); + false +} - let instr_count = blocks[block_i].instruction_used; - instr_flags[..instr_count].fill(0); - debug_assert!(blocks[block_i].start_depth >= 0); - let start_depth = blocks[block_i].start_depth as usize; - ref_stack_clear(&mut refs); - for _ in 0..start_depth { - push_ref(&mut refs, DUMMY_INSTR, NOT_LOCAL)?; +/// flowgraph.c basicblock_optimize_load_const +fn basicblock_optimize_load_const( + metadata: &mut CodeUnitMetadata, + block: &mut Block, +) -> crate::InternalResult<()> { + let mut i = 0; + let mut effective_opcode = None; + let mut effective_oparg = OpArg::new(0); + while i < block.instruction_used { + if matches!( + block.instructions[i].instr.real(), + Some(Instruction::LoadConst { .. }) + ) && let Some(constant) = get_const_value(metadata, &block.instructions[i]) + { + maybe_instr_make_load_smallint(&mut block.instructions[i], &constant); } - for i in 0..instr_count { - let info = blocks[block_i].instructions[i]; - let instr = info.instr; - let arg_u32 = u32::from(info.arg); - debug_assert!(!matches!(instr.real(), Some(Instruction::ExtendedArg))); - - match instr { - AnyInstruction::Real(Instruction::DeleteFast { var_num }) => { - kill_local( - &mut instr_flags, - &refs, - local_as_ref_local(usize::from(var_num.get(info.arg))), - ); - } - AnyInstruction::Real(Instruction::LoadFast { var_num }) => { - push_ref( - &mut refs, - i as isize, - local_as_ref_local(usize::from(var_num.get(info.arg))), - )?; - } - AnyInstruction::Real(Instruction::LoadFastAndClear { var_num }) => { - let local = local_as_ref_local(usize::from(var_num.get(info.arg))); - kill_local(&mut instr_flags, &refs, local); - push_ref(&mut refs, i as isize, local)?; - } - AnyInstruction::Real(Instruction::LoadFastLoadFast { .. }) => { - let local1 = (arg_u32 >> 4) as isize; - let local2 = (arg_u32 & 15) as isize; - push_ref(&mut refs, i as isize, local1)?; - push_ref(&mut refs, i as isize, local2)?; - } - AnyInstruction::Real(Instruction::StoreFast { var_num }) => { - let r = ref_stack_pop(&mut refs); - store_local( - &mut instr_flags, - &refs, - local_as_ref_local(usize::from(var_num.get(info.arg))), - r, - ); - } - AnyInstruction::Real(Instruction::StoreFastLoadFast { .. }) => { - let r = ref_stack_pop(&mut refs); - store_local(&mut instr_flags, &refs, (arg_u32 >> 4) as isize, r); - push_ref(&mut refs, i as isize, (arg_u32 & 15) as isize)?; - } - AnyInstruction::Real(Instruction::StoreFastStoreFast { .. }) => { - let r1 = ref_stack_pop(&mut refs); - store_local(&mut instr_flags, &refs, (arg_u32 >> 4) as isize, r1); - let r2 = ref_stack_pop(&mut refs); - store_local(&mut instr_flags, &refs, (arg_u32 & 15) as isize, r2); - } - AnyInstruction::Real(Instruction::Copy { i: _ }) => { - let depth = arg_u32 as usize; - assert!(depth > 0); - assert!(refs.size >= depth); - let r = ref_stack_at(&refs, refs.size - depth); - push_ref(&mut refs, r.instr, r.local)?; - } - AnyInstruction::Real(Instruction::Swap { i: _ }) => { - let depth = arg_u32 as usize; - assert!(depth >= 2); - assert!(refs.size >= depth); - ref_stack_swap_top(&mut refs, depth); - } - AnyInstruction::Real( - Instruction::FormatSimple - | Instruction::GetAnext - | Instruction::GetLen - | Instruction::GetYieldFromIter - | Instruction::ImportFrom { .. } - | Instruction::MatchKeys - | Instruction::MatchMapping - | Instruction::MatchSequence - | Instruction::WithExceptStart, - ) => { - let effect = instr.stack_effect_info(arg_u32); - let net_pushed = effect.pushed() as isize - effect.popped() as isize; - debug_assert!(net_pushed >= 0); - // CPython optimize_load_fast() shadows the outer - // instruction index in this produced-value loop. - for produced in 0..net_pushed { - push_ref(&mut refs, produced, NOT_LOCAL)?; - } - } - AnyInstruction::Real( - Instruction::DictMerge { .. } - | Instruction::DictUpdate { .. } - | Instruction::ListAppend { .. } - | Instruction::ListExtend { .. } - | Instruction::MapAdd { .. } - | Instruction::Reraise { .. } - | Instruction::SetAdd { .. } - | Instruction::SetUpdate { .. }, - ) => { - let effect = instr.stack_effect_info(arg_u32); - let net_popped = effect.popped() as isize - effect.pushed() as isize; - debug_assert!(net_popped > 0); - for _ in 0..net_popped { - let _ = ref_stack_pop(&mut refs); - } - } - AnyInstruction::Real( - Instruction::EndSend | Instruction::SetFunctionAttribute { .. }, - ) => { - let effect = instr.stack_effect_info(arg_u32); - debug_assert_eq!(effect.popped(), 2); - debug_assert_eq!(effect.pushed(), 1); - let tos = ref_stack_pop(&mut refs); - let _ = ref_stack_pop(&mut refs); - push_ref(&mut refs, tos.instr, tos.local)?; - } - AnyInstruction::Real(Instruction::CheckExcMatch) => { - let _ = ref_stack_pop(&mut refs); - push_ref(&mut refs, i as isize, NOT_LOCAL)?; - } - AnyInstruction::Real(Instruction::ForIter { .. }) => { - let target = info.target; - debug_assert!(target != BlockIdx::NULL); - load_fast_push_block(&mut worklist, blocks, target, refs.size + 1); - push_ref(&mut refs, i as isize, NOT_LOCAL)?; - } - AnyInstruction::Real( - Instruction::LoadAttr { .. } | Instruction::LoadSuperAttr { .. }, - ) => { - let self_ref = ref_stack_pop(&mut refs); - if matches!(instr.real(), Some(Instruction::LoadSuperAttr { .. })) { - let _ = ref_stack_pop(&mut refs); - let _ = ref_stack_pop(&mut refs); - } - push_ref(&mut refs, i as isize, NOT_LOCAL)?; - if arg_u32 & 1 != 0 { - push_ref(&mut refs, self_ref.instr, self_ref.local)?; - } - } - AnyInstruction::Real( - Instruction::LoadSpecial { .. } | Instruction::PushExcInfo, - ) => { - let tos = ref_stack_pop(&mut refs); - push_ref(&mut refs, i as isize, NOT_LOCAL)?; - push_ref(&mut refs, tos.instr, tos.local)?; - } - AnyInstruction::Real(Instruction::Send { .. }) => { - let target = info.target; - debug_assert!(target != BlockIdx::NULL); - load_fast_push_block(&mut worklist, blocks, target, refs.size); - let _ = ref_stack_pop(&mut refs); - push_ref(&mut refs, i as isize, NOT_LOCAL)?; - } - _ => { - let effect = instr.stack_effect_info(arg_u32); - let num_popped = effect.popped() as usize; - let num_pushed = effect.pushed() as usize; - let target = info.target; - if instr.has_target() { - debug_assert!(target != BlockIdx::NULL); - debug_assert!(refs.size >= num_popped); - let target_depth = refs.size - num_popped + num_pushed; - load_fast_push_block(&mut worklist, blocks, target, target_depth); - } - if !is_block_push(&info) { - for _ in 0..num_popped { - let _ = ref_stack_pop(&mut refs); - } - for _ in 0..num_pushed { - push_ref(&mut refs, i as isize, NOT_LOCAL)?; - } - } - } - } + let curr = block.instructions[i]; + let curr_arg = curr.arg; + + // Only combine if the source is a real instruction. + let Some(curr_instr) = curr.instr.real() else { + i += 1; + continue; + }; + + let is_copy_of_load_const = matches!( + (effective_opcode, curr_instr), + (Some(Instruction::LoadConst { .. }), Instruction::Copy { i }) if i.get(curr_arg) == 1 + ); + if !is_copy_of_load_const { + effective_opcode = Some(curr_instr); + effective_oparg = curr_arg; } + let Some(const_instr) = effective_opcode else { + i += 1; + continue; + }; + let const_arg = effective_oparg; - let fallthrough = blocks[block_i].next; - let term = basicblock_last_instr(&blocks[block_i]).copied(); - if let Some(term) = term - && fallthrough != BlockIdx::NULL - && !term.instr.is_unconditional_jump() - && !term.instr.is_scope_exit() - { - debug_assert!(bb_has_fallthrough(&blocks[block_i])); - load_fast_push_block(&mut worklist, blocks, fallthrough, refs.size); + if i + 1 >= block.instruction_used { + i += 1; + continue; } - for i in 0..refs.size { - let r = ref_stack_at(&refs, i); - if r.instr != DUMMY_INSTR { - instr_flags[r.instr as usize] |= LoadFastInstrFlag::RefUnconsumed as u8; - } - } + let next = block.instructions[i + 1]; + let next_arg = next.arg; - let block = &mut blocks[block_idx]; - let iused = block.instruction_used; - let mut i = 0; - while i < iused { - let info = &mut block.instructions[i]; - if instr_flags[i] != 0 { + if let Some(is_true) = load_const_truthiness(const_instr, const_arg, metadata) { + let const_jump = match (next.instr.real_opcode(), next.instr.pseudo_opcode()) { + (_, Some(PseudoOpcode::JumpIfTrue)) => Some((true, false)), + (_, Some(PseudoOpcode::JumpIfFalse)) => Some((false, false)), + (Some(Opcode::PopJumpIfTrue), _) => Some((true, true)), + (Some(Opcode::PopJumpIfFalse), _) => Some((false, true)), + _ => None, + }; + if let Some((jump_if_true, pops_condition)) = const_jump { + if pops_condition { + set_to_nop(&mut block.instructions[i]); + } + if is_true == jump_if_true { + block.instructions[i + 1].instr = PseudoOpcode::Jump.into(); + } else { + set_to_nop(&mut block.instructions[i + 1]); + } i += 1; continue; } + } + + // The remaining combinations require both instructions to be real. + let Some(next_instr) = next.instr.real() else { + i += 1; + continue; + }; + + if let Instruction::LoadConst { consti } = const_instr { + let constant = &metadata.consts[consti.get(const_arg).as_usize()]; + if matches!(constant, ConstantData::None) + && let Instruction::IsOp { invert } = next_instr + { + let mut jump_idx = i + 2; + if jump_idx >= block.instruction_used { + i += 1; + continue; + } - match info.instr.real_opcode() { - Some(Opcode::LoadFast) => { - info.instr = Opcode::LoadFastBorrow.into(); + if matches!( + block.instructions[jump_idx].instr.real(), + Some(Instruction::ToBool) + ) { + set_to_nop(&mut block.instructions[jump_idx]); + jump_idx += 1; + if jump_idx >= block.instruction_used { + i += 1; + continue; + } } - Some(Opcode::LoadFastLoadFast) => { - info.instr = Opcode::LoadFastBorrowLoadFastBorrow.into(); + + let Some(jump_instr) = block.instructions[jump_idx].instr.real() else { + i += 1; + continue; + }; + + let mut invert = matches!( + invert.get(next_arg), + rustpython_compiler_core::bytecode::Invert::Yes + ); + match jump_instr { + Instruction::PopJumpIfFalse { .. } => { + invert = !invert; + } + Instruction::PopJumpIfTrue { .. } => {} + _ => { + i += 1; + continue; + } + }; + + set_to_nop(&mut block.instructions[i]); + set_to_nop(&mut block.instructions[i + 1]); + block.instructions[jump_idx].instr = if invert { + Opcode::PopJumpIfNotNone + } else { + Opcode::PopJumpIfNone } - _ => {} + .into(); + i = jump_idx; + continue; } + } + + if matches!( + const_instr, + Instruction::LoadConst { .. } | Instruction::LoadSmallInt { .. } + ) && matches!(next_instr, Instruction::ToBool) + && let Some(value) = load_const_truthiness(const_instr, const_arg, metadata) + { + let const_idx = add_const(metadata, ConstantData::Boolean { value })?; + set_to_nop(&mut block.instructions[i]); + instr_set_op1( + &mut block.instructions[i + 1], + Opcode::LoadConst.into(), + OpArg::new(const_idx as u32), + ); i += 1; + continue; } + + i += 1; } Ok(()) } -/// flowgraph.c calculate_stackdepth -fn calculate_stackdepth(blocks: &mut Blocks) -> crate::InternalResult { - let mut current = BlockIdx(0); - while current != BlockIdx::NULL { - blocks[current.idx()].start_depth = START_DEPTH_UNSET; - current = blocks[current.idx()].next; - } - let mut stack = make_cfg_traversal_stack(blocks)?; - let mut maxdepth = 0i32; - stackdepth_push(&mut stack, blocks, BlockIdx(0), 0)?; - while let Some(block_idx) = stack.pop() { - let idx = block_idx.idx(); - let mut depth = blocks[idx].start_depth; - debug_assert!(depth >= 0); - let mut next = blocks[idx].next; - let instr_count = blocks[idx].instruction_used; - for i in 0..instr_count { - let ins = blocks[idx].instructions[i]; - let instr = &ins.instr; - let effects = get_stack_effects(*instr, ins.arg, 0)?; - let new_depth = depth + effects.net; - if new_depth < 0 { - return Err(InternalError::StackUnderflow); - } - maxdepth = maxdepth.max(depth); - if instr.has_target() && !matches!(instr.real(), Some(Instruction::EndAsyncFor)) { - debug_assert!(ins.target != BlockIdx::NULL); - let effects = get_stack_effects(*instr, ins.arg, 1)?; - let target_depth = depth + effects.net; - debug_assert!(target_depth >= 0); - maxdepth = maxdepth.max(depth); - stackdepth_push(&mut stack, blocks, ins.target, target_depth)?; - } - depth = new_depth; - debug_assert!(!instr.is_assembler()); - if instr.is_unconditional_jump() || instr.is_scope_exit() { - next = BlockIdx::NULL; - break; - } - } - if next != BlockIdx::NULL { - debug_assert!(bb_has_fallthrough(&blocks[idx])); - stackdepth_push(&mut stack, blocks, next, depth)?; - } +/// flowgraph.c optimize_load_const +fn optimize_load_const( + metadata: &mut CodeUnitMetadata, + blocks: &mut Blocks, +) -> crate::InternalResult<()> { + let mut block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + let next_block = blocks[block_idx.idx()].next; + let block = &mut blocks[block_idx]; + basicblock_optimize_load_const(metadata, block)?; + block_idx = next_block; } - - let stackdepth = maxdepth; - Ok(stackdepth as u32) + Ok(()) } #[cfg(test)] @@ -4490,8 +4713,9 @@ impl CodeInfo { "after_inline_small_or_no_lineno_blocks".to_owned(), self.debug_block_dump(), )); - remove_unreachable(&mut self.blocks)?; - resolve_line_numbers(&mut self.blocks, self.metadata.firstlineno)?; + self.blocks.remove_unreachable()?; + self.blocks + .resolve_line_numbers(self.metadata.firstlineno)?; optimize_load_const(&mut self.metadata, &mut self.blocks)?; trace.push(( "after_optimize_load_const".to_owned(), @@ -4500,19 +4724,21 @@ impl CodeInfo { let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { let next_block = self.blocks[block_idx].next; - optimize_basic_block(&mut self.blocks, &mut self.metadata, block_idx)?; + self.blocks + .optimize_basic_block(&mut self.metadata, block_idx)?; block_idx = next_block; } trace.push(( "after_optimize_basic_block".to_owned(), self.debug_block_dump(), )); - remove_redundant_nops_and_pairs(&mut self.blocks)?; - remove_unreachable(&mut self.blocks)?; + self.blocks.remove_redundant_nops_and_pairs()?; + self.blocks.remove_unreachable()?; remove_redundant_nops_and_jumps(&mut self.blocks)?; #[cfg(debug_assertions)] assert!(no_redundant_jumps(&self.blocks)); - remove_unused_consts(&mut self.blocks, &mut self.metadata.consts)?; + self.blocks + .remove_unused_consts(&mut self.metadata.consts)?; trace.push(( "after_optimize_cfg_cleanup".to_owned(), self.debug_block_dump(), @@ -4520,13 +4746,14 @@ impl CodeInfo { let nlocals = self.metadata.varnames.len(); let nparams = self.nparams; add_checks_for_loads_of_uninitialized_variables(&mut self.blocks, nlocals, nparams)?; - insert_superinstructions(&mut self.blocks)?; + self.blocks.insert_superinstructions()?; push_cold_blocks_to_end(&mut self.blocks)?; trace.push(( "after_push_cold_before_chain_reorder".to_owned(), self.debug_block_dump(), )); - resolve_line_numbers(&mut self.blocks, self.metadata.firstlineno)?; + self.blocks + .resolve_line_numbers(self.metadata.firstlineno)?; trace.push(( "after_push_cold_resolve_line_numbers".to_owned(), self.debug_block_dump(), @@ -4543,7 +4770,7 @@ impl CodeInfo { self.debug_block_dump(), )); - let _max_stackdepth = calculate_stackdepth(&mut self.blocks)?; + let _max_stackdepth = self.blocks.calculate_stackdepth()?; let _nlocalsplus = prepare_localsplus(&self.metadata, &mut self.blocks, self.flags)?; convert_pseudo_ops(&mut self.blocks)?; trace.push(( @@ -4551,11 +4778,11 @@ impl CodeInfo { self.debug_block_dump(), )); - normalize_jumps(&mut self.blocks)?; + self.blocks.normalize_jumps()?; #[cfg(debug_assertions)] assert!(no_redundant_jumps(&self.blocks)); trace.push(("after_normalize_jumps".to_owned(), self.debug_block_dump())); - optimize_load_fast(&mut self.blocks)?; + self.blocks.optimize_load_fast()?; trace.push(( "after_optimize_load_fast".to_owned(), self.debug_block_dump(), @@ -4619,57 +4846,6 @@ fn make_super_instruction( set_to_nop(inst2); } -/// flowgraph.c insert_superinstructions -fn insert_superinstructions(blocks: &mut Blocks) -> crate::InternalResult { - let mut block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - let next_block = blocks[block_idx.idx()].next; - let block = &mut blocks[block_idx]; - for i in 0..block.instruction_used { - let nextop = (i + 1 < block.instruction_used) - .then(|| block.instructions[i + 1].instr.real()) - .flatten(); - match block.instructions[i].instr.real() { - Some(Instruction::LoadFast { .. }) => { - if matches!(nextop, Some(Instruction::LoadFast { .. })) { - let (inst1, rest) = block.instructions[i..].split_at_mut(1); - make_super_instruction( - &mut inst1[0], - &mut rest[0], - Opcode::LoadFastLoadFast.into(), - ); - } - } - Some(Instruction::StoreFast { .. }) => match nextop { - Some(Instruction::LoadFast { .. }) => { - let (inst1, rest) = block.instructions[i..].split_at_mut(1); - make_super_instruction( - &mut inst1[0], - &mut rest[0], - Opcode::StoreFastLoadFast.into(), - ); - } - Some(Instruction::StoreFast { .. }) => { - let (inst1, rest) = block.instructions[i..].split_at_mut(1); - make_super_instruction( - &mut inst1[0], - &mut rest[0], - Opcode::StoreFastStoreFast.into(), - ); - } - _ => {} - }, - _ => {} - } - } - block_idx = next_block; - } - let res = remove_redundant_nops(blocks)?; - #[cfg(debug_assertions)] - assert!(no_redundant_nops(blocks)); - Ok(res) -} - /// flowgraph.c LoadFastInstrFlag #[derive(Clone, Copy, Eq, PartialEq)] #[repr(u8)] @@ -5188,7 +5364,7 @@ pub(crate) fn mark_except_handlers(blocks: &mut Blocks) -> crate::InternalResult /// optimize_load_fast to terminate fall-through at those placeholders. /// flowgraph.c mark_warm fn mark_warm(blocks: &mut Blocks) -> crate::InternalResult<()> { - let mut stack = make_cfg_traversal_stack(blocks)?; + let mut stack = blocks.make_cfg_traversal_stack()?; stack.push(BlockIdx(0)); blocks[0].visited = true; while let Some(block_idx) = stack.pop() { @@ -5221,7 +5397,7 @@ fn mark_warm(blocks: &mut Blocks) -> crate::InternalResult<()> { fn mark_cold(blocks: &mut Blocks) -> crate::InternalResult<()> { let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { - let block = &mut blocks[block_idx.idx()]; + let block = &mut blocks[block_idx]; debug_assert!(!block.cold); debug_assert!(!block.warm); block_idx = block.next; @@ -5229,7 +5405,7 @@ fn mark_cold(blocks: &mut Blocks) -> crate::InternalResult<()> { mark_warm(blocks)?; - let mut cold_stack = make_cfg_traversal_stack(blocks)?; + let mut cold_stack = blocks.make_cfg_traversal_stack()?; block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { let i = block_idx.idx(); @@ -5596,24 +5772,6 @@ fn normalize_jumps_in_block(blocks: &mut Blocks, block_idx: BlockIdx) -> crate:: Ok(()) } -/// flowgraph.c normalize_jumps -fn normalize_jumps(blocks: &mut Blocks) -> crate::InternalResult<()> { - let mut current = BlockIdx(0); - while current != BlockIdx::NULL { - blocks[current.idx()].visited = false; - current = blocks[current.idx()].next; - } - - let mut current = BlockIdx(0); - while current != BlockIdx::NULL { - let idx = current.idx(); - blocks[idx].visited = true; - normalize_jumps_in_block(blocks, current)?; - current = blocks[idx].next; - } - Ok(()) -} - /// flowgraph.c basicblock_inline_small_or_no_lineno_blocks fn basicblock_inline_small_or_no_lineno_blocks( blocks: &mut Blocks, @@ -5638,7 +5796,7 @@ fn basicblock_inline_small_or_no_lineno_blocks( let last = basicblock_last_instr_mut(&mut blocks[block_idx]) .expect("non-empty block has last instruction"); set_to_nop(last); - basicblock_append_block_instructions(blocks, block_idx, target)?; + blocks.basicblock_append_block_instructions(block_idx, target)?; if no_lineno_no_fallthrough { let last = basicblock_last_instr_mut(&mut blocks[block_idx]).unwrap(); if last.instr.is_unconditional_jump() @@ -5839,27 +5997,6 @@ fn remove_redundant_nops_and_jumps(blocks: &mut Blocks) -> crate::InternalResult Ok(()) } -/// flowgraph.c make_cfg_traversal_stack -fn make_cfg_traversal_stack(blocks: &mut Blocks) -> crate::InternalResult { - debug_assert!(!blocks.is_empty()); - let mut nblocks = 0; - let mut current = BlockIdx(0); - while current != BlockIdx::NULL { - blocks[current.idx()].visited = false; - nblocks += 1; - current = blocks[current.idx()].next; - } - debug_assert!(nblocks > 0); - let mut stack = Vec::new(); - stack - .try_reserve_exact(nblocks) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - stack.resize(nblocks, BlockIdx::NULL); - let stack = CfgTraversalStack { stack, sp: 0 }; - debug_assert_eq!(stack.capacity(), nblocks); - Ok(stack) -} - fn blocks_new_block(blocks: &mut Blocks) -> crate::InternalResult { blocks .try_reserve(1) @@ -6284,7 +6421,7 @@ fn add_checks_for_loads_of_uninitialized_variables( nlocals = LOCAL_UNSAFE_MASK_BITS; } - let mut worklist = make_cfg_traversal_stack(blocks)?; + let mut worklist = blocks.make_cfg_traversal_stack()?; let mut start_mask = 0u64; for i in nparams..nlocals { start_mask |= 1u64 << i; @@ -6405,14 +6542,6 @@ fn basicblock_has_no_lineno(block: &Block) -> bool { true } -/// flowgraph.c copy_basicblock -fn copy_basicblock(blocks: &mut Blocks, block_idx: BlockIdx) -> crate::InternalResult { - debug_assert!(bb_no_fallthrough(&blocks[block_idx.idx()])); - let result = blocks_new_block(blocks)?; - basicblock_append_block_instructions(blocks, result, block_idx)?; - Ok(result) -} - /// flowgraph.c get_max_label fn get_max_label(blocks: &Blocks) -> i32 { let mut lbl = -1; @@ -6425,110 +6554,6 @@ fn get_max_label(blocks: &Blocks) -> i32 { lbl } -fn duplicate_exits_without_lineno(blocks: &mut Blocks) -> crate::InternalResult<()> { - let mut next_lbl = get_max_label(blocks) + 1; - - let entryblock = BlockIdx(0); - let mut b = entryblock; - while b != BlockIdx::NULL { - let Some(last) = basicblock_last_instr(&blocks[b]).copied() else { - b = blocks[b].next; - continue; - }; - if is_jump(&last) { - debug_assert!(last.target != BlockIdx::NULL); - let target = next_nonempty_block(blocks, last.target); - debug_assert!(target != BlockIdx::NULL); - if is_exit_or_eval_check_without_lineno(&blocks[target]) - && blocks[target].predecessors > 1 - { - let new_target = copy_basicblock(blocks, target)?; - instr_set_location( - &mut blocks[new_target].instructions[0], - instr_location(&last), - ); - let last_mut = basicblock_last_instr_mut(&mut blocks[b]).unwrap(); - last_mut.target = new_target; - blocks[target].predecessors -= 1; - blocks[new_target].predecessors = 1; - blocks[new_target].next = blocks[target].next; - blocks[new_target].cpython_label = InstructionSequenceLabel(next_lbl); - next_lbl += 1; - blocks[target].next = new_target; - } - } - b = blocks[b].next; - } - - b = entryblock; - while b != BlockIdx::NULL { - let next = blocks[b].next; - if bb_has_fallthrough(&blocks[b]) - && next != BlockIdx::NULL - && blocks[b].instruction_used != 0 - && is_exit_or_eval_check_without_lineno(&blocks[next]) - { - let last = *basicblock_last_instr(&blocks[b]).expect("block has instructions"); - instr_set_location(&mut blocks[next].instructions[0], instr_location(&last)); - } - b = blocks[b].next; - } - Ok(()) -} - -fn propagate_line_numbers(blocks: &mut Blocks) { - let mut current = BlockIdx(0); - while current != BlockIdx::NULL { - let idx = current.idx(); - let Some(last) = basicblock_last_instr(&blocks[idx]).copied() else { - current = blocks[idx].next; - continue; - }; - - let mut prev_location = no_instruction_location(); - for i in 0..blocks[idx].instruction_used { - if instruction_is_no_location(&blocks[idx].instructions[i]) { - instr_set_location(&mut blocks[idx].instructions[i], prev_location); - } else { - prev_location = instr_location(&blocks[idx].instructions[i]); - } - } - - let next = blocks[idx].next; - if bb_has_fallthrough(&blocks[idx]) { - debug_assert!(next != BlockIdx::NULL); - if next != BlockIdx::NULL - && blocks[next].predecessors == 1 - && blocks[next].instruction_used != 0 - && instruction_is_no_location(&blocks[next].instructions[0]) - { - instr_set_location(&mut blocks[next].instructions[0], prev_location); - } - } - - if is_jump(&last) { - let target = last.target; - debug_assert!(target != BlockIdx::NULL); - if blocks[target].predecessors == 1 { - let instr = basicblock_raw_first_instr_mut(&mut blocks[target]); - if instruction_is_no_location(instr) { - instr_set_location(instr, prev_location); - } - } - } - current = blocks[current].next; - } -} - -fn resolve_line_numbers( - blocks: &mut Blocks, - _firstlineno: OneIndexed, -) -> crate::InternalResult<()> { - duplicate_exits_without_lineno(blocks)?; - propagate_line_numbers(blocks); - Ok(()) -} - /// flowgraph.c make_except_stack #[allow(clippy::unnecessary_wraps)] fn make_except_stack() -> crate::InternalResult { @@ -6592,7 +6617,7 @@ fn pop_except_block(stack: &mut CfgExceptStack, blocks: &Blocks) -> Option crate::InternalResult<()> { - let mut todo = make_cfg_traversal_stack(blocks)?; + let mut todo = blocks.make_cfg_traversal_stack()?; todo.push(BlockIdx(0)); blocks[0].visited = true; @@ -7014,7 +7039,7 @@ mod tests { blocks[0].visited = true; blocks[1].visited = true; - let mut stack = make_cfg_traversal_stack(&mut blocks).unwrap(); + let mut stack = blocks.make_cfg_traversal_stack().unwrap(); assert!(!blocks[0].visited); assert!(!blocks[1].visited); assert!(stack.capacity() >= 2); @@ -7230,7 +7255,8 @@ mod tests { basicblock_clear(&mut blocks[0]); test_block_push(&mut blocks[1], test_instr(Instruction::PopTop, 42)); - basicblock_append_block_instructions(&mut blocks, BlockIdx::new(0), BlockIdx::new(1)) + blocks + .basicblock_append_block_instructions(BlockIdx::new(0), BlockIdx::new(1)) .expect("basicblock_append_block_instructions succeeds"); // CPython `basicblock_append_instructions()` obtains a slot with @@ -7252,7 +7278,8 @@ mod tests { blocks[0].next = BlockIdx::new(1); let mut instr_sequence = instruction_sequence_new(); - cfg_to_instruction_sequence(&mut blocks, &mut instr_sequence) + blocks + .cfg_to_instruction_sequence(&mut instr_sequence) .expect("non-target NOP should ignore stale CPython i_target"); } @@ -7265,7 +7292,7 @@ mod tests { let mut blocks = Blocks::from([block]); let mut instr_sequence = instruction_sequence_new(); - let _ = cfg_to_instruction_sequence(&mut blocks, &mut instr_sequence); + let _ = blocks.cfg_to_instruction_sequence(&mut instr_sequence); } #[test] @@ -7381,7 +7408,9 @@ mod tests { test_block_push(&mut block, test_instr(Instruction::PopTop, 10)); let mut code = test_code_info(block); - optimize_load_fast(&mut code.blocks).expect("optimize_load_fast succeeds"); + code.blocks + .optimize_load_fast() + .expect("optimize_load_fast succeeds"); // CPython `optimize_load_fast()` shadows the outer instruction index in // the produced-value loop for GET_LEN, so the produced ref is recorded @@ -7471,8 +7500,12 @@ mod tests { test_block_push(&mut blocks[2], test_instr(Instruction::ReturnValue, 30)); blocks[2].instructions[0].lineno_override = Some(NO_LOCATION_OVERRIDE); - remove_unreachable(&mut blocks).expect("remove_unreachable succeeds"); - resolve_line_numbers(&mut blocks, OneIndexed::MIN).expect("resolve_line_numbers succeeds"); + blocks + .remove_unreachable() + .expect("remove_unreachable succeeds"); + blocks + .resolve_line_numbers(OneIndexed::MIN) + .expect("resolve_line_numbers succeeds"); // CPython `duplicate_exits_without_lineno()` copies a shared exit block // reached by jumps so each copy can inherit its sole predecessor's line. @@ -7495,10 +7528,12 @@ mod tests { block.instructions[1].lineno_override = Some(NEXT_LOCATION_OVERRIDE); test_block_push(&mut block, test_instr(Instruction::ReturnValue, 30)); block.instructions[2].lineno_override = Some(NO_LOCATION_OVERRIDE); - let mut blocks = [block].into(); + let mut blocks = Blocks::from([block]); - remove_unreachable(&mut blocks).expect("remove_unreachable succeeds"); - propagate_line_numbers(&mut blocks); + blocks + .remove_unreachable() + .expect("remove_unreachable succeeds"); + blocks.propagate_line_numbers(); // CPython `propagate_line_numbers()` only copies over NO_LOCATION // (`lineno == NO_LOCATION`). `NEXT_LOCATION` (`lineno == -2`) becomes the @@ -7524,8 +7559,10 @@ mod tests { basicblock_clear(&mut blocks[1]); test_block_push(&mut blocks[2], test_instr(Instruction::ReturnValue, 30)); - remove_unreachable(&mut blocks).expect("remove_unreachable succeeds"); - propagate_line_numbers(&mut blocks); + blocks + .remove_unreachable() + .expect("remove_unreachable succeeds"); + blocks.propagate_line_numbers(); // CPython `propagate_line_numbers()` directly reads `target->b_instr[0]` // for jump targets without checking `b_iused`. If @@ -7568,7 +7605,8 @@ mod tests { test_block_push(&mut blocks[3], test_instr(Instruction::ReturnValue, 40)); let mut metadata = test_code_info(Block::default()).metadata; - optimize_basic_block(&mut blocks, &mut metadata, BlockIdx::new(0)) + blocks + .optimize_basic_block(&mut metadata, BlockIdx::new(0)) .expect("valid jump chain"); // CPython `optimize_basic_block()` continues after `jump_thread()`, so From 39a2fda23f2577f70bccac6f1bbb1723aff3c625 Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:58:57 -0400 Subject: [PATCH 026/351] unicodedata: Remove UnicodeVersion struct (#8131) --- crates/stdlib/src/unicodedata.rs | 89 +++++++++++++------------------- 1 file changed, 36 insertions(+), 53 deletions(-) diff --git a/crates/stdlib/src/unicodedata.rs b/crates/stdlib/src/unicodedata.rs index 4152118f51d..b373b083e23 100644 --- a/crates/stdlib/src/unicodedata.rs +++ b/crates/stdlib/src/unicodedata.rs @@ -4,11 +4,7 @@ // spell-checker:ignore codep decomp DECOMP nfkc unistr unidata -use core::{ - cmp::Ordering, - fmt::{self, Display, Formatter}, - hint::cold_path, -}; +use core::{cmp::Ordering, hint::cold_path}; pub(crate) use unicodedata::module_def; @@ -28,25 +24,6 @@ include!(concat!( "/generated/unicode_numeric_value.rs" )); -#[derive(Clone, Copy, Debug, PartialEq)] -struct UnicodeVersion { - pub major: u8, - pub minor: u8, - pub micro: u8, -} - -impl Display for UnicodeVersion { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "{}.{}.{}", self.major, self.minor, self.micro) - } -} - -const UNICODE_VERSION: UnicodeVersion = UnicodeVersion { - major: char::UNICODE_VERSION.0, - minor: char::UNICODE_VERSION.1, - micro: char::UNICODE_VERSION.2, -}; - #[derive(Clone, Copy)] #[repr(u8)] enum DecompositionType { @@ -118,8 +95,8 @@ fn lookup_property(table: &[(u32, u32, T)], ch: char) -> Option { .map(|i| table[i].2) } -fn lookup_numeric_val(ch: char, version: UnicodeVersion) -> Option { - if version.major > 3 { +fn lookup_numeric_val(ch: char, modern: bool) -> Option { + if modern { lookup_property(NUMERIC_VALUES, ch) } else { cold_path(); @@ -162,8 +139,8 @@ mod unicodedata { use super::{ BIDI_CLASS, BIDI_MIRRORED, COMBINING_CLASS, DECOMP_COMPAT, DECOMP_RANGE, DECOMP_UPDATES, - EAST_ASIAN_WIDTH, GENERAL_CATEGORY, NUMERIC_TYPE_DIFF, NormalizeForm, UNICODE_VERSION, - UnicodeVersion, lookup_numeric_val, lookup_property, + EAST_ASIAN_WIDTH, GENERAL_CATEGORY, NUMERIC_TYPE_DIFF, NormalizeForm, lookup_numeric_val, + lookup_property, }; use crate::vm::{ Py, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, @@ -186,7 +163,7 @@ mod unicodedata { __module_exec(vm, module); // Add UCD methods as module-level functions - let ucd: PyObjectRef = Ucd::new(UNICODE_VERSION).into_ref(&vm.ctx).into(); + let ucd: PyObjectRef = Ucd::new(true).into_ref(&vm.ctx).into(); for attr in [ "category", @@ -213,12 +190,12 @@ mod unicodedata { #[pyclass(name = "UCD")] #[derive(Debug, PyPayload)] pub(super) struct Ucd { - unic_version: UnicodeVersion, + modern: bool, } impl Ucd { - pub(super) const fn new(unic_version: UnicodeVersion) -> Self { - Self { unic_version } + pub(super) const fn new(modern: bool) -> Self { + Self { modern } } fn extract_char(&self, character: PyStrRef, vm: &VirtualMachine) -> PyResult { @@ -238,7 +215,7 @@ mod unicodedata { let Some(c) = c.to_char() else { return GeneralCategory::Surrogate.short_name(); }; - if self.unic_version.major > 3 { + if self.modern { Some(GeneralCategory::for_char(c)) } else { cold_path(); @@ -291,7 +268,7 @@ mod unicodedata { self.extract_char(character, vm).map(|c| { c.to_char() .and_then(|c| { - if self.unic_version.major > 3 { + if self.modern { Some(BidiClass::for_char(c)) } else { cold_path(); @@ -312,7 +289,7 @@ mod unicodedata { self.extract_char(character, vm).map(|c| { c.to_char() .and_then(|c| { - if self.unic_version.major > 3 { + if self.modern { Some(EastAsianWidth::for_char(c)) } else { cold_path(); @@ -392,7 +369,7 @@ mod unicodedata { fn mirrored(&self, character: PyStrRef, vm: &VirtualMachine) -> PyResult { self.extract_char(character, vm).map(|c| { c.to_char().map_or(0, |c| { - (if self.unic_version.major > 3 { + (if self.modern { BidiMirrored::for_char(c) } else { cold_path(); @@ -418,7 +395,7 @@ mod unicodedata { self.extract_char(character, vm).map(|c| { c.to_char() .and_then(|c| { - if self.unic_version.major > 3 { + if self.modern { Some(CanonicalCombiningClass::for_char(c)) } else { cold_path(); @@ -442,7 +419,7 @@ mod unicodedata { // For 3.2.0, we use the original decomp for compatibility while ignoring the update. // // Finally, we don't have to do anything for the latest UCD as it's already updated. - if self.unic_version.major == 3 + if self.modern && let Some((_, original)) = DECOMP_UPDATES .iter() .find(|&&(codep, _original)| codep == ch as u32) @@ -485,7 +462,7 @@ mod unicodedata { fn numeric_type_matches(&self, ch: CodePoint, expected: &[NumericType]) -> Option { let ch = ch.to_char()?; - let actual = if self.unic_version.major > 3 { + let actual = if self.modern { NumericType::for_char(ch) } else { cold_path(); @@ -506,7 +483,7 @@ mod unicodedata { let expected = [NumericType::Decimal, NumericType::Digit]; self.numeric_type_matches(ch, &expected) .and_then(|ch| { - let value = lookup_numeric_val(ch, UNICODE_VERSION)?; + let value = lookup_numeric_val(ch, true)?; (value.trunc() == value).then(|| vm.ctx.new_int(value as u64).into()) }) .or_else(|| default.present()) @@ -525,7 +502,7 @@ mod unicodedata { let expected = [NumericType::Decimal]; self.numeric_type_matches(ch, &expected) .and_then(|ch| { - let value = lookup_numeric_val(ch, self.unic_version)?; + let value = lookup_numeric_val(ch, self.modern)?; (value.trunc() == value).then(|| vm.ctx.new_int(value as u64).into()) }) .or_else(|| default.present()) @@ -544,8 +521,7 @@ mod unicodedata { let expected = &NumericType::ALL_VALUES[1..]; self.numeric_type_matches(ch, expected) .and_then(|ch| { - lookup_numeric_val(ch, self.unic_version) - .map(|value| vm.ctx.new_float(value).into()) + lookup_numeric_val(ch, self.modern).map(|value| vm.ctx.new_float(value).into()) }) .or_else(|| default.present()) .map(Option::Some) @@ -554,24 +530,31 @@ mod unicodedata { #[pygetset] fn unidata_version(&self) -> String { - self.unic_version.to_string() + if self.modern { + format!( + "{}.{}.{}", + char::UNICODE_VERSION.0, + char::UNICODE_VERSION.1, + char::UNICODE_VERSION.2 + ) + } else { + "3.2.0".into() + } } } #[pyattr] fn ucd_3_2_0(vm: &VirtualMachine) -> PyRef { - Ucd { - unic_version: UnicodeVersion { - major: 3, - minor: 2, - micro: 0, - }, - } - .into_ref(&vm.ctx) + Ucd::new(false).into_ref(&vm.ctx) } #[pyattr] fn unidata_version(_vm: &VirtualMachine) -> String { - UNICODE_VERSION.to_string() + format!( + "{}.{}.{}", + char::UNICODE_VERSION.0, + char::UNICODE_VERSION.1, + char::UNICODE_VERSION.2 + ) } } From c5f318a819b84c4020469f1679057161667603bc Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Thu, 25 Jun 2026 07:00:32 +0200 Subject: [PATCH 027/351] Add remaining float functions to c-apo (#8157) --- crates/capi/src/floatobject.rs | 33 +++++++++++++++++++++++++++++++++ crates/vm/src/builtins/float.rs | 2 +- crates/vm/src/builtins/mod.rs | 1 + 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/capi/src/floatobject.rs b/crates/capi/src/floatobject.rs index f1bb078106d..831ca32af1e 100644 --- a/crates/capi/src/floatobject.rs +++ b/crates/capi/src/floatobject.rs @@ -1,6 +1,8 @@ use crate::object::define_py_check; use crate::{PyObject, pystate::with_vm}; use core::ffi::c_double; +use core::ptr::NonNull; +use rustpython_vm::AsObject; use rustpython_vm::builtins::PyFloat; define_py_check!(fn PyFloat_Check, types.float_type); @@ -24,6 +26,37 @@ pub unsafe extern "C" fn PyFloat_AsDouble(obj: *mut PyObject) -> c_double { }) } +#[unsafe(no_mangle)] +pub extern "C" fn PyFloat_GetMax() -> c_double { + c_double::MAX +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyFloat_GetMin() -> c_double { + c_double::MIN_POSITIVE +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyFloat_GetInfo() -> *mut PyObject { + with_vm(|vm| { + vm.sys_module + .as_object() + .get_attr("float_info", vm) + .map(|obj| obj.into_raw().as_ptr()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyFloat_FromString(obj: *mut PyObject) -> *mut PyObject { + with_vm(|vm| { + let obj = NonNull::new(obj) + .ok_or_else(|| vm.new_type_error("float() argument must be a string or a number"))?; + let obj = unsafe { obj.as_ref() }.to_owned(); + let float = rustpython_vm::builtins::parse_float_from_string(obj, vm)?; + Ok(vm.ctx.new_float(float)) + }) +} + #[cfg(false)] mod tests { use core::f64::consts::PI; diff --git a/crates/vm/src/builtins/float.rs b/crates/vm/src/builtins/float.rs index 5a12c4cbf31..36846640e59 100644 --- a/crates/vm/src/builtins/float.rs +++ b/crates/vm/src/builtins/float.rs @@ -205,7 +205,7 @@ impl Constructor for PyFloat { } } -fn float_from_string(val: PyObjectRef, vm: &VirtualMachine) -> PyResult { +pub fn float_from_string(val: PyObjectRef, vm: &VirtualMachine) -> PyResult { let (bytearray, buffer, buffer_lock, mapped_string); let b = if let Some(s) = val.downcast_ref::() { use crate::common::str::PyKindStr; diff --git a/crates/vm/src/builtins/mod.rs b/crates/vm/src/builtins/mod.rs index 27b52dacb15..eba5af36686 100644 --- a/crates/vm/src/builtins/mod.rs +++ b/crates/vm/src/builtins/mod.rs @@ -98,6 +98,7 @@ pub(crate) mod union_; pub use union_::{PyUnion, make_union}; pub(crate) mod descriptor; +pub use float::float_from_string as parse_float_from_string; pub use float::try_to_bigint as try_f64_to_bigint; pub use int::try_to_float as try_bigint_to_f64; From 9acc47387edeb11c46d13b2485af2e5397c4124e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:00:42 +0900 Subject: [PATCH 028/351] Bump taiki-e/install-action from 2.81.8 to 2.81.11 (#8147) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.81.8 to 2.81.11. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/0631aa6515c7d545823c67cfae7ef4fc7f490154...15449e3094499af05d8d964a1c884208e4b8b595) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.81.11 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cron-ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cron-ci.yaml b/.github/workflows/cron-ci.yaml index 5721a642615..4e5ed04eaae 100644 --- a/.github/workflows/cron-ci.yaml +++ b/.github/workflows/cron-ci.yaml @@ -33,7 +33,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - uses: taiki-e/install-action@0631aa6515c7d545823c67cfae7ef4fc7f490154 # v2.81.8 + - uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 with: tool: cargo-llvm-cov From 870ad2d81372ec04cd15c5377bbe135081fa72dd Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:21:11 +0300 Subject: [PATCH 029/351] Move some of free standing functions to be ir::Blocks methods (part 2) (#8155) * mark_except_handlers * mark_warm * mark_cold * push_cold_blocks_to_end * check_cfg * jump thread * basicblock_add_jump * convert_pseudo_conditional_jumps * normalize_jumps_in_block * basicblock_inline_small_or_no_lineno_blocks * inline_small_or_no_lineno_blocks * basicblock_remove_redundant_nops * remove_redundant_nops * no_redundant_nops * remove_redundant_jumps * no_redundant_jumps * remove_redundant_nops_and_jumps * blocks_new_block * clippy --- crates/codegen/src/ir.rs | 6478 +++++++++++++++++++------------------- 1 file changed, 3240 insertions(+), 3238 deletions(-) diff --git a/crates/codegen/src/ir.rs b/crates/codegen/src/ir.rs index 5728ddfbefe..548b57d85a4 100644 --- a/crates/codegen/src/ir.rs +++ b/crates/codegen/src/ir.rs @@ -1330,7 +1330,7 @@ impl Blocks { fn copy_basicblock(&mut self, block_idx: BlockIdx) -> crate::InternalResult { debug_assert!(bb_no_fallthrough(&self[block_idx])); - let result = blocks_new_block(self)?; + let result = self.blocks_new_block()?; self.basicblock_append_block_instructions(result, block_idx)?; Ok(result) } @@ -1464,19 +1464,19 @@ impl Blocks { AnyInstruction::Real( Instruction::PopJumpIfNotNone { .. } | Instruction::PopJumpIfNone { .. }, ) if matches!(target.instr.into(), AnyOpcode::Pseudo(PseudoOpcode::Jump)) - && jump_thread(self, block_idx, i, &target, inst.instr)? => + && self.jump_thread(block_idx, i, &target, inst.instr)? => { continue; } AnyInstruction::Real(Instruction::PopJumpIfFalse { .. }) if matches!(target.instr.into(), AnyOpcode::Pseudo(PseudoOpcode::Jump)) - && jump_thread(self, block_idx, i, &target, inst.instr)? => + && self.jump_thread(block_idx, i, &target, inst.instr)? => { continue; } AnyInstruction::Real(Instruction::PopJumpIfTrue { .. }) if matches!(target.instr.into(), AnyOpcode::Pseudo(PseudoOpcode::Jump)) - && jump_thread(self, block_idx, i, &target, inst.instr)? => + && self.jump_thread(block_idx, i, &target, inst.instr)? => { continue; } @@ -1487,7 +1487,7 @@ impl Blocks { let opcode = pseudo.into(); match target.instr.pseudo().map(Into::into) { Some(PseudoOpcode::Jump) - if jump_thread(self, block_idx, i, &target, opcode)? => + if self.jump_thread(block_idx, i, &target, opcode)? => { continue; } @@ -1495,7 +1495,7 @@ impl Blocks { if matches!( opcode, AnyInstruction::Pseudo(PseudoInstruction::JumpIfFalse { .. }) - ) && jump_thread(self, block_idx, i, &target, opcode)? => + ) && self.jump_thread(block_idx, i, &target, opcode)? => { continue; } @@ -1503,12 +1503,12 @@ impl Blocks { if matches!( opcode, AnyInstruction::Pseudo(PseudoInstruction::JumpIfTrue { .. }) - ) && jump_thread(self, block_idx, i, &target, opcode)? => + ) && self.jump_thread(block_idx, i, &target, opcode)? => { continue; } Some(PseudoOpcode::JumpIfFalse | PseudoOpcode::JumpIfTrue) => { - let next = self[inst.target.idx()].next; + let next = self[inst.target].next; debug_assert!(next != BlockIdx::NULL); debug_assert!(next != inst.target); self[block_idx].instructions[i].target = next; @@ -1521,12 +1521,17 @@ impl Blocks { PseudoInstruction::Jump { .. } | PseudoInstruction::JumpNoInterrupt { .. }, ) => match target.instr.into() { AnyOpcode::Pseudo(PseudoOpcode::Jump) - if jump_thread(self, block_idx, i, &target, PseudoOpcode::Jump.into())? => + if self.jump_thread( + block_idx, + i, + &target, + PseudoOpcode::Jump.into(), + )? => { continue; } AnyOpcode::Pseudo(PseudoOpcode::JumpNoInterrupt) - if jump_thread(self, block_idx, i, &target, inst.instr)? => + if self.jump_thread(block_idx, i, &target, inst.instr)? => { continue; } @@ -2007,7 +2012,7 @@ impl Blocks { let mut block_idx = BlockIdx::new(0); while block_idx != BlockIdx::NULL { - basicblock_remove_redundant_nops(self, block_idx)?; + self.basicblock_remove_redundant_nops(block_idx)?; if is_label(self[block_idx].cpython_label) { instr = None; } @@ -2140,7 +2145,7 @@ impl Blocks { let mut current = BlockIdx(0); while current != BlockIdx::NULL { self[current].visited = true; - normalize_jumps_in_block(self, current)?; + self.normalize_jumps_in_block(current)?; current = self[current].next; } @@ -2293,3722 +2298,3719 @@ impl Blocks { block_idx = next_block; } - let res = remove_redundant_nops(self)?; + let res = self.remove_redundant_nops()?; #[cfg(debug_assertions)] - assert!(no_redundant_nops(self)); + assert!(self.no_redundant_nops()); Ok(res) } -} -impl From> for Blocks { - fn from(value: Vec) -> Self { - Self(value) - } -} + /// Mark exception handler target blocks. + /// flowgraph.c mark_except_handlers + #[allow(clippy::unnecessary_wraps)] + pub(crate) fn mark_except_handlers(&mut self) -> crate::InternalResult<()> { + #[cfg(debug_assertions)] + { + let mut block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + assert!(!self[block_idx].except_handler); + block_idx = self[block_idx].next; + } + } -impl From> for Blocks { - fn from(value: Box<[Block]>) -> Self { - Self(value.into()) + let mut block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + let next = self[block_idx].next; + let instr_count = self[block_idx].instruction_used; + for i in 0..instr_count { + let instr = self[block_idx].instructions[i]; + if is_block_push(&instr) { + debug_assert!(instr.target != BlockIdx::NULL); + self[instr.target].except_handler = true; + } + } + block_idx = next; + } + Ok(()) } -} -impl From<&[Block]> for Blocks { - fn from(value: &[Block]) -> Self { - Self(value.to_vec()) - } -} + /// flowgraph.c mark_cold (two-pass to match CPython). + /// + /// Phase 1 (mark_warm): propagate "warm" from entry via fall-through and + /// jump targets. CPython asserts while visiting warm blocks that they are not + /// exception handlers. + /// + /// Phase 2 (mark_cold): propagate "cold" from except_handler blocks via + /// forward edges. Blocks reached only via runtime exception dispatch are + /// marked cold and pushed to the end by push_cold_blocks_to_end. + /// + /// Blocks reached by neither phase remain `cold=false`. They are typically + /// empty unreachable placeholders left by remove_unreachable; they stay in + /// their original chain position (e.g. between entry and the post-try + /// continuation for a nested try/except whose inner_end was emptied by + /// optimize_cfg). This matches CPython's behavior and is necessary for + /// optimize_load_fast to terminate fall-through at those placeholders. + /// flowgraph.c mark_warm + fn mark_warm(&mut self) -> crate::InternalResult<()> { + let mut stack = self.make_cfg_traversal_stack()?; + stack.push(BlockIdx(0)); + self[0].visited = true; + while let Some(block_idx) = stack.pop() { + debug_assert!(!self[block_idx].except_handler); + self[block_idx].warm = true; -impl From<&mut [Block]> for Blocks { - fn from(value: &mut [Block]) -> Self { - Self(value.to_vec()) - } -} + let next = self[block_idx].next; + if next != BlockIdx::NULL && bb_has_fallthrough(&self[block_idx]) && !self[next].visited + { + stack.push(next); + self[next].visited = true; + } -impl From<[Block; N]> for Blocks { - fn from(value: [Block; N]) -> Self { - Self(value.into()) + let instr_count = self[block_idx].instruction_used; + for i in 0..instr_count { + let instr = self[block_idx].instructions[i]; + if is_jump(&instr) { + let target = instr.target; + debug_assert!(target != BlockIdx::NULL); + if !self[target].visited { + stack.push(target); + self[target].visited = true; + } + } + } + } + Ok(()) } -} -impl From<&[Block; N]> for Blocks { - fn from(value: &[Block; N]) -> Self { - Self(value.to_vec()) - } -} + fn mark_cold(&mut self) -> crate::InternalResult<()> { + let mut block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + let block = &mut self[block_idx]; + debug_assert!(!block.cold); + debug_assert!(!block.warm); + block_idx = block.next; + } -impl Deref for Blocks { - type Target = [Block]; + self.mark_warm()?; - fn deref(&self) -> &Self::Target { - &self.0 - } -} + let mut cold_stack = self.make_cfg_traversal_stack()?; + block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + let next = self[block_idx].next; + let block = &self[block_idx]; + if block.except_handler { + debug_assert!(!block.warm); + cold_stack.push(block_idx); + self[block_idx].visited = true; + } + block_idx = next; + } -impl DerefMut for Blocks { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 + while let Some(block_idx) = cold_stack.pop() { + self[block_idx].cold = true; + let next = self[block_idx].next; + if next != BlockIdx::NULL + && bb_has_fallthrough(&self[block_idx]) + && !self[next].warm + && !self[next].visited + { + cold_stack.push(next); + self[next].visited = true; + } + + let instr_count = self[block_idx].instruction_used; + for i in 0..instr_count { + let instr = self[block_idx].instructions[i]; + if is_jump(&instr) { + debug_assert_eq!(i, instr_count - 1); + let target = instr.target; + debug_assert!(target != BlockIdx::NULL); + if !self[target].warm && !self[target].visited { + cold_stack.push(target); + self[target].visited = true; + } + } + } + } + Ok(()) } -} -impl Index for Blocks { - type Output = Block; + /// flowgraph.c push_cold_blocks_to_end + fn push_cold_blocks_to_end(&mut self) -> crate::InternalResult<()> { + if self[0].next == BlockIdx::NULL { + return Ok(()); + } - fn index(&self, idx: usize) -> &Self::Output { - &self.0[idx] - } -} + self.mark_cold()?; + let mut next_label = get_max_label(self) + 1; -impl IndexMut for Blocks { - fn index_mut(&mut self, idx: usize) -> &mut Self::Output { - &mut self.0[idx] - } -} + // If a cold block falls through to a warm block, add an explicit jump + let mut block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + let next = self[block_idx].next; + if self[block_idx].cold + && bb_has_fallthrough(&self[block_idx]) + && next != BlockIdx::NULL + && self[next].warm + { + let explicit_jump = self.blocks_new_block()?; + if !is_label(self[next].cpython_label) { + self[next].cpython_label = InstructionSequenceLabel::from_index(next_label); + next_label += 1; + } + let jump_label = self[next].cpython_label; + debug_assert!(is_label(jump_label)); + basicblock_addop( + &mut self[explicit_jump], + InstructionInfo { + instr: PseudoOpcode::JumpNoInterrupt.into(), + arg: instruction_sequence_label_oparg(jump_label), + target: BlockIdx::NULL, + location: SourceLocation::default(), + end_location: SourceLocation::default(), + except_handler: None, + lineno_override: Some(NO_LOCATION_OVERRIDE), + }, + )?; + self[explicit_jump].cold = true; + self[explicit_jump].next = next; + self[explicit_jump].predecessors = 1; + self[block_idx].next = explicit_jump; + let target = self[explicit_jump].next; + let last = basicblock_last_instr_mut(&mut self[explicit_jump]) + .expect("missing explicit jump"); + last.target = target; + } + block_idx = self[block_idx].next; + } -impl Index for Blocks { - type Output = Block; + assert!(!self[0].cold); + let mut cold_blocks: BlockIdx = BlockIdx::NULL; + let mut cold_blocks_tail: BlockIdx = BlockIdx::NULL; + let mut block_idx = BlockIdx(0); - fn index(&self, block_idx: BlockIdx) -> &Self::Output { - &self.0[block_idx.as_usize()] - } -} + while self[block_idx].next != BlockIdx::NULL { + debug_assert!(!self[block_idx].cold); + while self[block_idx].next != BlockIdx::NULL && !self[self[block_idx].next].cold { + block_idx = self[block_idx].next; + } -impl IndexMut for Blocks { - fn index_mut(&mut self, block_idx: BlockIdx) -> &mut Self::Output { - &mut self.0[block_idx.as_usize()] - } -} + if self[block_idx].next == BlockIdx::NULL { + break; + } -pub(crate) const START_DEPTH_UNSET: i32 = i32::MIN; -const CO_MAXBLOCKS: usize = 20; + debug_assert!(!self[block_idx].cold); + debug_assert!(self[self[block_idx].next].cold); -/// flowgraph.c struct _PyCfgExceptStack -#[derive(Clone, Debug)] -struct CfgExceptStack { - handlers: [BlockIdx; CO_MAXBLOCKS + 2], - depth: usize, -} + let mut block_end = self[block_idx].next; + while self[block_end].next != BlockIdx::NULL && self[self[block_end].next].cold { + block_end = self[block_end].next; + } -/// flowgraph.c `basicblock **stack` -#[derive(Clone, Debug)] -struct CfgTraversalStack { - stack: Vec, - sp: usize, -} + debug_assert!(self[block_end].cold); + debug_assert!( + self[block_end].next == BlockIdx::NULL || !self[self[block_end].next].cold + ); -impl CfgTraversalStack { - fn push(&mut self, block: BlockIdx) { - debug_assert!(self.sp < self.stack.len()); - self.stack[self.sp] = block; - self.sp += 1; - } + if cold_blocks == BlockIdx::NULL { + cold_blocks = self[block_idx].next; + } else { + self[cold_blocks_tail].next = self[block_idx].next; + } - fn pop(&mut self) -> Option { - if self.sp == 0 { - return None; + cold_blocks_tail = block_end; + self[block_idx].next = self[block_end].next; + self[block_end].next = BlockIdx::NULL; } - self.sp -= 1; - Some(self.stack[self.sp]) - } - fn capacity(&self) -> usize { - self.stack.len() - } -} - -#[derive(Clone, Debug)] -pub(crate) struct InstructionSequenceLabelMap { - block_labels: Vec, - /// Codegen-side shadow of CPython's instruction-sequence label map. - /// - /// `_PyInstructionSequence_UseLabel()` can map multiple labels to the same - /// instruction offset before `_PyCfg_FromInstructionSequence()` materializes - /// CFG blocks. The codegen CFG path keeps the same aliasing by resolving - /// those labels to the block that owns the shared offset. - cpython_block_by_label: Vec, -} + debug_assert!(self[block_idx].next == BlockIdx::NULL); + self[block_idx].next = cold_blocks; -fn instruction_sequence_label_map_register_label( - map: &mut InstructionSequenceLabelMap, - label: InstructionSequenceLabel, -) -> crate::InternalResult<()> { - debug_assert!(is_label(label)); - let old_size = map.cpython_block_by_label.len(); - let new_allocation = c_array_ensure_capacity::( - old_size, - label.idx(), - INITIAL_INSTR_SEQUENCE_LABELS_MAP_SIZE, - )?; - if new_allocation > old_size { - if new_allocation > map.cpython_block_by_label.capacity() { - map.cpython_block_by_label - .try_reserve_exact(new_allocation - map.cpython_block_by_label.capacity()) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; + if cold_blocks != BlockIdx::NULL { + self.remove_redundant_nops_and_jumps()?; } - map.cpython_block_by_label - .resize(new_allocation, BlockIdx::NULL); - for i in old_size..map.cpython_block_by_label.len() { - map.cpython_block_by_label[i] = BlockIdx::NULL; + Ok(()) + } + + /// flowgraph.c check_cfg + fn check_cfg(&self) -> crate::InternalResult<()> { + let mut block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + let block = &self[block_idx]; + for i in 0..block.instruction_used { + let opcode = block.instructions[i].instr; + debug_assert!(!opcode.is_assembler()); + if opcode.is_terminator() && i != block.instruction_used - 1 { + return Err(InternalError::MalformedControlFlowGraph); + } + } + block_idx = block.next; } + Ok(()) } - debug_assert!(map.cpython_block_by_label.len() > label.idx()); - Ok(()) -} -fn instruction_sequence_label_map_ensure_label_for_block( - map: &mut InstructionSequenceLabelMap, - seq: &mut InstructionSequence, - block: BlockIdx, -) -> crate::InternalResult { - debug_assert_ne!(block, BlockIdx::NULL); - let block_label = map.block_labels[block.idx()]; - if is_label(block_label) { - return Ok(block_label); + /// flowgraph.c jump_thread + fn jump_thread( + &mut self, + block_idx: BlockIdx, + instr_idx: usize, + target: &InstructionInfo, + opcode: AnyInstruction, + ) -> crate::InternalResult { + debug_assert!(is_jump(&self[block_idx].instructions[instr_idx])); + debug_assert!(is_jump(target)); + debug_assert_eq!(instr_idx + 1, self[block_idx].instruction_used); + debug_assert!(target.target != BlockIdx::NULL); + if self[block_idx].instructions[instr_idx].target != target.target { + set_to_nop(&mut self[block_idx].instructions[instr_idx]); + self.basicblock_add_jump(block_idx, opcode, target.target, target)?; + return Ok(true); + } + Ok(false) } - let label = instruction_sequence_new_label(seq); - debug_assert_eq!(label.0, seq.next_free_label); - instruction_sequence_label_map_register_label(map, label)?; - map.cpython_block_by_label[label.idx()] = block; - map.block_labels[block.idx()] = label; - Ok(label) -} -fn instruction_sequence_label_map_label_for_block( - map: &InstructionSequenceLabelMap, - block: BlockIdx, -) -> InstructionSequenceLabel { - debug_assert_ne!(block, BlockIdx::NULL); - map.block_labels - .get(block.idx()) - .copied() - .unwrap_or(InstructionSequenceLabel::NO_LABEL) -} - -fn instruction_sequence_label_map_block_for_label( - map: &InstructionSequenceLabelMap, - label: InstructionSequenceLabel, -) -> Option { - if !is_label(label) { - return None; - } - map.cpython_block_by_label - .get(label.idx()) - .copied() - .filter(|&block| block != BlockIdx::NULL) -} - -fn instruction_sequence_label_map_resolve_label( - map: &InstructionSequenceLabelMap, - block: BlockIdx, -) -> BlockIdx { - if block == BlockIdx::NULL { - return BlockIdx::NULL; - } - let label = instruction_sequence_label_map_label_for_block(map, block); - if !is_label(label) { - return block; - } - instruction_sequence_label_map_block_for_label(map, label).unwrap_or_else(|| { - debug_assert!( - false, - "CPython instruction-sequence label must map to a codegen CFG block" - ); - BlockIdx::NULL - }) -} - -fn instruction_sequence_label_map_resolve_label_to_block( - map: &InstructionSequenceLabelMap, - label: InstructionSequenceLabel, -) -> BlockIdx { - if !is_label(label) { - return BlockIdx::NULL; - } - instruction_sequence_label_map_block_for_label(map, label).unwrap_or_else(|| { - debug_assert!( - false, - "CPython instruction-sequence label must map to a codegen CFG block" - ); - BlockIdx::NULL - }) -} - -fn instruction_sequence_label_oparg(label: InstructionSequenceLabel) -> OpArg { - debug_assert!(is_label(label)); - OpArg::new(label.idx() as u32) -} - -fn instruction_sequence_label_map_use_label_at_block( - map: &mut InstructionSequenceLabelMap, - seq: &mut InstructionSequence, - from: BlockIdx, - to: BlockIdx, -) -> crate::InternalResult<()> { - if from == BlockIdx::NULL || from == to { - return Ok(()); - } - let from_label = instruction_sequence_label_map_ensure_label_for_block(map, seq, from)?; - debug_assert!(map.cpython_block_by_label.len() > from_label.idx()); - let to_block = instruction_sequence_label_map_resolve_label(map, to); - if to_block == BlockIdx::NULL { - debug_assert!( - false, - "CPython label target must map to a codegen CFG block" - ); - return Ok(()); + /// flowgraph.c basicblock_add_jump + fn basicblock_add_jump( + &mut self, + block_idx: BlockIdx, + instr: AnyInstruction, + target: BlockIdx, + loc_source: &InstructionInfo, + ) -> crate::InternalResult<()> { + let last = basicblock_last_instr(&self[block_idx]); + if last.is_some_and(is_jump) { + return Err(InternalError::MalformedControlFlowGraph); + } + debug_assert!(target != BlockIdx::NULL); + let label = self[target].cpython_label; + debug_assert!(is_label(label)); + let arg = instruction_sequence_label_oparg(label); + let block = &mut self[block_idx]; + basicblock_addop( + block, + InstructionInfo { + instr, + arg, + target: BlockIdx::NULL, + location: loc_source.location, + end_location: loc_source.end_location, + except_handler: None, + lineno_override: loc_source.lineno_override, + }, + )?; + let last = basicblock_last_instr_mut(block).expect("missing jump"); + debug_assert!(match (last.instr, instr) { + (AnyInstruction::Real(last), AnyInstruction::Real(opcode)) => + last.as_opcode() == opcode.as_opcode(), + (AnyInstruction::Pseudo(last), AnyInstruction::Pseudo(opcode)) => + last.as_opcode() == opcode.as_opcode(), + _ => false, + }); + last.target = target; + Ok(()) } - map.cpython_block_by_label[from_label.idx()] = to_block; - Ok(()) -} - -fn instruction_sequence_label_map_push_unlabeled_block( - map: &mut InstructionSequenceLabelMap, -) -> crate::InternalResult<()> { - map.block_labels - .try_reserve(1) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - map.block_labels.push(InstructionSequenceLabel::NO_LABEL); - Ok(()) -} -fn instruction_sequence_label_map_push_unmapped_label( - map: &mut InstructionSequenceLabelMap, - seq: &mut InstructionSequence, -) -> crate::InternalResult<()> { - let label = instruction_sequence_new_label(seq); - debug_assert_eq!(label.0, seq.next_free_label); - instruction_sequence_label_map_register_label(map, label)?; - let block = BlockIdx( - map.block_labels - .len() - .to_u32() - .ok_or(InternalError::MalformedControlFlowGraph)?, - ); - map.cpython_block_by_label[label.idx()] = block; - map.block_labels - .try_reserve(1) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - map.block_labels.push(label); - Ok(()) -} + /// flowgraph.c convert_pseudo_conditional_jumps + fn convert_pseudo_conditional_jumps(&mut self) -> crate::InternalResult<()> { + let mut block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + let next = self[block_idx].next; + let block = &mut self[block_idx]; + let mut i = 0; + while i < block.instruction_used { + let instr = block.instructions[i]; + let opcode = instr.instr; + if matches!( + opcode.pseudo_opcode(), + Some(PseudoOpcode::JumpIfFalse | PseudoOpcode::JumpIfTrue) + ) { + debug_assert_eq!(i, block.instruction_used - 1); + block.instructions[i].instr = + if matches!(opcode.pseudo_opcode(), Some(PseudoOpcode::JumpIfFalse)) { + Opcode::PopJumpIfFalse + } else { + Opcode::PopJumpIfTrue + } + .into(); + + let location = instr.location; + let end_location = instr.end_location; + let except_handler = instr.except_handler; + let lineno_override = instr.lineno_override; + let copy = InstructionInfo { + instr: Opcode::Copy.into(), + arg: OpArg::new(1), + target: BlockIdx::NULL, + location, + end_location, + except_handler, + lineno_override, + }; + basicblock_insert_instruction(block, i, copy)?; + i += 1; -impl InstructionSequenceLabelMap { - pub(crate) fn new() -> Self { - Self { - block_labels: vec![InstructionSequenceLabel::NO_LABEL], - cpython_block_by_label: Vec::new(), + let to_bool = InstructionInfo { + instr: Opcode::ToBool.into(), + arg: OpArg::new(0), + target: BlockIdx::NULL, + location, + end_location, + except_handler, + lineno_override, + }; + basicblock_insert_instruction(block, i, to_bool)?; + i += 1; + } + i += 1; + } + block_idx = next; } + Ok(()) } -} - -pub struct CodeInfo { - pub flags: CodeFlags, - pub source_path: String, - pub private: Option, // For private name mangling, mostly for class - pub blocks: Blocks, - pub current_block: BlockIdx, - pub(crate) instr_sequence: InstructionSequence, - pub(crate) instr_sequence_label_map: InstructionSequenceLabelMap, - pub(crate) annotations_instr_sequence: Option, + /// flowgraph.c normalize_jumps_in_block + fn normalize_jumps_in_block(&mut self, block_idx: BlockIdx) -> crate::InternalResult<()> { + let Some(last_ins) = basicblock_last_instr(&self[block_idx]).copied() else { + return Ok(()); + }; + if !is_conditional_jump_opcode(last_ins.instr) { + return Ok(()); + } + debug_assert!(!last_ins.instr.is_assembler()); - pub metadata: CodeUnitMetadata, + debug_assert!(last_ins.target != BlockIdx::NULL); + let is_forward = !self[last_ins.target].visited; - // For class scopes: attributes accessed via self.X - pub static_attributes: Option>, + if is_forward { + // Insert NOT_TAKEN after forward conditional jump. + let not_taken = InstructionInfo { + instr: Opcode::NotTaken.into(), + arg: OpArg::new(0), + target: BlockIdx::NULL, + location: last_ins.location, + end_location: last_ins.end_location, + except_handler: None, + lineno_override: last_ins.lineno_override, + }; + basicblock_addop(&mut self[block_idx], not_taken)?; + return Ok(()); + } - // True if compiling an inlined comprehension - pub in_inlined_comp: bool, + let reversed_opcode = match last_ins.instr.real_opcode() { + Some(Opcode::PopJumpIfNotNone) => Opcode::PopJumpIfNone.into(), + Some(Opcode::PopJumpIfNone) => Opcode::PopJumpIfNotNone.into(), + Some(Opcode::PopJumpIfFalse) => Opcode::PopJumpIfTrue.into(), + Some(Opcode::PopJumpIfTrue) => Opcode::PopJumpIfFalse.into(), + _ => unreachable!("conditional jump has reverse opcode"), + }; - // Block stack for tracking nested control structures - pub fblock: Vec, + // Transform 'conditional jump T' to 'reversed_jump b_next' followed by + // 'jump_backwards T'. + let loc = last_ins.location; + let end_loc = last_ins.end_location; - // Reference to the symbol table for this scope - pub symbol_table_index: usize, - // CPython compile.c uses PyList_GET_SIZE(u->u_ste->ste_varnames) - // when calling flowgraph.c _PyCfg_OptimizeCodeUnit(). - pub nparams: usize, + let target = last_ins.target; + let backwards_jump_idx = self.blocks_new_block()?; + basicblock_addop( + &mut self[backwards_jump_idx], + InstructionInfo { + instr: Opcode::NotTaken.into(), + arg: OpArg::new(0), + target: BlockIdx::NULL, + location: loc, + end_location: end_loc, + except_handler: None, + lineno_override: last_ins.lineno_override, + }, + )?; + self.basicblock_add_jump( + backwards_jump_idx, + PseudoOpcode::Jump.into(), + target, + &last_ins, + )?; + self[backwards_jump_idx].start_depth = self[target].start_depth; - // PEP 649: Track nesting depth inside conditional blocks (if/for/while/etc.) - // u_in_conditional_block - pub in_conditional_block: u32, + let old_next = self[block_idx].next; + debug_assert!(old_next != BlockIdx::NULL); - // PEP 649: Next index for conditional annotation tracking - // u_next_conditional_annotation_index - pub next_conditional_annotation_index: u32, -} + let last_mut = basicblock_last_instr_mut(&mut self[block_idx]).unwrap(); + last_mut.instr = reversed_opcode; + last_mut.target = old_next; -impl CodeInfo { - pub(crate) fn addop_to_instr_sequence( - &mut self, - mut info: InstructionInfo, - ) -> crate::InternalResult<()> { - if info.instr.has_target() && info.target != BlockIdx::NULL { - let label = instruction_sequence_label_map_ensure_label_for_block( - &mut self.instr_sequence_label_map, - &mut self.instr_sequence, - info.target, - )?; - info.arg = instruction_sequence_label_oparg(label); - info.target = BlockIdx::NULL; - } - instruction_sequence_addop(&mut self.instr_sequence, info)?; + self[backwards_jump_idx].cold = self[block_idx].cold; + self[backwards_jump_idx].next = old_next; + self[block_idx].next = backwards_jump_idx; Ok(()) } - pub(crate) fn addop_to_instr_sequence_with_target_label( + /// flowgraph.c basicblock_inline_small_or_no_lineno_blocks + fn basicblock_inline_small_or_no_lineno_blocks( &mut self, - mut info: InstructionInfo, - target_label: InstructionSequenceLabel, - ) -> crate::InternalResult<()> { - if !info.instr.has_target() { - return Err(InternalError::MalformedControlFlowGraph); + block_idx: BlockIdx, + ) -> crate::InternalResult { + let Some(last) = basicblock_last_instr(&self[block_idx]).copied() else { + return Ok(false); + }; + + if !last.instr.is_unconditional_jump() { + return Ok(false); } - info.arg = instruction_sequence_label_oparg(target_label); - info.target = BlockIdx::NULL; - instruction_sequence_addop(&mut self.instr_sequence, info)?; - Ok(()) - } - pub(crate) fn addop_to_current_block( - &mut self, - info: InstructionInfo, - ) -> crate::InternalResult<()> { - basicblock_addop(&mut self.blocks[self.current_block.idx()], info) + let target = last.target; + debug_assert!(target != BlockIdx::NULL); + let small_exit_block = + basicblock_exits_scope(&self[target]) && self[target].instruction_used <= MAX_COPY_SIZE; + let no_lineno_no_fallthrough = + basicblock_has_no_lineno(&self[target]) && !bb_has_fallthrough(&self[target]); + if small_exit_block || no_lineno_no_fallthrough { + debug_assert!(is_jump(&last)); + let removed_jump_opcode = last.instr; + let last = basicblock_last_instr_mut(&mut self[block_idx]) + .expect("non-empty block has last instruction"); + set_to_nop(last); + self.basicblock_append_block_instructions(block_idx, target)?; + if no_lineno_no_fallthrough { + let last = basicblock_last_instr_mut(&mut self[block_idx]).unwrap(); + if last.instr.is_unconditional_jump() + && matches!( + removed_jump_opcode.into(), + AnyOpcode::Pseudo(PseudoOpcode::Jump) + ) + { + last.instr = PseudoOpcode::Jump.into(); + } + } + self[target].predecessors -= 1; + return Ok(true); + } + Ok(false) } - pub(crate) fn last_current_block_instr_mut(&mut self) -> Option<&mut InstructionInfo> { - basicblock_last_instr_mut(&mut self.blocks[self.current_block.idx()]) - } + /// flowgraph.c inline_small_or_no_lineno_blocks + fn inline_small_or_no_lineno_blocks(&mut self) -> crate::InternalResult { + loop { + let mut changes = false; + let mut current = BlockIdx(0); + while current != BlockIdx::NULL { + let next = self[current].next; + let res = self.basicblock_inline_small_or_no_lineno_blocks(current)?; + if res { + changes = true; + } - pub(crate) fn set_last_instr_sequence_lineno_override(&mut self, lineno_override: i32) { - if let Some(last) = instruction_sequence_last_info_mut(&mut self.instr_sequence) { - last.lineno_override = Some(lineno_override); + current = next; + } + if !changes { + return Ok(changes); + } } } - pub(crate) fn use_instr_sequence_label( + /// flowgraph.c basicblock_remove_redundant_nops + #[allow(clippy::unnecessary_wraps)] + fn basicblock_remove_redundant_nops( &mut self, - block: BlockIdx, - ) -> crate::InternalResult<()> { - let label = instruction_sequence_label_map_ensure_label_for_block( - &mut self.instr_sequence_label_map, - &mut self.instr_sequence, - block, - )?; - instruction_sequence_use_label(&mut self.instr_sequence, label) - } + block_idx: BlockIdx, + ) -> crate::InternalResult { + let mut dest = 0; + let mut prev_lineno = -1i32; + let instr_count = self[block_idx].instruction_used; - pub(crate) fn new_instr_sequence_label(&mut self) -> InstructionSequenceLabel { - instruction_sequence_new_label(&mut self.instr_sequence) - } + for src in 0..instr_count { + let instr = self[block_idx].instructions[src]; + let lineno = instruction_lineno(&instr); - pub(crate) fn use_raw_instr_sequence_label( - &mut self, - label: InstructionSequenceLabel, - ) -> crate::InternalResult<()> { - instruction_sequence_use_label(&mut self.instr_sequence, label) + if matches!(instr.instr.real(), Some(Instruction::Nop)) { + if lineno < 0 { + continue; + } + if prev_lineno == lineno { + continue; + } + if src < instr_count - 1 { + let next_lineno = instruction_lineno(&self[block_idx].instructions[src + 1]); + if next_lineno == lineno { + continue; + } + if next_lineno < 0 { + instr_set_loc( + &mut self[block_idx].instructions[src + 1], + instr.location, + instr.end_location, + instr.lineno_override, + ); + continue; + } + } else { + let next = next_nonempty_block(self, self[block_idx].next); + if next != BlockIdx::NULL { + let mut next_loc = no_linetable_location(); + let mut next_i = 0; + while next_i < self[next].instruction_used { + let instr = self[next].instructions[next_i]; + if matches!(instr.instr.real(), Some(Instruction::Nop)) + && instruction_lineno(&instr) < 0 + { + next_i += 1; + continue; + } + next_loc = instruction_linetable_location(&instr); + break; + } + if lineno == next_loc.line { + continue; + } + } + } + } + + if dest != src { + self[block_idx].instructions[dest] = self[block_idx].instructions[src]; + } + dest += 1; + prev_lineno = lineno; + } + + debug_assert!(dest <= instr_count); + let num_removed = instr_count - dest; + self[block_idx].instruction_used = dest; + Ok(num_removed) } - pub(crate) fn mark_cpython_cfg_label(&mut self, block: BlockIdx) -> crate::InternalResult<()> { - let label = instruction_sequence_label_map_ensure_label_for_block( - &mut self.instr_sequence_label_map, - &mut self.instr_sequence, - block, - )?; - self.blocks[block.idx()].cpython_label = label; - Ok(()) + /// flowgraph.c remove_redundant_nops + #[allow(clippy::unnecessary_wraps)] + fn remove_redundant_nops(&mut self) -> crate::InternalResult { + let mut changes = 0; + let mut current = BlockIdx(0); + while current != BlockIdx::NULL { + let next = self[current].next; + let change = self.basicblock_remove_redundant_nops(current)?; + changes += change; + current = next; + } + Ok(changes) } - pub(crate) fn resolve_instr_sequence_label(&self, block: BlockIdx) -> BlockIdx { - instruction_sequence_label_map_resolve_label(&self.instr_sequence_label_map, block) + /// flowgraph.c no_redundant_nops + #[cfg(debug_assertions)] + fn no_redundant_nops(&mut self) -> bool { + matches!(self.remove_redundant_nops(), Ok(0)) } - pub(crate) fn block_for_instr_sequence_label( - &self, - label: InstructionSequenceLabel, - ) -> BlockIdx { - instruction_sequence_label_map_resolve_label_to_block(&self.instr_sequence_label_map, label) + /// flowgraph.c remove_redundant_jumps + fn remove_redundant_jumps(&mut self) -> crate::InternalResult { + let mut changes = 0; + let mut current = BlockIdx(0); + while current != BlockIdx::NULL { + let Some(last) = basicblock_last_instr(&self[current]).copied() else { + current = self[current].next; + continue; + }; + + debug_assert!(!last.instr.is_assembler()); + if last.instr.is_unconditional_jump() { + let jump_target = next_nonempty_block(self, last.target); + if jump_target == BlockIdx::NULL { + return Err(InternalError::MalformedControlFlowGraph); + } + let next = next_nonempty_block(self, self[current].next); + if jump_target == next { + changes += 1; + let last = basicblock_last_instr_mut(&mut self[current]).unwrap(); + set_to_nop(last); + } + } + current = self[current].next; + } + Ok(changes) } - pub(crate) fn use_instr_sequence_label_at_block( - &mut self, - from: BlockIdx, - to: BlockIdx, - ) -> crate::InternalResult<()> { - instruction_sequence_label_map_use_label_at_block( - &mut self.instr_sequence_label_map, - &mut self.instr_sequence, - from, - to, - ) + /// flowgraph.c no_redundant_jumps + #[cfg(debug_assertions)] + fn no_redundant_jumps(&self) -> bool { + let mut current = BlockIdx(0); + while current != BlockIdx::NULL { + let block = &self[current]; + if let Some(last) = basicblock_last_instr(block) + && last.instr.is_unconditional_jump() + { + let next = next_nonempty_block(self, block.next); + let jump_target = next_nonempty_block(self, last.target); + if jump_target == next { + assert!(next != BlockIdx::NULL); + if instruction_lineno(last) == instruction_lineno(&self[next].instructions[0]) { + assert_ne!( + instruction_lineno(last), + instruction_lineno(&self[next].instructions[0]), + "redundant jump has same line as fallthrough target" + ); + return false; + } + } + } + current = block.next; + } + true } - pub(crate) fn instr_sequence_label_for_block( - &mut self, - block: BlockIdx, - ) -> crate::InternalResult { - if block == BlockIdx::NULL { - Ok(InstructionSequenceLabel::NO_LABEL) - } else { - instruction_sequence_label_map_ensure_label_for_block( - &mut self.instr_sequence_label_map, - &mut self.instr_sequence, - block, - ) + fn remove_redundant_nops_and_jumps(&mut self) -> crate::InternalResult<()> { + loop { + // Convergence is guaranteed because the number of redundant jumps and + // nops only decreases. + let removed_nops = self.remove_redundant_nops()?; + let removed_jumps = self.remove_redundant_jumps()?; + if removed_nops + removed_jumps == 0 { + break; + } } + Ok(()) } - pub(crate) fn insert_start_setup_cleanup( - &mut self, - handler_block: BlockIdx, - ) -> crate::InternalResult<()> { - let handler_label = instruction_sequence_label_map_ensure_label_for_block( - &mut self.instr_sequence_label_map, - &mut self.instr_sequence, - handler_block, - )?; - instruction_sequence_insert_instruction( - &mut self.instr_sequence, - 0, - InstructionInfo { - instr: PseudoOpcode::SetupCleanup.into(), - arg: instruction_sequence_label_oparg(handler_label), - target: BlockIdx::NULL, - location: SourceLocation::default(), - end_location: SourceLocation::default(), - except_handler: None, - lineno_override: Some(NO_LOCATION_OVERRIDE), - }, - ) + fn blocks_new_block(&mut self) -> crate::InternalResult { + self.try_reserve(1) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + let block_idx = BlockIdx( + self.len() + .to_u32() + .ok_or(InternalError::MalformedControlFlowGraph)?, + ); + self.push(Block::default()); + Ok(block_idx) } +} - pub(crate) fn push_unmapped_instr_sequence_label(&mut self) -> crate::InternalResult<()> { - instruction_sequence_label_map_push_unmapped_label( - &mut self.instr_sequence_label_map, - &mut self.instr_sequence, - ) +impl From> for Blocks { + fn from(value: Vec) -> Self { + Self(value) } +} - pub(crate) fn push_unlabeled_instr_sequence_block(&mut self) -> crate::InternalResult<()> { - instruction_sequence_label_map_push_unlabeled_block(&mut self.instr_sequence_label_map) +impl From> for Blocks { + fn from(value: Box<[Block]>) -> Self { + Self(value.into()) } +} - fn take_recorded_instr_sequence(&mut self) -> crate::InternalResult { - let mut instr_sequence = - core::mem::replace(&mut self.instr_sequence, instruction_sequence_new()); - if let Some(mut annotations_instr_sequence) = self.annotations_instr_sequence.take() { - instruction_sequence_apply_label_map(&mut annotations_instr_sequence)?; - instruction_sequence_set_annotations_code( - &mut instr_sequence, - Some(Box::new(annotations_instr_sequence)), - ); - } - Ok(instr_sequence) +impl From<&[Block]> for Blocks { + fn from(value: &[Block]) -> Self { + Self(value.to_vec()) } +} - fn prepare_cfg_from_codegen(&mut self) -> crate::InternalResult { - // CPython compile.c optimize_and_assemble_code_unit passes - // u_instr_sequence directly into flowgraph.c _PyCfg_FromInstructionSequence(). - self.take_recorded_instr_sequence() +impl From<&mut [Block]> for Blocks { + fn from(value: &mut [Block]) -> Self { + Self(value.to_vec()) } } -fn optimize_code_unit( - metadata: &mut CodeUnitMetadata, - blocks: &mut Blocks, - instr_sequence: InstructionSequence, - nlocals: usize, - nparams: usize, -) -> crate::InternalResult<()> { - // Phase 1: _PyCfg_OptimizeCodeUnit (flowgraph.c) - *blocks = cfg_from_instruction_sequence(instr_sequence)?; - translate_jump_labels_to_targets(blocks)?; - mark_except_handlers(blocks)?; - label_exception_targets(blocks)?; - optimize_cfg(metadata, blocks, metadata.firstlineno)?; - blocks.remove_unused_consts(&mut metadata.consts)?; - add_checks_for_loads_of_uninitialized_variables(blocks, nlocals, nparams)?; - // CPython inserts superinstructions in _PyCfg_OptimizeCodeUnit, before - // later jump normalization / block reordering can create adjacencies - // that never exist at this stage in flowgraph.c. - blocks.insert_superinstructions()?; - push_cold_blocks_to_end(blocks)?; - // CPython resolves line numbers again after cold-block extraction. - blocks.resolve_line_numbers(metadata.firstlineno)?; - Ok(()) +impl From<[Block; N]> for Blocks { + fn from(value: [Block; N]) -> Self { + Self(value.into()) + } } -fn optimize_cfg( - metadata: &mut CodeUnitMetadata, - blocks: &mut Blocks, - firstlineno: OneIndexed, -) -> crate::InternalResult<()> { - // flowgraph.c optimize_cfg - // CPython optimize_cfg() starts with check_cfg() and raises - // SystemError if a jump or scope exit is not the last instruction in - // its block. - check_cfg(blocks)?; - inline_small_or_no_lineno_blocks(blocks)?; - // CPython does not re-run instruction-sequence label-map/CFG conversion - // after this point. Unreferenced label blocks left by jump inlining - // remain block boundaries and can preserve line-marker NOPs. - blocks.remove_unreachable()?; - // CPython optimize_cfg resolves line numbers before local checks and - // superinstruction insertion, so fusion decisions see propagated - // source locations. - blocks.resolve_line_numbers(firstlineno)?; - // CPython optimize_cfg() runs optimize_load_const() and then - // optimize_basic_block() after line numbers are resolved. - optimize_load_const(metadata, blocks)?; - let mut block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - let next_block = blocks[block_idx].next; - blocks.optimize_basic_block(metadata, block_idx)?; - block_idx = next_block; +impl From<&[Block; N]> for Blocks { + fn from(value: &[Block; N]) -> Self { + Self(value.to_vec()) } - blocks.remove_redundant_nops_and_pairs()?; - // CPython optimize_cfg() removes newly-unreachable blocks and - // redundant NOP/jump chains before _PyCfg_OptimizeCodeUnit() prunes - // unused constants. - blocks.remove_unreachable()?; - remove_redundant_nops_and_jumps(blocks)?; - #[cfg(debug_assertions)] - assert!(no_redundant_jumps(blocks)); - Ok(()) } -fn optimized_cfg_to_instruction_sequence( - metadata: &CodeUnitMetadata, - flags: CodeFlags, - blocks: &mut Blocks, -) -> crate::InternalResult<(u32, usize, InstructionSequence)> { - // Phase 2: _PyCfg_OptimizedCfgToInstructionSequence (flowgraph.c) - convert_pseudo_conditional_jumps(blocks)?; - let max_stackdepth = blocks.calculate_stackdepth()?; - debug_assert!(!is_generator(flags) || max_stackdepth != 0); - let nlocalsplus = prepare_localsplus(metadata, blocks, flags)?; - // Match CPython order: pseudo ops are lowered after stackdepth and - // localsplus preparation, before normalize_jumps. - convert_pseudo_ops(blocks)?; - blocks.normalize_jumps()?; - #[cfg(debug_assertions)] - assert!(no_redundant_jumps(blocks)); - // optimize_load_fast: after normalize_jumps - blocks.optimize_load_fast()?; +impl Deref for Blocks { + type Target = [Block]; - let mut instr_sequence = instruction_sequence_new(); - blocks.cfg_to_instruction_sequence(&mut instr_sequence)?; - Ok((max_stackdepth, nlocalsplus, instr_sequence)) + fn deref(&self) -> &Self::Target { + &self.0 + } } -impl CodeInfo { - pub fn finalize_code( - mut self, - opts: &crate::compile::CompileOpts, - ) -> crate::InternalResult { - let instr_sequence = self.prepare_cfg_from_codegen()?; - let nlocals = self.metadata.varnames.len(); - let nparams = self.nparams; - optimize_code_unit( - &mut self.metadata, - &mut self.blocks, - instr_sequence, - nlocals, - nparams, - )?; - let (max_stackdepth, nlocalsplus, mut instr_sequence) = - optimized_cfg_to_instruction_sequence(&self.metadata, self.flags, &mut self.blocks)?; - let localsplusinfo = compute_localsplus_info(&self.metadata, nlocalsplus, self.flags)?; +impl DerefMut for Blocks { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} - let Self { - flags, - source_path, - private: _, // private is only used during compilation +impl Index for Blocks { + type Output = Block; - blocks: _, - current_block: _, - instr_sequence: _, - instr_sequence_label_map: _, - annotations_instr_sequence: _, - metadata, - static_attributes: _, - in_inlined_comp: _, - fblock: _, - symbol_table_index: _, - nparams: _, - in_conditional_block: _, - next_conditional_annotation_index: _, - } = self; + fn index(&self, idx: usize) -> &Self::Output { + &self.0[idx] + } +} - let CodeUnitMetadata { - name: obj_name, - qualname, - consts: constants, - names: name_cache, - varnames: varname_cache, - cellvars: _, - freevars: freevar_cache, - fast_hidden: _, - fast_hidden_final: _, - argcount: arg_count, - posonlyargcount: posonlyarg_count, - kwonlyargcount: kwonlyarg_count, - firstlineno: first_line_number, - } = metadata; +impl IndexMut for Blocks { + fn index_mut(&mut self, idx: usize) -> &mut Self::Output { + &mut self.0[idx] + } +} - resolve_unconditional_jumps(&mut instr_sequence)?; - resolve_jump_offsets(&mut instr_sequence)?; - let assembled = assemble_emit( - &mut instr_sequence, - first_line_number.get() as i32, - opts.debug_ranges, - )?; - let locations = rustpython_compiler_core::marshal::linetable_to_locations( - &assembled.linetable, - first_line_number.get() as i32, - assembled.instructions.len(), - ); +impl Index for Blocks { + type Output = Block; - Ok(CodeObject { - flags, - posonlyarg_count, - arg_count, - kwonlyarg_count, - source_path, - first_line_number: Some(first_line_number), - obj_name: obj_name.clone(), - qualname: qualname.unwrap_or(obj_name), + fn index(&self, block_idx: BlockIdx) -> &Self::Output { + &self.0[block_idx.as_usize()] + } +} - max_stackdepth, - instructions: CodeUnits::from(assembled.instructions), - locations, - constants: constants.into_iter().collect(), - names: name_cache.into_iter().collect(), - varnames: varname_cache.into_iter().collect(), - cellvars: localsplusinfo.cellvars, - freevars: freevar_cache.into_iter().collect(), - localspluskinds: localsplusinfo.kinds, - linetable: assembled.linetable, - exceptiontable: assembled.exceptiontable, - }) +impl IndexMut for Blocks { + fn index_mut(&mut self, block_idx: BlockIdx) -> &mut Self::Output { + &mut self.0[block_idx.as_usize()] } } -/// flowgraph.c IS_GENERATOR -fn is_generator(flags: CodeFlags) -> bool { - flags.intersects(CodeFlags::GENERATOR | CodeFlags::COROUTINE | CodeFlags::ASYNC_GENERATOR) +pub(crate) const START_DEPTH_UNSET: i32 = i32::MIN; +const CO_MAXBLOCKS: usize = 20; + +/// flowgraph.c struct _PyCfgExceptStack +#[derive(Clone, Debug)] +struct CfgExceptStack { + handlers: [BlockIdx; CO_MAXBLOCKS + 2], + depth: usize, } -/// flowgraph.c insert_prefix_instructions -fn insert_prefix_instructions( - metadata: &CodeUnitMetadata, - blocks: &mut Blocks, - cellfixedoffsets: &[i32], - nfreevars: usize, - flags: CodeFlags, -) -> crate::InternalResult<()> { - debug_assert!(!blocks.is_empty()); - let entry = &mut blocks[0]; - let ncellvars = metadata.cellvars.len(); - let firstlineno = metadata.firstlineno; - debug_assert!(firstlineno.get() > 0); +/// flowgraph.c `basicblock **stack` +#[derive(Clone, Debug)] +struct CfgTraversalStack { + stack: Vec, + sp: usize, +} - if is_generator(flags) { - let location = SourceLocation { - line: firstlineno, - character_offset: OneIndexed::MIN, - }; - basicblock_insert_instruction( - entry, - 0, - InstructionInfo { - instr: Instruction::ReturnGenerator.into(), - arg: OpArg::new(0), - target: BlockIdx::NULL, - location, - end_location: location, - except_handler: None, - lineno_override: Some(LINE_ONLY_LOCATION_OVERRIDE), - }, - )?; - basicblock_insert_instruction( - entry, - 1, - InstructionInfo { - instr: Instruction::PopTop.into(), - arg: OpArg::new(0), - target: BlockIdx::NULL, - location, - end_location: location, - except_handler: None, - lineno_override: Some(LINE_ONLY_LOCATION_OVERRIDE), - }, - )?; +impl CfgTraversalStack { + fn push(&mut self, block: BlockIdx) { + debug_assert!(self.sp < self.stack.len()); + self.stack[self.sp] = block; + self.sp += 1; } - if ncellvars > 0 { - let nvars = metadata.varnames.len() + ncellvars; - let mut sorted = Vec::new(); - vec_try_reserve_exact(&mut sorted, nvars)?; - sorted.resize(nvars, 0i32); - for i in 0..ncellvars { - sorted[cellfixedoffsets[i] as usize] = i as i32 + 1; - } - let mut ncellsused = 0; - let mut i = 0; - while ncellsused < ncellvars { - let oldindex = sorted[i] - 1; - i += 1; - if oldindex == -1 { - continue; - } - basicblock_insert_instruction( - entry, - ncellsused, - InstructionInfo { - instr: Opcode::MakeCell.into(), - arg: OpArg::new(oldindex as u32), - target: BlockIdx::NULL, - location: SourceLocation::default(), - end_location: SourceLocation::default(), - except_handler: None, - lineno_override: Some(NO_LOCATION_OVERRIDE), - }, - )?; - ncellsused += 1; + fn pop(&mut self) -> Option { + if self.sp == 0 { + return None; } + self.sp -= 1; + Some(self.stack[self.sp]) } - if nfreevars > 0 { - basicblock_insert_instruction( - entry, - 0, - InstructionInfo { - instr: Opcode::CopyFreeVars.into(), - arg: OpArg::new(nfreevars as u32), - target: BlockIdx::NULL, - location: SourceLocation::default(), - end_location: SourceLocation::default(), - except_handler: None, - lineno_override: Some(NO_LOCATION_OVERRIDE), - }, - )?; + fn capacity(&self) -> usize { + self.stack.len() } - Ok(()) } -/// flowgraph.c prepare_localsplus -fn prepare_localsplus( - metadata: &CodeUnitMetadata, - blocks: &mut Blocks, - flags: CodeFlags, -) -> crate::InternalResult { - let nlocals = metadata.varnames.len(); - let ncellvars = metadata.cellvars.len(); - let nfreevars = metadata.freevars.len(); - let int_max = i32::MAX as usize; - debug_assert!(nlocals < int_max); - debug_assert!(ncellvars < int_max); - debug_assert!(nfreevars < int_max); - debug_assert!(int_max - nlocals - ncellvars > 0); - debug_assert!(int_max - nlocals - ncellvars - nfreevars > 0); - let mut nlocalsplus = nlocals + ncellvars + nfreevars; - let mut cellfixedoffsets = build_cellfixedoffsets(metadata)?; - - // This must be called before fix_cell_offsets(). - insert_prefix_instructions(metadata, blocks, &cellfixedoffsets, nfreevars, flags)?; - - let numdropped = fix_cell_offsets(metadata, blocks, &mut cellfixedoffsets); - nlocalsplus -= numdropped; - Ok(nlocalsplus) +#[derive(Clone, Debug)] +pub(crate) struct InstructionSequenceLabelMap { + block_labels: Vec, + /// Codegen-side shadow of CPython's instruction-sequence label map. + /// + /// `_PyInstructionSequence_UseLabel()` can map multiple labels to the same + /// instruction offset before `_PyCfg_FromInstructionSequence()` materializes + /// CFG blocks. The codegen CFG path keeps the same aliasing by resolving + /// those labels to the block that owns the shared offset. + cpython_block_by_label: Vec, } -/// flowgraph.c eval_const_unaryop -fn eval_const_unaryop( - operand: &ConstantData, - op: Instruction, - intrinsic: Option, -) -> Option { - match (operand, op, intrinsic) { - (ConstantData::Integer { value }, Instruction::UnaryNegative, None) => { - Some(ConstantData::Integer { value: -value }) - } - (ConstantData::Float { value }, Instruction::UnaryNegative, None) => { - Some(ConstantData::Float { value: -value }) - } - (ConstantData::Complex { value }, Instruction::UnaryNegative, None) => { - Some(ConstantData::Complex { value: -value }) - } - (ConstantData::Boolean { value }, Instruction::UnaryNegative, None) => { - Some(ConstantData::Integer { - value: BigInt::from(-i32::from(*value)), - }) +fn instruction_sequence_label_map_register_label( + map: &mut InstructionSequenceLabelMap, + label: InstructionSequenceLabel, +) -> crate::InternalResult<()> { + debug_assert!(is_label(label)); + let old_size = map.cpython_block_by_label.len(); + let new_allocation = c_array_ensure_capacity::( + old_size, + label.idx(), + INITIAL_INSTR_SEQUENCE_LABELS_MAP_SIZE, + )?; + if new_allocation > old_size { + if new_allocation > map.cpython_block_by_label.capacity() { + map.cpython_block_by_label + .try_reserve_exact(new_allocation - map.cpython_block_by_label.capacity()) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; } - (ConstantData::Integer { value }, Instruction::UnaryInvert, None) => { - Some(ConstantData::Integer { value: !value }) + map.cpython_block_by_label + .resize(new_allocation, BlockIdx::NULL); + for i in old_size..map.cpython_block_by_label.len() { + map.cpython_block_by_label[i] = BlockIdx::NULL; } - (ConstantData::Boolean { .. }, Instruction::UnaryInvert, None) => None, - (_, Instruction::UnaryNot, None) => Some(ConstantData::Boolean { - value: !operand.truthiness(), - }), - ( - ConstantData::Integer { value }, - Instruction::CallIntrinsic1 { .. }, - Some(oparg::IntrinsicFunction1::UnaryPositive), - ) => Some(ConstantData::Integer { - value: value.clone(), - }), - ( - ConstantData::Float { value }, - Instruction::CallIntrinsic1 { .. }, - Some(oparg::IntrinsicFunction1::UnaryPositive), - ) => Some(ConstantData::Float { value: *value }), - ( - ConstantData::Boolean { value }, - Instruction::CallIntrinsic1 { .. }, - Some(oparg::IntrinsicFunction1::UnaryPositive), - ) => Some(ConstantData::Integer { - value: BigInt::from(i32::from(*value)), - }), - ( - ConstantData::Complex { value }, - Instruction::CallIntrinsic1 { .. }, - Some(oparg::IntrinsicFunction1::UnaryPositive), - ) => Some(ConstantData::Complex { value: *value }), - _ => None, } + debug_assert!(map.cpython_block_by_label.len() > label.idx()); + Ok(()) } -fn load_const_truthiness( - instr: Instruction, - arg: OpArg, - metadata: &CodeUnitMetadata, -) -> Option { - match instr { - Instruction::LoadConst { consti } => { - let constant = &metadata.consts[consti.get(arg).as_usize()]; - Some(constant.truthiness()) - } - Instruction::LoadSmallInt { i } => Some(i.get(arg) != 0), - _ => None, +fn instruction_sequence_label_map_ensure_label_for_block( + map: &mut InstructionSequenceLabelMap, + seq: &mut InstructionSequence, + block: BlockIdx, +) -> crate::InternalResult { + debug_assert_ne!(block, BlockIdx::NULL); + let block_label = map.block_labels[block.idx()]; + if is_label(block_label) { + return Ok(block_label); } + let label = instruction_sequence_new_label(seq); + debug_assert_eq!(label.0, seq.next_free_label); + instruction_sequence_label_map_register_label(map, label)?; + map.cpython_block_by_label[label.idx()] = block; + map.block_labels[block.idx()] = label; + Ok(label) } -/// flowgraph.c add_const -fn add_const( - metadata: &mut CodeUnitMetadata, - constant: ConstantData, -) -> crate::InternalResult { - Ok(metadata.consts.try_insert_full(constant)?.0) +fn instruction_sequence_label_map_label_for_block( + map: &InstructionSequenceLabelMap, + block: BlockIdx, +) -> InstructionSequenceLabel { + debug_assert_ne!(block, BlockIdx::NULL); + map.block_labels + .get(block.idx()) + .copied() + .unwrap_or(InstructionSequenceLabel::NO_LABEL) } -fn instr_make_load_const( - metadata: &mut CodeUnitMetadata, - instr: &mut InstructionInfo, - constant: ConstantData, -) -> crate::InternalResult<()> { - if maybe_instr_make_load_smallint(instr, &constant) { - return Ok(()); +fn instruction_sequence_label_map_block_for_label( + map: &InstructionSequenceLabelMap, + label: InstructionSequenceLabel, +) -> Option { + if !is_label(label) { + return None; } - - let const_idx = add_const(metadata, constant)?; - instr_set_op1( - instr, - Opcode::LoadConst.into(), - OpArg::new(const_idx as u32), - ); - Ok(()) + map.cpython_block_by_label + .get(label.idx()) + .copied() + .filter(|&block| block != BlockIdx::NULL) } -/// flowgraph.c fold_const_unaryop -fn fold_const_unaryop( - metadata: &mut CodeUnitMetadata, - block: &mut Block, - i: usize, -) -> crate::InternalResult { - let instr = &block.instructions[i]; - let (op, intrinsic) = match instr.instr.real() { - Some(Instruction::UnaryNegative) => (Instruction::UnaryNegative, None), - Some(Instruction::UnaryInvert) => (Instruction::UnaryInvert, None), - Some(Instruction::UnaryNot) => (Instruction::UnaryNot, None), - Some(Instruction::CallIntrinsic1 { func }) - if matches!( - func.get(instr.arg), - oparg::IntrinsicFunction1::UnaryPositive - ) => - { - (Opcode::CallIntrinsic1.into(), Some(func.get(instr.arg))) - } - _ => return Ok(false), - }; - let Some(operand_index) = (if let Some(start) = i.checked_sub(1) { - get_const_loading_instrs(block, start, 1)? - } else { - None +fn instruction_sequence_label_map_resolve_label( + map: &InstructionSequenceLabelMap, + block: BlockIdx, +) -> BlockIdx { + if block == BlockIdx::NULL { + return BlockIdx::NULL; + } + let label = instruction_sequence_label_map_label_for_block(map, block); + if !is_label(label) { + return block; + } + instruction_sequence_label_map_block_for_label(map, label).unwrap_or_else(|| { + debug_assert!( + false, + "CPython instruction-sequence label must map to a codegen CFG block" + ); + BlockIdx::NULL }) - .and_then(|indices| indices.into_iter().next()) else { - return Ok(false); - }; - let operand = get_const_value(metadata, &block.instructions[operand_index]); - let Some(operand) = operand else { - return Ok(false); - }; - let Some(folded_const) = eval_const_unaryop(&operand, op, intrinsic) else { - return Ok(false); - }; - nop_out(block, &[operand_index]); - instr_make_load_const(metadata, &mut block.instructions[i], folded_const)?; - Ok(true) } -/// flowgraph.c get_const_loading_instrs -fn get_const_loading_instrs( - block: &Block, - mut start: usize, - size: usize, -) -> crate::InternalResult>> { - let mut indices = Vec::new(); - indices - .try_reserve_exact(size) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - loop { - if start >= block.instruction_used { - return Ok(None); - } - let instr = &block.instructions[start]; - if !matches!(instr.instr.real(), Some(Instruction::Nop)) { - if !loads_const(instr) { - return Ok(None); - } - indices.push(start); - if indices.len() == size { - break; - } - } - let Some(prev) = start.checked_sub(1) else { - return Ok(None); - }; - start = prev; +fn instruction_sequence_label_map_resolve_label_to_block( + map: &InstructionSequenceLabelMap, + label: InstructionSequenceLabel, +) -> BlockIdx { + if !is_label(label) { + return BlockIdx::NULL; } - indices.reverse(); - Ok(Some(indices)) + instruction_sequence_label_map_block_for_label(map, label).unwrap_or_else(|| { + debug_assert!( + false, + "CPython instruction-sequence label must map to a codegen CFG block" + ); + BlockIdx::NULL + }) } -/// flowgraph.c nop_out -fn nop_out(block: &mut Block, instrs: &[usize]) { - for &i in instrs { - nop_out_no_location(&mut block.instructions[i]); - } +fn instruction_sequence_label_oparg(label: InstructionSequenceLabel) -> OpArg { + debug_assert!(is_label(label)); + OpArg::new(label.idx() as u32) } -/// flowgraph.c fold_const_binop -fn fold_const_binop( - metadata: &mut CodeUnitMetadata, - block: &mut Block, - i: usize, -) -> crate::InternalResult { - use oparg::BinaryOperator as BinOp; - - let Some(Opcode::BinaryOp) = block.instructions[i].instr.real_opcode() else { - return Ok(false); - }; - - let Some(operand_indices) = (if let Some(start) = i.checked_sub(1) { - get_const_loading_instrs(block, start, 2)? - } else { - None - }) else { - return Ok(false); - }; - - let op_raw = u32::from(block.instructions[i].arg); - let Ok(op) = BinOp::try_from(op_raw) else { - return Ok(false); - }; - - let left = get_const_value(metadata, &block.instructions[operand_indices[0]]); - let right = get_const_value(metadata, &block.instructions[operand_indices[1]]); - let (Some(left_val), Some(right_val)) = (left, right) else { - return Ok(false); - }; - - let Some(result_const) = eval_const_binop(&left_val, &right_val, op) else { - return Ok(false); - }; +fn instruction_sequence_label_map_use_label_at_block( + map: &mut InstructionSequenceLabelMap, + seq: &mut InstructionSequence, + from: BlockIdx, + to: BlockIdx, +) -> crate::InternalResult<()> { + if from == BlockIdx::NULL || from == to { + return Ok(()); + } + let from_label = instruction_sequence_label_map_ensure_label_for_block(map, seq, from)?; + debug_assert!(map.cpython_block_by_label.len() > from_label.idx()); + let to_block = instruction_sequence_label_map_resolve_label(map, to); + if to_block == BlockIdx::NULL { + debug_assert!( + false, + "CPython label target must map to a codegen CFG block" + ); + return Ok(()); + } + map.cpython_block_by_label[from_label.idx()] = to_block; + Ok(()) +} - nop_out(block, &operand_indices); - instr_make_load_const(metadata, &mut block.instructions[i], result_const)?; - Ok(true) +fn instruction_sequence_label_map_push_unlabeled_block( + map: &mut InstructionSequenceLabelMap, +) -> crate::InternalResult<()> { + map.block_labels + .try_reserve(1) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + map.block_labels.push(InstructionSequenceLabel::NO_LABEL); + Ok(()) } -/// flowgraph.c loads_const -fn loads_const(info: &InstructionInfo) -> bool { - info.instr.has_const() || matches!(info.instr.real_opcode(), Some(Opcode::LoadSmallInt)) +fn instruction_sequence_label_map_push_unmapped_label( + map: &mut InstructionSequenceLabelMap, + seq: &mut InstructionSequence, +) -> crate::InternalResult<()> { + let label = instruction_sequence_new_label(seq); + debug_assert_eq!(label.0, seq.next_free_label); + instruction_sequence_label_map_register_label(map, label)?; + let block = BlockIdx( + map.block_labels + .len() + .to_u32() + .ok_or(InternalError::MalformedControlFlowGraph)?, + ); + map.cpython_block_by_label[label.idx()] = block; + map.block_labels + .try_reserve(1) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + map.block_labels.push(label); + Ok(()) } -/// flowgraph.c get_const_value -fn get_const_value(metadata: &CodeUnitMetadata, info: &InstructionInfo) -> Option { - match info.instr.real_opcode() { - Some(Opcode::LoadSmallInt) => { - let v = u32::from(info.arg) as i32; - Some(ConstantData::Integer { - value: BigInt::from(v), - }) - } - _ if info.instr.has_const() => { - let idx = u32::from(info.arg) as usize; - metadata.consts.get_index(idx).cloned() +impl InstructionSequenceLabelMap { + pub(crate) fn new() -> Self { + Self { + block_labels: vec![InstructionSequenceLabel::NO_LABEL], + cpython_block_by_label: Vec::new(), } - _ => None, } } -/// flowgraph.c const_folding_check_complexity -fn const_folding_check_complexity(obj: &ConstantData, mut limit: isize) -> Option { - if let ConstantData::Tuple { elements } = obj { - limit -= isize::try_from(elements.len()).ok()?; - if limit < 0 { - return None; +pub struct CodeInfo { + pub flags: CodeFlags, + pub source_path: String, + pub private: Option, // For private name mangling, mostly for class + + pub blocks: Blocks, + pub current_block: BlockIdx, + pub(crate) instr_sequence: InstructionSequence, + pub(crate) instr_sequence_label_map: InstructionSequenceLabelMap, + pub(crate) annotations_instr_sequence: Option, + + pub metadata: CodeUnitMetadata, + + // For class scopes: attributes accessed via self.X + pub static_attributes: Option>, + + // True if compiling an inlined comprehension + pub in_inlined_comp: bool, + + // Block stack for tracking nested control structures + pub fblock: Vec, + + // Reference to the symbol table for this scope + pub symbol_table_index: usize, + // CPython compile.c uses PyList_GET_SIZE(u->u_ste->ste_varnames) + // when calling flowgraph.c _PyCfg_OptimizeCodeUnit(). + pub nparams: usize, + + // PEP 649: Track nesting depth inside conditional blocks (if/for/while/etc.) + // u_in_conditional_block + pub in_conditional_block: u32, + + // PEP 649: Next index for conditional annotation tracking + // u_next_conditional_annotation_index + pub next_conditional_annotation_index: u32, +} + +impl CodeInfo { + pub(crate) fn addop_to_instr_sequence( + &mut self, + mut info: InstructionInfo, + ) -> crate::InternalResult<()> { + if info.instr.has_target() && info.target != BlockIdx::NULL { + let label = instruction_sequence_label_map_ensure_label_for_block( + &mut self.instr_sequence_label_map, + &mut self.instr_sequence, + info.target, + )?; + info.arg = instruction_sequence_label_oparg(label); + info.target = BlockIdx::NULL; } - for element in elements { - limit = const_folding_check_complexity(element, limit)?; + instruction_sequence_addop(&mut self.instr_sequence, info)?; + Ok(()) + } + + pub(crate) fn addop_to_instr_sequence_with_target_label( + &mut self, + mut info: InstructionInfo, + target_label: InstructionSequenceLabel, + ) -> crate::InternalResult<()> { + if !info.instr.has_target() { + return Err(InternalError::MalformedControlFlowGraph); } + info.arg = instruction_sequence_label_oparg(target_label); + info.target = BlockIdx::NULL; + instruction_sequence_addop(&mut self.instr_sequence, info)?; + Ok(()) } - Some(limit) -} -fn repeat_wtf8(value: &Wtf8Buf, n: usize) -> Option { - let mut result = Wtf8Buf::new(); - result.try_reserve_exact(value.len().checked_mul(n)?).ok()?; - for _ in 0..n { - result.push_wtf8(value); + pub(crate) fn addop_to_current_block( + &mut self, + info: InstructionInfo, + ) -> crate::InternalResult<()> { + basicblock_addop(&mut self.blocks[self.current_block.idx()], info) } - Some(result) -} -fn checked_repeat_count(n: &BigInt, item_size: usize) -> Option { - let n = n.to_isize()?; - if item_size != 0 && (n < 0 || n as usize > MAX_STR_SIZE / item_size) { - return None; + pub(crate) fn last_current_block_instr_mut(&mut self) -> Option<&mut InstructionInfo> { + basicblock_last_instr_mut(&mut self.blocks[self.current_block.idx()]) } - Some(n.max(0) as usize) -} -/// flowgraph.c const_folding_safe_multiply -fn const_folding_safe_multiply(left: &ConstantData, right: &ConstantData) -> Option { - match (left, right) { - (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) => { - if !l.is_zero() && !r.is_zero() && l.bits() + r.bits() > MAX_INT_SIZE { - return None; - } - Some(ConstantData::Integer { value: l * r }) - } - (ConstantData::Float { value: l }, ConstantData::Float { value: r }) => { - Some(ConstantData::Float { value: l * r }) - } - (ConstantData::Str { value: s }, ConstantData::Integer { value: n }) => { - let n = checked_repeat_count(n, s.code_points().count())?; - Some(ConstantData::Str { - value: repeat_wtf8(s, n)?, - }) - } - (ConstantData::Integer { .. }, ConstantData::Str { .. }) => { - const_folding_safe_multiply(right, left) - } - (ConstantData::Bytes { value: b }, ConstantData::Integer { value: n }) => { - let n = checked_repeat_count(n, b.len())?; - let mut value = Vec::new(); - value.try_reserve_exact(b.len().checked_mul(n)?).ok()?; - for _ in 0..n { - value.extend_from_slice(b); - } - Some(ConstantData::Bytes { value }) - } - (ConstantData::Integer { .. }, ConstantData::Bytes { .. }) => { - const_folding_safe_multiply(right, left) - } - (ConstantData::Tuple { elements }, ConstantData::Integer { value: n }) => { - let n = n.to_usize()?; - if n != 0 && !elements.is_empty() { - if n > MAX_COLLECTION_SIZE / elements.len() { - return None; - } - const_folding_check_complexity( - &ConstantData::Tuple { - elements: elements.clone(), - }, - MAX_TOTAL_ITEMS / isize::try_from(n).ok()?, - )?; - } - let mut result = Vec::new(); - result - .try_reserve_exact(elements.len().checked_mul(n)?) - .ok()?; - for _ in 0..n { - result.extend(elements.iter().cloned()); - } - Some(ConstantData::Tuple { elements: result }) - } - (ConstantData::Integer { .. }, ConstantData::Tuple { .. }) => { - const_folding_safe_multiply(right, left) + pub(crate) fn set_last_instr_sequence_lineno_override(&mut self, lineno_override: i32) { + if let Some(last) = instruction_sequence_last_info_mut(&mut self.instr_sequence) { + last.lineno_override = Some(lineno_override); } - _ => None, } -} -/// flowgraph.c const_folding_safe_power -fn const_folding_safe_power(left: &ConstantData, right: &ConstantData) -> Option { - match (left, right) { - (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) => { - if r < &BigInt::from(0) { - if l.is_zero() { - return None; - } - let base = l.to_f64()?; - if !base.is_finite() { - return None; - } - let result = if let Some(exp) = r.to_i32() { - base.powi(exp) - } else { - base.powf(r.to_f64()?) - }; - if !result.is_finite() { - return None; - } - return Some(ConstantData::Float { value: result }); - } - let exp: u64 = r.try_into().ok()?; - let exp_usize = usize::try_from(exp).ok()?; - if !l.is_zero() && exp > 0 && l.bits() > MAX_INT_SIZE / exp { - return None; - } - Some(ConstantData::Integer { - value: num_traits::pow::pow(l.clone(), exp_usize), - }) - } - (ConstantData::Float { value: l }, ConstantData::Float { value: r }) => { - let result = l.powf(*r); - result - .is_finite() - .then_some(ConstantData::Float { value: result }) - } - _ => None, + pub(crate) fn use_instr_sequence_label( + &mut self, + block: BlockIdx, + ) -> crate::InternalResult<()> { + let label = instruction_sequence_label_map_ensure_label_for_block( + &mut self.instr_sequence_label_map, + &mut self.instr_sequence, + block, + )?; + instruction_sequence_use_label(&mut self.instr_sequence, label) } -} -/// flowgraph.c const_folding_safe_lshift -fn const_folding_safe_lshift(left: &ConstantData, right: &ConstantData) -> Option { - let (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) = (left, right) - else { - return None; - }; - let shift: u64 = r.try_into().ok()?; - let shift_usize = usize::try_from(shift).ok()?; - if shift > MAX_INT_SIZE || (!l.is_zero() && l.bits() > MAX_INT_SIZE - shift) { - return None; + pub(crate) fn new_instr_sequence_label(&mut self) -> InstructionSequenceLabel { + instruction_sequence_new_label(&mut self.instr_sequence) } - Some(ConstantData::Integer { - value: l << shift_usize, - }) -} -/// flowgraph.c const_folding_safe_mod -fn const_folding_safe_mod(left: &ConstantData, right: &ConstantData) -> Option { - if matches!(left, ConstantData::Str { .. } | ConstantData::Bytes { .. }) { - return None; + pub(crate) fn use_raw_instr_sequence_label( + &mut self, + label: InstructionSequenceLabel, + ) -> crate::InternalResult<()> { + instruction_sequence_use_label(&mut self.instr_sequence, label) } - match (left, right) { - (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) => { - if r.is_zero() { - return None; - } - let rem = l.clone() % r.clone(); - let value = if !rem.is_zero() && (rem < BigInt::from(0)) != (*r < BigInt::from(0)) { - rem + r - } else { - rem - }; - Some(ConstantData::Integer { value }) - } - (ConstantData::Float { value: l }, ConstantData::Float { value: r }) => { - let (_, modulo) = float_div_mod(*l, *r)?; - Some(ConstantData::Float { value: modulo }) - } - _ => None, + pub(crate) fn mark_cpython_cfg_label(&mut self, block: BlockIdx) -> crate::InternalResult<()> { + let label = instruction_sequence_label_map_ensure_label_for_block( + &mut self.instr_sequence_label_map, + &mut self.instr_sequence, + block, + )?; + self.blocks[block.idx()].cpython_label = label; + Ok(()) } -} -fn float_div_mod(left: f64, right: f64) -> Option<(f64, f64)> { - if right == 0.0 { - return None; + pub(crate) fn resolve_instr_sequence_label(&self, block: BlockIdx) -> BlockIdx { + instruction_sequence_label_map_resolve_label(&self.instr_sequence_label_map, block) } - let mut modulo = left % right; - let div = (left - modulo) / right; - let floordiv = if modulo != 0.0 { - let div = if (right < 0.0) != (modulo < 0.0) { - modulo += right; - div - 1.0 + pub(crate) fn block_for_instr_sequence_label( + &self, + label: InstructionSequenceLabel, + ) -> BlockIdx { + instruction_sequence_label_map_resolve_label_to_block(&self.instr_sequence_label_map, label) + } + + pub(crate) fn use_instr_sequence_label_at_block( + &mut self, + from: BlockIdx, + to: BlockIdx, + ) -> crate::InternalResult<()> { + instruction_sequence_label_map_use_label_at_block( + &mut self.instr_sequence_label_map, + &mut self.instr_sequence, + from, + to, + ) + } + + pub(crate) fn instr_sequence_label_for_block( + &mut self, + block: BlockIdx, + ) -> crate::InternalResult { + if block == BlockIdx::NULL { + Ok(InstructionSequenceLabel::NO_LABEL) } else { - div - }; - let mut floordiv = div.floor(); - if div - floordiv > 0.5 { - floordiv += 1.0; + instruction_sequence_label_map_ensure_label_for_block( + &mut self.instr_sequence_label_map, + &mut self.instr_sequence, + block, + ) } - floordiv - } else { - modulo = 0.0f64.copysign(right); - 0.0f64.copysign(left / right) - }; + } - Some((floordiv, modulo)) -} + pub(crate) fn insert_start_setup_cleanup( + &mut self, + handler_block: BlockIdx, + ) -> crate::InternalResult<()> { + let handler_label = instruction_sequence_label_map_ensure_label_for_block( + &mut self.instr_sequence_label_map, + &mut self.instr_sequence, + handler_block, + )?; + instruction_sequence_insert_instruction( + &mut self.instr_sequence, + 0, + InstructionInfo { + instr: PseudoOpcode::SetupCleanup.into(), + arg: instruction_sequence_label_oparg(handler_label), + target: BlockIdx::NULL, + location: SourceLocation::default(), + end_location: SourceLocation::default(), + except_handler: None, + lineno_override: Some(NO_LOCATION_OVERRIDE), + }, + ) + } -/// flowgraph.c eval_const_binop complex result construction -fn eval_const_complex_const(value: Complex) -> Option { - (value.re.is_finite() && value.im.is_finite()).then_some(ConstantData::Complex { value }) -} + pub(crate) fn push_unmapped_instr_sequence_label(&mut self) -> crate::InternalResult<()> { + instruction_sequence_label_map_push_unmapped_label( + &mut self.instr_sequence_label_map, + &mut self.instr_sequence, + ) + } -/// flowgraph.c eval_const_binop complex operations -fn eval_const_complex_binop( - left: Complex, - right: Complex, - op: oparg::BinaryOperator, -) -> Option { - use oparg::BinaryOperator as BinOp; + pub(crate) fn push_unlabeled_instr_sequence_block(&mut self) -> crate::InternalResult<()> { + instruction_sequence_label_map_push_unlabeled_block(&mut self.instr_sequence_label_map) + } - let value = match op { - BinOp::Add => left + right, - BinOp::Subtract => { - let re = left.re - right.re; - // Preserve CPython's signed-zero behavior for real-zero - // minus zero-complex expressions such as `0 - 0j`. - let im = if left.re == 0.0 - && left.im == 0.0 - && right.re == 0.0 - && right.im == 0.0 - && !right.im.is_sign_negative() - { - -0.0 - } else { - left.im - right.im - }; - Complex::new(re, im) - } - BinOp::Multiply => left * right, - BinOp::TrueDivide => { - if right == Complex::new(0.0, 0.0) { - return None; - } - left / right + fn take_recorded_instr_sequence(&mut self) -> crate::InternalResult { + let mut instr_sequence = + core::mem::replace(&mut self.instr_sequence, instruction_sequence_new()); + if let Some(mut annotations_instr_sequence) = self.annotations_instr_sequence.take() { + instruction_sequence_apply_label_map(&mut annotations_instr_sequence)?; + instruction_sequence_set_annotations_code( + &mut instr_sequence, + Some(Box::new(annotations_instr_sequence)), + ); } - BinOp::Power => { - if left == Complex::new(0.0, 0.0) { - if right.im != 0.0 || right.re < 0.0 { - return None; - } + Ok(instr_sequence) + } - return eval_const_complex_const(if right.re == 0.0 { - Complex::new(1.0, 0.0) - } else { - Complex::new(0.0, 0.0) - }); - } + fn prepare_cfg_from_codegen(&mut self) -> crate::InternalResult { + // CPython compile.c optimize_and_assemble_code_unit passes + // u_instr_sequence directly into flowgraph.c _PyCfg_FromInstructionSequence(). + self.take_recorded_instr_sequence() + } +} - if right.im == 0.0 - && right.re.fract() == 0.0 - && right.re >= f64::from(i32::MIN) - && right.re <= f64::from(i32::MAX) - { - left.powi(right.re as i32) - } else { - left.powc(right) - } - } - _ => return None, - }; - eval_const_complex_const(value) +fn optimize_code_unit( + metadata: &mut CodeUnitMetadata, + blocks: &mut Blocks, + instr_sequence: InstructionSequence, + nlocals: usize, + nparams: usize, +) -> crate::InternalResult<()> { + // Phase 1: _PyCfg_OptimizeCodeUnit (flowgraph.c) + *blocks = cfg_from_instruction_sequence(instr_sequence)?; + translate_jump_labels_to_targets(blocks)?; + blocks.mark_except_handlers()?; + label_exception_targets(blocks)?; + optimize_cfg(metadata, blocks, metadata.firstlineno)?; + blocks.remove_unused_consts(&mut metadata.consts)?; + add_checks_for_loads_of_uninitialized_variables(blocks, nlocals, nparams)?; + // CPython inserts superinstructions in _PyCfg_OptimizeCodeUnit, before + // later jump normalization / block reordering can create adjacencies + // that never exist at this stage in flowgraph.c. + blocks.insert_superinstructions()?; + blocks.push_cold_blocks_to_end()?; + // CPython resolves line numbers again after cold-block extraction. + blocks.resolve_line_numbers(metadata.firstlineno)?; + Ok(()) } -/// flowgraph.c eval_const_binop subscript index conversion -fn constant_as_index(value: &ConstantData) -> Option { - match value { - ConstantData::Integer { value } => value.to_i64().or_else(|| { - if value < &BigInt::from(0) { - Some(i64::MIN) - } else { - Some(i64::MAX) - } - }), - ConstantData::Boolean { value } => Some(i64::from(*value)), - _ => None, +fn optimize_cfg( + metadata: &mut CodeUnitMetadata, + blocks: &mut Blocks, + firstlineno: OneIndexed, +) -> crate::InternalResult<()> { + // flowgraph.c optimize_cfg + // CPython optimize_cfg() starts with check_cfg() and raises + // SystemError if a jump or scope exit is not the last instruction in + // its block. + blocks.check_cfg()?; + blocks.inline_small_or_no_lineno_blocks()?; + // CPython does not re-run instruction-sequence label-map/CFG conversion + // after this point. Unreferenced label blocks left by jump inlining + // remain block boundaries and can preserve line-marker NOPs. + blocks.remove_unreachable()?; + // CPython optimize_cfg resolves line numbers before local checks and + // superinstruction insertion, so fusion decisions see propagated + // source locations. + blocks.resolve_line_numbers(firstlineno)?; + // CPython optimize_cfg() runs optimize_load_const() and then + // optimize_basic_block() after line numbers are resolved. + optimize_load_const(metadata, blocks)?; + let mut block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + let next_block = blocks[block_idx].next; + blocks.optimize_basic_block(metadata, block_idx)?; + block_idx = next_block; } + blocks.remove_redundant_nops_and_pairs()?; + // CPython optimize_cfg() removes newly-unreachable blocks and + // redundant NOP/jump chains before _PyCfg_OptimizeCodeUnit() prunes + // unused constants. + blocks.remove_unreachable()?; + blocks.remove_redundant_nops_and_jumps()?; + #[cfg(debug_assertions)] + assert!(blocks.no_redundant_jumps()); + Ok(()) } -/// flowgraph.c eval_const_binop subscript slice bound conversion -fn slice_bound(value: &ConstantData) -> Option> { - match value { - ConstantData::None => Some(None), - _ => constant_as_index(value).map(Some), - } +fn optimized_cfg_to_instruction_sequence( + metadata: &CodeUnitMetadata, + flags: CodeFlags, + blocks: &mut Blocks, +) -> crate::InternalResult<(u32, usize, InstructionSequence)> { + // Phase 2: _PyCfg_OptimizedCfgToInstructionSequence (flowgraph.c) + blocks.convert_pseudo_conditional_jumps()?; + let max_stackdepth = blocks.calculate_stackdepth()?; + debug_assert!(!is_generator(flags) || max_stackdepth != 0); + let nlocalsplus = prepare_localsplus(metadata, blocks, flags)?; + // Match CPython order: pseudo ops are lowered after stackdepth and + // localsplus preparation, before normalize_jumps. + convert_pseudo_ops(blocks)?; + blocks.normalize_jumps()?; + #[cfg(debug_assertions)] + assert!(blocks.no_redundant_jumps()); + // optimize_load_fast: after normalize_jumps + blocks.optimize_load_fast()?; + + let mut instr_sequence = instruction_sequence_new(); + blocks.cfg_to_instruction_sequence(&mut instr_sequence)?; + Ok((max_stackdepth, nlocalsplus, instr_sequence)) } -/// flowgraph.c eval_const_binop subscript slice index adjustment -fn adjusted_slice_indices(len: usize, slice: &[ConstantData; 3]) -> Option> { - let len = i64::try_from(len).ok()?; - let start = slice_bound(&slice[0])?; - let stop = slice_bound(&slice[1])?; - let step = slice_bound(&slice[2])?.unwrap_or(1); - if step == 0 || step == i64::MIN { - return None; - } +impl CodeInfo { + pub fn finalize_code( + mut self, + opts: &crate::compile::CompileOpts, + ) -> crate::InternalResult { + let instr_sequence = self.prepare_cfg_from_codegen()?; + let nlocals = self.metadata.varnames.len(); + let nparams = self.nparams; + optimize_code_unit( + &mut self.metadata, + &mut self.blocks, + instr_sequence, + nlocals, + nparams, + )?; + let (max_stackdepth, nlocalsplus, mut instr_sequence) = + optimized_cfg_to_instruction_sequence(&self.metadata, self.flags, &mut self.blocks)?; + let localsplusinfo = compute_localsplus_info(&self.metadata, nlocalsplus, self.flags)?; - let step_is_negative = step < 0; - let lower = if step_is_negative { -1 } else { 0 }; - let upper = if step_is_negative { len - 1 } else { len }; - let adjust = |value: Option, default: i64| { - let mut value = value.unwrap_or(default); - if value < 0 { - value = value.saturating_add(len); - if value < 0 { - value = lower; - } - } else if value >= len { - value = upper; - } - value - }; - let start = adjust(start, if step_is_negative { upper } else { lower }); - let stop = adjust(stop, if step_is_negative { lower } else { upper }); + let Self { + flags, + source_path, + private: _, // private is only used during compilation - let mut index = i128::from(start); - let stop = i128::from(stop); - let step = i128::from(step); - let slice_len = if step > 0 { - if index < stop { - usize::try_from((stop - index - 1) / step + 1).ok()? - } else { - 0 - } - } else if index > stop { - usize::try_from((index - stop - 1) / -step + 1).ok()? - } else { - 0 - }; - let mut indices = Vec::new(); - indices.try_reserve_exact(slice_len).ok()?; - if step > 0 { - while index < stop { - indices.push(usize::try_from(index).ok()?); - index += step; - } - } else { - while index > stop { - indices.push(usize::try_from(index).ok()?); - index += step; - } + blocks: _, + current_block: _, + instr_sequence: _, + instr_sequence_label_map: _, + annotations_instr_sequence: _, + metadata, + static_attributes: _, + in_inlined_comp: _, + fblock: _, + symbol_table_index: _, + nparams: _, + in_conditional_block: _, + next_conditional_annotation_index: _, + } = self; + + let CodeUnitMetadata { + name: obj_name, + qualname, + consts: constants, + names: name_cache, + varnames: varname_cache, + cellvars: _, + freevars: freevar_cache, + fast_hidden: _, + fast_hidden_final: _, + argcount: arg_count, + posonlyargcount: posonlyarg_count, + kwonlyargcount: kwonlyarg_count, + firstlineno: first_line_number, + } = metadata; + + resolve_unconditional_jumps(&mut instr_sequence)?; + resolve_jump_offsets(&mut instr_sequence)?; + let assembled = assemble_emit( + &mut instr_sequence, + first_line_number.get() as i32, + opts.debug_ranges, + )?; + let locations = rustpython_compiler_core::marshal::linetable_to_locations( + &assembled.linetable, + first_line_number.get() as i32, + assembled.instructions.len(), + ); + + Ok(CodeObject { + flags, + posonlyarg_count, + arg_count, + kwonlyarg_count, + source_path, + first_line_number: Some(first_line_number), + obj_name: obj_name.clone(), + qualname: qualname.unwrap_or(obj_name), + + max_stackdepth, + instructions: CodeUnits::from(assembled.instructions), + locations, + constants: constants.into_iter().collect(), + names: name_cache.into_iter().collect(), + varnames: varname_cache.into_iter().collect(), + cellvars: localsplusinfo.cellvars, + freevars: freevar_cache.into_iter().collect(), + localspluskinds: localsplusinfo.kinds, + linetable: assembled.linetable, + exceptiontable: assembled.exceptiontable, + }) } - Some(indices) } -/// flowgraph.c eval_const_binop subscript index adjustment -fn adjusted_const_index(len: usize, index: &ConstantData) -> Option { - let len = i64::try_from(len).ok()?; - let index = constant_as_index(index)?; - let index = if index < 0 { - index.saturating_add(len) - } else { - index - }; - if index < 0 || index >= len { - return None; - } - usize::try_from(index).ok() +/// flowgraph.c IS_GENERATOR +fn is_generator(flags: CodeFlags) -> bool { + flags.intersects(CodeFlags::GENERATOR | CodeFlags::COROUTINE | CodeFlags::ASYNC_GENERATOR) } -/// flowgraph.c eval_const_binop NB_SUBSCR -fn eval_const_subscript(container: &ConstantData, index: &ConstantData) -> Option { - match (container, index) { - ( - ConstantData::Str { value }, - ConstantData::Integer { .. } | ConstantData::Boolean { .. }, - ) => { - let string = value.to_string(); - if string.contains(char::REPLACEMENT_CHARACTER) { - return None; - } - let mut chars = Vec::new(); - chars.try_reserve_exact(string.chars().count()).ok()?; - chars.extend(string.chars()); - let index = adjusted_const_index(chars.len(), index)?; - Some(ConstantData::Str { - value: chars[index].to_string().into(), - }) +/// flowgraph.c insert_prefix_instructions +fn insert_prefix_instructions( + metadata: &CodeUnitMetadata, + blocks: &mut Blocks, + cellfixedoffsets: &[i32], + nfreevars: usize, + flags: CodeFlags, +) -> crate::InternalResult<()> { + debug_assert!(!blocks.is_empty()); + let entry = &mut blocks[0]; + let ncellvars = metadata.cellvars.len(); + let firstlineno = metadata.firstlineno; + debug_assert!(firstlineno.get() > 0); + + if is_generator(flags) { + let location = SourceLocation { + line: firstlineno, + character_offset: OneIndexed::MIN, + }; + basicblock_insert_instruction( + entry, + 0, + InstructionInfo { + instr: Instruction::ReturnGenerator.into(), + arg: OpArg::new(0), + target: BlockIdx::NULL, + location, + end_location: location, + except_handler: None, + lineno_override: Some(LINE_ONLY_LOCATION_OVERRIDE), + }, + )?; + basicblock_insert_instruction( + entry, + 1, + InstructionInfo { + instr: Instruction::PopTop.into(), + arg: OpArg::new(0), + target: BlockIdx::NULL, + location, + end_location: location, + except_handler: None, + lineno_override: Some(LINE_ONLY_LOCATION_OVERRIDE), + }, + )?; + } + + if ncellvars > 0 { + let nvars = metadata.varnames.len() + ncellvars; + let mut sorted = Vec::new(); + vec_try_reserve_exact(&mut sorted, nvars)?; + sorted.resize(nvars, 0i32); + for i in 0..ncellvars { + sorted[cellfixedoffsets[i] as usize] = i as i32 + 1; } - (ConstantData::Str { value }, ConstantData::Slice { elements }) => { - let string = value.to_string(); - if string.contains(char::REPLACEMENT_CHARACTER) { - return None; - } - let mut chars = Vec::new(); - chars.try_reserve_exact(string.chars().count()).ok()?; - chars.extend(string.chars()); - let indices = adjusted_slice_indices(chars.len(), elements)?; - let capacity = indices.iter().try_fold(0usize, |capacity, &index| { - capacity.checked_add(chars[index].len_utf8()) - })?; - let mut result = String::new(); - result.try_reserve_exact(capacity).ok()?; - for index in indices { - result.push(chars[index]); + let mut ncellsused = 0; + let mut i = 0; + while ncellsused < ncellvars { + let oldindex = sorted[i] - 1; + i += 1; + if oldindex == -1 { + continue; } - Some(ConstantData::Str { - value: result.into(), - }) + basicblock_insert_instruction( + entry, + ncellsused, + InstructionInfo { + instr: Opcode::MakeCell.into(), + arg: OpArg::new(oldindex as u32), + target: BlockIdx::NULL, + location: SourceLocation::default(), + end_location: SourceLocation::default(), + except_handler: None, + lineno_override: Some(NO_LOCATION_OVERRIDE), + }, + )?; + ncellsused += 1; } - ( - ConstantData::Bytes { value }, - ConstantData::Integer { .. } | ConstantData::Boolean { .. }, - ) => { - let index = adjusted_const_index(value.len(), index)?; + } + + if nfreevars > 0 { + basicblock_insert_instruction( + entry, + 0, + InstructionInfo { + instr: Opcode::CopyFreeVars.into(), + arg: OpArg::new(nfreevars as u32), + target: BlockIdx::NULL, + location: SourceLocation::default(), + end_location: SourceLocation::default(), + except_handler: None, + lineno_override: Some(NO_LOCATION_OVERRIDE), + }, + )?; + } + Ok(()) +} + +/// flowgraph.c prepare_localsplus +fn prepare_localsplus( + metadata: &CodeUnitMetadata, + blocks: &mut Blocks, + flags: CodeFlags, +) -> crate::InternalResult { + let nlocals = metadata.varnames.len(); + let ncellvars = metadata.cellvars.len(); + let nfreevars = metadata.freevars.len(); + let int_max = i32::MAX as usize; + debug_assert!(nlocals < int_max); + debug_assert!(ncellvars < int_max); + debug_assert!(nfreevars < int_max); + debug_assert!(int_max - nlocals - ncellvars > 0); + debug_assert!(int_max - nlocals - ncellvars - nfreevars > 0); + let mut nlocalsplus = nlocals + ncellvars + nfreevars; + let mut cellfixedoffsets = build_cellfixedoffsets(metadata)?; + + // This must be called before fix_cell_offsets(). + insert_prefix_instructions(metadata, blocks, &cellfixedoffsets, nfreevars, flags)?; + + let numdropped = fix_cell_offsets(metadata, blocks, &mut cellfixedoffsets); + nlocalsplus -= numdropped; + Ok(nlocalsplus) +} + +/// flowgraph.c eval_const_unaryop +fn eval_const_unaryop( + operand: &ConstantData, + op: Instruction, + intrinsic: Option, +) -> Option { + match (operand, op, intrinsic) { + (ConstantData::Integer { value }, Instruction::UnaryNegative, None) => { + Some(ConstantData::Integer { value: -value }) + } + (ConstantData::Float { value }, Instruction::UnaryNegative, None) => { + Some(ConstantData::Float { value: -value }) + } + (ConstantData::Complex { value }, Instruction::UnaryNegative, None) => { + Some(ConstantData::Complex { value: -value }) + } + (ConstantData::Boolean { value }, Instruction::UnaryNegative, None) => { Some(ConstantData::Integer { - value: BigInt::from(value[index]), + value: BigInt::from(-i32::from(*value)), }) } - (ConstantData::Bytes { value }, ConstantData::Slice { elements }) => { - let indices = adjusted_slice_indices(value.len(), elements)?; - let mut result = Vec::new(); - result.try_reserve_exact(indices.len()).ok()?; - for index in indices { - result.push(value[index]); - } - Some(ConstantData::Bytes { value: result }) + (ConstantData::Integer { value }, Instruction::UnaryInvert, None) => { + Some(ConstantData::Integer { value: !value }) } + (ConstantData::Boolean { .. }, Instruction::UnaryInvert, None) => None, + (_, Instruction::UnaryNot, None) => Some(ConstantData::Boolean { + value: !operand.truthiness(), + }), ( - ConstantData::Tuple { elements }, - ConstantData::Integer { .. } | ConstantData::Boolean { .. }, - ) => { - let index = adjusted_const_index(elements.len(), index)?; - Some(elements[index].clone()) - } - (ConstantData::Tuple { elements }, ConstantData::Slice { elements: slice }) => { - let indices = adjusted_slice_indices(elements.len(), slice)?; - let mut result = Vec::new(); - result.try_reserve_exact(indices.len()).ok()?; - for index in indices { - result.push(elements[index].clone()); - } - Some(ConstantData::Tuple { elements: result }) - } + ConstantData::Integer { value }, + Instruction::CallIntrinsic1 { .. }, + Some(oparg::IntrinsicFunction1::UnaryPositive), + ) => Some(ConstantData::Integer { + value: value.clone(), + }), + ( + ConstantData::Float { value }, + Instruction::CallIntrinsic1 { .. }, + Some(oparg::IntrinsicFunction1::UnaryPositive), + ) => Some(ConstantData::Float { value: *value }), + ( + ConstantData::Boolean { value }, + Instruction::CallIntrinsic1 { .. }, + Some(oparg::IntrinsicFunction1::UnaryPositive), + ) => Some(ConstantData::Integer { + value: BigInt::from(i32::from(*value)), + }), + ( + ConstantData::Complex { value }, + Instruction::CallIntrinsic1 { .. }, + Some(oparg::IntrinsicFunction1::UnaryPositive), + ) => Some(ConstantData::Complex { value: *value }), _ => None, } } -/// flowgraph.c eval_const_binop bool/int coercion -fn constant_as_int(value: &ConstantData) -> Option<(BigInt, bool)> { - match value { - ConstantData::Boolean { value } => Some((BigInt::from(u8::from(*value)), true)), - ConstantData::Integer { value } => Some((value.clone(), false)), +fn load_const_truthiness( + instr: Instruction, + arg: OpArg, + metadata: &CodeUnitMetadata, +) -> Option { + match instr { + Instruction::LoadConst { consti } => { + let constant = &metadata.consts[consti.get(arg).as_usize()]; + Some(constant.truthiness()) + } + Instruction::LoadSmallInt { i } => Some(i.get(arg) != 0), _ => None, } } -/// flowgraph.c eval_const_binop -fn eval_const_binop( - left: &ConstantData, - right: &ConstantData, - op: oparg::BinaryOperator, -) -> Option { - use oparg::BinaryOperator as BinOp; - - if matches!(op, BinOp::Subscr) { - return eval_const_subscript(left, right); - } - - if let (Some((left_int, left_is_bool)), Some((right_int, right_is_bool))) = - (constant_as_int(left), constant_as_int(right)) - && (left_is_bool || right_is_bool) - { - if left_is_bool && right_is_bool { - match op { - BinOp::And => { - return Some(ConstantData::Boolean { - value: !left_int.is_zero() & !right_int.is_zero(), - }); - } - BinOp::Or => { - return Some(ConstantData::Boolean { - value: !left_int.is_zero() | !right_int.is_zero(), - }); - } - BinOp::Xor => { - return Some(ConstantData::Boolean { - value: !left_int.is_zero() ^ !right_int.is_zero(), - }); - } - _ => {} - } - } +/// flowgraph.c add_const +fn add_const( + metadata: &mut CodeUnitMetadata, + constant: ConstantData, +) -> crate::InternalResult { + Ok(metadata.consts.try_insert_full(constant)?.0) +} - return eval_const_binop( - &ConstantData::Integer { value: left_int }, - &ConstantData::Integer { value: right_int }, - op, - ); +fn instr_make_load_const( + metadata: &mut CodeUnitMetadata, + instr: &mut InstructionInfo, + constant: ConstantData, +) -> crate::InternalResult<()> { + if maybe_instr_make_load_smallint(instr, &constant) { + return Ok(()); } - match (left, right) { - (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) => { - let result = match op { - BinOp::Add => l + r, - BinOp::Subtract => l - r, - BinOp::Multiply => { - return const_folding_safe_multiply(left, right); - } - BinOp::TrueDivide => { - if r.is_zero() { - return None; - } - let l_f = l.to_f64()?; - let r_f = r.to_f64()?; - let result = l_f / r_f; - if !result.is_finite() { - return None; - } - return Some(ConstantData::Float { value: result }); - } - BinOp::FloorDivide => { - if r.is_zero() { - return None; - } - // Python floor division: round towards negative infinity - let (q, rem) = (l.clone() / r.clone(), l.clone() % r.clone()); - if !rem.is_zero() && (rem < BigInt::from(0)) != (*r < BigInt::from(0)) { - q - 1 - } else { - q - } - } - BinOp::Remainder => return const_folding_safe_mod(left, right), - BinOp::Power => return const_folding_safe_power(left, right), - BinOp::Lshift => return const_folding_safe_lshift(left, right), - BinOp::Rshift => { - let shift: u32 = r.try_into().ok()?; - l >> (shift as usize) - } - BinOp::And => l & r, - BinOp::Or => l | r, - BinOp::Xor => l ^ r, - _ => return None, - }; - Some(ConstantData::Integer { value: result }) - } - (ConstantData::Float { value: l }, ConstantData::Float { value: r }) => { - let result = match op { - BinOp::Add => l + r, - BinOp::Subtract => l - r, - BinOp::Multiply => return const_folding_safe_multiply(left, right), - BinOp::TrueDivide => { - if *r == 0.0 { - return None; - } - l / r - } - BinOp::FloorDivide => { - let (floordiv, _) = float_div_mod(*l, *r)?; - floordiv - } - BinOp::Remainder => return const_folding_safe_mod(left, right), - BinOp::Power => return const_folding_safe_power(left, right), - _ => return None, - }; - if matches!(op, BinOp::Power) && !result.is_finite() { - return None; - } - Some(ConstantData::Float { value: result }) - } - // Int op Float or Float op Int → Float - (ConstantData::Integer { value: l }, ConstantData::Float { value: r }) => { - let l_f = l.to_f64()?; - eval_const_binop( - &ConstantData::Float { value: l_f }, - &ConstantData::Float { value: *r }, - op, - ) - } - (ConstantData::Float { value: l }, ConstantData::Integer { value: r }) => { - let r_f = r.to_f64()?; - eval_const_binop( - &ConstantData::Float { value: *l }, - &ConstantData::Float { value: r_f }, - op, - ) - } - (ConstantData::Integer { value: l }, ConstantData::Complex { value: r }) => { - eval_const_complex_binop(Complex::new(l.to_f64()?, 0.0), *r, op) - } - (ConstantData::Complex { value: l }, ConstantData::Integer { value: r }) => { - eval_const_complex_binop(*l, Complex::new(r.to_f64()?, 0.0), op) - } - (ConstantData::Float { value: l }, ConstantData::Complex { value: r }) => { - eval_const_complex_binop(Complex::new(*l, 0.0), *r, op) - } - (ConstantData::Complex { value: l }, ConstantData::Float { value: r }) => { - eval_const_complex_binop(*l, Complex::new(*r, 0.0), op) - } - (ConstantData::Complex { value: l }, ConstantData::Complex { value: r }) => { - eval_const_complex_binop(*l, *r, op) - } - // String concatenation and repetition - (ConstantData::Str { value: l }, ConstantData::Str { value: r }) - if matches!(op, BinOp::Add) => - { - let mut result = Wtf8Buf::new(); - result - .try_reserve_exact(l.len().checked_add(r.len())?) - .ok()?; - result.push_wtf8(l); - result.push_wtf8(r); - Some(ConstantData::Str { value: result }) - } - (ConstantData::Str { .. }, ConstantData::Integer { .. }) - if matches!(op, BinOp::Multiply) => - { - const_folding_safe_multiply(left, right) - } - (ConstantData::Tuple { elements: l }, ConstantData::Tuple { elements: r }) - if matches!(op, BinOp::Add) => - { - let mut result = Vec::new(); - result - .try_reserve_exact(l.len().checked_add(r.len())?) - .ok()?; - result.extend(l.iter().cloned()); - result.extend(r.iter().cloned()); - Some(ConstantData::Tuple { elements: result }) - } - (ConstantData::Tuple { .. }, ConstantData::Integer { .. }) - if matches!(op, BinOp::Multiply) => - { - const_folding_safe_multiply(left, right) - } - (ConstantData::Integer { .. }, ConstantData::Tuple { .. }) - if matches!(op, BinOp::Multiply) => - { - const_folding_safe_multiply(left, right) - } - (ConstantData::Integer { .. }, ConstantData::Str { .. }) - if matches!(op, BinOp::Multiply) => - { - const_folding_safe_multiply(left, right) - } - (ConstantData::Bytes { value: l }, ConstantData::Bytes { value: r }) - if matches!(op, BinOp::Add) => - { - let mut result = Vec::new(); - result - .try_reserve_exact(l.len().checked_add(r.len())?) - .ok()?; - result.extend_from_slice(l); - result.extend_from_slice(r); - Some(ConstantData::Bytes { value: result }) - } - (ConstantData::Bytes { .. }, ConstantData::Integer { .. }) - if matches!(op, BinOp::Multiply) => - { - const_folding_safe_multiply(left, right) - } - (ConstantData::Integer { .. }, ConstantData::Bytes { .. }) - if matches!(op, BinOp::Multiply) => - { - const_folding_safe_multiply(left, right) - } - _ => None, - } + let const_idx = add_const(metadata, constant)?; + instr_set_op1( + instr, + Opcode::LoadConst.into(), + OpArg::new(const_idx as u32), + ); + Ok(()) } -/// flowgraph.c fold_tuple_of_constants -fn fold_tuple_of_constants( +/// flowgraph.c fold_const_unaryop +fn fold_const_unaryop( metadata: &mut CodeUnitMetadata, block: &mut Block, i: usize, ) -> crate::InternalResult { - let Some(Opcode::BuildTuple) = block.instructions[i].instr.real_opcode() else { - return Ok(false); + let instr = &block.instructions[i]; + let (op, intrinsic) = match instr.instr.real() { + Some(Instruction::UnaryNegative) => (Instruction::UnaryNegative, None), + Some(Instruction::UnaryInvert) => (Instruction::UnaryInvert, None), + Some(Instruction::UnaryNot) => (Instruction::UnaryNot, None), + Some(Instruction::CallIntrinsic1 { func }) + if matches!( + func.get(instr.arg), + oparg::IntrinsicFunction1::UnaryPositive + ) => + { + (Opcode::CallIntrinsic1.into(), Some(func.get(instr.arg))) + } + _ => return Ok(false), }; - - let tuple_size = u32::from(block.instructions[i].arg) as usize; - if tuple_size > STACK_USE_GUIDELINE { - return Ok(false); - } - - let Some(operand_indices) = (if tuple_size == 0 { - Some(Vec::new()) - } else if let Some(start) = i.checked_sub(1) { - get_const_loading_instrs(block, start, tuple_size)? + let Some(operand_index) = (if let Some(start) = i.checked_sub(1) { + get_const_loading_instrs(block, start, 1)? } else { None - }) else { + }) + .and_then(|indices| indices.into_iter().next()) else { return Ok(false); }; - - let mut elements = Vec::new(); - elements - .try_reserve_exact(tuple_size) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - for &j in &operand_indices { - let Some(element) = get_const_value(metadata, &block.instructions[j]) else { - return Ok(false); - }; - elements.push(element); - } - - nop_out(block, &operand_indices); - instr_make_load_const( - metadata, - &mut block.instructions[i], - ConstantData::Tuple { elements }, - )?; - Ok(true) -} - -fn fold_constant_intrinsic_list_to_tuple( - metadata: &mut CodeUnitMetadata, - block: &mut Block, - i: usize, -) -> crate::InternalResult { - let Some(Instruction::CallIntrinsic1 { func }) = block.instructions[i].instr.real() else { + let operand = get_const_value(metadata, &block.instructions[operand_index]); + let Some(operand) = operand else { return Ok(false); }; - if func.get(block.instructions[i].arg) != IntrinsicFunction1::ListToTuple { + let Some(folded_const) = eval_const_unaryop(&operand, op, intrinsic) else { return Ok(false); - } - - let mut consts_found = 0usize; - let mut expect_append = true; - let mut pos = i; - while let Some(prev) = pos.checked_sub(1) { - pos = prev; - let instr = &block.instructions[pos]; - if matches!(instr.instr.real(), Some(Instruction::Nop)) { - continue; - } - - if matches!(instr.instr.real(), Some(Instruction::BuildList { .. })) - && u32::from(instr.arg) == 0 - { - if !expect_append { - return Ok(false); - } + }; + nop_out(block, &[operand_index]); + instr_make_load_const(metadata, &mut block.instructions[i], folded_const)?; + Ok(true) +} - let mut elements = Vec::new(); - elements - .try_reserve_exact(consts_found) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - for idx in (pos..i).rev() { - if matches!(block.instructions[idx].instr.real(), Some(Instruction::Nop)) { - continue; - } - if loads_const(&block.instructions[idx]) { - let Some(value) = get_const_value(metadata, &block.instructions[idx]) else { - return Ok(false); - }; - elements.push(value); - } - nop_out_no_location(&mut block.instructions[idx]); - } - debug_assert_eq!(elements.len(), consts_found); - elements.reverse(); - instr_make_load_const( - metadata, - &mut block.instructions[i], - ConstantData::Tuple { elements }, - )?; - return Ok(true); +/// flowgraph.c get_const_loading_instrs +fn get_const_loading_instrs( + block: &Block, + mut start: usize, + size: usize, +) -> crate::InternalResult>> { + let mut indices = Vec::new(); + indices + .try_reserve_exact(size) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + loop { + if start >= block.instruction_used { + return Ok(None); } - - if expect_append { - if !matches!(instr.instr.real(), Some(Instruction::ListAppend { .. })) - || u32::from(instr.arg) != 1 - { - return Ok(false); - } - } else { + let instr = &block.instructions[start]; + if !matches!(instr.instr.real(), Some(Instruction::Nop)) { if !loads_const(instr) { - return Ok(false); + return Ok(None); + } + indices.push(start); + if indices.len() == size { + break; } - consts_found += 1; } - expect_append = !expect_append; + let Some(prev) = start.checked_sub(1) else { + return Ok(None); + }; + start = prev; } + indices.reverse(); + Ok(Some(indices)) +} - Ok(false) +/// flowgraph.c nop_out +fn nop_out(block: &mut Block, instrs: &[usize]) { + for &i in instrs { + nop_out_no_location(&mut block.instructions[i]); + } } -/// Port of CPython's flowgraph.c optimize_lists_and_sets(). -fn optimize_lists_and_sets( +/// flowgraph.c fold_const_binop +fn fold_const_binop( metadata: &mut CodeUnitMetadata, block: &mut Block, i: usize, - nextop: Option, ) -> crate::InternalResult { - let Some(instr) = block.instructions[i].instr.real() else { - return Ok(false); - }; - let is_list = matches!(instr, Instruction::BuildList { .. }); - let is_set = matches!(instr, Instruction::BuildSet { .. }); - if !is_list && !is_set { - return Ok(false); - } + use oparg::BinaryOperator as BinOp; - let contains_or_iter = matches!( - nextop, - Some(Instruction::GetIter | Instruction::ContainsOp { .. }) - ); - let seq_size = u32::from(block.instructions[i].arg) as usize; - if seq_size > STACK_USE_GUIDELINE || (seq_size < MIN_CONST_SEQUENCE_SIZE && !contains_or_iter) { + let Some(Opcode::BinaryOp) = block.instructions[i].instr.real_opcode() else { return Ok(false); - } + }; - let Some(operand_indices) = (if seq_size == 0 { - Some(Vec::new()) - } else if let Some(start) = i.checked_sub(1) { - get_const_loading_instrs(block, start, seq_size)? + let Some(operand_indices) = (if let Some(start) = i.checked_sub(1) { + get_const_loading_instrs(block, start, 2)? } else { None }) else { - if contains_or_iter && is_list { - let arg = block.instructions[i].arg; - instr_set_op1(&mut block.instructions[i], Opcode::BuildTuple.into(), arg); - return Ok(true); - } return Ok(false); }; - let mut elements = Vec::new(); - elements - .try_reserve_exact(seq_size) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - for &j in &operand_indices { - let Some(element) = get_const_value(metadata, &block.instructions[j]) else { - return Ok(false); - }; - elements.push(element); - } - - let const_data = if is_list { - ConstantData::Tuple { elements } - } else { - ConstantData::Frozenset { elements } + let op_raw = u32::from(block.instructions[i].arg); + let Ok(op) = BinOp::try_from(op_raw) else { + return Ok(false); }; - let const_idx = add_const(metadata, const_data)?; - - if !contains_or_iter { - debug_assert!(i >= 2); - let folded_loc = block.instructions[i].location; - let end_loc = block.instructions[i].end_location; - - nop_out(block, &operand_indices); - - let build_instr = if is_list { - Opcode::BuildList - } else { - Opcode::BuildSet - } - .into(); - instr_set_op1(&mut block.instructions[i - 2], build_instr, OpArg::new(0)); - block.instructions[i - 2].location = folded_loc; - block.instructions[i - 2].end_location = end_loc; - block.instructions[i - 2].lineno_override = None; - instr_set_op1( - &mut block.instructions[i - 1], - Opcode::LoadConst.into(), - OpArg::new(const_idx as u32), - ); + let left = get_const_value(metadata, &block.instructions[operand_indices[0]]); + let right = get_const_value(metadata, &block.instructions[operand_indices[1]]); + let (Some(left_val), Some(right_val)) = (left, right) else { + return Ok(false); + }; - let extend_instr = if is_list { - Opcode::ListExtend - } else { - Opcode::SetUpdate - }; - instr_set_op1( - &mut block.instructions[i], - extend_instr.into(), - OpArg::new(1), - ); - return Ok(true); - } + let Some(result_const) = eval_const_binop(&left_val, &right_val, op) else { + return Ok(false); + }; nop_out(block, &operand_indices); - - instr_set_op1( - &mut block.instructions[i], - Opcode::LoadConst.into(), - OpArg::new(const_idx as u32), - ); + instr_make_load_const(metadata, &mut block.instructions[i], result_const)?; Ok(true) } -/// flowgraph.c VISITED -const VISITED: i32 = -1; - -/// flowgraph.c SWAPPABLE -fn is_swappable(instr: AnyInstruction) -> bool { - matches!( - instr.into(), - AnyOpcode::Real(Opcode::StoreFast | Opcode::PopTop) - | AnyOpcode::Pseudo(PseudoOpcode::StoreFastMaybeNull) - ) +/// flowgraph.c loads_const +fn loads_const(info: &InstructionInfo) -> bool { + info.instr.has_const() || matches!(info.instr.real_opcode(), Some(Opcode::LoadSmallInt)) } -/// flowgraph.c STORES_TO -fn stores_to(info: &InstructionInfo) -> i32 { - match info.instr.into() { - AnyOpcode::Real(Opcode::StoreFast) - | AnyOpcode::Pseudo(PseudoOpcode::StoreFastMaybeNull) => u32::from(info.arg) as i32, - _ => -1, +/// flowgraph.c get_const_value +fn get_const_value(metadata: &CodeUnitMetadata, info: &InstructionInfo) -> Option { + match info.instr.real_opcode() { + Some(Opcode::LoadSmallInt) => { + let v = u32::from(info.arg) as i32; + Some(ConstantData::Integer { + value: BigInt::from(v), + }) + } + _ if info.instr.has_const() => { + let idx = u32::from(info.arg) as usize; + metadata.consts.get_index(idx).cloned() + } + _ => None, } } -/// flowgraph.c next_swappable_instruction -fn next_swappable_instruction(block: &Block, mut i: usize, lineno: i32) -> Option { - loop { - i += 1; - if i >= block.instruction_used { - return None; - } - - let info = &block.instructions[i]; - let info_lineno = instruction_lineno(info); - - if lineno >= 0 && info_lineno != lineno { +/// flowgraph.c const_folding_check_complexity +fn const_folding_check_complexity(obj: &ConstantData, mut limit: isize) -> Option { + if let ConstantData::Tuple { elements } = obj { + limit -= isize::try_from(elements.len()).ok()?; + if limit < 0 { return None; } - - if matches!(info.instr, AnyInstruction::Real(Instruction::Nop)) { - continue; + for element in elements { + limit = const_folding_check_complexity(element, limit)?; } + } + Some(limit) +} - if is_swappable(info.instr) { - return Some(i); - } +fn repeat_wtf8(value: &Wtf8Buf, n: usize) -> Option { + let mut result = Wtf8Buf::new(); + result.try_reserve_exact(value.len().checked_mul(n)?).ok()?; + for _ in 0..n { + result.push_wtf8(value); + } + Some(result) +} +fn checked_repeat_count(n: &BigInt, item_size: usize) -> Option { + let n = n.to_isize()?; + if item_size != 0 && (n < 0 || n as usize > MAX_STR_SIZE / item_size) { return None; } + Some(n.max(0) as usize) } -/// flowgraph.c swaptimize -fn swaptimize(block: &mut Block, ix: &mut usize) -> crate::InternalResult<()> { - debug_assert!(matches!( - block.instructions[*ix].instr.real_opcode(), - Some(Opcode::Swap) - )); - let mut depth = u32::from(block.instructions[*ix].arg) as usize; - let mut len = 1usize; - let mut more = false; - let limit = block.instruction_used - *ix; - while len < limit { - match block.instructions[*ix + len].instr.real_opcode() { - Some(Opcode::Swap) => { - depth = depth.max(u32::from(block.instructions[*ix + len].arg) as usize); - more = true; - len += 1; - } - Some(Opcode::Nop) => { - len += 1; +/// flowgraph.c const_folding_safe_multiply +fn const_folding_safe_multiply(left: &ConstantData, right: &ConstantData) -> Option { + match (left, right) { + (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) => { + if !l.is_zero() && !r.is_zero() && l.bits() + r.bits() > MAX_INT_SIZE { + return None; } - _ => break, + Some(ConstantData::Integer { value: l * r }) } - } - - if !more { - return Ok(()); - } - - let mut stack = Vec::new(); - stack - .try_reserve_exact(depth) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - stack.resize(depth, 0); - let mut i = 0; - while i < depth { - stack[i] = i as i32; - i += 1; - } - - i = 0; - while i < len { - let info = &block.instructions[*ix + i]; - if matches!(info.instr.real_opcode(), Some(Opcode::Swap)) { - let oparg = u32::from(info.arg) as usize; - stack.swap(0, oparg - 1); + (ConstantData::Float { value: l }, ConstantData::Float { value: r }) => { + Some(ConstantData::Float { value: l * r }) } - i += 1; - } - - let mut current = len as isize - 1; - for i in 0..depth { - if stack[i] == VISITED || stack[i] == i as i32 { - continue; + (ConstantData::Str { value: s }, ConstantData::Integer { value: n }) => { + let n = checked_repeat_count(n, s.code_points().count())?; + Some(ConstantData::Str { + value: repeat_wtf8(s, n)?, + }) } - let mut j = i; - loop { - if j != 0 { - debug_assert!(current >= 0); - let out = &mut block.instructions[*ix + current as usize]; - out.instr = Opcode::Swap.into(); - out.arg = OpArg::new((j + 1) as u32); - current -= 1; + (ConstantData::Integer { .. }, ConstantData::Str { .. }) => { + const_folding_safe_multiply(right, left) + } + (ConstantData::Bytes { value: b }, ConstantData::Integer { value: n }) => { + let n = checked_repeat_count(n, b.len())?; + let mut value = Vec::new(); + value.try_reserve_exact(b.len().checked_mul(n)?).ok()?; + for _ in 0..n { + value.extend_from_slice(b); } - if stack[j] == VISITED { - debug_assert_eq!(j, i); - break; + Some(ConstantData::Bytes { value }) + } + (ConstantData::Integer { .. }, ConstantData::Bytes { .. }) => { + const_folding_safe_multiply(right, left) + } + (ConstantData::Tuple { elements }, ConstantData::Integer { value: n }) => { + let n = n.to_usize()?; + if n != 0 && !elements.is_empty() { + if n > MAX_COLLECTION_SIZE / elements.len() { + return None; + } + const_folding_check_complexity( + &ConstantData::Tuple { + elements: elements.clone(), + }, + MAX_TOTAL_ITEMS / isize::try_from(n).ok()?, + )?; } - let next_j = stack[j] as usize; - stack[j] = VISITED; - j = next_j; + let mut result = Vec::new(); + result + .try_reserve_exact(elements.len().checked_mul(n)?) + .ok()?; + for _ in 0..n { + result.extend(elements.iter().cloned()); + } + Some(ConstantData::Tuple { elements: result }) } + (ConstantData::Integer { .. }, ConstantData::Tuple { .. }) => { + const_folding_safe_multiply(right, left) + } + _ => None, } - - while current >= 0 { - set_to_nop(&mut block.instructions[*ix + current as usize]); - current -= 1; - } - *ix += len - 1; - Ok(()) } -/// flowgraph.c apply_static_swaps -fn apply_static_swaps(block: &mut Block, mut i: isize) { - while i >= 0 { - let idx = i as usize; - debug_assert!(idx < block.instruction_used); - let swap_arg = match block.instructions[idx].instr.real_opcode() { - Some(Opcode::Swap) => u32::from(block.instructions[idx].arg), - Some(Opcode::Nop | Opcode::PopTop | Opcode::StoreFast) => { - i -= 1; - continue; +/// flowgraph.c const_folding_safe_power +fn const_folding_safe_power(left: &ConstantData, right: &ConstantData) -> Option { + match (left, right) { + (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) => { + if r < &BigInt::from(0) { + if l.is_zero() { + return None; + } + let base = l.to_f64()?; + if !base.is_finite() { + return None; + } + let result = if let Some(exp) = r.to_i32() { + base.powi(exp) + } else { + base.powf(r.to_f64()?) + }; + if !result.is_finite() { + return None; + } + return Some(ConstantData::Float { value: result }); } - _ if matches!( - block.instructions[idx].instr.pseudo_opcode(), - Some(PseudoOpcode::StoreFastMaybeNull) - ) => - { - i -= 1; - continue; + let exp: u64 = r.try_into().ok()?; + let exp_usize = usize::try_from(exp).ok()?; + if !l.is_zero() && exp > 0 && l.bits() > MAX_INT_SIZE / exp { + return None; } - _ => return, - }; - - let Some(j) = next_swappable_instruction(block, idx, -1) else { - return; - }; - let lineno = instruction_lineno(&block.instructions[j]); - let mut k = j; - for _ in 1..swap_arg { - let Some(next) = next_swappable_instruction(block, k, lineno) else { - return; - }; - k = next; + Some(ConstantData::Integer { + value: num_traits::pow::pow(l.clone(), exp_usize), + }) } - - let store_j = stores_to(&block.instructions[j]); - let store_k = stores_to(&block.instructions[k]); - if store_j >= 0 || store_k >= 0 { - if store_j == store_k { - return; - } - let mut idx = j + 1; - while idx < k { - let store_idx = stores_to(&block.instructions[idx]); - if store_idx >= 0 && (store_idx == store_j || store_idx == store_k) { - return; - } - idx += 1; - } + (ConstantData::Float { value: l }, ConstantData::Float { value: r }) => { + let result = l.powf(*r); + result + .is_finite() + .then_some(ConstantData::Float { value: result }) } - - set_to_nop(&mut block.instructions[idx]); - block.instructions.swap(j, k); - i -= 1; + _ => None, } } -/// flowgraph.c optimize_basic_block swap pass -fn apply_static_swaps_block(block: &mut Block) -> crate::InternalResult<()> { - let mut i = 0; - while i < block.instruction_used { - if matches!( - block.instructions[i].instr.real_opcode(), - Some(Opcode::Swap) - ) { - swaptimize(block, &mut i)?; - apply_static_swaps(block, i as isize); - } - i += 1; +/// flowgraph.c const_folding_safe_lshift +fn const_folding_safe_lshift(left: &ConstantData, right: &ConstantData) -> Option { + let (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) = (left, right) + else { + return None; + }; + let shift: u64 = r.try_into().ok()?; + let shift_usize = usize::try_from(shift).ok()?; + if shift > MAX_INT_SIZE || (!l.is_zero() && l.bits() > MAX_INT_SIZE - shift) { + return None; } - Ok(()) + Some(ConstantData::Integer { + value: l << shift_usize, + }) } -/// flowgraph.c maybe_instr_make_load_smallint -fn maybe_instr_make_load_smallint(instr: &mut InstructionInfo, constant: &ConstantData) -> bool { - if let ConstantData::Integer { value } = constant - && let Some(small) = value.to_i32().filter(|v| (0..=255).contains(v)) - { - instr_set_op1(instr, Opcode::LoadSmallInt.into(), OpArg::new(small as u32)); - return true; +/// flowgraph.c const_folding_safe_mod +fn const_folding_safe_mod(left: &ConstantData, right: &ConstantData) -> Option { + if matches!(left, ConstantData::Str { .. } | ConstantData::Bytes { .. }) { + return None; } - false -} -/// flowgraph.c basicblock_optimize_load_const -fn basicblock_optimize_load_const( - metadata: &mut CodeUnitMetadata, - block: &mut Block, -) -> crate::InternalResult<()> { - let mut i = 0; - let mut effective_opcode = None; - let mut effective_oparg = OpArg::new(0); - while i < block.instruction_used { - if matches!( - block.instructions[i].instr.real(), - Some(Instruction::LoadConst { .. }) - ) && let Some(constant) = get_const_value(metadata, &block.instructions[i]) - { - maybe_instr_make_load_smallint(&mut block.instructions[i], &constant); + match (left, right) { + (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) => { + if r.is_zero() { + return None; + } + let rem = l.clone() % r.clone(); + let value = if !rem.is_zero() && (rem < BigInt::from(0)) != (*r < BigInt::from(0)) { + rem + r + } else { + rem + }; + Some(ConstantData::Integer { value }) + } + (ConstantData::Float { value: l }, ConstantData::Float { value: r }) => { + let (_, modulo) = float_div_mod(*l, *r)?; + Some(ConstantData::Float { value: modulo }) } + _ => None, + } +} - let curr = block.instructions[i]; - let curr_arg = curr.arg; +fn float_div_mod(left: f64, right: f64) -> Option<(f64, f64)> { + if right == 0.0 { + return None; + } - // Only combine if the source is a real instruction. - let Some(curr_instr) = curr.instr.real() else { - i += 1; - continue; + let mut modulo = left % right; + let div = (left - modulo) / right; + let floordiv = if modulo != 0.0 { + let div = if (right < 0.0) != (modulo < 0.0) { + modulo += right; + div - 1.0 + } else { + div }; - - let is_copy_of_load_const = matches!( - (effective_opcode, curr_instr), - (Some(Instruction::LoadConst { .. }), Instruction::Copy { i }) if i.get(curr_arg) == 1 - ); - if !is_copy_of_load_const { - effective_opcode = Some(curr_instr); - effective_oparg = curr_arg; + let mut floordiv = div.floor(); + if div - floordiv > 0.5 { + floordiv += 1.0; } - let Some(const_instr) = effective_opcode else { - i += 1; - continue; - }; - let const_arg = effective_oparg; + floordiv + } else { + modulo = 0.0f64.copysign(right); + 0.0f64.copysign(left / right) + }; - if i + 1 >= block.instruction_used { - i += 1; - continue; - } + Some((floordiv, modulo)) +} - let next = block.instructions[i + 1]; - let next_arg = next.arg; +/// flowgraph.c eval_const_binop complex result construction +fn eval_const_complex_const(value: Complex) -> Option { + (value.re.is_finite() && value.im.is_finite()).then_some(ConstantData::Complex { value }) +} - if let Some(is_true) = load_const_truthiness(const_instr, const_arg, metadata) { - let const_jump = match (next.instr.real_opcode(), next.instr.pseudo_opcode()) { - (_, Some(PseudoOpcode::JumpIfTrue)) => Some((true, false)), - (_, Some(PseudoOpcode::JumpIfFalse)) => Some((false, false)), - (Some(Opcode::PopJumpIfTrue), _) => Some((true, true)), - (Some(Opcode::PopJumpIfFalse), _) => Some((false, true)), - _ => None, +/// flowgraph.c eval_const_binop complex operations +fn eval_const_complex_binop( + left: Complex, + right: Complex, + op: oparg::BinaryOperator, +) -> Option { + use oparg::BinaryOperator as BinOp; + + let value = match op { + BinOp::Add => left + right, + BinOp::Subtract => { + let re = left.re - right.re; + // Preserve CPython's signed-zero behavior for real-zero + // minus zero-complex expressions such as `0 - 0j`. + let im = if left.re == 0.0 + && left.im == 0.0 + && right.re == 0.0 + && right.im == 0.0 + && !right.im.is_sign_negative() + { + -0.0 + } else { + left.im - right.im }; - if let Some((jump_if_true, pops_condition)) = const_jump { - if pops_condition { - set_to_nop(&mut block.instructions[i]); - } - if is_true == jump_if_true { - block.instructions[i + 1].instr = PseudoOpcode::Jump.into(); - } else { - set_to_nop(&mut block.instructions[i + 1]); - } - i += 1; - continue; + Complex::new(re, im) + } + BinOp::Multiply => left * right, + BinOp::TrueDivide => { + if right == Complex::new(0.0, 0.0) { + return None; } + left / right } - - // The remaining combinations require both instructions to be real. - let Some(next_instr) = next.instr.real() else { - i += 1; - continue; - }; - - if let Instruction::LoadConst { consti } = const_instr { - let constant = &metadata.consts[consti.get(const_arg).as_usize()]; - if matches!(constant, ConstantData::None) - && let Instruction::IsOp { invert } = next_instr - { - let mut jump_idx = i + 2; - if jump_idx >= block.instruction_used { - i += 1; - continue; - } - - if matches!( - block.instructions[jump_idx].instr.real(), - Some(Instruction::ToBool) - ) { - set_to_nop(&mut block.instructions[jump_idx]); - jump_idx += 1; - if jump_idx >= block.instruction_used { - i += 1; - continue; - } + BinOp::Power => { + if left == Complex::new(0.0, 0.0) { + if right.im != 0.0 || right.re < 0.0 { + return None; } - let Some(jump_instr) = block.instructions[jump_idx].instr.real() else { - i += 1; - continue; - }; - - let mut invert = matches!( - invert.get(next_arg), - rustpython_compiler_core::bytecode::Invert::Yes - ); - match jump_instr { - Instruction::PopJumpIfFalse { .. } => { - invert = !invert; - } - Instruction::PopJumpIfTrue { .. } => {} - _ => { - i += 1; - continue; - } - }; - - set_to_nop(&mut block.instructions[i]); - set_to_nop(&mut block.instructions[i + 1]); - block.instructions[jump_idx].instr = if invert { - Opcode::PopJumpIfNotNone + return eval_const_complex_const(if right.re == 0.0 { + Complex::new(1.0, 0.0) } else { - Opcode::PopJumpIfNone - } - .into(); - i = jump_idx; - continue; + Complex::new(0.0, 0.0) + }); } - } - if matches!( - const_instr, - Instruction::LoadConst { .. } | Instruction::LoadSmallInt { .. } - ) && matches!(next_instr, Instruction::ToBool) - && let Some(value) = load_const_truthiness(const_instr, const_arg, metadata) - { - let const_idx = add_const(metadata, ConstantData::Boolean { value })?; - set_to_nop(&mut block.instructions[i]); - instr_set_op1( - &mut block.instructions[i + 1], - Opcode::LoadConst.into(), - OpArg::new(const_idx as u32), - ); - i += 1; - continue; + if right.im == 0.0 + && right.re.fract() == 0.0 + && right.re >= f64::from(i32::MIN) + && right.re <= f64::from(i32::MAX) + { + left.powi(right.re as i32) + } else { + left.powc(right) + } } + _ => return None, + }; + eval_const_complex_const(value) +} - i += 1; +/// flowgraph.c eval_const_binop subscript index conversion +fn constant_as_index(value: &ConstantData) -> Option { + match value { + ConstantData::Integer { value } => value.to_i64().or_else(|| { + if value < &BigInt::from(0) { + Some(i64::MIN) + } else { + Some(i64::MAX) + } + }), + ConstantData::Boolean { value } => Some(i64::from(*value)), + _ => None, } - Ok(()) } -/// flowgraph.c optimize_load_const -fn optimize_load_const( - metadata: &mut CodeUnitMetadata, - blocks: &mut Blocks, -) -> crate::InternalResult<()> { - let mut block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - let next_block = blocks[block_idx.idx()].next; - let block = &mut blocks[block_idx]; - basicblock_optimize_load_const(metadata, block)?; - block_idx = next_block; +/// flowgraph.c eval_const_binop subscript slice bound conversion +fn slice_bound(value: &ConstantData) -> Option> { + match value { + ConstantData::None => Some(None), + _ => constant_as_index(value).map(Some), } - Ok(()) } -#[cfg(test)] -impl CodeInfo { - fn debug_block_dump(&self) -> String { - let mut out = String::new(); - let mut block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - use core::fmt::Write; - let block = &self.blocks[block_idx.idx()]; - let block_return = if basicblock_returns(block) { - " return" - } else { - "" - }; - let _ = writeln!( - out, - "block {} next={} cold={} except={} preserve_lasti={} start_depth={}{}", - u32::from(block_idx), - if block.next == BlockIdx::NULL { - String::from("NULL") - } else { - u32::from(block.next).to_string() - }, - block.cold, - block.except_handler, - block.preserve_lasti, - if block.start_depth < 0 { - String::from("None") - } else { - block.start_depth.to_string() - }, - block_return, - ); - for info in &block.instructions[..block.instruction_used] { - let lineno = instruction_lineno(info); - let _ = writeln!( - out, - " [disp={}:{} raw={}:{}-{}:{} override={:?}] {:?} arg={} target={}", - lineno, - info.location.character_offset.get(), - info.location.line.get(), - info.location.character_offset.get(), - info.end_location.line.get(), - info.end_location.character_offset.get(), - info.lineno_override, - info.instr, - u32::from(info.arg), - if info.target == BlockIdx::NULL { - String::from("NULL") - } else { - u32::from(info.target).to_string() - } - ); - } - block_idx = block.next; - } - out +/// flowgraph.c eval_const_binop subscript slice index adjustment +fn adjusted_slice_indices(len: usize, slice: &[ConstantData; 3]) -> Option> { + let len = i64::try_from(len).ok()?; + let start = slice_bound(&slice[0])?; + let stop = slice_bound(&slice[1])?; + let step = slice_bound(&slice[2])?.unwrap_or(1); + if step == 0 || step == i64::MIN { + return None; } - pub(crate) fn debug_late_cfg_trace(mut self) -> crate::InternalResult> { - let mut trace = Vec::new(); - trace.push(("initial".to_owned(), self.debug_block_dump())); - - let instr_sequence = self.prepare_cfg_from_codegen()?; - self.blocks = cfg_from_instruction_sequence(instr_sequence)?; - trace.push(( - "after_cfg_from_instruction_sequence".to_owned(), - self.debug_block_dump(), - )); - translate_jump_labels_to_targets(&mut self.blocks)?; - mark_except_handlers(&mut self.blocks)?; - label_exception_targets(&mut self.blocks)?; - check_cfg(&self.blocks)?; - inline_small_or_no_lineno_blocks(&mut self.blocks)?; - trace.push(( - "after_inline_small_or_no_lineno_blocks".to_owned(), - self.debug_block_dump(), - )); - self.blocks.remove_unreachable()?; - self.blocks - .resolve_line_numbers(self.metadata.firstlineno)?; - optimize_load_const(&mut self.metadata, &mut self.blocks)?; - trace.push(( - "after_optimize_load_const".to_owned(), - self.debug_block_dump(), - )); - let mut block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - let next_block = self.blocks[block_idx].next; - self.blocks - .optimize_basic_block(&mut self.metadata, block_idx)?; - block_idx = next_block; + let step_is_negative = step < 0; + let lower = if step_is_negative { -1 } else { 0 }; + let upper = if step_is_negative { len - 1 } else { len }; + let adjust = |value: Option, default: i64| { + let mut value = value.unwrap_or(default); + if value < 0 { + value = value.saturating_add(len); + if value < 0 { + value = lower; + } + } else if value >= len { + value = upper; } - trace.push(( - "after_optimize_basic_block".to_owned(), - self.debug_block_dump(), - )); - self.blocks.remove_redundant_nops_and_pairs()?; - self.blocks.remove_unreachable()?; - remove_redundant_nops_and_jumps(&mut self.blocks)?; - #[cfg(debug_assertions)] - assert!(no_redundant_jumps(&self.blocks)); - self.blocks - .remove_unused_consts(&mut self.metadata.consts)?; - trace.push(( - "after_optimize_cfg_cleanup".to_owned(), - self.debug_block_dump(), - )); - let nlocals = self.metadata.varnames.len(); - let nparams = self.nparams; - add_checks_for_loads_of_uninitialized_variables(&mut self.blocks, nlocals, nparams)?; - self.blocks.insert_superinstructions()?; - push_cold_blocks_to_end(&mut self.blocks)?; - trace.push(( - "after_push_cold_before_chain_reorder".to_owned(), - self.debug_block_dump(), - )); - self.blocks - .resolve_line_numbers(self.metadata.firstlineno)?; - trace.push(( - "after_push_cold_resolve_line_numbers".to_owned(), - self.debug_block_dump(), - )); - - trace.push(( - "after_push_cold_blocks_to_end".to_owned(), - self.debug_block_dump(), - )); + value + }; + let start = adjust(start, if step_is_negative { upper } else { lower }); + let stop = adjust(stop, if step_is_negative { lower } else { upper }); - convert_pseudo_conditional_jumps(&mut self.blocks)?; - trace.push(( - "after_convert_pseudo_conditional_jumps".to_owned(), - self.debug_block_dump(), - )); + let mut index = i128::from(start); + let stop = i128::from(stop); + let step = i128::from(step); + let slice_len = if step > 0 { + if index < stop { + usize::try_from((stop - index - 1) / step + 1).ok()? + } else { + 0 + } + } else if index > stop { + usize::try_from((index - stop - 1) / -step + 1).ok()? + } else { + 0 + }; + let mut indices = Vec::new(); + indices.try_reserve_exact(slice_len).ok()?; + if step > 0 { + while index < stop { + indices.push(usize::try_from(index).ok()?); + index += step; + } + } else { + while index > stop { + indices.push(usize::try_from(index).ok()?); + index += step; + } + } + Some(indices) +} - let _max_stackdepth = self.blocks.calculate_stackdepth()?; - let _nlocalsplus = prepare_localsplus(&self.metadata, &mut self.blocks, self.flags)?; - convert_pseudo_ops(&mut self.blocks)?; - trace.push(( - "after_convert_pseudo_ops".to_owned(), - self.debug_block_dump(), - )); +/// flowgraph.c eval_const_binop subscript index adjustment +fn adjusted_const_index(len: usize, index: &ConstantData) -> Option { + let len = i64::try_from(len).ok()?; + let index = constant_as_index(index)?; + let index = if index < 0 { + index.saturating_add(len) + } else { + index + }; + if index < 0 || index >= len { + return None; + } + usize::try_from(index).ok() +} - self.blocks.normalize_jumps()?; - #[cfg(debug_assertions)] - assert!(no_redundant_jumps(&self.blocks)); - trace.push(("after_normalize_jumps".to_owned(), self.debug_block_dump())); - self.blocks.optimize_load_fast()?; - trace.push(( - "after_optimize_load_fast".to_owned(), - self.debug_block_dump(), - )); +/// flowgraph.c eval_const_binop NB_SUBSCR +fn eval_const_subscript(container: &ConstantData, index: &ConstantData) -> Option { + match (container, index) { + ( + ConstantData::Str { value }, + ConstantData::Integer { .. } | ConstantData::Boolean { .. }, + ) => { + let string = value.to_string(); + if string.contains(char::REPLACEMENT_CHARACTER) { + return None; + } + let mut chars = Vec::new(); + chars.try_reserve_exact(string.chars().count()).ok()?; + chars.extend(string.chars()); + let index = adjusted_const_index(chars.len(), index)?; + Some(ConstantData::Str { + value: chars[index].to_string().into(), + }) + } + (ConstantData::Str { value }, ConstantData::Slice { elements }) => { + let string = value.to_string(); + if string.contains(char::REPLACEMENT_CHARACTER) { + return None; + } + let mut chars = Vec::new(); + chars.try_reserve_exact(string.chars().count()).ok()?; + chars.extend(string.chars()); + let indices = adjusted_slice_indices(chars.len(), elements)?; + let capacity = indices.iter().try_fold(0usize, |capacity, &index| { + capacity.checked_add(chars[index].len_utf8()) + })?; + let mut result = String::new(); + result.try_reserve_exact(capacity).ok()?; + for index in indices { + result.push(chars[index]); + } + Some(ConstantData::Str { + value: result.into(), + }) + } + ( + ConstantData::Bytes { value }, + ConstantData::Integer { .. } | ConstantData::Boolean { .. }, + ) => { + let index = adjusted_const_index(value.len(), index)?; + Some(ConstantData::Integer { + value: BigInt::from(value[index]), + }) + } + (ConstantData::Bytes { value }, ConstantData::Slice { elements }) => { + let indices = adjusted_slice_indices(value.len(), elements)?; + let mut result = Vec::new(); + result.try_reserve_exact(indices.len()).ok()?; + for index in indices { + result.push(value[index]); + } + Some(ConstantData::Bytes { value: result }) + } + ( + ConstantData::Tuple { elements }, + ConstantData::Integer { .. } | ConstantData::Boolean { .. }, + ) => { + let index = adjusted_const_index(elements.len(), index)?; + Some(elements[index].clone()) + } + (ConstantData::Tuple { elements }, ConstantData::Slice { elements: slice }) => { + let indices = adjusted_slice_indices(elements.len(), slice)?; + let mut result = Vec::new(); + result.try_reserve_exact(indices.len()).ok()?; + for index in indices { + result.push(elements[index].clone()); + } + Some(ConstantData::Tuple { elements: result }) + } + _ => None, + } +} + +/// flowgraph.c eval_const_binop bool/int coercion +fn constant_as_int(value: &ConstantData) -> Option<(BigInt, bool)> { + match value { + ConstantData::Boolean { value } => Some((BigInt::from(u8::from(*value)), true)), + ConstantData::Integer { value } => Some((value.clone(), false)), + _ => None, + } +} + +/// flowgraph.c eval_const_binop +fn eval_const_binop( + left: &ConstantData, + right: &ConstantData, + op: oparg::BinaryOperator, +) -> Option { + use oparg::BinaryOperator as BinOp; + + if matches!(op, BinOp::Subscr) { + return eval_const_subscript(left, right); + } + + if let (Some((left_int, left_is_bool)), Some((right_int, right_is_bool))) = + (constant_as_int(left), constant_as_int(right)) + && (left_is_bool || right_is_bool) + { + if left_is_bool && right_is_bool { + match op { + BinOp::And => { + return Some(ConstantData::Boolean { + value: !left_int.is_zero() & !right_int.is_zero(), + }); + } + BinOp::Or => { + return Some(ConstantData::Boolean { + value: !left_int.is_zero() | !right_int.is_zero(), + }); + } + BinOp::Xor => { + return Some(ConstantData::Boolean { + value: !left_int.is_zero() ^ !right_int.is_zero(), + }); + } + _ => {} + } + } + + return eval_const_binop( + &ConstantData::Integer { value: left_int }, + &ConstantData::Integer { value: right_int }, + op, + ); + } + + match (left, right) { + (ConstantData::Integer { value: l }, ConstantData::Integer { value: r }) => { + let result = match op { + BinOp::Add => l + r, + BinOp::Subtract => l - r, + BinOp::Multiply => { + return const_folding_safe_multiply(left, right); + } + BinOp::TrueDivide => { + if r.is_zero() { + return None; + } + let l_f = l.to_f64()?; + let r_f = r.to_f64()?; + let result = l_f / r_f; + if !result.is_finite() { + return None; + } + return Some(ConstantData::Float { value: result }); + } + BinOp::FloorDivide => { + if r.is_zero() { + return None; + } + // Python floor division: round towards negative infinity + let (q, rem) = (l.clone() / r.clone(), l.clone() % r.clone()); + if !rem.is_zero() && (rem < BigInt::from(0)) != (*r < BigInt::from(0)) { + q - 1 + } else { + q + } + } + BinOp::Remainder => return const_folding_safe_mod(left, right), + BinOp::Power => return const_folding_safe_power(left, right), + BinOp::Lshift => return const_folding_safe_lshift(left, right), + BinOp::Rshift => { + let shift: u32 = r.try_into().ok()?; + l >> (shift as usize) + } + BinOp::And => l & r, + BinOp::Or => l | r, + BinOp::Xor => l ^ r, + _ => return None, + }; + Some(ConstantData::Integer { value: result }) + } + (ConstantData::Float { value: l }, ConstantData::Float { value: r }) => { + let result = match op { + BinOp::Add => l + r, + BinOp::Subtract => l - r, + BinOp::Multiply => return const_folding_safe_multiply(left, right), + BinOp::TrueDivide => { + if *r == 0.0 { + return None; + } + l / r + } + BinOp::FloorDivide => { + let (floordiv, _) = float_div_mod(*l, *r)?; + floordiv + } + BinOp::Remainder => return const_folding_safe_mod(left, right), + BinOp::Power => return const_folding_safe_power(left, right), + _ => return None, + }; + if matches!(op, BinOp::Power) && !result.is_finite() { + return None; + } + Some(ConstantData::Float { value: result }) + } + // Int op Float or Float op Int → Float + (ConstantData::Integer { value: l }, ConstantData::Float { value: r }) => { + let l_f = l.to_f64()?; + eval_const_binop( + &ConstantData::Float { value: l_f }, + &ConstantData::Float { value: *r }, + op, + ) + } + (ConstantData::Float { value: l }, ConstantData::Integer { value: r }) => { + let r_f = r.to_f64()?; + eval_const_binop( + &ConstantData::Float { value: *l }, + &ConstantData::Float { value: r_f }, + op, + ) + } + (ConstantData::Integer { value: l }, ConstantData::Complex { value: r }) => { + eval_const_complex_binop(Complex::new(l.to_f64()?, 0.0), *r, op) + } + (ConstantData::Complex { value: l }, ConstantData::Integer { value: r }) => { + eval_const_complex_binop(*l, Complex::new(r.to_f64()?, 0.0), op) + } + (ConstantData::Float { value: l }, ConstantData::Complex { value: r }) => { + eval_const_complex_binop(Complex::new(*l, 0.0), *r, op) + } + (ConstantData::Complex { value: l }, ConstantData::Float { value: r }) => { + eval_const_complex_binop(*l, Complex::new(*r, 0.0), op) + } + (ConstantData::Complex { value: l }, ConstantData::Complex { value: r }) => { + eval_const_complex_binop(*l, *r, op) + } + // String concatenation and repetition + (ConstantData::Str { value: l }, ConstantData::Str { value: r }) + if matches!(op, BinOp::Add) => + { + let mut result = Wtf8Buf::new(); + result + .try_reserve_exact(l.len().checked_add(r.len())?) + .ok()?; + result.push_wtf8(l); + result.push_wtf8(r); + Some(ConstantData::Str { value: result }) + } + (ConstantData::Str { .. }, ConstantData::Integer { .. }) + if matches!(op, BinOp::Multiply) => + { + const_folding_safe_multiply(left, right) + } + (ConstantData::Tuple { elements: l }, ConstantData::Tuple { elements: r }) + if matches!(op, BinOp::Add) => + { + let mut result = Vec::new(); + result + .try_reserve_exact(l.len().checked_add(r.len())?) + .ok()?; + result.extend(l.iter().cloned()); + result.extend(r.iter().cloned()); + Some(ConstantData::Tuple { elements: result }) + } + (ConstantData::Tuple { .. }, ConstantData::Integer { .. }) + if matches!(op, BinOp::Multiply) => + { + const_folding_safe_multiply(left, right) + } + (ConstantData::Integer { .. }, ConstantData::Tuple { .. }) + if matches!(op, BinOp::Multiply) => + { + const_folding_safe_multiply(left, right) + } + (ConstantData::Integer { .. }, ConstantData::Str { .. }) + if matches!(op, BinOp::Multiply) => + { + const_folding_safe_multiply(left, right) + } + (ConstantData::Bytes { value: l }, ConstantData::Bytes { value: r }) + if matches!(op, BinOp::Add) => + { + let mut result = Vec::new(); + result + .try_reserve_exact(l.len().checked_add(r.len())?) + .ok()?; + result.extend_from_slice(l); + result.extend_from_slice(r); + Some(ConstantData::Bytes { value: result }) + } + (ConstantData::Bytes { .. }, ConstantData::Integer { .. }) + if matches!(op, BinOp::Multiply) => + { + const_folding_safe_multiply(left, right) + } + (ConstantData::Integer { .. }, ConstantData::Bytes { .. }) + if matches!(op, BinOp::Multiply) => + { + const_folding_safe_multiply(left, right) + } + _ => None, + } +} + +/// flowgraph.c fold_tuple_of_constants +fn fold_tuple_of_constants( + metadata: &mut CodeUnitMetadata, + block: &mut Block, + i: usize, +) -> crate::InternalResult { + let Some(Opcode::BuildTuple) = block.instructions[i].instr.real_opcode() else { + return Ok(false); + }; - Ok(trace) + let tuple_size = u32::from(block.instructions[i].arg) as usize; + if tuple_size > STACK_USE_GUIDELINE { + return Ok(false); } -} -impl InstrDisplayContext for CodeInfo { - type Constant = ConstantData; + let Some(operand_indices) = (if tuple_size == 0 { + Some(Vec::new()) + } else if let Some(start) = i.checked_sub(1) { + get_const_loading_instrs(block, start, tuple_size)? + } else { + None + }) else { + return Ok(false); + }; - fn get_constant(&self, consti: oparg::ConstIdx) -> &ConstantData { - &self.metadata.consts[consti.as_usize()] + let mut elements = Vec::new(); + elements + .try_reserve_exact(tuple_size) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + for &j in &operand_indices { + let Some(element) = get_const_value(metadata, &block.instructions[j]) else { + return Ok(false); + }; + elements.push(element); } - fn get_name(&self, i: usize) -> &str { - self.metadata.names[i].as_ref() - } + nop_out(block, &operand_indices); + instr_make_load_const( + metadata, + &mut block.instructions[i], + ConstantData::Tuple { elements }, + )?; + Ok(true) +} - fn get_varname(&self, var_num: oparg::VarNum) -> &str { - self.metadata.varnames[var_num.as_usize()].as_ref() +fn fold_constant_intrinsic_list_to_tuple( + metadata: &mut CodeUnitMetadata, + block: &mut Block, + i: usize, +) -> crate::InternalResult { + let Some(Instruction::CallIntrinsic1 { func }) = block.instructions[i].instr.real() else { + return Ok(false); + }; + if func.get(block.instructions[i].arg) != IntrinsicFunction1::ListToTuple { + return Ok(false); } - fn get_localsplus_name(&self, var_num: oparg::VarNum) -> &str { - let idx = var_num.as_usize(); - let nlocals = self.metadata.varnames.len(); - if idx < nlocals { - self.metadata.varnames[idx].as_ref() + let mut consts_found = 0usize; + let mut expect_append = true; + let mut pos = i; + while let Some(prev) = pos.checked_sub(1) { + pos = prev; + let instr = &block.instructions[pos]; + if matches!(instr.instr.real(), Some(Instruction::Nop)) { + continue; + } + + if matches!(instr.instr.real(), Some(Instruction::BuildList { .. })) + && u32::from(instr.arg) == 0 + { + if !expect_append { + return Ok(false); + } + + let mut elements = Vec::new(); + elements + .try_reserve_exact(consts_found) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + for idx in (pos..i).rev() { + if matches!(block.instructions[idx].instr.real(), Some(Instruction::Nop)) { + continue; + } + if loads_const(&block.instructions[idx]) { + let Some(value) = get_const_value(metadata, &block.instructions[idx]) else { + return Ok(false); + }; + elements.push(value); + } + nop_out_no_location(&mut block.instructions[idx]); + } + debug_assert_eq!(elements.len(), consts_found); + elements.reverse(); + instr_make_load_const( + metadata, + &mut block.instructions[i], + ConstantData::Tuple { elements }, + )?; + return Ok(true); + } + + if expect_append { + if !matches!(instr.instr.real(), Some(Instruction::ListAppend { .. })) + || u32::from(instr.arg) != 1 + { + return Ok(false); + } } else { - let cell_idx = idx - nlocals; - self.metadata - .cellvars - .get_index(cell_idx) - .unwrap_or_else(|| &self.metadata.freevars[cell_idx - self.metadata.cellvars.len()]) - .as_ref() + if !loads_const(instr) { + return Ok(false); + } + consts_found += 1; } + expect_append = !expect_append; } + + Ok(false) } -const NOT_LOCAL: isize = -1; -const DUMMY_INSTR: isize = -1; +/// Port of CPython's flowgraph.c optimize_lists_and_sets(). +fn optimize_lists_and_sets( + metadata: &mut CodeUnitMetadata, + block: &mut Block, + i: usize, + nextop: Option, +) -> crate::InternalResult { + let Some(instr) = block.instructions[i].instr.real() else { + return Ok(false); + }; + let is_list = matches!(instr, Instruction::BuildList { .. }); + let is_set = matches!(instr, Instruction::BuildSet { .. }); + if !is_list && !is_set { + return Ok(false); + } -/// flowgraph.c make_super_instruction -fn make_super_instruction( - inst1: &mut InstructionInfo, - inst2: &mut InstructionInfo, - super_op: AnyInstruction, -) { - let line1 = instruction_lineno(inst1); - let line2 = instruction_lineno(inst2); - if line1 >= 0 && line2 >= 0 && line1 != line2 { - return; + let contains_or_iter = matches!( + nextop, + Some(Instruction::GetIter | Instruction::ContainsOp { .. }) + ); + let seq_size = u32::from(block.instructions[i].arg) as usize; + if seq_size > STACK_USE_GUIDELINE || (seq_size < MIN_CONST_SEQUENCE_SIZE && !contains_or_iter) { + return Ok(false); } - let arg1 = u32::from(inst1.arg); - let arg2 = u32::from(inst2.arg); - if arg1 >= 16 || arg2 >= 16 { - return; + + let Some(operand_indices) = (if seq_size == 0 { + Some(Vec::new()) + } else if let Some(start) = i.checked_sub(1) { + get_const_loading_instrs(block, start, seq_size)? + } else { + None + }) else { + if contains_or_iter && is_list { + let arg = block.instructions[i].arg; + instr_set_op1(&mut block.instructions[i], Opcode::BuildTuple.into(), arg); + return Ok(true); + } + return Ok(false); + }; + + let mut elements = Vec::new(); + elements + .try_reserve_exact(seq_size) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + for &j in &operand_indices { + let Some(element) = get_const_value(metadata, &block.instructions[j]) else { + return Ok(false); + }; + elements.push(element); } - instr_set_op1(inst1, super_op, OpArg::new((arg1 << 4) | arg2)); - set_to_nop(inst2); -} -/// flowgraph.c LoadFastInstrFlag -#[derive(Clone, Copy, Eq, PartialEq)] -#[repr(u8)] -enum LoadFastInstrFlag { - SupportKilled = 1, - StoredAsLocal = 2, - RefUnconsumed = 4, -} + let const_data = if is_list { + ConstantData::Tuple { elements } + } else { + ConstantData::Frozenset { elements } + }; + let const_idx = add_const(metadata, const_data)?; -/// flowgraph.c ref -#[derive(Clone, Copy)] -struct Ref { - instr: isize, - local: isize, -} + if !contains_or_iter { + debug_assert!(i >= 2); + let folded_loc = block.instructions[i].location; + let end_loc = block.instructions[i].end_location; -/// flowgraph.c ref_stack -struct RefStack { - refs: Vec, - size: usize, - capacity: usize, -} + nop_out(block, &operand_indices); -/// flowgraph.c ref_stack_push -fn ref_stack_push(stack: &mut RefStack, r: Ref) -> crate::InternalResult<()> { - debug_assert_eq!(stack.refs.len(), stack.capacity); - if stack.size == stack.capacity { - let doubled = stack.capacity * 2; - let new_cap = 32.max(doubled); - stack - .refs - .try_reserve_exact(new_cap - stack.capacity) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - stack.refs.resize(new_cap, Ref { instr: 0, local: 0 }); - stack.capacity = new_cap; + let build_instr = if is_list { + Opcode::BuildList + } else { + Opcode::BuildSet + } + .into(); + instr_set_op1(&mut block.instructions[i - 2], build_instr, OpArg::new(0)); + block.instructions[i - 2].location = folded_loc; + block.instructions[i - 2].end_location = end_loc; + block.instructions[i - 2].lineno_override = None; + + instr_set_op1( + &mut block.instructions[i - 1], + Opcode::LoadConst.into(), + OpArg::new(const_idx as u32), + ); + + let extend_instr = if is_list { + Opcode::ListExtend + } else { + Opcode::SetUpdate + }; + instr_set_op1( + &mut block.instructions[i], + extend_instr.into(), + OpArg::new(1), + ); + return Ok(true); } - stack.refs[stack.size] = r; - stack.size += 1; - Ok(()) -} -/// flowgraph.c ref_stack_pop -fn ref_stack_pop(stack: &mut RefStack) -> Ref { - assert!(stack.size > 0); - stack.size -= 1; - stack.refs[stack.size] -} + nop_out(block, &operand_indices); -/// flowgraph.c ref_stack_swap_top -fn ref_stack_swap_top(stack: &mut RefStack, off: usize) { - assert!(off >= 2 && stack.size >= off); - let top = stack.size - 1; - let other = stack.size - off; - stack.refs.swap(top, other); + instr_set_op1( + &mut block.instructions[i], + Opcode::LoadConst.into(), + OpArg::new(const_idx as u32), + ); + Ok(true) } -/// flowgraph.c ref_stack_at -fn ref_stack_at(stack: &RefStack, idx: usize) -> Ref { - assert!(idx < stack.size); - stack.refs[idx] -} +/// flowgraph.c VISITED +const VISITED: i32 = -1; -/// flowgraph.c ref_stack_clear -fn ref_stack_clear(stack: &mut RefStack) { - stack.size = 0; +/// flowgraph.c SWAPPABLE +fn is_swappable(instr: AnyInstruction) -> bool { + matches!( + instr.into(), + AnyOpcode::Real(Opcode::StoreFast | Opcode::PopTop) + | AnyOpcode::Pseudo(PseudoOpcode::StoreFastMaybeNull) + ) } -/// flowgraph.c optimize_load_fast PUSH_REF -fn push_ref(stack: &mut RefStack, instr: isize, local: isize) -> crate::InternalResult<()> { - ref_stack_push(stack, Ref { instr, local }) +/// flowgraph.c STORES_TO +fn stores_to(info: &InstructionInfo) -> i32 { + match info.instr.into() { + AnyOpcode::Real(Opcode::StoreFast) + | AnyOpcode::Pseudo(PseudoOpcode::StoreFastMaybeNull) => u32::from(info.arg) as i32, + _ => -1, + } } -/// flowgraph.c kill_local -fn kill_local(instr_flags: &mut [u8], refs: &RefStack, local: isize) { - for i in 0..refs.size { - let r = ref_stack_at(refs, i); - if r.local != local { +/// flowgraph.c next_swappable_instruction +fn next_swappable_instruction(block: &Block, mut i: usize, lineno: i32) -> Option { + loop { + i += 1; + if i >= block.instruction_used { + return None; + } + + let info = &block.instructions[i]; + let info_lineno = instruction_lineno(info); + + if lineno >= 0 && info_lineno != lineno { + return None; + } + + if matches!(info.instr, AnyInstruction::Real(Instruction::Nop)) { continue; } - debug_assert!(r.instr >= 0); - instr_flags[r.instr as usize] |= LoadFastInstrFlag::SupportKilled as u8; + + if is_swappable(info.instr) { + return Some(i); + } + + return None; } } -/// flowgraph.c store_local -fn store_local(instr_flags: &mut [u8], refs: &RefStack, local: isize, r: Ref) { - kill_local(instr_flags, refs, local); - if r.instr != DUMMY_INSTR { - instr_flags[r.instr as usize] |= LoadFastInstrFlag::StoredAsLocal as u8; +/// flowgraph.c swaptimize +fn swaptimize(block: &mut Block, ix: &mut usize) -> crate::InternalResult<()> { + debug_assert!(matches!( + block.instructions[*ix].instr.real_opcode(), + Some(Opcode::Swap) + )); + let mut depth = u32::from(block.instructions[*ix].arg) as usize; + let mut len = 1usize; + let mut more = false; + let limit = block.instruction_used - *ix; + while len < limit { + match block.instructions[*ix + len].instr.real_opcode() { + Some(Opcode::Swap) => { + depth = depth.max(u32::from(block.instructions[*ix + len].arg) as usize); + more = true; + len += 1; + } + Some(Opcode::Nop) => { + len += 1; + } + _ => break, + } } -} -fn local_as_ref_local(local: usize) -> isize { - local as isize -} + if !more { + return Ok(()); + } -/// flowgraph.c load_fast_push_block -fn load_fast_push_block( - worklist: &mut CfgTraversalStack, - blocks: &mut Blocks, - target: BlockIdx, - start_depth: usize, -) { - debug_assert!(target != BlockIdx::NULL); - debug_assert!(blocks[target].start_depth >= 0); - debug_assert_eq!(blocks[target].start_depth as usize, start_depth,); - if !blocks[target].visited { - blocks[target].visited = true; - worklist.push(target); + let mut stack = Vec::new(); + stack + .try_reserve_exact(depth) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + stack.resize(depth, 0); + let mut i = 0; + while i < depth { + stack[i] = i as i32; + i += 1; } -} -fn stackdepth_push( - stack: &mut CfgTraversalStack, - blocks: &mut Blocks, - target: BlockIdx, - depth: i32, -) -> crate::InternalResult<()> { - let idx = target.idx(); - let block_depth = &mut blocks[idx].start_depth; - if !(*block_depth < 0 || *block_depth == depth) { - return Err(InternalError::InconsistentStackDepth); + i = 0; + while i < len { + let info = &block.instructions[*ix + i]; + if matches!(info.instr.real_opcode(), Some(Opcode::Swap)) { + let oparg = u32::from(info.arg) as usize; + stack.swap(0, oparg - 1); + } + i += 1; } - if *block_depth < depth && *block_depth < 100 { - debug_assert!(*block_depth < 0); - *block_depth = depth; - stack.push(target); + + let mut current = len as isize - 1; + for i in 0..depth { + if stack[i] == VISITED || stack[i] == i as i32 { + continue; + } + let mut j = i; + loop { + if j != 0 { + debug_assert!(current >= 0); + let out = &mut block.instructions[*ix + current as usize]; + out.instr = Opcode::Swap.into(); + out.arg = OpArg::new((j + 1) as u32); + current -= 1; + } + if stack[j] == VISITED { + debug_assert_eq!(j, i); + break; + } + let next_j = stack[j] as usize; + stack[j] = VISITED; + j = next_j; + } + } + + while current >= 0 { + set_to_nop(&mut block.instructions[*ix + current as usize]); + current -= 1; } + *ix += len - 1; Ok(()) } -/// flowgraph.c stack_effects -#[derive(Clone, Copy, Eq, PartialEq)] -struct StackEffects { - net: i32, -} +/// flowgraph.c apply_static_swaps +fn apply_static_swaps(block: &mut Block, mut i: isize) { + while i >= 0 { + let idx = i as usize; + debug_assert!(idx < block.instruction_used); + let swap_arg = match block.instructions[idx].instr.real_opcode() { + Some(Opcode::Swap) => u32::from(block.instructions[idx].arg), + Some(Opcode::Nop | Opcode::PopTop | Opcode::StoreFast) => { + i -= 1; + continue; + } + _ if matches!( + block.instructions[idx].instr.pseudo_opcode(), + Some(PseudoOpcode::StoreFastMaybeNull) + ) => + { + i -= 1; + continue; + } + _ => return, + }; -/// flowgraph.c get_stack_effects -#[allow(clippy::unnecessary_wraps)] -fn get_stack_effects( - instr: AnyInstruction, - oparg: OpArg, - jump: i32, -) -> crate::InternalResult { - if instr - .real() - .is_some_and(|op| op.as_opcode().deopt().is_some()) - { - return Err(InternalError::InvalidStackEffect); - } - let oparg = u32::from(oparg); - let net = if instr.is_block_push() && jump == 0 { - 0 - } else if jump != 0 { - instr.stack_effect_jump(oparg) - } else { - instr.stack_effect(oparg) - }; - Ok(StackEffects { net }) -} + let Some(j) = next_swappable_instruction(block, idx, -1) else { + return; + }; + let lineno = instruction_lineno(&block.instructions[j]); + let mut k = j; + for _ in 1..swap_arg { + let Some(next) = next_swappable_instruction(block, k, lineno) else { + return; + }; + k = next; + } + + let store_j = stores_to(&block.instructions[j]); + let store_k = stores_to(&block.instructions[k]); + if store_j >= 0 || store_k >= 0 { + if store_j == store_k { + return; + } + let mut idx = j + 1; + while idx < k { + let store_idx = stores_to(&block.instructions[idx]); + if store_idx >= 0 && (store_idx == store_j || store_idx == store_k) { + return; + } + idx += 1; + } + } -fn vec_try_reserve_exact(vec: &mut Vec, additional: usize) -> crate::InternalResult<()> { - vec.try_reserve_exact(additional) - .map_err(|_| InternalError::MalformedControlFlowGraph) + set_to_nop(&mut block.instructions[idx]); + block.instructions.swap(j, k); + i -= 1; + } } -fn vec_try_resize_to_double_capacity(vec: &mut Vec) -> crate::InternalResult<()> { - let capacity = vec.capacity(); - debug_assert!(capacity > 0); - let len = capacity - .checked_mul(core::mem::size_of::()) - .ok_or(InternalError::MalformedControlFlowGraph)?; - if capacity == 0 || len > usize::MAX / 2 { - return Err(InternalError::MalformedControlFlowGraph); +/// flowgraph.c optimize_basic_block swap pass +fn apply_static_swaps_block(block: &mut Block) -> crate::InternalResult<()> { + let mut i = 0; + while i < block.instruction_used { + if matches!( + block.instructions[i].instr.real_opcode(), + Some(Opcode::Swap) + ) { + swaptimize(block, &mut i)?; + apply_static_swaps(block, i as isize); + } + i += 1; } - let new_capacity = capacity * 2; - let additional = new_capacity - .checked_sub(vec.len()) - .ok_or(InternalError::MalformedControlFlowGraph)?; - vec_try_reserve_exact(vec, additional) + Ok(()) } -/// assemble.c write_location_first_byte -fn write_location_first_byte(linetable: &mut Vec, code: u8, length: usize) { - linetable.extend(write_location_entry_start(code, length)); +/// flowgraph.c maybe_instr_make_load_smallint +fn maybe_instr_make_load_smallint(instr: &mut InstructionInfo, constant: &ConstantData) -> bool { + if let ConstantData::Integer { value } = constant + && let Some(small) = value.to_i32().filter(|v| (0..=255).contains(v)) + { + instr_set_op1(instr, Opcode::LoadSmallInt.into(), OpArg::new(small as u32)); + return true; + } + false } -/// pycore_code.h write_location_entry_start -fn write_location_entry_start(code: u8, length: usize) -> [u8; 1] { - debug_assert!(length > 0 && length <= 8); - debug_assert_eq!(code & 15, code); - [0x80 | (code << 3) | ((length - 1) as u8)] -} +/// flowgraph.c basicblock_optimize_load_const +fn basicblock_optimize_load_const( + metadata: &mut CodeUnitMetadata, + block: &mut Block, +) -> crate::InternalResult<()> { + let mut i = 0; + let mut effective_opcode = None; + let mut effective_oparg = OpArg::new(0); + while i < block.instruction_used { + if matches!( + block.instructions[i].instr.real(), + Some(Instruction::LoadConst { .. }) + ) && let Some(constant) = get_const_value(metadata, &block.instructions[i]) + { + maybe_instr_make_load_smallint(&mut block.instructions[i], &constant); + } -/// assemble.c write_location_byte -fn write_location_byte(linetable: &mut Vec, value: u8) { - linetable.push(value); -} + let curr = block.instructions[i]; + let curr_arg = curr.arg; -/// assemble.c write_location_varint -fn write_location_varint(linetable: &mut Vec, value: u32) { - write_varint(linetable, value); -} + // Only combine if the source is a real instruction. + let Some(curr_instr) = curr.instr.real() else { + i += 1; + continue; + }; -/// assemble.c write_location_signed_varint -fn write_location_signed_varint(linetable: &mut Vec, value: i32) { - write_signed_varint(linetable, value); -} + let is_copy_of_load_const = matches!( + (effective_opcode, curr_instr), + (Some(Instruction::LoadConst { .. }), Instruction::Copy { i }) if i.get(curr_arg) == 1 + ); + if !is_copy_of_load_const { + effective_opcode = Some(curr_instr); + effective_oparg = curr_arg; + } + let Some(const_instr) = effective_opcode else { + i += 1; + continue; + }; + let const_arg = effective_oparg; -/// assemble.c write_location_info_short_form -fn write_location_info_short_form( - linetable: &mut Vec, - length: usize, - column: i32, - end_column: i32, -) { - debug_assert!(length > 0 && length <= 8); - debug_assert!(column < 80); - debug_assert!(end_column >= column); - debug_assert!(end_column - column < 16); - let column_low_bits = column & 7; - let column_group = column >> 3; - let code = PyCodeLocationInfoKind::Short0 as u8 + column_group as u8; - write_location_first_byte(linetable, code, length); - write_location_byte( - linetable, - ((column_low_bits as u8) << 4) | ((end_column - column) as u8), - ); -} + if i + 1 >= block.instruction_used { + i += 1; + continue; + } -/// assemble.c write_location_info_oneline_form -fn write_location_info_oneline_form( - linetable: &mut Vec, - length: usize, - line_delta: i32, - column: i32, - end_column: i32, -) { - debug_assert!(length > 0 && length <= 8); - debug_assert!((0..3).contains(&line_delta)); - debug_assert!(column < 128); - debug_assert!(end_column < 128); - let code = PyCodeLocationInfoKind::OneLine0 as u8 + line_delta as u8; - write_location_first_byte(linetable, code, length); - write_location_byte(linetable, column as u8); - write_location_byte(linetable, end_column as u8); -} + let next = block.instructions[i + 1]; + let next_arg = next.arg; -/// assemble.c write_location_info_long_form -fn write_location_info_long_form( - linetable: &mut Vec, - loc: LineTableLocation, - length: usize, - line_delta: i32, -) { - debug_assert!(length > 0 && length <= 8); - write_location_first_byte(linetable, PyCodeLocationInfoKind::Long as u8, length); - write_location_signed_varint(linetable, line_delta); - debug_assert!(loc.end_line >= loc.line); - write_location_varint(linetable, (loc.end_line - loc.line) as u32); - write_location_varint( - linetable, - if loc.col < 0 { 0 } else { (loc.col as u32) + 1 }, - ); - write_location_varint( - linetable, - if loc.end_col < 0 { - 0 - } else { - (loc.end_col as u32) + 1 - }, - ); -} + if let Some(is_true) = load_const_truthiness(const_instr, const_arg, metadata) { + let const_jump = match (next.instr.real_opcode(), next.instr.pseudo_opcode()) { + (_, Some(PseudoOpcode::JumpIfTrue)) => Some((true, false)), + (_, Some(PseudoOpcode::JumpIfFalse)) => Some((false, false)), + (Some(Opcode::PopJumpIfTrue), _) => Some((true, true)), + (Some(Opcode::PopJumpIfFalse), _) => Some((false, true)), + _ => None, + }; + if let Some((jump_if_true, pops_condition)) = const_jump { + if pops_condition { + set_to_nop(&mut block.instructions[i]); + } + if is_true == jump_if_true { + block.instructions[i + 1].instr = PseudoOpcode::Jump.into(); + } else { + set_to_nop(&mut block.instructions[i + 1]); + } + i += 1; + continue; + } + } -/// assemble.c write_location_info_none -fn write_location_info_none(linetable: &mut Vec, length: usize) { - write_location_first_byte(linetable, PyCodeLocationInfoKind::None as u8, length); -} + // The remaining combinations require both instructions to be real. + let Some(next_instr) = next.instr.real() else { + i += 1; + continue; + }; -/// assemble.c write_location_info_no_column -fn write_location_info_no_column(linetable: &mut Vec, length: usize, line_delta: i32) { - write_location_first_byte(linetable, PyCodeLocationInfoKind::NoColumns as u8, length); - write_location_signed_varint(linetable, line_delta); -} + if let Instruction::LoadConst { consti } = const_instr { + let constant = &metadata.consts[consti.get(const_arg).as_usize()]; + if matches!(constant, ConstantData::None) + && let Instruction::IsOp { invert } = next_instr + { + let mut jump_idx = i + 2; + if jump_idx >= block.instruction_used { + i += 1; + continue; + } -/// assemble.c write_location_info_entry -fn write_location_info_entry( - linetable: &mut Vec, - loc: LineTableLocation, - length: usize, - prev_line: &mut i32, - debug_ranges: bool, -) -> crate::InternalResult<()> { - const THEORETICAL_MAX_ENTRY_SIZE: usize = 25; - if linetable - .len() - .checked_add(THEORETICAL_MAX_ENTRY_SIZE) - .ok_or(InternalError::MalformedControlFlowGraph)? - >= linetable.capacity() - { - debug_assert!(linetable.capacity() > THEORETICAL_MAX_ENTRY_SIZE); - vec_try_resize_to_double_capacity(linetable)?; - } - if loc.line == NO_LOCATION_OVERRIDE { - write_location_info_none(linetable, length); - return Ok(()); - } + if matches!( + block.instructions[jump_idx].instr.real(), + Some(Instruction::ToBool) + ) { + set_to_nop(&mut block.instructions[jump_idx]); + jump_idx += 1; + if jump_idx >= block.instruction_used { + i += 1; + continue; + } + } - let line_delta = loc.line - *prev_line; - let column = loc.col; - let end_column = loc.end_col; - if !debug_ranges - || ((column < 0 || end_column < 0) && (loc.end_line == loc.line || loc.end_line < 0)) - { - write_location_info_no_column(linetable, length, line_delta); - *prev_line = loc.line; - return Ok(()); - } + let Some(jump_instr) = block.instructions[jump_idx].instr.real() else { + i += 1; + continue; + }; - if loc.end_line == loc.line { - if line_delta == 0 && column < 80 && end_column - column < 16 && end_column >= column { - write_location_info_short_form(linetable, length, column, end_column); - return Ok(()); + let mut invert = matches!( + invert.get(next_arg), + rustpython_compiler_core::bytecode::Invert::Yes + ); + match jump_instr { + Instruction::PopJumpIfFalse { .. } => { + invert = !invert; + } + Instruction::PopJumpIfTrue { .. } => {} + _ => { + i += 1; + continue; + } + }; + + set_to_nop(&mut block.instructions[i]); + set_to_nop(&mut block.instructions[i + 1]); + block.instructions[jump_idx].instr = if invert { + Opcode::PopJumpIfNotNone + } else { + Opcode::PopJumpIfNone + } + .into(); + i = jump_idx; + continue; + } } - if (0..3).contains(&line_delta) && column < 128 && end_column < 128 { - write_location_info_oneline_form(linetable, length, line_delta, column, end_column); - *prev_line = loc.line; - return Ok(()); + + if matches!( + const_instr, + Instruction::LoadConst { .. } | Instruction::LoadSmallInt { .. } + ) && matches!(next_instr, Instruction::ToBool) + && let Some(value) = load_const_truthiness(const_instr, const_arg, metadata) + { + let const_idx = add_const(metadata, ConstantData::Boolean { value })?; + set_to_nop(&mut block.instructions[i]); + instr_set_op1( + &mut block.instructions[i + 1], + Opcode::LoadConst.into(), + OpArg::new(const_idx as u32), + ); + i += 1; + continue; } - } - write_location_info_long_form(linetable, loc, length, line_delta); - *prev_line = loc.line; + i += 1; + } Ok(()) } -/// assemble.c assemble_emit_location -fn assemble_emit_location( - linetable: &mut Vec, - loc: LineTableLocation, - mut size: usize, - prev_line: &mut i32, - debug_ranges: bool, +/// flowgraph.c optimize_load_const +fn optimize_load_const( + metadata: &mut CodeUnitMetadata, + blocks: &mut Blocks, ) -> crate::InternalResult<()> { - if size == 0 { - return Ok(()); - } - while size > 8 { - write_location_info_entry(linetable, loc, 8, prev_line, debug_ranges)?; - size -= 8; + let mut block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + let next_block = blocks[block_idx.idx()].next; + let block = &mut blocks[block_idx]; + basicblock_optimize_load_const(metadata, block)?; + block_idx = next_block; } - write_location_info_entry(linetable, loc, size, prev_line, debug_ranges) + Ok(()) } -fn no_linetable_location() -> LineTableLocation { - LineTableLocation { - line: NO_LOCATION_OVERRIDE, - end_line: NO_LOCATION_OVERRIDE, - col: NO_LOCATION_OVERRIDE, - end_col: NO_LOCATION_OVERRIDE, +#[cfg(test)] +impl CodeInfo { + fn debug_block_dump(&self) -> String { + let mut out = String::new(); + let mut block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + use core::fmt::Write; + let block = &self.blocks[block_idx.idx()]; + let block_return = if basicblock_returns(block) { + " return" + } else { + "" + }; + let _ = writeln!( + out, + "block {} next={} cold={} except={} preserve_lasti={} start_depth={}{}", + u32::from(block_idx), + if block.next == BlockIdx::NULL { + String::from("NULL") + } else { + u32::from(block.next).to_string() + }, + block.cold, + block.except_handler, + block.preserve_lasti, + if block.start_depth < 0 { + String::from("None") + } else { + block.start_depth.to_string() + }, + block_return, + ); + for info in &block.instructions[..block.instruction_used] { + let lineno = instruction_lineno(info); + let _ = writeln!( + out, + " [disp={}:{} raw={}:{}-{}:{} override={:?}] {:?} arg={} target={}", + lineno, + info.location.character_offset.get(), + info.location.line.get(), + info.location.character_offset.get(), + info.end_location.line.get(), + info.end_location.character_offset.get(), + info.lineno_override, + info.instr, + u32::from(info.arg), + if info.target == BlockIdx::NULL { + String::from("NULL") + } else { + u32::from(info.target).to_string() + } + ); + } + block_idx = block.next; + } + out } -} -fn next_linetable_location() -> LineTableLocation { - LineTableLocation { - line: NEXT_LOCATION_OVERRIDE, - end_line: NEXT_LOCATION_OVERRIDE, - col: NEXT_LOCATION_OVERRIDE, - end_col: NEXT_LOCATION_OVERRIDE, + pub(crate) fn debug_late_cfg_trace(mut self) -> crate::InternalResult> { + let mut trace = Vec::new(); + trace.push(("initial".to_owned(), self.debug_block_dump())); + + let instr_sequence = self.prepare_cfg_from_codegen()?; + self.blocks = cfg_from_instruction_sequence(instr_sequence)?; + trace.push(( + "after_cfg_from_instruction_sequence".to_owned(), + self.debug_block_dump(), + )); + translate_jump_labels_to_targets(&mut self.blocks)?; + self.blocks.mark_except_handlers()?; + label_exception_targets(&mut self.blocks)?; + self.blocks.check_cfg()?; + self.blocks.inline_small_or_no_lineno_blocks()?; + trace.push(( + "after_inline_small_or_no_lineno_blocks".to_owned(), + self.debug_block_dump(), + )); + self.blocks.remove_unreachable()?; + self.blocks + .resolve_line_numbers(self.metadata.firstlineno)?; + optimize_load_const(&mut self.metadata, &mut self.blocks)?; + trace.push(( + "after_optimize_load_const".to_owned(), + self.debug_block_dump(), + )); + let mut block_idx = BlockIdx(0); + while block_idx != BlockIdx::NULL { + let next_block = self.blocks[block_idx].next; + self.blocks + .optimize_basic_block(&mut self.metadata, block_idx)?; + block_idx = next_block; + } + trace.push(( + "after_optimize_basic_block".to_owned(), + self.debug_block_dump(), + )); + self.blocks.remove_redundant_nops_and_pairs()?; + self.blocks.remove_unreachable()?; + self.blocks.remove_redundant_nops_and_jumps()?; + + #[cfg(debug_assertions)] + assert!(self.blocks.no_redundant_jumps()); + + self.blocks + .remove_unused_consts(&mut self.metadata.consts)?; + trace.push(( + "after_optimize_cfg_cleanup".to_owned(), + self.debug_block_dump(), + )); + let nlocals = self.metadata.varnames.len(); + let nparams = self.nparams; + add_checks_for_loads_of_uninitialized_variables(&mut self.blocks, nlocals, nparams)?; + self.blocks.insert_superinstructions()?; + self.blocks.push_cold_blocks_to_end()?; + trace.push(( + "after_push_cold_before_chain_reorder".to_owned(), + self.debug_block_dump(), + )); + self.blocks + .resolve_line_numbers(self.metadata.firstlineno)?; + trace.push(( + "after_push_cold_resolve_line_numbers".to_owned(), + self.debug_block_dump(), + )); + + trace.push(( + "after_push_cold_blocks_to_end".to_owned(), + self.debug_block_dump(), + )); + + self.blocks.convert_pseudo_conditional_jumps()?; + trace.push(( + "after_convert_pseudo_conditional_jumps".to_owned(), + self.debug_block_dump(), + )); + + let _max_stackdepth = self.blocks.calculate_stackdepth()?; + let _nlocalsplus = prepare_localsplus(&self.metadata, &mut self.blocks, self.flags)?; + convert_pseudo_ops(&mut self.blocks)?; + trace.push(( + "after_convert_pseudo_ops".to_owned(), + self.debug_block_dump(), + )); + + self.blocks.normalize_jumps()?; + + #[cfg(debug_assertions)] + assert!(self.blocks.no_redundant_jumps()); + + trace.push(("after_normalize_jumps".to_owned(), self.debug_block_dump())); + self.blocks.optimize_load_fast()?; + trace.push(( + "after_optimize_load_fast".to_owned(), + self.debug_block_dump(), + )); + + Ok(trace) } } -/// assemble.c assemble_emit_exception_table_item -fn assemble_emit_exception_table_item(table: &mut Vec, value: i32, mut msb: u8) { - debug_assert!((msb | 128) == 128); - debug_assert!((0..(1 << 30)).contains(&value)); - let value = value as u32; - const CONTINUATION_BIT: u8 = 64; - if value >= 1 << 24 { - table.push(((value >> 24) as u8) | CONTINUATION_BIT | msb); - msb = 0; +impl InstrDisplayContext for CodeInfo { + type Constant = ConstantData; + + fn get_constant(&self, consti: oparg::ConstIdx) -> &ConstantData { + &self.metadata.consts[consti.as_usize()] } - if value >= 1 << 18 { - table.push((((value >> 18) & 0x3f) as u8) | CONTINUATION_BIT | msb); - msb = 0; + + fn get_name(&self, i: usize) -> &str { + self.metadata.names[i].as_ref() } - if value >= 1 << 12 { - table.push((((value >> 12) & 0x3f) as u8) | CONTINUATION_BIT | msb); - msb = 0; + + fn get_varname(&self, var_num: oparg::VarNum) -> &str { + self.metadata.varnames[var_num.as_usize()].as_ref() } - if value >= 1 << 6 { - table.push((((value >> 6) & 0x3f) as u8) | CONTINUATION_BIT | msb); - msb = 0; + + fn get_localsplus_name(&self, var_num: oparg::VarNum) -> &str { + let idx = var_num.as_usize(); + let nlocals = self.metadata.varnames.len(); + if idx < nlocals { + self.metadata.varnames[idx].as_ref() + } else { + let cell_idx = idx - nlocals; + self.metadata + .cellvars + .get_index(cell_idx) + .unwrap_or_else(|| &self.metadata.freevars[cell_idx - self.metadata.cellvars.len()]) + .as_ref() + } } - table.push(((value & 0x3f) as u8) | msb); } -/// assemble.c assemble_emit_exception_table_entry -fn assemble_emit_exception_table_entry( - table: &mut Vec, - start: i32, - end: i32, - handler_offset: i32, - handler: InstructionSequenceExceptHandlerInfo, -) -> crate::InternalResult<()> { - const MAX_SIZE_OF_ENTRY: usize = 20; - if table - .len() - .checked_add(MAX_SIZE_OF_ENTRY) - .ok_or(InternalError::MalformedControlFlowGraph)? - >= table.capacity() - { - vec_try_resize_to_double_capacity(table)?; +const NOT_LOCAL: isize = -1; +const DUMMY_INSTR: isize = -1; + +/// flowgraph.c make_super_instruction +fn make_super_instruction( + inst1: &mut InstructionInfo, + inst2: &mut InstructionInfo, + super_op: AnyInstruction, +) { + let line1 = instruction_lineno(inst1); + let line2 = instruction_lineno(inst2); + if line1 >= 0 && line2 >= 0 && line1 != line2 { + return; } - let size = end - start; - debug_assert!(end > start); - let target = handler_offset; - let mut depth = handler.start_depth - 1; - if handler.preserve_lasti > 0 { - depth -= 1; + let arg1 = u32::from(inst1.arg); + let arg2 = u32::from(inst2.arg); + if arg1 >= 16 || arg2 >= 16 { + return; } - debug_assert!(depth >= 0); - let depth_lasti = (depth << 1) | handler.preserve_lasti; - assemble_emit_exception_table_item(table, start, 1 << 7); - assemble_emit_exception_table_item(table, size, 0); - assemble_emit_exception_table_item(table, target, 0); - assemble_emit_exception_table_item(table, depth_lasti, 0); - Ok(()) + instr_set_op1(inst1, super_op, OpArg::new((arg1 << 4) | arg2)); + set_to_nop(inst2); } -/// assemble.c assemble_exception_table -fn assemble_exception_table( - instrs: &[InstructionSequenceEntry], -) -> crate::InternalResult> { - let mut table = Vec::new(); - vec_try_reserve_exact(&mut table, DEFAULT_LNOTAB_SIZE)?; - let mut handler = InstructionSequenceExceptHandlerInfo { - h_label: NO_EXCEPTION_HANDLER_LABEL, - start_depth: -1, - preserve_lasti: -1, - }; - let mut start = -1; - let mut ioffset = 0i32; +/// flowgraph.c LoadFastInstrFlag +#[derive(Clone, Copy, Eq, PartialEq)] +#[repr(u8)] +enum LoadFastInstrFlag { + SupportKilled = 1, + StoredAsLocal = 2, + RefUnconsumed = 4, +} - for i in 0..instrs.len() { - let instr = &instrs[i]; - if instr.except_handler.h_label != handler.h_label { - if handler.h_label >= 0 { - let handler_offset = instrs[handler.h_label as usize].i_offset; - assemble_emit_exception_table_entry( - &mut table, - start, - ioffset, - handler_offset, - handler, - )?; - } - start = ioffset; - handler = instr.except_handler; - } - ioffset += instr_size(&instr.info) as i32; - } +/// flowgraph.c ref +#[derive(Clone, Copy)] +struct Ref { + instr: isize, + local: isize, +} - if handler.h_label >= 0 { - let handler_offset = instrs[handler.h_label as usize].i_offset; - assemble_emit_exception_table_entry(&mut table, start, ioffset, handler_offset, handler)?; +/// flowgraph.c ref_stack +struct RefStack { + refs: Vec, + size: usize, + capacity: usize, +} + +/// flowgraph.c ref_stack_push +fn ref_stack_push(stack: &mut RefStack, r: Ref) -> crate::InternalResult<()> { + debug_assert_eq!(stack.refs.len(), stack.capacity); + if stack.size == stack.capacity { + let doubled = stack.capacity * 2; + let new_cap = 32.max(doubled); + stack + .refs + .try_reserve_exact(new_cap - stack.capacity) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + stack.refs.resize(new_cap, Ref { instr: 0, local: 0 }); + stack.capacity = new_cap; } + stack.refs[stack.size] = r; + stack.size += 1; + Ok(()) +} - Ok(table.into_boxed_slice()) +/// flowgraph.c ref_stack_pop +fn ref_stack_pop(stack: &mut RefStack) -> Ref { + assert!(stack.size > 0); + stack.size -= 1; + stack.refs[stack.size] } -/// Mark exception handler target blocks. -/// flowgraph.c mark_except_handlers -#[allow(clippy::unnecessary_wraps)] -pub(crate) fn mark_except_handlers(blocks: &mut Blocks) -> crate::InternalResult<()> { - #[cfg(debug_assertions)] - { - let mut block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - assert!(!blocks[block_idx].except_handler); - block_idx = blocks[block_idx].next; - } - } +/// flowgraph.c ref_stack_swap_top +fn ref_stack_swap_top(stack: &mut RefStack, off: usize) { + assert!(off >= 2 && stack.size >= off); + let top = stack.size - 1; + let other = stack.size - off; + stack.refs.swap(top, other); +} - let mut block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - let next = blocks[block_idx].next; - let instr_count = blocks[block_idx].instruction_used; - for i in 0..instr_count { - let instr = blocks[block_idx].instructions[i]; - if is_block_push(&instr) { - debug_assert!(instr.target != BlockIdx::NULL); - blocks[instr.target].except_handler = true; - } - } - block_idx = next; - } - Ok(()) +/// flowgraph.c ref_stack_at +fn ref_stack_at(stack: &RefStack, idx: usize) -> Ref { + assert!(idx < stack.size); + stack.refs[idx] } -/// flowgraph.c mark_cold (two-pass to match CPython). -/// -/// Phase 1 (mark_warm): propagate "warm" from entry via fall-through and -/// jump targets. CPython asserts while visiting warm blocks that they are not -/// exception handlers. -/// -/// Phase 2 (mark_cold): propagate "cold" from except_handler blocks via -/// forward edges. Blocks reached only via runtime exception dispatch are -/// marked cold and pushed to the end by push_cold_blocks_to_end. -/// -/// Blocks reached by neither phase remain `cold=false`. They are typically -/// empty unreachable placeholders left by remove_unreachable; they stay in -/// their original chain position (e.g. between entry and the post-try -/// continuation for a nested try/except whose inner_end was emptied by -/// optimize_cfg). This matches CPython's behavior and is necessary for -/// optimize_load_fast to terminate fall-through at those placeholders. -/// flowgraph.c mark_warm -fn mark_warm(blocks: &mut Blocks) -> crate::InternalResult<()> { - let mut stack = blocks.make_cfg_traversal_stack()?; - stack.push(BlockIdx(0)); - blocks[0].visited = true; - while let Some(block_idx) = stack.pop() { - let idx = block_idx.idx(); - debug_assert!(!blocks[idx].except_handler); - blocks[idx].warm = true; +/// flowgraph.c ref_stack_clear +fn ref_stack_clear(stack: &mut RefStack) { + stack.size = 0; +} - let next = blocks[idx].next; - if next != BlockIdx::NULL && bb_has_fallthrough(&blocks[idx]) && !blocks[next].visited { - stack.push(next); - blocks[next.idx()].visited = true; - } +/// flowgraph.c optimize_load_fast PUSH_REF +fn push_ref(stack: &mut RefStack, instr: isize, local: isize) -> crate::InternalResult<()> { + ref_stack_push(stack, Ref { instr, local }) +} - let instr_count = blocks[idx].instruction_used; - for i in 0..instr_count { - let instr = blocks[idx].instructions[i]; - if is_jump(&instr) { - let target = instr.target; - debug_assert!(target != BlockIdx::NULL); - if !blocks[target.idx()].visited { - stack.push(target); - blocks[target.idx()].visited = true; - } - } +/// flowgraph.c kill_local +fn kill_local(instr_flags: &mut [u8], refs: &RefStack, local: isize) { + for i in 0..refs.size { + let r = ref_stack_at(refs, i); + if r.local != local { + continue; } + debug_assert!(r.instr >= 0); + instr_flags[r.instr as usize] |= LoadFastInstrFlag::SupportKilled as u8; } - Ok(()) } -fn mark_cold(blocks: &mut Blocks) -> crate::InternalResult<()> { - let mut block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - let block = &mut blocks[block_idx]; - debug_assert!(!block.cold); - debug_assert!(!block.warm); - block_idx = block.next; +/// flowgraph.c store_local +fn store_local(instr_flags: &mut [u8], refs: &RefStack, local: isize, r: Ref) { + kill_local(instr_flags, refs, local); + if r.instr != DUMMY_INSTR { + instr_flags[r.instr as usize] |= LoadFastInstrFlag::StoredAsLocal as u8; } +} - mark_warm(blocks)?; - - let mut cold_stack = blocks.make_cfg_traversal_stack()?; - block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - let i = block_idx.idx(); - let next = blocks[i].next; - let block = &blocks[i]; - if block.except_handler { - debug_assert!(!block.warm); - cold_stack.push(block_idx); - blocks[i].visited = true; - } - block_idx = next; - } - while let Some(block_idx) = cold_stack.pop() { - let idx = block_idx.idx(); - blocks[idx].cold = true; - let next = blocks[idx].next; - if next != BlockIdx::NULL && bb_has_fallthrough(&blocks[idx]) { - let next_idx = next.idx(); - if !blocks[next_idx].warm && !blocks[next_idx].visited { - cold_stack.push(next); - blocks[next_idx].visited = true; - } - } +fn local_as_ref_local(local: usize) -> isize { + local as isize +} - let instr_count = blocks[idx].instruction_used; - for i in 0..instr_count { - let instr = blocks[idx].instructions[i]; - if is_jump(&instr) { - debug_assert_eq!(i, instr_count - 1); - let target = instr.target; - debug_assert!(target != BlockIdx::NULL); - if !blocks[target.idx()].warm && !blocks[target.idx()].visited { - cold_stack.push(target); - blocks[target.idx()].visited = true; - } - } - } +/// flowgraph.c load_fast_push_block +fn load_fast_push_block( + worklist: &mut CfgTraversalStack, + blocks: &mut Blocks, + target: BlockIdx, + start_depth: usize, +) { + debug_assert!(target != BlockIdx::NULL); + debug_assert!(blocks[target].start_depth >= 0); + debug_assert_eq!(blocks[target].start_depth as usize, start_depth,); + if !blocks[target].visited { + blocks[target].visited = true; + worklist.push(target); } - Ok(()) } -/// flowgraph.c push_cold_blocks_to_end -fn push_cold_blocks_to_end(blocks: &mut Blocks) -> crate::InternalResult<()> { - if blocks[0].next == BlockIdx::NULL { - return Ok(()); +fn stackdepth_push( + stack: &mut CfgTraversalStack, + blocks: &mut Blocks, + target: BlockIdx, + depth: i32, +) -> crate::InternalResult<()> { + let idx = target.idx(); + let block_depth = &mut blocks[idx].start_depth; + if !(*block_depth < 0 || *block_depth == depth) { + return Err(InternalError::InconsistentStackDepth); } - - mark_cold(blocks)?; - let mut next_label = get_max_label(blocks) + 1; - - // If a cold block falls through to a warm block, add an explicit jump - let mut block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - let next = blocks[block_idx].next; - if blocks[block_idx].cold - && bb_has_fallthrough(&blocks[block_idx]) - && next != BlockIdx::NULL - && blocks[next].warm - { - let explicit_jump = blocks_new_block(blocks)?; - if !is_label(blocks[next].cpython_label) { - blocks[next].cpython_label = InstructionSequenceLabel::from_index(next_label); - next_label += 1; - } - let jump_label = blocks[next].cpython_label; - debug_assert!(is_label(jump_label)); - basicblock_addop( - &mut blocks[explicit_jump], - InstructionInfo { - instr: PseudoOpcode::JumpNoInterrupt.into(), - arg: instruction_sequence_label_oparg(jump_label), - target: BlockIdx::NULL, - location: SourceLocation::default(), - end_location: SourceLocation::default(), - except_handler: None, - lineno_override: Some(NO_LOCATION_OVERRIDE), - }, - )?; - blocks[explicit_jump].cold = true; - blocks[explicit_jump].next = next; - blocks[explicit_jump].predecessors = 1; - blocks[block_idx].next = explicit_jump; - let target = blocks[explicit_jump].next; - let last = basicblock_last_instr_mut(&mut blocks[explicit_jump]) - .expect("missing explicit jump"); - last.target = target; - } - block_idx = blocks[block_idx].next; + if *block_depth < depth && *block_depth < 100 { + debug_assert!(*block_depth < 0); + *block_depth = depth; + stack.push(target); } + Ok(()) +} - assert!(!blocks[0].cold); - let mut cold_blocks: BlockIdx = BlockIdx::NULL; - let mut cold_blocks_tail: BlockIdx = BlockIdx::NULL; - let mut block_idx = BlockIdx(0); +/// flowgraph.c stack_effects +#[derive(Clone, Copy, Eq, PartialEq)] +struct StackEffects { + net: i32, +} - while blocks[block_idx].next != BlockIdx::NULL { - debug_assert!(!blocks[block_idx].cold); - while blocks[block_idx].next != BlockIdx::NULL && !blocks[blocks[block_idx].next].cold { - block_idx = blocks[block_idx].next; - } - if blocks[block_idx].next == BlockIdx::NULL { - break; - } +/// flowgraph.c get_stack_effects +#[allow(clippy::unnecessary_wraps)] +fn get_stack_effects( + instr: AnyInstruction, + oparg: OpArg, + jump: i32, +) -> crate::InternalResult { + if instr + .real() + .is_some_and(|op| op.as_opcode().deopt().is_some()) + { + return Err(InternalError::InvalidStackEffect); + } + let oparg = u32::from(oparg); + let net = if instr.is_block_push() && jump == 0 { + 0 + } else if jump != 0 { + instr.stack_effect_jump(oparg) + } else { + instr.stack_effect(oparg) + }; + Ok(StackEffects { net }) +} - debug_assert!(!blocks[block_idx].cold); - debug_assert!(blocks[blocks[block_idx].next].cold); +fn vec_try_reserve_exact(vec: &mut Vec, additional: usize) -> crate::InternalResult<()> { + vec.try_reserve_exact(additional) + .map_err(|_| InternalError::MalformedControlFlowGraph) +} - let mut block_end = blocks[block_idx].next; - while blocks[block_end].next != BlockIdx::NULL && blocks[blocks[block_end].next].cold { - block_end = blocks[block_end].next; - } +fn vec_try_resize_to_double_capacity(vec: &mut Vec) -> crate::InternalResult<()> { + let capacity = vec.capacity(); + debug_assert!(capacity > 0); + let len = capacity + .checked_mul(core::mem::size_of::()) + .ok_or(InternalError::MalformedControlFlowGraph)?; + if capacity == 0 || len > usize::MAX / 2 { + return Err(InternalError::MalformedControlFlowGraph); + } + let new_capacity = capacity * 2; + let additional = new_capacity + .checked_sub(vec.len()) + .ok_or(InternalError::MalformedControlFlowGraph)?; + vec_try_reserve_exact(vec, additional) +} - debug_assert!(blocks[block_end].cold); - debug_assert!( - blocks[block_end].next == BlockIdx::NULL || !blocks[blocks[block_end].next].cold - ); +/// assemble.c write_location_first_byte +fn write_location_first_byte(linetable: &mut Vec, code: u8, length: usize) { + linetable.extend(write_location_entry_start(code, length)); +} - if cold_blocks == BlockIdx::NULL { - cold_blocks = blocks[block_idx].next; - } else { - blocks[cold_blocks_tail].next = blocks[block_idx].next; - } +/// pycore_code.h write_location_entry_start +fn write_location_entry_start(code: u8, length: usize) -> [u8; 1] { + debug_assert!(length > 0 && length <= 8); + debug_assert_eq!(code & 15, code); + [0x80 | (code << 3) | ((length - 1) as u8)] +} - cold_blocks_tail = block_end; - blocks[block_idx].next = blocks[block_end].next; - blocks[block_end].next = BlockIdx::NULL; - } +/// assemble.c write_location_byte +fn write_location_byte(linetable: &mut Vec, value: u8) { + linetable.push(value); +} - debug_assert!(blocks[block_idx].next == BlockIdx::NULL); - blocks[block_idx].next = cold_blocks; +/// assemble.c write_location_varint +fn write_location_varint(linetable: &mut Vec, value: u32) { + write_varint(linetable, value); +} - if cold_blocks != BlockIdx::NULL { - remove_redundant_nops_and_jumps(blocks)?; - } - Ok(()) +/// assemble.c write_location_signed_varint +fn write_location_signed_varint(linetable: &mut Vec, value: i32) { + write_signed_varint(linetable, value); } -/// flowgraph.c check_cfg -fn check_cfg(blocks: &Blocks) -> crate::InternalResult<()> { - let mut block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - let block = &blocks[block_idx]; - for i in 0..block.instruction_used { - let opcode = block.instructions[i].instr; - debug_assert!(!opcode.is_assembler()); - if opcode.is_terminator() && i != block.instruction_used - 1 { - return Err(InternalError::MalformedControlFlowGraph); - } - } - block_idx = block.next; - } - Ok(()) +/// assemble.c write_location_info_short_form +fn write_location_info_short_form( + linetable: &mut Vec, + length: usize, + column: i32, + end_column: i32, +) { + debug_assert!(length > 0 && length <= 8); + debug_assert!(column < 80); + debug_assert!(end_column >= column); + debug_assert!(end_column - column < 16); + let column_low_bits = column & 7; + let column_group = column >> 3; + let code = PyCodeLocationInfoKind::Short0 as u8 + column_group as u8; + write_location_first_byte(linetable, code, length); + write_location_byte( + linetable, + ((column_low_bits as u8) << 4) | ((end_column - column) as u8), + ); } -/// flowgraph.c jump_thread -fn jump_thread( - blocks: &mut Blocks, - block_idx: BlockIdx, - instr_idx: usize, - target: &InstructionInfo, - opcode: AnyInstruction, -) -> crate::InternalResult { - let bi = block_idx.idx(); - debug_assert!(is_jump(&blocks[bi].instructions[instr_idx])); - debug_assert!(is_jump(target)); - debug_assert_eq!(instr_idx + 1, blocks[bi].instruction_used); - debug_assert!(target.target != BlockIdx::NULL); - if blocks[bi].instructions[instr_idx].target != target.target { - set_to_nop(&mut blocks[bi].instructions[instr_idx]); - basicblock_add_jump(blocks, block_idx, opcode, target.target, target)?; - return Ok(true); - } - Ok(false) +/// assemble.c write_location_info_oneline_form +fn write_location_info_oneline_form( + linetable: &mut Vec, + length: usize, + line_delta: i32, + column: i32, + end_column: i32, +) { + debug_assert!(length > 0 && length <= 8); + debug_assert!((0..3).contains(&line_delta)); + debug_assert!(column < 128); + debug_assert!(end_column < 128); + let code = PyCodeLocationInfoKind::OneLine0 as u8 + line_delta as u8; + write_location_first_byte(linetable, code, length); + write_location_byte(linetable, column as u8); + write_location_byte(linetable, end_column as u8); } -/// flowgraph.c basicblock_add_jump -fn basicblock_add_jump( - blocks: &mut Blocks, - block_idx: BlockIdx, - instr: AnyInstruction, - target: BlockIdx, - loc_source: &InstructionInfo, -) -> crate::InternalResult<()> { - let bi = block_idx.idx(); - let last = basicblock_last_instr(&blocks[bi]); - if last.is_some_and(is_jump) { - return Err(InternalError::MalformedControlFlowGraph); - } - debug_assert!(target != BlockIdx::NULL); - let label = blocks[target.idx()].cpython_label; - debug_assert!(is_label(label)); - let arg = instruction_sequence_label_oparg(label); - let block = &mut blocks[bi]; - basicblock_addop( - block, - InstructionInfo { - instr, - arg, - target: BlockIdx::NULL, - location: loc_source.location, - end_location: loc_source.end_location, - except_handler: None, - lineno_override: loc_source.lineno_override, +/// assemble.c write_location_info_long_form +fn write_location_info_long_form( + linetable: &mut Vec, + loc: LineTableLocation, + length: usize, + line_delta: i32, +) { + debug_assert!(length > 0 && length <= 8); + write_location_first_byte(linetable, PyCodeLocationInfoKind::Long as u8, length); + write_location_signed_varint(linetable, line_delta); + debug_assert!(loc.end_line >= loc.line); + write_location_varint(linetable, (loc.end_line - loc.line) as u32); + write_location_varint( + linetable, + if loc.col < 0 { 0 } else { (loc.col as u32) + 1 }, + ); + write_location_varint( + linetable, + if loc.end_col < 0 { + 0 + } else { + (loc.end_col as u32) + 1 }, - )?; - let last = basicblock_last_instr_mut(block).expect("missing jump"); - debug_assert!(match (last.instr, instr) { - (AnyInstruction::Real(last), AnyInstruction::Real(opcode)) => - last.as_opcode() == opcode.as_opcode(), - (AnyInstruction::Pseudo(last), AnyInstruction::Pseudo(opcode)) => - last.as_opcode() == opcode.as_opcode(), - _ => false, - }); - last.target = target; - Ok(()) + ); } -/// pycore_opcode_utils.h IS_CONDITIONAL_JUMP_OPCODE -fn is_conditional_jump_opcode(instr: AnyInstruction) -> bool { - matches!( - instr.real().map(Into::into), - Some( - Opcode::PopJumpIfFalse - | Opcode::PopJumpIfTrue - | Opcode::PopJumpIfNone - | Opcode::PopJumpIfNotNone - ) - ) +/// assemble.c write_location_info_none +fn write_location_info_none(linetable: &mut Vec, length: usize) { + write_location_first_byte(linetable, PyCodeLocationInfoKind::None as u8, length); } -/// flowgraph.c convert_pseudo_conditional_jumps -fn convert_pseudo_conditional_jumps(blocks: &mut Blocks) -> crate::InternalResult<()> { - let mut block_idx = BlockIdx(0); - while block_idx != BlockIdx::NULL { - let next = blocks[block_idx.idx()].next; - let block = &mut blocks[block_idx.idx()]; - let mut i = 0; - while i < block.instruction_used { - let instr = block.instructions[i]; - let opcode = instr.instr; - if matches!( - opcode.pseudo_opcode(), - Some(PseudoOpcode::JumpIfFalse | PseudoOpcode::JumpIfTrue) - ) { - debug_assert_eq!(i, block.instruction_used - 1); - block.instructions[i].instr = - if matches!(opcode.pseudo_opcode(), Some(PseudoOpcode::JumpIfFalse)) { - Opcode::PopJumpIfFalse - } else { - Opcode::PopJumpIfTrue - } - .into(); - - let location = instr.location; - let end_location = instr.end_location; - let except_handler = instr.except_handler; - let lineno_override = instr.lineno_override; - let copy = InstructionInfo { - instr: Opcode::Copy.into(), - arg: OpArg::new(1), - target: BlockIdx::NULL, - location, - end_location, - except_handler, - lineno_override, - }; - basicblock_insert_instruction(block, i, copy)?; - i += 1; +/// assemble.c write_location_info_no_column +fn write_location_info_no_column(linetable: &mut Vec, length: usize, line_delta: i32) { + write_location_first_byte(linetable, PyCodeLocationInfoKind::NoColumns as u8, length); + write_location_signed_varint(linetable, line_delta); +} - let to_bool = InstructionInfo { - instr: Opcode::ToBool.into(), - arg: OpArg::new(0), - target: BlockIdx::NULL, - location, - end_location, - except_handler, - lineno_override, - }; - basicblock_insert_instruction(block, i, to_bool)?; - i += 1; - } - i += 1; - } - block_idx = next; +/// assemble.c write_location_info_entry +fn write_location_info_entry( + linetable: &mut Vec, + loc: LineTableLocation, + length: usize, + prev_line: &mut i32, + debug_ranges: bool, +) -> crate::InternalResult<()> { + const THEORETICAL_MAX_ENTRY_SIZE: usize = 25; + if linetable + .len() + .checked_add(THEORETICAL_MAX_ENTRY_SIZE) + .ok_or(InternalError::MalformedControlFlowGraph)? + >= linetable.capacity() + { + debug_assert!(linetable.capacity() > THEORETICAL_MAX_ENTRY_SIZE); + vec_try_resize_to_double_capacity(linetable)?; } - Ok(()) -} - -/// flowgraph.c normalize_jumps_in_block -fn normalize_jumps_in_block(blocks: &mut Blocks, block_idx: BlockIdx) -> crate::InternalResult<()> { - let idx = block_idx.idx(); - let Some(last_ins) = basicblock_last_instr(&blocks[idx]).copied() else { - return Ok(()); - }; - if !is_conditional_jump_opcode(last_ins.instr) { + if loc.line == NO_LOCATION_OVERRIDE { + write_location_info_none(linetable, length); return Ok(()); } - debug_assert!(!last_ins.instr.is_assembler()); - - debug_assert!(last_ins.target != BlockIdx::NULL); - let is_forward = !blocks[last_ins.target.idx()].visited; - if is_forward { - // Insert NOT_TAKEN after forward conditional jump. - let not_taken = InstructionInfo { - instr: Opcode::NotTaken.into(), - arg: OpArg::new(0), - target: BlockIdx::NULL, - location: last_ins.location, - end_location: last_ins.end_location, - except_handler: None, - lineno_override: last_ins.lineno_override, - }; - basicblock_addop(&mut blocks[idx], not_taken)?; + let line_delta = loc.line - *prev_line; + let column = loc.col; + let end_column = loc.end_col; + if !debug_ranges + || ((column < 0 || end_column < 0) && (loc.end_line == loc.line || loc.end_line < 0)) + { + write_location_info_no_column(linetable, length, line_delta); + *prev_line = loc.line; return Ok(()); } - let reversed_opcode = match last_ins.instr.real_opcode() { - Some(Opcode::PopJumpIfNotNone) => Opcode::PopJumpIfNone.into(), - Some(Opcode::PopJumpIfNone) => Opcode::PopJumpIfNotNone.into(), - Some(Opcode::PopJumpIfFalse) => Opcode::PopJumpIfTrue.into(), - Some(Opcode::PopJumpIfTrue) => Opcode::PopJumpIfFalse.into(), - _ => unreachable!("conditional jump has reverse opcode"), - }; - - // Transform 'conditional jump T' to 'reversed_jump b_next' followed by - // 'jump_backwards T'. - let loc = last_ins.location; - let end_loc = last_ins.end_location; - - let target = last_ins.target; - let backwards_jump_idx = blocks_new_block(blocks)?; - basicblock_addop( - &mut blocks[backwards_jump_idx.idx()], - InstructionInfo { - instr: Opcode::NotTaken.into(), - arg: OpArg::new(0), - target: BlockIdx::NULL, - location: loc, - end_location: end_loc, - except_handler: None, - lineno_override: last_ins.lineno_override, - }, - )?; - basicblock_add_jump( - blocks, - backwards_jump_idx, - PseudoOpcode::Jump.into(), - target, - &last_ins, - )?; - blocks[backwards_jump_idx.idx()].start_depth = blocks[target.idx()].start_depth; - - let old_next = blocks[idx].next; - debug_assert!(old_next != BlockIdx::NULL); - - let last_mut = basicblock_last_instr_mut(&mut blocks[idx]).unwrap(); - last_mut.instr = reversed_opcode; - last_mut.target = old_next; + if loc.end_line == loc.line { + if line_delta == 0 && column < 80 && end_column - column < 16 && end_column >= column { + write_location_info_short_form(linetable, length, column, end_column); + return Ok(()); + } + if (0..3).contains(&line_delta) && column < 128 && end_column < 128 { + write_location_info_oneline_form(linetable, length, line_delta, column, end_column); + *prev_line = loc.line; + return Ok(()); + } + } - blocks[backwards_jump_idx.idx()].cold = blocks[idx].cold; - blocks[backwards_jump_idx.idx()].next = old_next; - blocks[idx].next = backwards_jump_idx; + write_location_info_long_form(linetable, loc, length, line_delta); + *prev_line = loc.line; Ok(()) } -/// flowgraph.c basicblock_inline_small_or_no_lineno_blocks -fn basicblock_inline_small_or_no_lineno_blocks( - blocks: &mut Blocks, - block_idx: BlockIdx, -) -> crate::InternalResult { - let Some(last) = basicblock_last_instr(&blocks[block_idx]).copied() else { - return Ok(false); - }; - if !last.instr.is_unconditional_jump() { - return Ok(false); +/// assemble.c assemble_emit_location +fn assemble_emit_location( + linetable: &mut Vec, + loc: LineTableLocation, + mut size: usize, + prev_line: &mut i32, + debug_ranges: bool, +) -> crate::InternalResult<()> { + if size == 0 { + return Ok(()); } - - let target = last.target; - debug_assert!(target != BlockIdx::NULL); - let small_exit_block = - basicblock_exits_scope(&blocks[target]) && blocks[target].instruction_used <= MAX_COPY_SIZE; - let no_lineno_no_fallthrough = - basicblock_has_no_lineno(&blocks[target]) && !bb_has_fallthrough(&blocks[target]); - if small_exit_block || no_lineno_no_fallthrough { - debug_assert!(is_jump(&last)); - let removed_jump_opcode = last.instr; - let last = basicblock_last_instr_mut(&mut blocks[block_idx]) - .expect("non-empty block has last instruction"); - set_to_nop(last); - blocks.basicblock_append_block_instructions(block_idx, target)?; - if no_lineno_no_fallthrough { - let last = basicblock_last_instr_mut(&mut blocks[block_idx]).unwrap(); - if last.instr.is_unconditional_jump() - && matches!( - removed_jump_opcode.into(), - AnyOpcode::Pseudo(PseudoOpcode::Jump) - ) - { - last.instr = PseudoOpcode::Jump.into(); - } - } - blocks[target].predecessors -= 1; - return Ok(true); + while size > 8 { + write_location_info_entry(linetable, loc, 8, prev_line, debug_ranges)?; + size -= 8; } - Ok(false) + write_location_info_entry(linetable, loc, size, prev_line, debug_ranges) } -/// flowgraph.c inline_small_or_no_lineno_blocks -fn inline_small_or_no_lineno_blocks(blocks: &mut Blocks) -> crate::InternalResult { - loop { - let mut changes = false; - let mut current = BlockIdx(0); - while current != BlockIdx::NULL { - let next = blocks[current.idx()].next; - let res = basicblock_inline_small_or_no_lineno_blocks(blocks, current)?; - if res { - changes = true; - } - - current = next; - } - if !changes { - return Ok(changes); - } +fn no_linetable_location() -> LineTableLocation { + LineTableLocation { + line: NO_LOCATION_OVERRIDE, + end_line: NO_LOCATION_OVERRIDE, + col: NO_LOCATION_OVERRIDE, + end_col: NO_LOCATION_OVERRIDE, } } -/// flowgraph.c basicblock_remove_redundant_nops -#[allow(clippy::unnecessary_wraps)] -fn basicblock_remove_redundant_nops( - blocks: &mut Blocks, - block_idx: BlockIdx, -) -> crate::InternalResult { - let bi = block_idx.idx(); - let mut dest = 0; - let mut prev_lineno = -1i32; - let instr_count = blocks[bi].instruction_used; - - for src in 0..instr_count { - let instr = blocks[bi].instructions[src]; - let lineno = instruction_lineno(&instr); - - if matches!(instr.instr.real(), Some(Instruction::Nop)) { - if lineno < 0 { - continue; - } - if prev_lineno == lineno { - continue; - } - if src < instr_count - 1 { - let next_lineno = instruction_lineno(&blocks[bi].instructions[src + 1]); - if next_lineno == lineno { - continue; - } - if next_lineno < 0 { - instr_set_loc( - &mut blocks[bi].instructions[src + 1], - instr.location, - instr.end_location, - instr.lineno_override, - ); - continue; - } - } else { - let next = next_nonempty_block(blocks, blocks[bi].next); - if next != BlockIdx::NULL { - let mut next_loc = no_linetable_location(); - let mut next_i = 0; - while next_i < blocks[next.idx()].instruction_used { - let instr = blocks[next.idx()].instructions[next_i]; - if matches!(instr.instr.real(), Some(Instruction::Nop)) - && instruction_lineno(&instr) < 0 - { - next_i += 1; - continue; - } - next_loc = instruction_linetable_location(&instr); - break; - } - if lineno == next_loc.line { - continue; - } - } - } - } - - if dest != src { - blocks[bi].instructions[dest] = blocks[bi].instructions[src]; - } - dest += 1; - prev_lineno = lineno; +fn next_linetable_location() -> LineTableLocation { + LineTableLocation { + line: NEXT_LOCATION_OVERRIDE, + end_line: NEXT_LOCATION_OVERRIDE, + col: NEXT_LOCATION_OVERRIDE, + end_col: NEXT_LOCATION_OVERRIDE, } - - debug_assert!(dest <= instr_count); - let num_removed = instr_count - dest; - blocks[bi].instruction_used = dest; - Ok(num_removed) } -/// flowgraph.c remove_redundant_nops -#[allow(clippy::unnecessary_wraps)] -fn remove_redundant_nops(blocks: &mut Blocks) -> crate::InternalResult { - let mut changes = 0; - let mut current = BlockIdx(0); - while current != BlockIdx::NULL { - let next = blocks[current.idx()].next; - let change = basicblock_remove_redundant_nops(blocks, current)?; - changes += change; - current = next; +/// assemble.c assemble_emit_exception_table_item +fn assemble_emit_exception_table_item(table: &mut Vec, value: i32, mut msb: u8) { + debug_assert!((msb | 128) == 128); + debug_assert!((0..(1 << 30)).contains(&value)); + let value = value as u32; + const CONTINUATION_BIT: u8 = 64; + if value >= 1 << 24 { + table.push(((value >> 24) as u8) | CONTINUATION_BIT | msb); + msb = 0; } - Ok(changes) -} - -/// flowgraph.c no_redundant_nops -#[cfg(debug_assertions)] -fn no_redundant_nops(blocks: &mut Blocks) -> bool { - matches!(remove_redundant_nops(blocks), Ok(0)) + if value >= 1 << 18 { + table.push((((value >> 18) & 0x3f) as u8) | CONTINUATION_BIT | msb); + msb = 0; + } + if value >= 1 << 12 { + table.push((((value >> 12) & 0x3f) as u8) | CONTINUATION_BIT | msb); + msb = 0; + } + if value >= 1 << 6 { + table.push((((value >> 6) & 0x3f) as u8) | CONTINUATION_BIT | msb); + msb = 0; + } + table.push(((value & 0x3f) as u8) | msb); } -/// flowgraph.c remove_redundant_jumps -fn remove_redundant_jumps(blocks: &mut Blocks) -> crate::InternalResult { - let mut changes = 0; - let mut current = BlockIdx(0); - while current != BlockIdx::NULL { - let block_idx = current.idx(); - let Some(last) = basicblock_last_instr(&blocks[block_idx]).copied() else { - current = blocks[block_idx].next; - continue; - }; - debug_assert!(!last.instr.is_assembler()); - if last.instr.is_unconditional_jump() { - let jump_target = next_nonempty_block(blocks, last.target); - if jump_target == BlockIdx::NULL { - return Err(InternalError::MalformedControlFlowGraph); - } - let next = next_nonempty_block(blocks, blocks[block_idx].next); - if jump_target == next { - changes += 1; - let last = basicblock_last_instr_mut(&mut blocks[block_idx]).unwrap(); - set_to_nop(last); - } - } - current = blocks[block_idx].next; +/// assemble.c assemble_emit_exception_table_entry +fn assemble_emit_exception_table_entry( + table: &mut Vec, + start: i32, + end: i32, + handler_offset: i32, + handler: InstructionSequenceExceptHandlerInfo, +) -> crate::InternalResult<()> { + const MAX_SIZE_OF_ENTRY: usize = 20; + if table + .len() + .checked_add(MAX_SIZE_OF_ENTRY) + .ok_or(InternalError::MalformedControlFlowGraph)? + >= table.capacity() + { + vec_try_resize_to_double_capacity(table)?; + } + let size = end - start; + debug_assert!(end > start); + let target = handler_offset; + let mut depth = handler.start_depth - 1; + if handler.preserve_lasti > 0 { + depth -= 1; } - Ok(changes) + debug_assert!(depth >= 0); + let depth_lasti = (depth << 1) | handler.preserve_lasti; + assemble_emit_exception_table_item(table, start, 1 << 7); + assemble_emit_exception_table_item(table, size, 0); + assemble_emit_exception_table_item(table, target, 0); + assemble_emit_exception_table_item(table, depth_lasti, 0); + Ok(()) } -/// flowgraph.c no_redundant_jumps -#[cfg(debug_assertions)] -fn no_redundant_jumps(blocks: &Blocks) -> bool { - let mut current = BlockIdx(0); - while current != BlockIdx::NULL { - let block = &blocks[current.idx()]; - if let Some(last) = basicblock_last_instr(block) - && last.instr.is_unconditional_jump() - { - let next = next_nonempty_block(blocks, block.next); - let jump_target = next_nonempty_block(blocks, last.target); - if jump_target == next { - assert!(next != BlockIdx::NULL); - if instruction_lineno(last) - == instruction_lineno(&blocks[next.idx()].instructions[0]) - { - assert_ne!( - instruction_lineno(last), - instruction_lineno(&blocks[next.idx()].instructions[0]), - "redundant jump has same line as fallthrough target" - ); - return false; - } +/// assemble.c assemble_exception_table +fn assemble_exception_table( + instrs: &[InstructionSequenceEntry], +) -> crate::InternalResult> { + let mut table = Vec::new(); + vec_try_reserve_exact(&mut table, DEFAULT_LNOTAB_SIZE)?; + let mut handler = InstructionSequenceExceptHandlerInfo { + h_label: NO_EXCEPTION_HANDLER_LABEL, + start_depth: -1, + preserve_lasti: -1, + }; + let mut start = -1; + let mut ioffset = 0i32; + + for i in 0..instrs.len() { + let instr = &instrs[i]; + if instr.except_handler.h_label != handler.h_label { + if handler.h_label >= 0 { + let handler_offset = instrs[handler.h_label as usize].i_offset; + assemble_emit_exception_table_entry( + &mut table, + start, + ioffset, + handler_offset, + handler, + )?; } + start = ioffset; + handler = instr.except_handler; } - current = block.next; + ioffset += instr_size(&instr.info) as i32; } - true -} -fn remove_redundant_nops_and_jumps(blocks: &mut Blocks) -> crate::InternalResult<()> { - loop { - // Convergence is guaranteed because the number of redundant jumps and - // nops only decreases. - let removed_nops = remove_redundant_nops(blocks)?; - let removed_jumps = remove_redundant_jumps(blocks)?; - if removed_nops + removed_jumps == 0 { - break; - } + if handler.h_label >= 0 { + let handler_offset = instrs[handler.h_label as usize].i_offset; + assemble_emit_exception_table_entry(&mut table, start, ioffset, handler_offset, handler)?; } - Ok(()) + + Ok(table.into_boxed_slice()) } -fn blocks_new_block(blocks: &mut Blocks) -> crate::InternalResult { - blocks - .try_reserve(1) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - let block_idx = BlockIdx( - blocks - .len() - .to_u32() - .ok_or(InternalError::MalformedControlFlowGraph)?, - ); - blocks.push(Block::default()); - Ok(block_idx) +/// pycore_opcode_utils.h IS_CONDITIONAL_JUMP_OPCODE +fn is_conditional_jump_opcode(instr: AnyInstruction) -> bool { + matches!( + instr.real().map(Into::into), + Some( + Opcode::PopJumpIfFalse + | Opcode::PopJumpIfTrue + | Opcode::PopJumpIfNone + | Opcode::PopJumpIfNotNone + ) + ) } /// flowgraph.c struct _PyCfgBuilder @@ -6022,7 +6024,7 @@ struct CfgBuilder { /// flowgraph.c cfg_builder_new_block fn cfg_builder_new_block(g: &mut CfgBuilder) -> crate::InternalResult { - let block = blocks_new_block(&mut g.blocks)?; + let block = g.blocks.blocks_new_block()?; g.blocks[block.idx()].allocation_next = g.block_list; g.blocks[block.idx()].cpython_label = InstructionSequenceLabel::NO_LABEL; g.block_list = block; @@ -6760,7 +6762,7 @@ pub(crate) fn convert_pseudo_ops(blocks: &mut Blocks) -> crate::InternalResult<( } // CPython flowgraph.c::convert_pseudo_ops() finishes by calling // remove_redundant_nops_and_jumps(). - remove_redundant_nops_and_jumps(blocks) + blocks.remove_redundant_nops_and_jumps() } /// flowgraph.c build_cellfixedoffsets From 6938f367426f1a59694ee4d14720a35018f0c46f Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:20:44 +0200 Subject: [PATCH 030/351] Add more number functions to c-api (#8158) --- crates/capi/src/abstract_/number.rs | 202 ++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/crates/capi/src/abstract_/number.rs b/crates/capi/src/abstract_/number.rs index c5ec492a73d..b1dc627d828 100644 --- a/crates/capi/src/abstract_/number.rs +++ b/crates/capi/src/abstract_/number.rs @@ -1,15 +1,217 @@ use crate::{PyObject, pystate::with_vm}; +use core::ffi::c_int; +use rustpython_vm::protocol::PyNumber; #[unsafe(no_mangle)] pub unsafe extern "C" fn PyNumber_Add(o1: *mut PyObject, o2: *mut PyObject) -> *mut PyObject { with_vm(|vm| vm._add(unsafe { &*o1 }, unsafe { &*o2 })) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyIndex_Check(obj: *mut PyObject) -> c_int { + with_vm(|_vm| unsafe { obj.as_ref() }.is_some_and(|obj| obj.number().is_index())) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_Absolute(o: *mut PyObject) -> *mut PyObject { + with_vm(|vm| vm._abs(unsafe { &*o })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_And(o1: *mut PyObject, o2: *mut PyObject) -> *mut PyObject { + with_vm(|vm| vm._and(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_Check(o: *mut PyObject) -> c_int { + with_vm(|_vm| unsafe { o.as_ref() }.is_some_and(PyNumber::check)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_Divmod(o1: *mut PyObject, o2: *mut PyObject) -> *mut PyObject { + with_vm(|vm| vm._divmod(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_Float(o: *mut PyObject) -> *mut PyObject { + with_vm(|vm| unsafe { &*o }.try_float(vm)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_FloorDivide( + o1: *mut PyObject, + o2: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| vm._floordiv(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_InPlaceAdd( + o1: *mut PyObject, + o2: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| vm._iadd(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_InPlaceAnd( + o1: *mut PyObject, + o2: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| vm._iand(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_InPlaceFloorDivide( + o1: *mut PyObject, + o2: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| vm._ifloordiv(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_InPlaceLshift( + o1: *mut PyObject, + o2: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| vm._ilshift(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_InPlaceMatrixMultiply( + o1: *mut PyObject, + o2: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| vm._imatmul(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_InPlaceMultiply( + o1: *mut PyObject, + o2: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| vm._imul(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_InPlaceOr(o1: *mut PyObject, o2: *mut PyObject) -> *mut PyObject { + with_vm(|vm| vm._ior(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_InPlacePower( + o1: *mut PyObject, + o2: *mut PyObject, + o3: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| vm._ipow(unsafe { &*o1 }, unsafe { &*o2 }, unsafe { &*o3 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_InPlaceRemainder( + o1: *mut PyObject, + o2: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| vm._imod(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_InPlaceRshift( + o1: *mut PyObject, + o2: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| vm._irshift(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_InPlaceSubtract( + o1: *mut PyObject, + o2: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| vm._isub(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_InPlaceTrueDivide( + o1: *mut PyObject, + o2: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| vm._itruediv(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_InPlaceXor( + o1: *mut PyObject, + o2: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| vm._ixor(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_Invert(o: *mut PyObject) -> *mut PyObject { + with_vm(|vm| vm._invert(unsafe { &*o })) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyNumber_Index(obj: *mut PyObject) -> *mut PyObject { with_vm(|vm| unsafe { &*obj }.try_index(vm)) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_MatrixMultiply( + o1: *mut PyObject, + o2: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| vm._matmul(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_Multiply(o1: *mut PyObject, o2: *mut PyObject) -> *mut PyObject { + with_vm(|vm| vm._mul(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_Negative(o: *mut PyObject) -> *mut PyObject { + with_vm(|vm| vm._neg(unsafe { &*o })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_Positive(o: *mut PyObject) -> *mut PyObject { + with_vm(|vm| vm._pos(unsafe { &*o })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_Power( + o1: *mut PyObject, + o2: *mut PyObject, + o3: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| vm._pow(unsafe { &*o1 }, unsafe { &*o2 }, unsafe { &*o3 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_Remainder(o1: *mut PyObject, o2: *mut PyObject) -> *mut PyObject { + with_vm(|vm| vm._mod(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_TrueDivide( + o1: *mut PyObject, + o2: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| vm._truediv(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_Xor(o1: *mut PyObject, o2: *mut PyObject) -> *mut PyObject { + with_vm(|vm| vm._xor(unsafe { &*o1 }, unsafe { &*o2 })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyNumber_Long(obj: *mut PyObject) -> *mut PyObject { + with_vm(|vm| unsafe { &*obj }.try_int(vm)) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyNumber_Lshift(o1: *mut PyObject, o2: *mut PyObject) -> *mut PyObject { with_vm(|vm| vm._lshift(unsafe { &*o1 }, unsafe { &*o2 })) From 41d6dab133233379ef95ac5753206cc468c23244 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:25:32 +0300 Subject: [PATCH 031/351] Update ensurepip to 26.1.2 (cpython 3.14.6) (#8163) --- Lib/ensurepip/__init__.py | 2 +- ...ne-any.whl => pip-26.1.2-py3-none-any.whl} | Bin 1812777 -> 1813144 bytes 2 files changed, 1 insertion(+), 1 deletion(-) rename Lib/ensurepip/_bundled/{pip-26.1.1-py3-none-any.whl => pip-26.1.2-py3-none-any.whl} (93%) diff --git a/Lib/ensurepip/__init__.py b/Lib/ensurepip/__init__.py index a8040457abf..7d13414ff82 100644 --- a/Lib/ensurepip/__init__.py +++ b/Lib/ensurepip/__init__.py @@ -10,7 +10,7 @@ __all__ = ["version", "bootstrap"] -_PIP_VERSION = "26.1.1" +_PIP_VERSION = "26.1.2" # Directory of system wheel packages. Some Linux distribution packaging # policies recommend against bundling dependencies. For example, Fedora diff --git a/Lib/ensurepip/_bundled/pip-26.1.1-py3-none-any.whl b/Lib/ensurepip/_bundled/pip-26.1.2-py3-none-any.whl similarity index 93% rename from Lib/ensurepip/_bundled/pip-26.1.1-py3-none-any.whl rename to Lib/ensurepip/_bundled/pip-26.1.2-py3-none-any.whl index ab0307c7716212a514231bfc5149437cfb9df70a..24b5cc90ace64391f8ff6ac218837e03f980d485 100644 GIT binary patch delta 73433 zcmY(pWl&sQ6D>T<;O;KLJ-EBOy9N*LZi5Av;O<|K0Z@^@^c!Xz^5fCzA@t(~g&^%NUt;0|YeD}V zG_XTlKztaKAZXD4jKt5-haV1H7~6j*VX*eNAD+ig+=$5kL%S+i0L!8Q07u-3fo_KLA>@AJ8A3Me8j155wI}2mS-8l7bG-^1mvB0g3U? zxT&iI1PBQM&L1&u0b{TqhFF9HBGUh^wYU=0>Zbw#mK%O0qhf-+R1FSgDbtZkPQ1l( zeqVNZfo`&l>p}Er8c_nrR*^pOPjF?kZ*(lEZG!S%*DrU0IXS5UhUJVl6m=JPdCs#4 zk%B6H>_L=3i*fIPuvfnsLP#~l16oaWUfhUQ8$w0kJa-PdGgLl-OKpsLjHpg>VwdnA zT+>04!xJkn?lS>0C=kuR z!e$%Kdvr^02wo@ErS`Xq3$mfz`pp)JG3emiN;g0Nw0OGCjnuq3;G{c_fBpD`Xr;n&M!)jKo-6g`Yd4iEcVOIT(lr z6)5=c&hjt}9S!;NNRv7yxsVY>$Tu-}L~kKsBkHHVw7F}s(W^2`z53xl!YWo8b8%R5 zKd+sf`}VS0`tVzUahjrn4Iyu51es7S&Fm@s462=)O_=u@IHHSa3$SMYmFAZTAfP=RLNzMz3+3f14 z2}_bP>k<2wp;_6Ey@zPfTqWf34Xohpj_d~}io7rw1H&rFEd;!8TDHws`B<)?SBcHW zm3{#i;A5a~pC_7Hsq>h*{k6j4&Rx_zaqZ@hRhAKMR3$zy5p2An63_z&#HZ(IymR%N z5ge7A-@?HdLyA!hJ0x_Twen*yFge1DI= z-cdj!DSWGvF|{8t1l1H1>A1chUG7d-xvzrdGvazlP2+}JbMTE;|u*kh@;&9Bd zhMhq;kBh~6stjvQH&SYLn?e526Ep7UNNhlC%$%h=$iNEat9HjMaM70 z>BK>HWpkwKVaf4f9H?RLe&%~+Tn17oSk9wK3(?GMH8XmzjwhH;HZFJ9DkidM%{#VL z_RpVT+*pZtio~WH%@ed-HN^w=0`fJ@CnNi%97+OA5xcLvDR@-cyfgtO%&amKdrpuj zt94vHde{Z!Gv227b^`Z%cqD~an_OU?Al;almB`V=z>2dH;7|DoUPxBIrbg`zd<;K@TtItdx&S4@Ur z$0fN$pUCy+ecns!qNHA9T-7MPI>DI-)?iJk6_girTj*jisIX19+Iqp_2BbXOQtyN=3R^o6J^h zD5k~q*CUcA%J`sW@KN`b!~{!Au!h^D%-1Yol0oi8K4k-7zq#d=$h8U84;9Qc?J=Dh zAj7d;f;#{6+)|bK+Id|o>;#7ZQ^a1U45yO*s3Yy8gIqWS-0v74-!jj$nitoi?g*ShL&J<_Fex2~o%?Q?j!H+5 zpNa|H;N+!9gfAY5w|rE0@RjtE|np||=Oj&i>x z+-V=fKIAF|=Y*sAQ~59iFW74z?)9@}sD-;Q?Arbh`e)IM?JA_BR~wO(R1?~BrKmy`>Dz1(;ANs5S-#GmQ)cxWSIY$p zsZEzEr>jrB*)X=%7=gg}ZGKWne^Lmt=~U0v9X`us=;|%t;wL>^Bgthe#3ZE#kf>PJ zA7?0czI|>oc|eq=7Fhed?I>!ZC|w|guJ1B=^dR5%{gDTcRCo8rJRhcu9QAoa(0#0u zY5AmCtc%fVly-_J9Ut;t@yq877g75ZkzfIyWYz|1s9^%n!PN_}#xRE$bd0)UV2cb@ z^JwZ&3}*TX>%i?jXNRa63q}?>bvd{s1(Jzx0Nc5Rxyf~z1PJ$zo#I@Qhvvd zJA-Tb$I*ORNQ&1Lk)S0cURq?>r#LXvsAKwp#BnvniEM&K5=(L)r1VbsZ{}+8BZb-O z)yd&6xdkwft+*cG@#npsE0pxR&ibU*;vE+^wtPk|tdzy?PaE%VpeySBMWZJC0J!^S zXt0u|p(CYCsYn~X_({nAPf9o*9I+XBcZW^}eWY(KZKMlIMbb!wm7E|7y0qGejg7y| z>4%@b+a(x$sp{(=Y*!$gyR|?jZ94oxdz0K~oz62p^Ca*Bwx+UZSm}R%^!_bKefyjL z=de_s8>>U@MS!<9(&f=cIEmD`dw|_y_q>wtJIp6zwaVq5TseHR*#K~4bu#VzMA@j9 zpPp)5GmJcg;U-m}(&s(WY(>*cGEjWzykmV)^kK3J22G8(oD8%1+U?N-14~!whG^H- zuhOEpkZ0K)aMqR&HBuK>y`jT5pD&=ekzWhj-SpF_4Mh8acj&H(v7qI)3Sl)@Ib;{Y zK*qhVv=7rp2zQhW56LnP+(87BLfQ5q>lVZFN`%Ym=U3b%Ih*!Gp zSbi_e(+XmAQ9H`csD4Tq_?f|Q1gRBNGWLM0wnwbxCW>dP)V1I=pf;&$I=(oVVmV1; z63ZD(_3pVdJIQ-5}9#q|JMfZk^}vOvs8X1UUHyz16b%~wM?X#^R%>sHC`yLPAi_+ih-nbGGUdYM>@}<}Uak7Ynz*-3lN68Pf$j&P?oEQKZk| z0Dmw6_{@&={Kg6;ccnMC6wm(m5~^@?Wy1~m7MMAiuZ7Ma(dS1DX&s4Y8m6&HJAeJG zQVB!Um6r9eU;2a~3FaU?N{>ndvwKcV#Y2kPZvU-*nrlQPb}?q?Z-$^Ji{`HoiW zl;HjUR#Dei0v}eHRk}HlB9<7gEHO(he5Y=W*1p!v!_y>ul3=0PHFbrtR2nH>EpO`k znZNTSLm_cuhe)%2aqnN4qw5PE3X*|A5*xjC-^Dn&stkTp?K~NbFdWgdHJ?htM&c!a z>!$7L22H(75Y(j7*Bz~K!(@auC935I0&r{Z(R;T3&e%8pK-bvGIa#0`W^ICmg5ivj zkkNqt1uQdtPy(%q^k3%e^x!da?Y&vq7pN01W+(2oG-TbE18)|2b8Yf-lgg{aB%=j| zScv0&hv#4EM5K+g%whf;pZv*N*r%(v(;B9u(x4RCW_`!tbG^UDKf4;Q2!K zOL8QXgxuP=xC=dO`n*yA`3|;Tj=kv9Lhm<8@l6g`BIrbmK;WpVbQ3=pA-B%Q9cc>g zPHvu@Pi)xwz0@>ZBq+DorH#-2f(#;kyL)ti`9<7?a}};^>HoIH!?{XOF)hPP<|qiR zQzCX@p9DpZJV^^xyhM%9jOyR<^n0W#Us%f@7wT%+F1JW#h-SuWoPv)(8G5aiy9>?9Zw@A-YIKV{S z!_{$LCz1vn}5~ny2K>|76NUlW+;UhtYO6kRXm6|1>rz%u3(_ zVL@3;Lnt4kRUSEIw?fC?xcS524p`ZGvn4n)54G?N-%BL_mwhOnO6}zEo&Wu@XNrf` zJ_~oy`~gW?@WN9IEf@hJYnjv}{p`o~D!w1pgt2#kNyg}W4h1-iC7wp2Z?v;%#gMC8 zr+$JnMV_govj2yF_fm&~D!$+sADaf*Tm~NRmR5&Y^pUi^EeVyOG)es#!pk&yg1kIB z<)`u_O3}7?aRiPY1J#VL3h?IsW#Vd_t%;Omxu%z@e6DTW7%|Sak!mAf$N;V9((Nr2 zTQ@3kT;}Ctt+C*1;8hJK%`L_dc{m0Xrs@KFqOIYX(roLXIvQ z_tC5@Y{YeJ#`bd9i0INXEX6~;X5S$%DW&TTPVb<@#6j%A64RmcRi18U_jpGg zW&TVLq!-iBUMa~uzByw^jouYKXXfWarpDy^zjBzvM1$+AZ>BCyt&0)yTBdU+ z{+l6^pNh@l5WTROY9uA$cHst~0>63e0<_tf2{=R8j&;#m;fs&ik}&DgOqXwI8- z;j{ZhHzF5hrO6XWcPK;QQ4&L!CkSyp$$ir@a)?#Fs|4qb^q>bRi^(IFUxk2^vlATVr z7RN1ao;q*lyb(qyONNB?0oCC34OXD_lZh4o!1vgwzIQI29efkuTE86x;_b=cYfne7 zS5{0UTc1(yM)zKb)OKG?p|l*L{y}GMVCsq8?Ekg~mWJXkKpM;Qfij}Zq(d87Eod!E zo#_X2hkn`ALFCaV=PKPrnuhi0mK_aPgQqO@PHGQ@4$S7{p=!Mum+^D>Gd?}`d@v@{ zFUIYl(Cp$@#`kLoaXDQ}tb=nj!Qsl9(A|GeE*c+U{Bd$Hp5z@9p4;J3k6=BS`tgEb zZ=WQxR(pFL5Ji?5iWzHy_UU5Gp_gRiYPuMlSh+StcQT8AL$eM! zps!hyuo)QTMB3PS?CysW#b#l?h5Za`?x`+a)yx%uLaMyaQTP#2!%v!?L!F4UVX1D` zvLd%hU2|oD6y*$4E*3mrGSxZE5ij%!Y#6mlwarzU8w*5EzWvOT(kcHCyW82xGvxlq zVpb53DYfX3_+YQ$nc)Y5*HITd1*W>P!^6JBq;Lc@jH_2Y0n^tD0{a}VmNU+{cC97O z%QR}v&HW(W%RrJW>C`8VFAXLezAP=H97-!(jXaFI28Q<3$8;43f%-*r{8txrVCPYu z5$()UY2?Y419#lRd=fX(*V0ntjFwi37g^7n(ubli3+xiQ@-yIR1H;cbYnkl~onRJ^ zA^wD`mmfOBBL4T0=pLdS?Q*(i0HsXoiDIlt>1P+F8>8rYmE-73S)Y^cNrizSMEiY{ zpF*9Yo7Z$(KTnH06vPeWhh+;7q5hB9d-1VW=>`G-{jiDM$yljdI1q5D z_MZq~T5d2QMu8u}-8c~EAJ{FmL=Z&?A3iNwi2e`OuZ5Tu0`7w~Y@y?Zp#KN2%@2X` zK~lEZ3qj!hLx~iD==>KaD-H1t677FP;nc5kCN**ZU?3O(AWfadguqV?ASZxtfmDDn zAphtUW5EBc5D5T~A$^2AWh}j7!2keJIRAf0i;X`770gGW zN#7uf|5^Mo5O;7N8O)P7TkDVj03k|ZK)^@Our2zj|IOy()IN%a;U_cz@QD3?Cj#5z zodq%auXw3K2)~c#*D_S}f16w{h1mS(`&ABc@Gq{c7Qzeof#(<;cyDdjC3|qyn=4>!m5joQM)10FXERm`xlB5Uz!h9>|IRafgKj z5DoEvR;srOuNNJsEP8CrbePhvJM9TY!Lmo82{2* z2o{02a39;cSCm=;!~+1nD*ynWQdKH|c&R+wKxFVLcf#f!eXXm(w~Y8>DJl*OgX0ta z*u=YPjCPZ+CDp#4p~*<$Oe6Qui_9rA-@De40w7786Mn3}I_Ka^^cPNB-V!Ky<#wRLd7$Sfn7T_#4dM4_@(KByuo zqFy12E@qU$2H#8*Jmy7+Q;V{C6*j8+EHIKH*S#rNy|0_Jsy}Di@T8Y>7ygOZqKr z#L~751qL?+g)F9_SCoCIl*$5Q%7;8ST#7!hQrRpb+da~*wC$YYz>5}s5DNjkFN+Ai zy$f7%1651WApD(QK21cy;o6?x-yf*KNi79uGebVT2E6&YxXepTSor{F@Y96KxAn1r z$eR6^ZVw@yICNnym`cLR<|-1yhZUr0=!eX-8udviJIT?b|D{1fpz_VHC45DmI9;H( zTo=&emkj!uXGn+>3m|Ctw#i|`DGxRt`Ua>Fc0&zC_Dl!11{4EPi9R`xoL?2w^l3AA zCMV@flbFR9+r*~?t3?!vkj?t=Jp;Q?kT@bKw=`PoO*ebd|7bv4lYTQznufa-;7HY8 zQ0zKDxYni0V3H~naJPrlrW}Vo?qn%;*AZlACN#hHe-fB0t9nNPz2@Q z$(Tby=Rnl%&@o^%`8=X4RxQ9j5-Ax!Ts5iR9eGhEa)$(?IC;XZD%D%|N~5Kht_K+D zPLRSDjEf5UKW+&cG>=Z}pz2~I9&#nt&n!e9n;ZI0`r zhD(4sNJ=$H$68&ku+R!xn*k0LUR zg^)u3bl~>ROF8b^eh6St`RfXmyOU1l$)LyFDL4jOV)E96s@6<5=IiaqoNE5v7A5vg z)3)Z_o0rP6D=ZtHbNe+8jC}no_)BP8>1<(e;jTT7vdYr_ zM&Mj}tbmb-G6Fnd0up$Wt|7i#KB$W`@`=GgQxpzbHjBqPQs7GTj~9@a7e*q6(X~w? zc_u>33XwlFxLy|G`>YH5-(u|_0dA*4vY@EZ= z(H;goz_S||js6(Cbn^PN{j#GWoqJfrKjs8RI~I_R;OjpY7`fT($2Rz>rXy}-F}Y2U z#@CeaH>5M(y!Q~1peCPmvBOy{;A>@`MjyXGO@6k(K?FEDz(}mLWO=Qf_ZR$BjabGv zS&%JrmXv!&$c_2 z#DpHLKpv=-c)N`OeNW7HgCSZGRdv$8q;DBQbbn>WV+UQ;YY)jFjipgXnche$RQ5mx zglzFyH8{M`6bp@+60WaC9OXzdNIW};H{xrNQWiG$on6TTwoLZsl{Itd#8VoR8vV`N zM3x7~u$&}WP34CI#_plT#slF=1c}8($AGIh{W>0tE1k`&$2aYlWQjPQJtXP!uM4`v z-fz2U;sai(J&Hs1st|{?ld`2pz~#@vA2^3mUv;NUdDJo(=~KZwVXl z{$jp1Y)>Li@^iXs6JmYbl1VxExCYR!7x=tvu)PrVrvj;nSO>%s0p(FWv1^ou>;(D3 zcG5lJeAS?V-i8m86Fl~#!2@+V>{m9p^OxW+-_p_^!5>*2cnPGqZRGpAai9|?NF*Ay zsrx|=YPU}rr~pvMXQ-@vMzfwa*F5d)Jxl4)b?D6G=m0qu`M3ZX7@S~fa6Zw3D?$#o zR^GGV3}W~d(pH>pk2x?fIw+5wF|n#{d`o1}anxiLn=uH@LL*&Ex|J%B@Ov)U*86E? zZ($2zN0~feMHFHRYhXOk#qZ|iPv1mHvNI!K<&4?0PAujq=@TRl72DD^@=wzO@dEL7 zf}`V$0S$ml484n;qL_BHZPQ`Qv`B85D$>LgH@HNK7>X%2nEc>ZmeenCA)=|nv&XAP z%lE5+x9zd(_mU|=Wckn|aeh%SE`IVE3ZMXli8mBs)D_A#c&5FaS>dI4Icwf>KS|0- zZ#-N3Mfp~Q;rFdh#F{C|r!x!b2AVe|BZ(_VwB1@ zI1ur6`SC4~T7%%6oz2J<$Efq*VfYGBTzsc2`uiLn{2HPY2OXubW~oY$irBI=I*$W9 zfa-ah&Y5UhaW+AeLY8AHiw4mxT50++?6l!wdxQY-*U{;Z#LPs=5%z{$Wv07Ny+8c% zTYK^z(gznrX4e^YlYx$kU17~)_Pl~Mz^6qDjsv(55Wc2ziJ|(+(6a^^uv3;ZkJToiKwc)0BtzR1- zVj=m?4hSrAuHzVcCDP)0nMI5chd`KYDOR=!G3d6+{0VZehUUFffp^rL5YW%2A0S4Re&o5Uu@2$V&S$$l#fH=4WN zHf|?SNo$4W2=IXhBZ$y4IzG;~Sl*8h@89XDn7RIdX0-d{qq(bgFug*MDR@{`Vut+^ zN$u=EQva8PjE?85TR*sdZ!Fde|5Kk*n9yAq?y6wn(hAJnU|=T$ zF77xNbA8X;7tdTGnlB}1Y{CE*obqM}*uDT7U69U2G38V7ZBa^i_1OeyYkldlSup)%rN79d>J4|iXOGgOIJ$I=$4X( zeEQKYk3X%8LEw$@L7R7IRKVrBL<0#*lZAe`t4_5MGLL%-avBrCWtvxyM(g{+Z%iuP zKlfqa)qqlv)vlaWg|;7GOalRUf_fhVdi{&0-PCDjRJBs7A}*8^4* zcZC*bNCeIpE?A@640bDpzs&u&j<^@~VM5%YHTk=9t@mr@vi^_T#59eNda7FHh=iZ3 zu|tq8!9?N9r55_hrndxhkGfa87EE)yYZ(_SYHC`5;q7l<*G*ODvKPD(Bp#Z2F10MU z^#@gaPzwJ+9eZy3p>DUPMA5&z;Iu{=Tch48Trws&}9HPpHZbTsn#xrNVUL(njSwcF^xiuML3jCSk5epxlW>|WR8NyDpW?U)-e zb%g8D{9WaGbj(id*^H(ox>y_zbNbhc#tXWGLkTkZ6E$iR4HCe3(h0lH{P#{F034_n znMOzBq8Yoye&!|Rrsd&>Ylfja{v)23R<2?R0^u)&!>Llr;JRdGw02{Dy71XT-}N(l zmyANXGla)C`cI=WArSX(c+mN2pTuYN7m86)I+m!>MqKih>_`@nBOF@uq}vbjjL1e>rjG*DelB6b{3v;YOBY zQKjL=!E^IUnnyTRs&|~~9U5I8{T<8w5*th;ej-}>e6&x^&i<^r&1a$QC54R+^}gGpmWpY=3Dv=KmkXt_wY#EI15fu(L}?H zg7e{Im2o8;sJ);xMPs&WhIW{NXb#$lVfLxBW1xMWtkFk39UeI?tKf;`Dh8E!ySwjfuev|s z2WjpYGI#`2C)pqYiyX*vdYl+Z;iVJ@Y^ISLMRT3-5K*^Keb4L+2$n!`f4UBGjno#W zU@S;x&suSXqiIH$kAHZy&7HE3R6M%%mILSbv2=9r^NC4z!f}R#8o;kLWQ)S*#aQ&; za$G5tdnWCTOEJW8g>sY}(Ow?^Ru5sasOnT*uVZvgRwP}-D+~1eLXbzYzbb*aJlNu| ztm*MS`;JwI?P$suz_VzhvSdy7y9R~z#F4%l7!`sL3zxWT?D*0f(eIve^u5iJkAT;7 zkNv0a7JC0CZ)z16wh6f0HJfT#SG|Lk>Kj(AOV#!?C*Sw+F}|Ys7~0?C;meriQPT0> zONLYZJi*(Rux(IHYJs~Ge^?B#@UV(lVN!nh%7;j43`DtT6y z8D%rUx3Yey^mdFmnrxO8-~Pv>pEkWKeNW zP!^LoT(*X>U45p(`I$X3b{xERF0u; zAQ9>(!XnwbXX!gI1)%YJ9HT}_q3z;yV;H9&T?mlGW=niMB;bB>)w*~9bJp-m9`4+~ zabXM%<4EU}2rYL7dS(j5zd*A0c*A!1Tn(RFdVIkA{0q#bv~G24_J_Wnds!m&GEZC! zBYGuT3cI(X3~&-&FL-fvJ-O}eisLKimsLz5@Tdu_;B^HbLieOY0IFTc`P{qnyJp+S z&qK`pOs=@?r~8KD;L3d}iC`z_nGdSVkaK?!Cd)2y=TeEKMJjOP=J@NOE@!_f+PXH% zVdn4o8QQRI{AtwFYcS-Jq~-(4oO;4ccz1*BATwU?hvyUtP+JvD)B~r8kOrRwDw>(v ze~~XOjAetlE(N$9)E)iq&8X}2ft~2SLln!Z`{GfGwTx&m21o8N)K)In`K$6Hk0ILQ zwm?QLQtRM(r^#WY$SY3#yXPyN0O&z+;W*xN{T5wdJ~r_NLH<|s@#K>JuP=v61F82= z6eZpqD||QY*ni?+Gya+d3!ch+8ML!}s;ULZ;rM~mc7_Q$QA+=sn2-$XWHcEjlTTY~ z=<{Nr4G4O>)=c>K)uvtazy7Bc+7fm$G%w&G8rYj6lbF(k(N#&j8XUz&NaFUKjT+s~ z4|SIX9P(fXof&@RFVk6lyZ)USrpXo9sS|})uIVuDZ?++l9Ymsf9`i+@@$4eG3*z?Y zSl=X=6Q+!#cKUMjY>vPY-reLe=}Yaa5^gKN&WFi#e*87eR!u48zb~7%vE1U1Y^?1! z>x!gx-X8GJMX}Q z7ASE^%W>fW<;DQj7yfdGl&jqN@mS=i^%FYE0GxZRCGQ(Bny;{9j5$fInOfyK>W zObb)(Z}_ua)0QF%>)PNr)qd1yjcIslPdS~pvM;d3TrFti-XiQqZ(g5*;M)5w{IYu< z(Hpkyg%xUOcYY*g==LZ+nL^04Q_{E=mpza(__KGd3$+oP@8HV=PLA8c8}2MVXF8Ot ztCrG5R_dxE&mwh+Fd9LrcI?h?z$KV~$OfCxgG z_TXY>j<=#**%$3}=6UHOA@1q!e*?+@WJi7Pi1Lf*DivkW-j+9&kA;~hy2RqZ_xfsK zi$96P+~)40GSVWUCN!#P;&X@7(wlHQOUg6HGmeN*x@XcU47TN>jTKj=6<*(3;k^^5 z^N%@pa>a6c{P|hG$_qOXKARr8t{-H7%Bnl)_Ys4MT)eJ10JYSSz}djRerC^Zr;x-X zTxo-!ezR5}!9`tqxkY~41B6I}RlnMtP3a+`rE5<=hFPp>HP1V7DBc}6yK$yhRd;dH z-^a~!2*jdVfbQgdw5oI(>09=**fyC4ry*Jz zm<6fC4mQ?_zG^=zWli+dgG9Xe3>{ab+MVbWHkhZ5zS1H(7qDQ`EL!-1J8Ls_`&}7T zBW8*4sBFtGG}%h-sBdL%$aGao5~!+#Q;0)s3_q0wrQb_2%Uf%`hXjrsHKPYmL9*?x zBJQj=BJq(1#j1|Hi7f~3yRB*KVG}WLe_lP?RxwOw&Li|C{Dc=_J4t7$cGvlpY$HQrRN{+Q(Z*yat zrDzq2ORIir^<~T%eo>FyD8J=vvjn_k1=%x*`m*^PGfW{#n9&o z4~BPY(7tNJ+G{-HE~3wx1w}ZEJZw!m%`c!%#Eo`GBAR{HUAtNn0pg*}+Jy3x_k-kd z&-Mf_Tb{O>qEQtNODy8#G_uQ$Ll4f{M=-WR{s~jFw@=vi$OQpQYBmw~3ojybwVGszR#TN@yL-fCl)X!`n zsgG1oHqg>PeMgNKRPawTdgKR<{1fG_gd<j0_@}SqeS-yyMbMsLgB3XzD-b8viZEZfnoCbMh?H8xsIvy7OoGZRIQ=A6&LzXS>K<)+e#QB2<^Mxcp z?Y`SYgXZRYZ>7~Nrd{*Q@yi=do4&f-L`;V{C3xA_SQlI)w{B)IFF(JF+%Ar^uZ{VjgHh~f9wL~ktatHQiSt@_(teaQZwMTrIX zS#T#@++RJzzK}EcxA?`){h#)ae}3v~^nP|Tq(1)kIaMQ(;RYKnqcMuAhOfO1^3U=O#e8qP1HCNE`|AMH~I<*?AuV{MyDG&_!U6hNlgXFY*!Jv7(jiG3(r8^?{PZZ2?B8(8R(mc8Kb3Ltnz~bl0+m%0kBYnRJ-#xSb`!Ma2x)yXaul=P! zslMI@TrX<=*QOb8QnKErsMW6>QypdN1WIz-SYh;<;o#MKhg|JksD*M{E*KA@RGQaW z$blQ?wQ1{E6~TmVqm568Znvqk!q3{U$Zk565AIN4y18IrQX90LA0D)c_|i_L=jts( zUTTp1`HpTCvzsiSv+*-0W8-v`!XYQ+SuLg+xQBP5&!bR$e;FMmExq?dcfz)=rKN?Y zzM1kOVgfpM+|z%8>>Hfc1gk_muiDh{5F2LAqks9T;s(Is`p;DTA9bd$*2vD*O1f3* znFdEc=Lf6{kh@frDpYS^AIptB)1V`r*2~Drr!b7xNse@~W^&|bM>cXYAsR8aF{Li7 zz#+ie^s|x4xv?;6u#cgE=&rpQ&Qr3D3{ok>fFVTl(ZlH?Ge(wUVLX9J`_J->K0!eT z5#e!R19!kx_0yA$+a+>Uc%T|zNN*Hq{ZT(ymO*2@%>)k75uQ7eK{nFDK?*z z(HT8fCg_Tx{bB#rGiU{iG&`M`xpH3>oRP&<1815k8Q_iv;)$I5DprM+OS#6JBcD)Q zB84_u_s5=H20sbYjgUwJu_0a~ZMpAMTay{hH!w)?hE4!x-D_oVxGjWJbdvRpYczo} z4`?mx^qlg$xX921zPsX&HraUvsMUrgy2n!WbRb?@7~ayzUEWGasa0Ov}pIR<%9)X>8~39x4&PZ{@fqmNQv971J4SS zix=ejC-i?PAjyg_l?$8^pO63diJt?bgS_YabU$ibFS80;9Mx}yYqE7o{e7g9IaScZ zRXe2FK6-9H#wt8Ez#`iwE3ap|iCdOG4w1JYBKQ<-pRZBn4B@-M&pM}{cA4BVDX@t< zYTtp1aJU5!i?NgVLsI@+0YYMpTkV>Q(3VL}QT%a)0ih33EhakW0H;(wR4}yx69MXI zUG|jOivTi?Exj3nvB1Kdf`%K-ip_8NPe>-FkmJ!DOS_VRrWvH8!W6*o!9(XgY|udr zD17Z@pG9&M=g@}3kOyPTjOw#~n^n-G+S^4B_SzbK^(>e?w@UI*!+J|mKz8@l3shxu zUypBVv-n|_h(c1pP{dQj4hLR4;EsW;+L-g19wc^v;PS3Xm@QeEYi!$CQW=izY^m|Z z9p~{zMPOkq;5Q)@Hi2}K#$!0ZgFEho9;ntJKsn*`xV9T zg)-$U+o{K=9zzV3?WiXvC8l6z?;?mxn>DT4rOGurvD(UM3JBo?Qf=@b++_kt5ymdH zosrH30nLSW9TAVJfSptL?sLGzf+AFJSL?W*!VSW=Vp#G9RtF`FmD;`>>G{hDNsFSt zDXy@`hL80cwM>oI!6K6f46PBhc`b)}jq4VInKn;<@MR8tz$Tr>V$7aqrQ|k+b^ZP- zJXuWIW|r)|xPZxU=ST3jakR`FzrcjZEB5ngwe@LI{ULg;wNnbc&!#9F{adHhRVf-? z2_Q0P@kq~y#-?@Uv#`Z+vIGwJvg2!l zgz5&RCgN)xHdKjY(r&U;y3@qFO9(Wpe3Wgx-sRkzJe#wg%aKf_X3aWx6 zN_55pLk>Q@pjVrbwW+$|$AY4k2S&BX8F<)U@KJj^b5{(Yir{#VzCE-dRfRn;4F~4P zTY^Dr02#=Ol~gdm-GD11;4(T1okcuu@!U$53i#*vw6ku%=Uvqy!h)fJd+I+*7kGaro6!8S8&a0H3YZkNeQZ<(SkvZ-QGxBegK6c`G9wbLk!vi zoLa5wHQyQ#X_dVJkEp>W5JdMSt7pZJF@alrxOVHxo!z2MmmIkEeOhd(?l1a_GHTRM zH3-U2PtsGy{m8IQRh(NnM_7$c0pyl*_ST*o1?&NE;e(k$UrXnEM0AL@5z563_GNjLl&uERoT@;hA+vk* z)+e7&9y6brQpy_hg2jB08RQOTFQBE$bzqx+>O7~_E`3_DaSzJ!RMd#;wtqx@bb|65*GS>lb zaat`S15`Xzt(n1cXr}i0?#A%F*y=X5lFonPe9Nh4Se}ZLh|Yg1o{eA*utUy+iN%}mvhyuza{phak3J=kcb81 z>Vl?{!TIYk*x2FJB{ZPL)3(*oD1Z4NI1O7XK;u$R5v`GmdEJ1@?=&gM`HWp1wVA~J z7c(3YYKS=?r&0D#%`~1Z37r4_F>UP_=@7BV@lJuw6UF&C*|S|w2;6lzxE*hpS!Zd+&>q@%}8@tSOJPdcBx9Y zC{|)|542dg`vYhZ$BeijnlMvo9Tue<6A678f^aL@i=w3v(jo!6>TQA5{d*-i*XS)p zmpB@+KwMB>lD82>o&r1#OPOew2v{K$VFaFI3j5G9FIlef>9!}3o$s`*Et`lqCx~!$ z93!vCxU;IEkGBTprg{D7{%Wk|0=zGW%<{D|E3#9#E@XyBS(-1HJiAcgyOzpIgE|`C zXEJGCSB*C7a=i|6LIEgaHXJ>T0=Fv%RALrQDj7a2fxA*Osc&^g;+HWb&|u=|T`Cp~ zTS3NoC9l?y`G8~X_&p(zf7tnkWptfk?dEbp3fZPhbyQ?|+qjmIw&j?Lgj=K)En-Mk zi)2H3@^@@~PC#6rU!M3YU`m=Ty<>CcL4>x;wyj71eC51eVo_SnN-9v$iMrwwODoLG%!y_*@uLcSg)SN83x(Q_(k$ z6itg)fQB~@I_8Wc@H~oN?m()(zc?j^yHG{J`gTy9iE!JxcajE;SMP^_btcdGW*v`csBemR4Ut`D0f0h z&O$h=j(aF=)Ix<-U8WUn;z@4^XKLOcSJKyK0i7O8&VcWEe~><-)NOl7I0>PmBdApv zYbsHZowXpCCJtYr=c%xlc3C}ySLWW8fHKgf*Vn9*X=ShS3BlfzyEq(Wh~r5fYl_ zEp*>!_?&6aTrhQRy9d0#Lh=Q6#FM*NGo{g|fH34yLVomneMs!|`lOnceUe&Lx>4Yz z8`55hgocTb^lHZ)8Trw7_(u8lX3YV+V58d_vskmg&n^XKR6BsBp(m3RxH8B&GCS$(*0-Zd z5-FAb&WMpl0ig%}frA$s(V-BCNp&v)y_|PFEUrPTRf)UMHJoXm7)a4^^@5j3E1BkA z&$=iskOvNtpU?Wb1E<{r3SL&|nnTYe9;kC_vNY!gwR^Nf6sqGk!*V%(yMPq|&_baG zpiFhs)=kQdd13l?<#h%tYQS?IFhjlxL8@0f+u#x^Mt8g(69VA|I^;y1OcT}aT8l3Q#}=RWW%hiBQtnoR{a}4E8h^LoXDP!E~)mu(Qskq zQBf7U$|u>Jf*Zt}cb>m|#;RY>7qq)z9)F*S|8Oc#CvrWs?92DWOi&h567v2(T)lNz zRm~SLN_W>Gq`Nz$6{Ne7mXhv{Lzk4)L0Y;~0coT|LAo1B>CTV$?BBi5{q7$;%rmok z*36!>_N=w@5SG9rPdC(m6!mIKsp1QRjI&bNeAKxxt%{;r$fX>`Oq*AcZhYJymO~*D zZ6|3#^(IMnT-tLhulEnYGrVao9#+v--A@|r+ympk_`>4zyTv>rbu1B_x-`1{L*lqw z`h)+y!X%>n!WV&Vdj)qUMQ>Y4@rt!dq*9?E3|KM8g%-}V-9+Jli68a6!4 z);ffD#KklJt+92==l038JhS@zqEtzDv_PVi*)|>5hPhi4>!l<)_hl0fq!mH1Bia6B zN^F&AI&r*BL@DVH1Aawo-=k!17U!($v`aQGhcJ7cQ~doLHKifFGmEsr))$hRv+G4Z zAn&P>e&WUBa-6pRz8om;CEjq zGg*{N+Ic3KQ#(Ig_*>bIy#6Ru$@Xqe9-GhVLl2{wn+P%Aw^eWtL)jM?K5mm9BD^<2 z*M=v5XRz%P@+YT%Dri4YIg!Vw+&tbXX87x6|LeFz4c#-P)KI6dm;JuCuRKtOg7MZ^ z#fEmz%k(^HQ~r`A&pcE4M=fC_hJCdz@h+@$3X-PYvXw2FLcma@(0Im<0;-aR8aHm5 zePcFq)Y}`)a9j^?Y-|H_7A%~K{(M7D(YS`Xr6Nz|A}?3$m{dDADD%I zzF1c@5$M8=WlXnIChGadY4414$m|gx8WL>YU>Fhm>r}vivp>ecsh56+g<1I%f;X0$ zptG9@A#->Io9`8TTjra25sMtdgg3n?w1n8s^Be78#pUSHN`)F`gr;OeoR&x+>wtfr zvB4$#S!Vs$mv?XmtxC=tCBVtlUlMRVI=jK2+BT#hymyxs5aP9)W% zU`)fS5&HKT!j2mueT$!cgP)%#SnwUejrzAfLr4oht z?qQmBlL{`A{0IA{UlxBbb$4xGo4%oKI;k|@!>@1Bg$tHjKemj(0*gF?#7H(2-kzFm z2Vc#tOGA#f@zqs`PPCLw4L64w@`33>ns2Ku?k@0GmVUcc~M!#P$P6nxVc0Ba+FN5I_s$D8Z zJ7e!iR|2F%Y@5G`D+g8r@cP|RQs}5EBmE|zbaDAum0;Y7l?-Icbk!RhB zT@OUIfyHMc9qiQ%jFyn#xxU(Zn^;@5dFd9JxrNh4>XRE~j}sw>xj3)!Ci;Md()_AKkGhyhD$SNpWwX@l32W7Zt7 z9^QF!L8h36wMERY$!-2(J^Wq@UP&a-{Os){NM$s7t73j8$1LsaFOBdL@sgv@PI=3P zm6JH5M*@oQX9xYdT=bKpS=Huxx1Y(2>!!@;5;_iUb{CNANmlaTH^8FPY|p*aSI!%( z@VxxmHKD0uHXYlC&(XK=?Ulp&WKcb6oiSG@SD*ZQPhKxV?;x?eV6X}`n^IOVx*HXz z=xY`mI$>N8)yI@^3Q@h&4MUCM6|+nnGdcwYjT{vrMMC}r8NX`8XKf8`UwP|a%hhcT z+1%LRUXq(^Ex$qQ;!BMv84cnFH@&OeF)?^!<`BOVK~D5#cvV4|SBVwT3%Essd$MT1 zE<;SJRjjn@lD^3g(?0XL+mkqyxAkz{-mCoe+1Dv2-<70x)u(Zo|BXH@9-;ik!BkUS z?iD<*WnM&6)D>FacX=3}ZM+Q>Gl9x}E-Di^u-UoChrF-hiI848qOfBZ9GKf*>qy5T zIOg`HkE+dok&n~!zK!+5x%tq+W2vq>w(pE6Pp*2y%~?J5QQg(HaCpT!>pFmLU9*MW zo6{Q`|AEVeVLYzYjh4}9)~_{Lz}7g^)Ue9?!Hh$OHf$8Eur@~7;9E&lxvY-S&zU?} z0X~8yIIiyDo+M))sAKh~N0(AU;adG!tB8gH?-oIl@L8U5?A72<;rS;f^54ECdZ)At z={@S(N!Wy%>|etN2eK#YY4U7lEwWaM2^YG<(DF}v%$KP5w;#sSwaFzhf-XPM{M9ml zrh@%^6}}vxYbvBk%)9v!^oaE79gD9r+($494OgxYX^#6lF0+zx_j@YjE`x~IkLzrS zrvLB~1iimp$avl(ThgNoVBuE{IbUyvnGxi|wpP$Z>Gksl5T@8@NDnq3#%+JOLSEM9 zRH`-F8uC9G3~gM`@BZ=a59wQqRm51h=g*st6mzw!aifRy;=TymMtFsKKhNwqOKrx% zhv5&SKBwGaukXGZ-^<;F|J@tFQ}6#!hP?4$=wwZdJ0x65WBm!_^l42&>?Xtt;*~ji zxsphE_vuDr19@($2gS)2C*|i-xXeE*j;{8vKR1;P)dDzQ1=Y}KfP3CcFZ}9Rcf?b; z(SOIO_=sglt~K!2^elDpVr|pJ2OWVr865O0oFQ4nKmbk1^#|H(dY)MeW%f7MS@3>K zckeNryLtp8O=vHi9OM+HjRjw( zWc~oZ&0wWJ?k8!5kYK{t#Ymi%!8r4dY&CV?Z`t{<~rV% zFpsGdg%hzaKbn}wwa&z?kY1@voS8aFHXoQX=rq*cu{tv=<9yTuoZOBc}Eb}X3tF(V!( zx4Aw?rOlNsQ4fnX^&#_~PM)uAV$7`e<^G!WfZvac?zl+qK9OQSm2!ns>}7s)SIu_T zki$Mn*oGFJvYRs(&IGdhy$<0(f$(RpL!~y42PHgrz0_Qv>z?MDU`nk;?0Uk1v0Bfr zR1Lq@;NFLLt)uEztqA0ZwKDK)(Vb(=bpPiGRxUVSuK3=!!Vdy?yW0y$B z%}-LmvZger=?HU{Hk?d&uEJQNEc)Lj!I`ZfT~zX=M&Gr zXr^2CU;)*snZV>!)AqH4GPKv)%q?XRR&ou$An}n)&ra#tMncRu3`C=9c z`uMLDU_}`;Ecii-K5G~Tb@wn!%H1PGO?5-^YNyG3n|Z{f;`o%qsFYWfUoigtC9*qk zpZS|L`_X+_Yvfl5;ef`Npt$VWyob2wFWC>aqIs&?=ImAiAquiJU^zBX^p9Auq^ei~ zlP98Ug^c>TnfY>lT=AY+Z?QAOF&JToS03YO#~Gytrlw%xVP6H~?8kJJ&!IYJ+BrYT ztc6z&_Z7FpU#_bMJTzy%c|>2p2{Fu)uV$b)hBNN1o*jAf4k$=tex`&9iWWlR(K=s%afn-yxi8VIBvye8 z-cB=iVbpf-ja!q_FMj;Ol^%3WHpimLRi2}1a5{ANs5VpV^##AU)jDBLBG46%0zG!( zGnMO^%5Sr*`oPx}NCofjdnt^2>%D~G^%S)8+L<(dTV?cA>zj?tUTa5Rj|b5thYp_G z{wTUPU8wspcJA1rpLGylPGobZQQE60^zbku~AiK{JQ~e7~iMyRS@f*JTtVPCq zF^(cfqhCosDsX!{CC`tR9!sqJ$8x#Tkj!D7v3Cjvbkx8G;ODc`=dGKVn?gbIR*>U4 zhvGljJgTXqbI$QP@KxU(UwiO;o-AMD*qbKDX}t&0S51%*fdOaFw?_%~!m*f5=4F~| z)ZT=@qIhQUYii9ZU3+g;-NhE}Ytq`yj*tkf5@9ozet{Uw#N^GZ30**$ujgL%m|?YQ zE_Z@W9$Ue8V$II0Sv)t`st3cf9y&isN$ryKmV{omsm;ZvTheoqw8G0#oBJl7eg|7D zx9ex=;9Y)ruS1leIkhX2!TUZEHhyFET6f_H#MK&8b~93BeQ#%4rR5P!BqqU=qojfb zd||5APS;t;>@JKpZ+ z5T5;Gj@Pks@T&%l95^+`t>Ei;q3-?db4miE^Ouy5cS3W|lf>q+hbv$dW6wwZIQcd~;RiqawD|oI`7pG+;>E4wl(%R4i7nw!kn?8bYZ&dVm#}pI z=f}UsF||TGZ>4CgsF&M6@Vn>FxuI2HNR8Zp#R`eSj#5a2Z+-7QZ>*W}6-~{SC?;5z zNKG+2dIn|V2jIsgOU{JuLgj}xOAPVS!o4i@D4pA@UT%-%VRPnZPfKfUZO-!3=vC1a zh~`T$XZj>eQvu zUkMZu7N7A@q;!!!FPF)d5Wr6pTWrWr%jYHG9fVr2=P!3w&J_3S{_%2;TQewEMzM0s zE@h3oJam7o10j2>itBOcCT$$mHf9FJ&e)0Ay{ViuGrA&fe0tWgpIk|Lo0E6;NkzQt z)4fS8DE`}RmGwy*MB5imi2O~%^P3b(+0XR67`o|8%YU3&jW^m7+UPMdxWC80PtbcB zNZJ_;BqOm6Q;{{tjx-|w7r!)X#;f>)_MY9IZ~ort|GtrfY7I{XWM#A2!KZ6&1D5QYF{fB7LG1C(cj5MCoglf;`Rhasge$QJO>e10gdP!vKtfO`{- zpo;{(FocE-=EZwSrKf!7_r-AA3i;i8X%`l=kS%?>YJ7E^9}y`CjrFmf?(frl zUh`pkeWfuhTp-NP@-x)x(lAaE!b(dUTxnO^YyqI^%EdB z@y{%`x_`?uHN?Zth@aBVG#{<~_8ZZ(;00T(`A}0{%bo>~&*|BGL&?~fYNz{5sJuA* zp-SZGP$7%gKySIe@9CAeIkM7Ui8E9&N;?0YLfX`5B2f`q7CLKXod!zwzI@|ISdG=c zBc!Qirnq3PCdC}9(tXo?F1N0nVfz6cpP`(8<;ooMp}XDWkg>ymX{)j7s_cSW$)(3; z?)3z$X;YTW7KF0~)hl6|p4aF|!qZz_Jsm`*cVuRf+@$^00yg!*E)XB2oI!yHJon-9 zEN0@IpBTYer#!zrw~FOr8FGRa-}#pLY5FcLZJL1Z)S0`!&?|E(mBWQdW}$Y3d5q`( z4AIRrKbcMv`mLee9+8RuORyuX=p>gZbif7|DYE<9?c?Khurad{w>K==AI)Y%Qm5`u z{XWN0Yb>dXGy_k&;*uGfNae-)Om@75vsu`y;23J3k_ksZE2mC!yz&E{igicKLLn~dW^O& zW_bYI`7WX`sLG^()s#S7950rzV<5R!?(=x8`IY%_`vaQ{I}NtNh9Xp{%Kr;E4Aka=AM^g~^^R&deP{!aG}= z8^IVCq+j*Hg*~a|?e%`IZqHEr!+KBTA^z2@Bij?IF*!N;JQYfBpNIAtf~i`pjd=Ch zr^w)4;bV=)a+sYNL`ap-fyvbGFGd5HacN&!kv5@ydH$fvtwAgbX81i>8m|zR@ zVlVm&M%{ry#W^+ESd#Xrmv=W3j~O{Cd))2M(yYrzjcLs$7PP&cTzRCjzt6%>@_rb> z3Rfa5t!vckW1!R8f}{C{Za?}_XEf^EFI98r3Ou2%^QL~o%UtB&!8K2)SBf-E&!_r; z?x|fo2xs7oC`sNAABgmhyFm9{<`ftuOtoR_Z2anWIZIRsf$`ZxBn<( zo{7|*Z*)+I0(@{9C@dvB!}w384VD$P#!t0d9#dBQhn0-P7;W6%LBS_+k4(nELCtv- zQbT!__BzR45)3an?!$NX z|BymTTC^;>xystAwb0D^3p)h9P=9&KvN@I?(xksbwD~B28~oJN=4$Uozo^ImFWR1- zmKq}5YD-u7)!jq^`A{9%tQoS&ZPa%&=xqx3uy=eO6!2wakubs|O2nT8tRcMx@oAx( z?Zyo5MXltRP|y`&ld8iSR}lE_AKHbQPozNB-ix%hh-OMT^{0N{;M?hH)I&DL55fL( z@9X@oQh3Bts^$=!?k;<-yqcJGX74>uxsPg#Z z_19|$?oI-fwZRbs2?K9uz%0HG8bbDS-}FHbn?3qoe4RfJO}>~!;}p(;HGQXvNunOE zCt10SsZ|PxLrTA3!W7?74%QMU$0pIpCFWz4IJ`vPE*FD_9|Fu-UQLmvzsY^$gE6+q z0?rCqO(iY-=I!?^Q`pw=hpOlx^Aw?VV|%Uo&|PpaLuQJQlm6q-7}&PkeE)aI4C^-< z=GxtN$7c?5N~`{rEyFW!q_n$azUb;vRyD49-9dTR@{b4@cDBOlK1@m`&* zSjTcg)~MY^PLp(B6@YP2Y{e((;UdM#Uk&bk)-54?63upZhBj#{^x_^v5xQpZqJLN55^{#)_Tbu@i-ZLjU9Aqlc4 zEMPWK8{#DxnNq;H@C(*vU>Wm31(8c=IUlDX*hx}c)#K}#C3*r9O_*P>3=GaYh-nd* zkPO1@-Ay;*!bxkp0xIR`+_ClH?)tZ5uVok6>$S1RObpSCm+F+)v3{MaYFJF-i#HB% zEQivBH1fJOA5>FD9jB3_)c+LEQT(7`YuzFt5G-TcH}kUF3(qWPc}c8ZC;i~o9`wP? zuk`DT*@q`YZ#{GmZ};Q%o!lqKr3AiqUWM-jaELCULzpz8bVyx}TA`TlcykAdVQ#W^9DY z@d{;6?m9;o?7f0P3Q;3<`C4S5Fajz`eHfB#l<3eGf-oYS=Cy80TL9}d_JTM1MR`y~aw|@iV)nZ_2X+djFJJOh-B|UxTjxs#2d^- z(m?WbJ{e5Dh9X)aO7$NwgAcI^>K3B7f3?U|=NKQs3nNkmW|YN0tn&4^vIvUFENR4LNLysH9u-={E5wpEw-tcn_{LJWwL%y64__Qd2A=ZgU zuIF0}wo^k_j@?+>}-C1=57Z| zoCdg$nNaRyxxb__N4p-$b?*(|npmrOm488n&=4cy!J0$(i4mE>CGiENpmEH`)}%Hz zySOyv5oy%AcP=2yj!OJDFds}kKPobe&fCLow{YW3uWolV7PZ58-Amr5xt{VYsrS>{ z2QE38F4##}c%<^`a9x{R2!`z_lvOU*q_r@)szKb3wqR)Aw)Fc&Oc5_2h#c@;6hF^a z_&p9>D7Y=I&G$Khx4_~gk|X(*YLkB#$Ny#uzraKXMhy9aB54hh&l< zl7W3NyOMQ>kV51*8jg^|G5-(^z?zU~GhP{;e^#~^x9cY2yOD9@_?s1Jxx;)le>3aeQee;TiBDLrLt4A17mc~Issp7W@(x3lIW z)|%}jW+~p!ZW-h;WF|-{YCj`8hv{U|J8{V&*$QNs5Y;Y30L-cjkrMz*A3&sq5{7gPA>Km8Y#By81x!>ujtFFH zHAzk)x&vTM(}7GU;huF^`*8PVbNi~c3@`uz!HH#cB10`5W zjxqoh6yi#a@*3JzO^xywsJ#f`LCFPle9ebq#|j-SF+n*1A|k1FC=x)RY&HaisvXMU zP6D;*P691T910sAh&D6|ZK*4wLxAAdK{C4GHz=%v97dCZ9403R6vE#VGprBVf)~Zg z21*m&n>sJizD)i!JS5K!g$NR*L59^dp@_u}3)PZbtIbCAMN3IYw6kRsbP95ldJE+V^t=%Lo9Y8N<@}uPY4^L=KS{!$L~aAw!0+ zAc6pt+A%EoDk#c>KXwPeqdgG&72r8bLD+jh-(U!KHQ;qV(b&#FF!8Vuy9tU~T!hU8 z`0Jkr>}vpZq!AnV7E+UNGxh-xWMgW_{)`F5XdA{x0vx7q4Ey|luDzWo!!| zv=(%wT$q9e1GCQv89T;#1z|UMiP}|dVdnrSD#zH$z^v!oVUqz&+Mciz0hd+9$FXGo zj~%2*6bG=&r6`U*2~+}g9UN;wp;kkjMloogdj^gfJ+w8~iNg;V2xkTt03SAd74n?*Hm>N>|`|0_&~49ybqYnYQ3!Lap}S0tI}z zt`qkgAk@bp-2av6+!@9-0_-C@je7&|jY~>>b^any2|6Tj8JE1tb`JM{6X3RtOApL( z<_hi@pp3&hE>v8>_*ei1TMzvIRr?k6#`grgwe~Z9Bw%sjbo^St<^L7nYXKP2Mfe_oHf^Q&PeA$F z_BQ->z!CvJ@qYlee?5So2Vg7>;>$8ZZNhMZe*yH7pW%;FL;Ftf2^Iiuzeowv0o;$2 z1Q^i8)Z`^cKmgb&UV#7?78=m@Nf4p;sqCn|= zjw9$ohl&y2c#~@TvR$k*Lby-x@gZv#goqGKuNU)%e<5%O*2F^rfwc=1&x()`AE4zA zB6NrHgq)KSPC*BU$q4`V8X<<9Fa@wUH8bHUAQ~Y%p*|qE3kTs6&{E?fYzJ(G!%s*E z^f3t#_5zkX7A1rL-Vm)whzpEnD-n_ctrlfMY`~LV0!QaS@V^kLSZw)aA6b1-h#mq< zMo0)bHX}ra82i6C`i%wQ4zRL!b}tyDUY3FqCWMNekhlr)BcT>B@rDkBod9_XC&F(i zPz#ZB?136yDm0^#LApZ-Kuy;3Ga#l#v;-WcURe;)0i#kMiSmHazn(;4fcg%>MESsB z?%7(kaOQ`W%|11O2*5am-s zcP=@{jqe1UFD0!HUW#4=l(HkpP?|70h^RTCigNuUk^*)}&nqGV;3*Ub2Xclg|MD0Y z_zw#58WB_u1^kyL6bz z3R7M_c35Ki|7g^NKnyAcoUn!sWC);Q@qpNYmbf73lnClWr)b4$(=VIYJ_0aLrh*{E zCVdsqCZNBB8iB2Xb;%A%|3%f010waK@&_+G6k!26vHo%q*g3QkPC>o3DN;t z1RO!#<4R5T#SyGv00s}AUK}B>2c!?&CGRj{Rbo0*wET;%}&k37V!bh^e4@L9TI$@1YjG#3Ke6$m0`NL5V?b35f@x zJbQ_V?NOmpvogcnyn2z16b2G>3IaiF6aFj6O-0NAg^Q*lZUmMkHose`{>$oRLWJZh z5>qtU(GoiV5-!pc`vd#Qdv4+>V7KOeN30FlCt98u1%OLYBo@Q@{~Z-y&dvA`6Ix<) z$b$hf0aPIM{m0UX7e?<8AnV4&q)nzjNizWgGseU}0rO8c9eYnW--u@C-Z&6YzE!mVzV>O7AlTi2?V2Ey%Gd zi8QoJK#k-#P!k;4ibM{m^LK%i(NK>@ZZ z<{lCX07IdVqySL%WSqnbV5UDq0t@`6Gdd&@2iWEykQxF)Jz|pT0aKtyOd1B5Y?Y4G z3z!0ZA=01!<0ed+0i7U7t0XBYEDvNchl~tzDoH9q@nRptZ+(fIJul_XjW}N3U%xn? zvyC%5j{pZZ2M^>;C@BH>jkcDYs)iD=~d+ zow`ql25vP}C~vF?cv+H3vIdpT7}onnU&6bMro<=xK_J_Y0`O)q@(!pGMLEjK@Wwh| zgoEUp>jb5le|6Kl2C>gWx?9obH@Wz?PF5nbZ0D-t$}(WjW8ER}AVD$9{!(FqHkXJd z4Qi@lO@Ptd1YNNbf-yOitYFAZi^dy8c5x=`|{BmCU@7&49lPIfd zA{x8;Dy!D^;3XRojBaI~`I~CUXx=5KVm}*OIWRjGO><|Dt+}dXt{DT%#ANruTrDz5TJ*8COXqUB#xkzz49%NfoW?3-7YQC zT*Q4ME%KD@80?)#?hK)5SPEaA4);s_wmnSX%YSn2w0|*`mQWpYeKc`yTK9+L;E%_A zwS}JE@lB+R#*c9;5~4tRTbp{<^ueV{5Sw#fW~@PA=92F6KzI%5F-zvkZsmB}hP=V7 zcs@(}B8LkmAD%Vi&=j8;S9R3t;)G6Q!S9j+S35nzG4MvD0&CMTw#Qyv+?f4|$R-z_ zPPr}Ub>M(lLEyqD|DMBfnD%{&@y+O9K}TEr{k}!)^p;h7<42nGy3g{+pS1fsJtDIa zbuTIQ7TKKO`-z-eE>?8Y)4vh1SUvns4?2=E=bjwbDYgGo-1KptU3oIERCqw64)d46 z$CAwS?`2>wtABWtA61m&hzxxpUs5OkH434ybP7-lx~8%}C4~P?6Tyj_po}{!K%RXN zY9BPxBZIukRNmZ23TX8;SIlu&Qrq|eis;ktJynqEjboo^)!t3>n@J;de1-L#*8J0e zVIVBQJa^vq-|)oMjTQuA`6?k`wznHWdD_MO_@D(W;bv2GqSC`iYpLuIx@{+bJY=Am z?cfl#3n>Y@G8-kMx`` z*GD;lVJ6LjNj}LVq zjfD$M&hXo^t0=K`QAt-1g`J0WQ$jVP9!F@HB7dmPi1ZwKsC%3AcCWEll();z;K$A? zhH2WXS>?ffk8Y)AgjuG48D56pg73NXU$e&#gJYD&&5o3^**sUQm6#RNas$$`Ocg)z ziF*(?&|qS9f_nWY=NI^2Q>EmLX!yn$HvLksf zFM!)#^)EF!r}-UWhisSC__q!ti}H)mXTydaT|QxNnJp$1@MfnJ$8oyg#8w$BQlcF# zC?Tz&KQKU2`6BP-gT%#u`{In2$4mdrjEp~ou;r#nJU*1Sci>TWxK;kI3m-65)l!9)z8l(wM7D-_bKR+OeqHmJgiq zrHWRorA5|3-;;FSJ=VJcuo@*{lMDfZg%>@PEa2UDk> zbdvA%L)DQ2Vikf}d&Z|#2K7<%vfX?)MI%bRVNG_iZXjnox8y<^ z7ux(B*&63kT};vy_sX4E_0e|Qc;G(4E4#5*7SB0A=)9v!C-O9F+!Fic<}+@O+e6xT zj>VU#c$G#^b2OVwB@%VnD{UP+QnQ)dpqS2jItkM3(IdyPz@<+Pch$-~t<<~VFX2v4 zVPG&CR_pFjNn@jOraqkk&ykF#S>dR*&0?lM^;9t?Y!0$%%v!;02cZ+frcBM6%% z5#n|ukx`4f&Gx#*-__5y4;Nd(i!FF#ls$pM_l&&xv_<6-)7K@06m&$_SK8i+uA1F7 zZ=nrSASOmkSkTzO`;+}jEC^%=v!1O!s?_Yr?>K%@*uMPpI*k=kGJZIA;9EjM+_Qza zO7YnYN0_N{Ez4o=aNSjX{hz7`ZX*+uQ#5tPR<(w{D61~kt`+^Jpj5o+24jxCi7k=0 zCrGKe8U5@#sm@^C)JOf^%rLB`sO_LqI&{QemS_>ZlrMH&(v0BEe8WpZ1al$B>rnk} z_@67+JtV0pInIs!Oa}Hi8}d}IL&c-qH0?-pyHdIK8oF56q7#(o8E6>73LC9Rt%5)S zZ~GvAqC||;ve<)87KO3sf57xYa!Ybu? zZbi5a5_w%>C^XfHO@CU0FKkwzAou7}Z|7Fb%o0)Qc1AO4J$`DBNo7+1e)3@Ov*)!o zth{^G$=fQ8zJY9r9ua*Psm6x4(jM(~GzODj_%-q9FvrD5ZY~2mnMm+wowmA^?4)_6 zV_(_1{AQm%@4!mk-|$2j%)MHi7`+Oo9n)^uOp~l{sSGCi5Kp!EKQLz&Tv#<$i0d-h z8Oyn0*V4;8ovOc^udLT6!d8coK?*g~V4Fc68a?PH6=8B^-uV<&B1t^*b8lf>^t2yg z>f-VDSI3+T)+WQesG>Qrw(mw7&LtMo#%wdF!7D+T1U;T{Qz@r%De#}J!?jI43s0vK zQ_P|H3EJB0?c9)_@Kw__Mg zF0hBU(Rc?7RU~-3y(rw@>rkj|(e(o*Best4u$nSek{Y{{<$|UR*$cguj10&hPj_-3 z?S2Pa1;0X6;Atcj$Ugsl!^4Yrw6&zQt=&s~C0(3A~CSNN0xj14BluRUx>H@9f=5e|np54-URwP#&z=hilc<=24& z&gfgqI|W$A&S$UYndc{`P|^OuyGv)+dE%nq9rqr5*ht{5fMrqd-SwgrWc@)l>>s|g zjasG1B`o*DY>zY<@)9AhQuxgoM|h`m%-)<(YgG81TcEiXgfMKR9V~2jR3Hc8*?A=U zUNlyS%{L*ZaDtOQx%73av2ergd3NPjPs98s?=~mi9IoC&;tKm*aU+bF_WV#4^l>Q8 zqn22$i>SE6C8b0ar-~)Hc4k<{Q#9yau=1M;Cz9xCX@TW&(L&6_EqjpBpu(2>G!)=F@$ZIHdX&-1-g664%tq_~?UA&@v(<8;KA%=k*qU@2*b^o^%1_AdGj*#M zcsH+!8k$|}Zah_cgqCNz*;HQj5jemdZI=gFQ^|QX^5gz{ucQ~7C#?|Q=suuHimTZWC?5Z-*Hq+=X z!8V&7ks#2cv--Vr(7A)ctL<3Q)|T&z_IR2P<1Sw+mbQFo3&(Wg-*E5; z>qmF~k~YJ!Ke665Pb+)xURX<3b4L8GN5D0A>6_v=_l+sMRvU9M`Oz|nJl|z>DudJe zO1K`67!t8&Ter{1)as)C?;-aW!}{+g>MvCz|2+NbKTOWtsrmEsTu>deZ0h8}LyjTW zUt`(7d4@@GQZWod>|)xB<1+vyWyZ~VJg zs%e7z_Ga5+@$EvFFk#JsN{%sRdI4e>H&fOti~-3tt2e~)umfYA@u@d3B~}LIZphX0 zRd>wZ@f1^YjD(T3q`J~`Gk$4B-cNs9O;C3{A;$B>F$YoE4wg6@zmdH$$5uX%--Cy6 z0}_7W(M%+8O1Ex){1DTFYQfX&Jb$tK1{0}o`1|`zPf(HX$(IUO+%b6Zrs{+duyqrUClaz}g~XcHar##-RQ#Ma zC&de~^Ez6$IFZNrnE>+GJiKJ*E0xyOR2<1JHHzGreAzj$$o|aHi8(c zlXAZNbB%z{uTT1lwQK6ocGm(bQ^9yVJq}DY76s#%tJHFhfsI(+`@9W4+m|MX(H<(M z@5V8#bj)m(O0e9B%DR)H$Xn9AriePKzQlh<%8i!~>wHE}z`Yx0f2UgzGgA&)r@<4X zBb&r!Oho$UN`D|Zy4sr5Q`HF0Wg18t(gmNN>NebuoX%Ug3sWqQ^+TE?5=WRf6de$GP^IM z^N65L6;^Fv+xmQ-09H{5&sTM$4~(k28bCo6II;T7nCoqrJ0vR^7)LQ%@WTKUfDC58 zqO?pNPo~f#lnJv|`+QKQ)#4$_`;$xxmLZ6q`Ip9bvlP=BiVObtVN{`l4n+cZ#~s|I zmoxmfDVUfo2DYQ`r^zx-v?}jH4Mtu$2ABlcaI!c36jJ^!e7EsQm4tQqj6>*?vg43k zTKPA{a?c^K{P$mtL(8Yz4@ZG~_bflaeqn0Kw0O^@92YZ;Ui9>AHcdQon}6lUt;+PK zf~qS?eUbTa%`7~Sga&Nfo(JD5=EWtWmzO5q{w^5}_}x%Q*1jFADUXQ&oJ3#Hx|w-H}jZrSozIGnpit~HN-nl2W{hg6Yn zryzemhvj}Yo!~wCdRU@Le1Y}y-Kof^n2i=HZi~i*TW$sWult?;Paa(<<|Q#xRr-;z z>geL&03kknsl&+fSNF+GPOp5s43jW-OQ}PZU6d-AXx+TNTGk^(mie!<^j&V5@*qc7D(I053(|5z^K2xkxlP0^ts_U_0(N8LyY zYTGypWIlyd+V#!xe$nGcmj>eB_ ztFo9~o4G~)z+0Rv96db|7Xt7?Hy5KV>_d2N z;fgonTXO-bH$(p$WOX`~ujr&JdxwNVCyN#N4WXTyO~vj0jTGWwkIFO*;|O|_9;8j1 zlnfKsZa5*m;L8WJ{ZrS*$o=uJH~5i?Vr^V#A`0l>ZMhlC7?k#b&5*lC8b_ zi1*X&*fYxyr|FbIEf4lGxN+~L-|9j%#;?A5Ds6N?6m&>Qapp5G-d-}Jn#r&g&7}~I zDnZy zqtJ4x#M?wb$bJEO-SU3Mip~-c!Sm|J!df?eiiVQ$!$xz>xuSJ~e7!H+ZqQT06ilE;1^m(rJ7R5I2G!jK ztQL;vL82->K4k@mjc6;I-hmLrPr`ykt&DrS%@y0?;5vY6`hxFK%=GrLq_7iBNm*Tt z53t6m<@G~PKnjD`!qghQ&&xO9`$vtg@6!j9-sjDMo$e>2_`RUsCv<>b$NR^4GZXOa zz)i-`EJI%4g)H5rC5Q56y8Ah{GLp;6wAKAr5NM#&7ImRu33%@50>2Db01#@v! zFTu%xlT$!oe5<>C zMux!#!zAU zX>)*w)hm;zLiS%b3cHQ^*$^l)@e!M#wP=CXZFNvinb-wJbzvPZQ)r`s<=}1ali$Y~ zhc8Qc5xXX)Ir@tIOFAd5E4;u6sDPo>?!PhaOQ*^O#gooMKeD)dk*={s6kT8W;ufBp zzT>_JqxEC^KA|dMnY#gZqCKItE~cpu_|yTZ={XMyToS>zZ!oxg&Lkn8JpAMKBj@Bf z>pdZsa4~A5e4UXK_kNg%qHoXC{*Ne>0xJeq=EZZ8W55$xt#7Xb7LcII9Y3;>8R)Dl zt7UyTGO{KGLk83qmrkAUGPpy$5E)_|QZpND~x4u?SuQG8`Bq_tAZr#7; zzwVUZs+1ltmOqCKOm-AR|_u%>>p$^v@WD$@fx#V8>0$&A^Z22!=<0=%T4tEkCD2ShmYR8Nea`1@=r>nJ*^<~ zuM>d$^dKPrV?g^)O7tyNF8_Sw`Yh)E`XPRY z{BL2YQL6aJ&95PN3I8yw)Y!29Te2&}M@`)d!wdfh=UIjS#|m0=M>yUK;y+1a72qY> zzqUl^3&)E7N7DZxS>IB>0%0~Iq~Q(zy8}eZ;t}#+a^P2;|4qLAKk_O(RE>Y-|6l8s z$bX6M^JSp=*NpW~G4LS7mNBN!XtRzgilAMtgrpS!W$idV%QEI z)U)SD4%DmB%7UX7J_k;%p2i5JX0eM1mf?Du^zrtPaiWO5QYqLN1Pq7geL1;TA#ME3 z=P!oy1gm59!3X5VIGPBW6-csRnY)6#THR~bN|wBDSAhNhxJ{4xUmwt((WT|-b}=>K6iaHOHW6hyuDuD zm2s$le*ENwjrRu?3zj~gR}X|LkDPmgkz}eW9WF5lxXo-jqG9ZJl@^RU@OyNT%A8Yi z&0C2L@ivJzW?Yw2cX##|zOMHciB(QkzOo#Ew(EXj;^^r0yZv(Li8=A1K&FA2a-&?& zeKA$*V2n-95qL%F-S?oS#iQbPD0Bv zSk+gu?=ybQmH1Rb&{vP25W*OBc4fbUkbT`>hQ$0eYfWr_N0LR#Fbb7~lqJ}|Z!c$t z((6Yd(l6mIGX#j4Lxg8)yO!>LYyDFVz>eh%34a;-b7I!6X}7OK@``r6T8cXuT7|pH zm;Z}`GVAVppbjIRyQLFJe3R`knQPkfbjS+tIl6l6w9}cXy7XN9*}e)2A9v6_ z5lRTDtm_g&eC7?>{l-Le=H^wHk1D&g`>5q!s8l6KLF^rsmbr?%4X-iG&RWZ!ayL*^7uAduuae1m=T9K?-Hvz6VP zSP%*b5;MQ~OQ#s)$9vA-1glm7L+RhRe`1oyGxS-q@E`Cb8o;j3*2T^dX!fuPXp{-g z9RhyfwSVW2JsVOgpNXNW*XZNx0pJH=1>OWyu~fh-@zt<71wA@UObV+?Aav?DW@*go zIG=2OOy{$Y6W3KZKm)>|5kJ1~|LmAT+({$=FHfeGK_@h_MEEl#D$Ff!^-U%~|Cj%q z39KVJ2VddML#?<|Kh!)rKOm2ebg(4QVhST9qDcVGOLv-6L~Kbfjb4$415h@#(lm5? z$vvOX2y$Il2>Np=wXF=t)_EPU%iy2WwpLs24Hj3!w6eZot4&O1SAvDG#eXjZ+jl9C zlBJYTmM}I12DOMrvD2!Q{9OfD=s=pb=djZne!<=1^ljIl+;^Ygq%8yzt`aV}MS>*7 zKj{}_^;n77IqYD&#=hsgHJ~&{i{FF|D7N;bm-Uxya5N+*eP^$IUL5+0Rq0t<^6F86 zYL&4cTT8=9BO>KD=f0FH@r0!7m^E>L?E!Uj@VQW?DstWq_(u8h6<&A*(LxgHd7nUyb zr?CGk=+b&)lopq!fsa_yX<+VF*T)B^gf$f&Fm@GXZ{NwaSx6U2X&-y{YXS+5RqcS< z__4FA3kSZgPK<3R=lweD=YKwrXy=N{etO7&fD9LXIlKPj+n3SU!pWJz0$^)u$D|}K zDz7B=r*pKa@Mlvkca0h9Q>h&kJBU9-m?}vdY-HD!u!!fY$R$w@4-b!`+uyN_AhYL) z_X*~;P8+rdaUj6E+dOjR30T^FRl?`T@(xt>3}jIDLt}_cfnA#Ip`&Z?E{rkbmEsrc zG={1}8sCe#L_v7#e$`8(1W>Osr71p4-5n><-`N}dVF>tZZ8Wy$p+$xh2XCuN??N^Q z=l+Kr_8!^vjf$DkCNoM;9|B>FEhlh*LW=oWDj>`A+GiFqMXpVeG`~18mIdRN%nFhS z%Q1FUH*nhfD)E^ZW>|fx(v?F?5cL&0MgP@^*&{Y)j%txfsi^mrkm^wSS-Gai7!WLSsYibI6joMG7gio1hN6-49+Glp! zNQYV?woV~qW@DgmQyb;trE*BPc%$zqI#;30ee+MWTQ`nQ_#DmB&C9ssDv@#GCfSo* z8s}$_AWM=%J`8F0hiE(uU*~OAK#Bcor;GC0B`S~V-7AbL2uM=)T#mgZ700pe;%_r& zAC#ULS31&u_bsN*->eW8cxy47VWFGOloSx+rnKq@xz2j`j z^MJ6WUX!pF#A4bs77D?5WBau|e|zbRd$|jzgc;E=#+;j3$yM)Sms>4t3&Mb}@1L8S zoUCah4DwE#L&T$Z!gi6>qp~YFAl&&072gz-y+r zpXH~|j)sAv!ZHbe=&MvHRTItG4~sZQVEVg!|7-i#l1tW(G(3lR=LsFZH66uYW87^& zp#Tfh^!1L$!;$5$lip0Q&gvyV+M`O^nUN|HuTyCniC+v8&CfFXZ6rvz z-otsI?^tb=V-nSF2MC{~?NR%8q_FO(NdbKP60s|f8HJwqLe!jzqb<|)se!yOuT3c3 z*!r)=fnXFXp|7k&7i98IJ|t)y2Z)8k+i*J`(3=wqS^IVQj2X5U+L{{SJ630fux2J^BkRmn13sKa?od5vzcrYI@ zEf-2Xm_fF3d$r63UV8Syu*GTrh(XEEQOOBV(LSLfS1_0yrr#ET1f z|AKEYX@xQ5d&Lg$Vr*lMem!I~>}gJnn|N4~Jnyl-JCfJL@N}Dkc5chM_6#}R4P1kDNHBR()=w-Qd0&;6{whx3zzHW*@{nBqZ zg=!jJLAU$Qj5bN`2Qw|?%;}@Lz1Qx2c@Hq>3}P+KIXDhNfBIo=CCE7sHZCC7BvYu1 zsM-zW5zS>@ZupbzMR zETQB1tA-JD!hV}>DAY9;N=?*w#)+ondOUW!@n2j~A zDJzWu#i$i*8;aXE>O3I*_9$>s^K54P_VsS0qO~+w_U)ABI5A-evU#cFZybx?j*N2z ze-oZ%J^ebN7#UFDL69z|BV&dZoogyIN#djm&FI{pj$`MzfT&}2*Yw-W&|DUUcQB!w zMDG>W2^}D!`xG#-(s?4%^84c?rqX26-HS(ZN!K*6I+BqGkNA*}TQ0cVwmuq=cu{lx zB>V7k4O1cMOR}dGu1lcb=nZf!(kfv#5UH?~L|Il~EM=N$~p$xEw#huZ5*QJmrN;^GvBPhuJ zGc|^7$e8cQYKr3tI5Y?G)G6_IlH(vkIMU-cP2iCH2I~*Kfc8aHy2goO-n_n03qGB) z2Ilj>q)q?>n^U<*{;bY=KC^z4aasF)AASxa?DsN`;2#?dH4n2QT1nI`)Z8*}YKjrn z*vKST$9tX$>XG&;;;2_Q6YxH5^4*0c*gUOQla2u0z>h%A$Gvi7)%F8!GA{95cdThy z-ds}o`pd*$7cje-bU@?}*&T~~8l73)zv)$E*Ul04*!%+a0b#6g5n$;*DBw)W0*6g_ zar!qgyCe@ukPf%!J@veVikAAOyz_>TzS#sACauG|xBlU7HM483F7Z=lzawf*7k<_O z0|o*ZOPS)0sP=O@g1kUIYsbw!+%G;E`}Pju6UtSDJqubX7q( zY3~UqgZrv=wv@IlyDYD1y$JY0NSIkBbGVpm^P25}jE zf(uoYXub(#rIBwXi-=t(+kygzx(`V97WR0htOXFO4_mMD(?KOOb1)AJPRXy-hQ+1QybL< z@lvfcw&1?LKhDlUtHGrBL4r**bu!0DbvV_7_CdN5BWCQABxk)d6Gs~?{^?CcWA?y% zwPU6|`Xw;BsinYO^+C5~IfXV7dqxG&{#&#~a;A+B$9X{X?mmDu!qH4S1ijQottslY z-T4!0c(BC-4uGqtgYUwDqFkC?(J9G7Q&C~IL~69tZB8Ds<;ODDr+#D&n2a~Jh{kz(_8A4&>k6lB-Rv>P$Zd4~z*uCK~x0is}5Gg0eVbYX0+H>*4tc$7Qw)YCE>I zhw%+_Ocq7b)Xm)9Ly*7_Or#Ld#=;g#AUj?3NEQ1x7t-St8WuN^=S3&UTh9&x&8cUP zN?D(e1~g1unMaLwD&%5@f=cK>QNu3KIbxO}RjiBR67Z~?L^WCYObD9HJanEN-!rLb8NdWh zm-jH!MtloA9MICs+HDF5z-5?YMdgawavVfM6xP6aIp8QlW@^2!zwvRWIez0+-`#C)8$bSki_3mAE+ihl#{kDu4 z>{M)0Iu)So7FGv5iA=L6NcckjhK@EdjF?=okk}Ti9!ip#Ut_^FGqENI!SIK;hz`qr zK^UTo3ppDO-BI(t_-lK;d{)#ENR9Ru{!Y{}_cj`yZ;F@XgL;?3#t<~17Zv}jetT2m zR8m4-qUX-8phHynR0kZ`TP@4~I9q87e$*sVZJCiU?mq@NF*4c>5tFvK5J{g9+qp3n zvd4R6e5J`=C-seG~n&&Xox!LIyx(lMv zc?wxZ=w)ea#dcrU#C35x?wlq-^5UKCgMUTfT{GgoS%6y}|%bkk# zSN^JtCHT}0d{fiZ%BS2t{C7F|qdQys?&t?wt2hO4&0-YAK#Us4oUH%ryLz-a zSikS7UO!l8a%<1_aAhF1x!AtC8Tnj;wL8f_2ArgzKSCd7_$Q*1LY5V%^>5T4 z15oP#A?|urgL8f8XSvWH7+MujfAh*;O4}3wSQ`Qy4U{+T_~!Rm$Kul|(RSj8U;n~n z*J89!g=^?sf8V78nvT4;?%f9l8jL zxHYKt$UIHKiU-J>(}k{5U@8mnvMa=2DFJvOi3IHP{cPh{+%}u+w_RY3l&zavV{zIA21$vw!lZPtOlY1dw5YZNYYlO=aw?rmFLPple1&`ChMAX1XrnwH49~ehIo8S?5hO1f#73nztQ5nh zqr7VCIo4TWXoL_CcOoF|Bx~nzBH{!EbY>jR5BV>14t_wtbz)g-ASZXg@Y_~k>uot@ z*R?sgh?PNyJW|OrvCZ`pml4u-%ft9)TqkvyqV!hbL6o{!{KC&Hhj>8o*cT;eOC1Ua zEdyuY-Y1=W_-%Jbu_N#G6y!_$<#7LTVCKgIO~$6c{4Kv>f?SiF*_xI3tqpM;a5riZ z!vY3{ROt`Z9vIi{D!E(A@JBYsTcB zHAeD6&mi|c>8;a7Hw7J2;Cnt0pfK{nV%Hu|+E1Y39AgjT3C-;5T)b-Ym%)JM z4>=u9ti?zmn^!)xPpINneZjQRdzE&_D)W8BOs$+T6o!x!b*Rs#qPGr#^>=>GcLR?k zV>}eo%bm>Gje_y3$HRFAi|aqhPfJYo(?&_rTzE11XoTLfTJjc|T6{$wf;nJsWQP5L*i`2x+MZFLS}#emTR?cZ}$sx?m_n_&0;& zOrv+cnX7=|RDV-j`4MO(08R|{LYE>NQmbkOft(Zeu}>KIz3lC)hPMz_v#0=f=JXzd zGz+EdoNxIJY;1<*C`Z80`V!Ki83ZN0c@t?bm+0x$b0`do9A38NQ(m-B)ia>QZ#z7% zd77k}1R@gJ%)JvB1rd#jOo={z)|i~7AdM@kvmKYwhDL;sMX`H2fZD5_)iJR-uJw-H zQMZfvC`yy|chcb`-N3`=`a%06{a)yi!d49u&?82NEN<~o=QYUvHCv#;(#9Dmn8Izs z*b61GJ8~4a*jp3Zw+N>b4G1kN3kA!9HVlTE(zS-I-Hq-KQz#!|Y`s2o3-8(ts@&fM z1gZ9$H484nS*!5HfX+JO7L%kh0Ua}fEmk}jsx9_Q&PT0a4|&NB^10H}A9vrj$f{KN zn_6??9A?tQ9w1AVVz2jD249grH({@(82Snr58DNySI#}QU79C0<3StCXWIGQeByc9IQbW z^dZa;%HqazepnP*)l#Tb@06^4oevSt@*vZ!Du^n7bsDy(So;`8(Bt_)3v$=7C2a!!SJm9tck6E-MyFr+WLT5)@yKMEA z&LUuBI_9S4!^+VwPLw%m3G+4rNAc;t7y%_rF&*BX1xs`>?F_Otbpz_N182qbw18c_WNMO$9w(h+pUDb z-j$`ml9AW zS^x+%iup?FDC!=FE_~9+qhlY?oS98t8L#=J(B+mX=mAtv95EN(JhyM+z8r{|0S}+1 z{<@RQnDO)mm8;9;v>tr*qSN0p=lz5Z+|5%SVO(oh22E(Rn@m6i+YgI(USo zJ~Hq^>P{7TKrNYu@>$vghuz$sA+lR@#dkEL?$8$WYAn7Z+0R~b{#;yqch!*h;971H z^&_jkL1IWrVb5tE94V(-xQp+4aMHnS%5;GdJzCkxvmJZc9w;uhC*Co|+ZYuDGX8 zPu7NAc3g_KAZEmuH)??wED{P;rxTa^0CTj!m|cy22H zP#fis*tWSb_-+_~; zF!|==dFr;Q-cH_fMzft$k)$h`oDi*X-(9$o-QLxSX9yvI^FQvOpUPby)hQ?WRs*t* z+icXy()x|!i0H-saw-AEn$sA zI`u{sB-sGT6vp`b`Ux}WY3;VmTG7uFWT5Dq%lo|kiOrc)5apu?sVd}hYw&?i^_5Uk+$TD z*{V@>NVCJ86@D_Xf`JeW7);1s1Fa*@GReFoI6!9w+bUBtC4?2aXk5})Xw-3h#%_~T zt9ny-qKtZK%=O^+0~`i59?1DS`z1r75eXSJHB?l0wI4J0I*pTWf*D8ZU|zDDwGX_F=M1 zhfeRc-6pQJ?0{C9EDnl~5;)HsGy$H3x!#_Dhe&7qIUq{%&IZKY+ZbYCdu0X5tnmVT zrzMe*bKq+A@HAnxcKhoQiWeVs^r2Mf*J~&D7pn^BGNDPD2JexQ0g<$)u*p}%ioUBl`es^Nw@j>muw<)a6*1&{`@4`rQ!Z;-yYtdBhF|Cwm`m>AlXbAT8?rL+{k!x zvi3!Zc2ibx4xZSAiU1>#aT8n8x)4R9C+#yg25Y1%>j4hki6Qp#_Wpa&MOLqF78c+n zEInLaSmK-PoVZ7144-vz0B|q1`W@47^-95;@=AlwkFf70&8(B#27VOCniLkCAHLD9 z7?M6>zUq%*QebY9H41h=*~=2!&_;vlCHX9o;qj~LTm_S({aicS3ulPSwrtTwG3$wG z&fD~s_5d#LN*ekl1CAwC-W0op09O?_nEQ>>jQN6q8$bwl@lHAyW|) zP};EWa(+bvR9J2@EQ{`k$LrE;GwE-gBd*M#h1B=B>ptA8lUTWHT3`z#N1z?kA4T?D zNoKDsJdm{f%17Zm38ka!m6_6_iqGVC-KEFVrhrbfs*AwD35Z|axdC-DCDLb{$oe+D zY&o!RkzPTI^C?ipJ?5Gr7yMqyThs}FE z9g_$w#-QS+TmhNvo3&eGGWH%EV|SF?J&mqtcMxoEh4!PBF!Iz_Y52-0*(Sd;(eBEv zr~z``1HPvZNw)c_vZy~r3ka}U<{35V*-}%)2m)qP0u7N@<9T7Z9?l=U-Hw29rRMan zObw^z9?l2BndR!6|kPvZv#AtrRiw4ykP^R&*W}Dwt$ z{f{kKE)rZ#kSrDF9?kXxHjjG=l!P90K8)9_4FbdfWZg;)Juai~F(n3r?IkO7=Ae7C zGH`8AWohK=xqH0o>)57IIacswxkqmk>ALO7CKvA62!D!h!7r6C4L94N;J|d@dI2{x z?%&-2-<-1+7HmTIxL$BQ-`eWUzGtXHD8#a=NSf!tB7ZnvGPBuCL0!4D*lrbPO=6a; zs$%si-m=}ljaA=lOv+Q3H^jRrK9Pn6H(u%z*U@F|*P?1IUsuP{N?jf|oJV}h_!d(h zI4Dn7pQkOXKuDclff5l-1e)f?G6VMYOV5Ha>UP1hdd#BJzI7`PUvAK?lWNOaoffs3 zOeHl(IvbPIMB7lZb6=hRxhatQK;SZ{7JNe&IKCWu*GJt>VnX(dH(hP)pdrdCXOb;fA9t2EiMLyuG zbLGzl_HpX;bVr^+eS8d`?M$LjTA<8wTNT94<_>cdkddJ~$F^uShh% zLy32-1w_AYHEfEnLjh#n?y$M`Soxk$Cnwk*T%9pyhHPVUzI9~nQx0Xn7CX*M`0=mH zR@QQMKSaJ5#2VWusE@*D?*MS9>l)@lJ8+Oz!{@Ud{iVgHpGFCxYsHd)%7_3H0VZHq z%8T(b*D4&UtiTjW?Pk_Qd--;y?>(B@wDYqnSJ-sUP1_!ae3vmU#!J6x=dWO-16p0f&O*_Y|g2QeTHfyr{PrmsqTodq5uBIyKb@uhXLF zP)6lTSYVvPT)UYLiG|irh#!I|xj^?bb=DZ;bZ7}A(TIRS+@w>pmhN;lpyS4pqJfiN zK>h~q=-{0Gb`dV>bO;vI|l{BW<?s?xTxXVS;sfTe&KSF*e`BP}&Q zJmyx)7U;l=Co^`b{C9f2xDt_7l%ZBW4&?HQNAz^l($H_<$Qan$V{y)Sj9uC{S$Xe0 zk&YX!aCNYqN&(~iHERZdSn|y{3xy?v>tb^x)40du-m`UPZE{1-Q$HC2lCi~u-|=SP z{341$>1#OiSkV124=O01SWn#^W$E3kNg|@iNu2giU>q31@;*u0>R2h5JN54I`Aa$@ zCcI%}pMFt`=?IjF0ScpA0sn}W}64;O;inr)viR-n%*C2IfApZW%hF1*) zosbM2ZksJ*ZI0)f3iag^VU#9#Exa=yHH7EedON@vCl4CMv>LX0v!PHX6|Lvn3JOK@ zkr9?1)N}BAyNf+;x4Ihv{q&AMiPx?FXsK%WP1xnI4B~F*h!%+!XX@bu0yI-b7k)JM z^SX+p7GIZZ1Oa0$KUfackh3~B8kOsS{R-=rP}!Vw$ujA3y_}o@tY;|cg1pwtrpPmo z&oBVp{LH=p>oY9pY1nstS91VuHIjj9KI6-$k~?@*oHelF3?1UDnH8Iwlc0=VA6z1w zXq@kOqUP0)A5uXW++M6S^fTeWI%(cyiyHvEX@h{st<*JsDiNXQ4q zP%$}}%;38_rrce&(-`>zQSfBd_P#tv1uS6b^z5cX_jeR#Fb4eHL9|mO#Z@vb0qyCm z<00#8$sTmENW?XcV&-p09dr=AU>_7s?KQW7Wf8nPFkNPnPK(V`9Vi!9Q4jlQi zV^=X_U120q1Z7{lj~eyT?IcPih{sk(te3CpTNPIFvFH?1@lg!HI1jtzGz8E!cOL=7 z@17xN5=_^kS_w$ZEW=tHu4F&YN4VG=V!q$^$=2D|^S&`Jkn80rdW~DUQHvM|&g9A~ zSi@brz@vtSN0r-xs2y?1JrM6<@^9bZwr4NF9{jkW4Omh#m@Dic&+A^Y>F(Xi&@dl7 z==}S)D#y@L#ZBciV3xd6UBr&a^8ObfRA*l^YJ;eRfi?v79s2yO3MG9x>#RI%TQYEc z<#x1(676inx+K#yb-s&vXjJNv#rvcQ1O7u2*p=%xIxCV!b14Fuc5>X?TELbTuSjiR zBH^U0YbpY~-l^2Lj>!_E-iLP75tHQgsa|~Dh2uF2cwnhVJ{`5anxkgm{^0@maHxUJ zAkQ|eDg1R)aRu`XU>R5#t`X?<-CUu*#Fb<0yrTULIGeWR7lF#f#`63rE-61xKYgGA z==%HQiu8DpaP1|OFrEDa`q_~&I&ny3IPyrW<5hv9CV0nt{poLk^ZNI!-Xn4^ySh)c z+fuKUe42pI7VY}Iid1$^nLS5Z&M-+?QNGl|%>y$C-cyKBsTs~+%Q2p}1g*A{hKQayS-xkx$l1KSrF zMd(gQDnBmVJ^AzN#wuJ7CQk^>-vh$V)xQ-IA^_msyXLwnJ}Jl(V&mcfZes$zno1l)swz@qYn6nK-=)S4xXW|!_+I=ve4M8=Fg$VBDG3P7EmI^ z8BGhR4>o17t5w6SpT8Ir-7=kbx%0;?x;J)}E5G`+3qRT835dNxR&upL3{*!GXBXUF z3PU?NO7wnFRwHz=jZ}OV0@Yc>q zyLER0Fp_Zxv;EH?w#j|QsR+1VP}tbZG9QY&2s7t}dQljdH$SXqN3*v;YEC>-SQp`> zFDal|HEMg9YBbKyMa5{KQMYsP8*k-8#5hsY2aT@J+%!#uJcl#@>@#wCBjV!9R!=1B zOrYv?PrON$#W?~uaBMRfGfx$bd=3YV2*F&Q%7=lU&K9sG`3CJf?dwtF#!g^(HKw)O z5v~zihFUfeVaeb@VX{8Qq+1kn?-B+b&rZj~> zemX6vMDQ|<&>jTf*-+amFgx=;+u_f+U2|SE40BvYLonr)IeLqR%Jq&ZdEGgC*OD}! z|Lw;MZWi5=OeGBX=<(UiJL%fTcaE!jw3h~Zo?51X$a(xF$dSYAs$+_dgA1PxylZ$tlEVwP2?ecS`MZVjqXgi2sXHp(>sj3&!f?;@DYKl zGOH#tv981=4JDBgF|sdU(E=bo+Y6j;CKz(W_4BP}l=2z+Y%ACPQv>hahgQgwC;^Vg z(&$K#s74NeM=&PVdy9~=4n{Y}S5QISR0Z>H&H(zod5(7Ry>VI5aYcA3UzZi|nkDQK z&365KBK2XL#%7ACnL|`iY75APhQ1md6kKK*O=vvRqeJJUnkh3{d3yI}&gAtt!^1+c`+m(zEYHL8VTyyHSD=#y70HXe>pDzseGj z4WF|eI_jMIAinhm7T{=Bd5ZEkmS7s~zqk+gSc&(?7rr%y1Vcyhju99&5l+qV5(ER{ z8uS1jJC#_Wbv_dtL(Oe85E`zHz%=p@rkC+qOxZYKtUZgi<-tI_C zm@d495ueEuzaJgAGpt%9-RP@QZd!|Fbp~58&dZ;aV2Qq<3O6K3{j~PTk zOF*uFJCJkV$_W^!BKJt+hWOWt3hz_(K3mZWAUU$y$;|V(%sd!YH(1W8cZ|!c^qRh~ zf{>g{;dNO?l$mJJ%6;Yq)fjyj-U4iZm;*$^`U{q%P#Vu(7A6`G^R#3(xRV5@RYCzC z%`{4kIiZ&dVb2Cu0Nj$;Eqi##bkSOJ`5>?Xg~EdH&|VJzg8OObtGa5}@=h;|pUAHs z{gAIa~`CvB4Z1e)jeK14euwgymnJ?CWL_Z=aM1?(92>(FA^MPE@ zE`BfvopG#)Ujh}uiSC;1UcEAHC%b?(>|ibD)9lnlr1`~9jISm&y~vE`sX4yddC9AN za|~mhmnWi3nT_;@m!6RrsUAryeu4wrzi1GJW6C-Lh3~20??Qn-KG$vSM2rM7lgL?_ z^aBn$->2I`eez`a0W_i(!$Usof2F z>UfapJX*B72O55Vx7nO_llKKY*Z}y#(J`y!gV~k*MN?|{(X0QW;vom;>~?s#}%Eek}rR4HPvE; zY{~u22~IbDC0e{pEay&uq_*akoz-xg|%jf-bhD9KP${3+3`NwLb<7KKQEl~kr#C|0Kf3}7V>zeIh zh5Dfl1QwpK*NMvOr+DB4f3>aHlD`Xjb_gB(!3%h|9n>rrd#1RzUE3@2{YQ(2d_N@z za_|#1qOL2PN7QWnS}xNwH;h@fCrtdJ1}GE#d;`^?gtZ!`p5;fr63Adra#?`~35+u- z^X-y^i%NfJz=Ad)cOwqr3LM-ahm`HdM=cC-EX+LgVxLV0R8Wkcx5Vs)uVRAKg%G%b zdN0XuxPu3tvU7{jK8Rb~kH-jmpTl2pzS>w+pbbr@M@>!B4TgwyVL0LS+z0}N#DN5U zuSldb#D{O=WSteFJZ;9XVH5MRxYn(1Kf_U zsM2-s;?MkUjQl+#5#8K24(6x&+hC|hBaDfa2YY)1WOF*Z?Wgsag?-b`a~p0?B1MkR zb-#r!L~8ot)FG76y@z2&jwV#FDHC#vQQ?WEyfrNnO^BZnM?N-V?Qh){ix3iij{0@0S%VCK#f z-Gqmu%Mk}{fxfMkD;waCUX@pRznOtU>7#5WBC!Qg&%@nxOm{+twoh?fxa~+os)5Rf zFNkY^rN~0#mLVP+a-YWA^Z!bHEkNRzHmj;g^wGe{UG0OK0RQp~` zZ}U(9d0{HMt?fqxXr?L_k|gIOS54=yI?|v0pEcra#J0tN^jfkKb1(Jc9633wNo~02 zkz{=kc!~wAWO&_x89we&;{vVg$PTMfy+=F$HxXSBIr^#E9+*QIe9r>&BM^e@j^EMT zHot%)Ce8w6Bw@btL4xbNKpb5ZW*YMKMvF$kj3YvTR`x5L+HFASKy)ULO?vAeD6Goc zV4FDLAdWB>|Ca2lqlxBM|Feq|3RlXw^!tZ4qk;9a>7mY~-(K`#mlcC0lD;8BQ;!XoPL>0a((aMZ71S=ec8$7u=-1@xig$RH<%w<%``?g`zE<8dnAR2v;tfRasHa78kzwJENOGXUf{%ss1eyRsr_k zt25i*-Kvw{RxP_-u<3DY`U`Jf!#dW-@b#pYCzf9ZQ_m-;+PqDxDXWMK*dA{u=$4jX zWStpZC$@N}tVTxesOy!UQ$M?=zq8hwX@K7~S)ZQQCOL&j}{O~sO-ieKzvd+1*1jmW|3Z=`T zXTCc5d|;D3>Uhiid~=oY>GOL0|e)o!aM{>o9@%^ff zkq;EwuBx=iz12*R;Ew(|RNeAxeyIxAi~Exaiz%PSn^w(F_O>5_L&9Kp?)<%suNvbmQ~%M zI`h8RTqIwg7K z+~CHeO4ctoYF1btyS0OJYh9dig-WNOPPv9IJ9v8iymZqS_G6s9tai-x2lml4=l5Hz z{pYvvKmEPy%w-nWVYjT%eTqlUzf8!8&begywt_({XG2g+y4qAo|K6+bj0|b<>$}EO z4lsmsu6tP@rf~nd*R^?(=RT-)B)1N}ZH(Q=zU2K{1*#pdiR;$^ub;GEj-3}?_%RPz zw_LA?8I;Z) zKad^2qQm&*sB?g{WWC_QKBGYjL*~!Vw@toTp4+XpddnG(dWJq9vFQE-B?g6(>z3=E zHcwhHx_v_5Xq@^%sEk3MC34Kvs%~4vf!e%xhD>{!S%6JW$#kPVS8Pt9fAg^~c6@G! zjA+{iCRlexOK1FyKl+AqyWaN;DQ^~A3dl%o+u?elOkyhkjpy}rA)Vx-y&jCKg-+c8 zOxdfA^LLxC4EQ$qJYFhQ7_ zq@))g<>ES-U6nr#RAtw{*+EG~xEey(5VvAO)>Rg=9A4`{o8#k~5h5FK!HS>umphSdjp9VT`-aj@C6 zRb>5?)O=d4Ja+pt`<=@?fulNE#*9nM+9zKm+;>sUHHhs|Yw!c}lcz$|rSJf4F2Gdm* zyb{-?CeJs1=bij`l&!Z}Uz63E%IhjRmDhgEPrSZ_op#_{{ZYHptW#H>EN%4e)lg6{ zKl0AdI`L%T&zCBy7uIKeO!Z}3ymLw8u0yOf??N}8^8>%wsyNCYq>B$c$}y~dn3?oa zZE;x$s?b-L#*L!_W*q|@>o~24{i88s$sh1Tqs_oSzj^|vd$o~2&@Kc0H&i9j_ zgw?E)!j&T@8}oQt9!7Y7Un;iZ_}ka&`i0an-QdS>-fyWddy*VKy=9N)x#Lvj%Z`sS z8uKUT$tpiJP#97Wl4Nvlp7eF!{8PG!yTdhj1=3ZH~s){?rR!G1jVf2EPZ*DrZjl z{qmuT@T@mExK-rr;8%XZNexEU2g%<(yo3AL`OU&!rWBaym%j2ir?zf^a==!;8u#tb z_x?z*z39Ybws+^JO0euSj}{MvHTl7uof{9~~}D+jy#X{$+X2#K!ls z@wcyMH4aquhrA6^-lq}d63A^evt);aimv^_Lz7Rb4ww2n3_6O>zn3JvMOO%e2{v&0@R*6#-Y_h?6Y#v`k+%|QtMpp6VQWN%eJj15SYx(B! zbDp9u{~@tR5)*v(eso>1-f@d4X}hwej%hn6LGc4Ni#r#--Ee*7@UFt;wL8*xoxId& zXe%_BeXSUXvSGqh^SElJ^y-pNroK9f?O*$}xUTQ%mNw)+=eY8z=VIH;!}%jU66yN{ z1*Zlln7z7xm0wU#TE{8kQ#4N+FhYXG|A6JTe9oX+< zzb<=a)5HC@-ee_m`j=etnEF~5E)lUTFWfa~C?Ro&Wr+A=6H9+;nU`MQdqsP_>fzok z17Ud|+(mxH1h8lsD;~4AdG;t@O19d1-n)sM;;bJQT`^RJR0Tg9N@SOB)#1&Y*V(@D zYns+9zVk!Q^4(7_TKqDDRc5>_ol=nzRlPNmkBhIIoA)j;V#!zG-Rhf+ZPci8uFszU zA?BqMQJX86B(r{;v|yoLD5-H`&bB{Ixh$SvC84&zonv>AlCk&N$Ex1nY+0Axel2qH zBt<&!cs)zYQ}3xecSM52V|P@pG`(gykMGx;$K@}Ns_ydL)xJNzovV~5Us*M*X<_bL z=^x)N^(d!^N-dKPh@DcuRa}o>s+#RK?_sl4OrFh5Qoi`!br%EQeaoa`9y|By%C{CV z-AoMi0AqQ(;@O(|wCWA6%NKp!Eti{61sKl@9coPHNGLtE1>;qmR=ae0zfMcv>w(~> z$&2(~&=zRys%^nf>mmMXGHMUk2Kq(smb~=*X>7!o*mOEZ4zmfW33*;$ zR65xlz#QLj&_4a3`Z7~%p5s2<&I^u295tBfySkS{I;%1DhyT&Cj;T+%y!F(nvs!np z*QCWVytX&&)iR!t9*eHcFW)I_Q&IDlv0Kwm#^{`zp3!9N?PFY!&eC?}@&tpEFrKX{NfVF#T3u{^x5yWMx6wk(-Jk)x7Rx#RGEukm=dSzth1-#kSLoT|Ka=%nAe2&(cy_m1~X znU2{`JlA9ouPdo9@=0zS)6$kXBEuX0VAp2(9J%AMC6yWpSi(*}Yv#Kz#9VgNiong- zgJ-tP-`g$xIsB{qo2>_4MO1~)TOzr~#c(w5Nu*7Y#O>CZo9j{wUHCuJm>AybX&6!? zb%S$u_w%;AbBcPv_i4gG>Y<{p&LyrLZzK~rIuruN`h-lEwFN#6^}WmevRvtLR>F+2&Hzvw_sjc%rZSlJFyX-1z&H8i6cRjAKYO<`%zIWIvykYt0g)7E7N8>Y+wUe%n`>?c`U7kuz)nA_; z_4R8U)xJJ8>yqgU2LGd{MOe%E;v7%t_1mtnl$jr=Go3T?EM}mht?z}eGh3#V!QqS2 z592Rf4B9g!VfrA}YyE~zMt$x}E10~h&bKC>IXj)w=FUS^JnU zrFczDrOTGHz^>)@9BRF5`BnK3c@|vzvUz<_P3PMe z9|QNQ|7_g*PktgMY%e|gSDTR)C>XrsQ7`J>InQ=8I?O}EI_d%V_EPXba=KP|4 zNuvhyPxv%Uw~j?Fmq-Qgb*j7FIQ2BGt z#*ke*YYlCZ?)P@s!jIKys@#gwmbF#WJXvBYA$j-tir71&R3SdKO9#U0X`2srd>y{; zRx7MQrR2SMF!nvEy5gg(J=e_JE``9Osp7N~9#)IxZ|(tRq$v|0RP zn{Lq5MEOkq*)oM=Ij=vATkzj(*9{oI)28kAa#t#*9G-ieGm-Pz`ntC0=C6&p$9$+x zXVw}`5@0v53FKtj>)Gi#_LKRZcpg!JaSczFD#xJAe2o^ra#!o+|dZ?N^bK zL&RZa+seLIM>l!Qbnj{X5nj<>^_j;=DskoOb*?>OwNm__;yNaG%5@6;8bAImWY5(e zs{=j@*7-hlZyZ?k>q2X1Xs683(x%SXaEFcU@jH@yKK;CW^7yS+dg@P?D2%A3v(g4_EqG#aA^GWt`;}sCrPYuUW*uggZ39Vn4Ee}CDg^43U4e1Omo z)}4+$S#k{xFHXN0Fl&p>MbWW4ol~4I}ZH#Mbxn1e5&-TA9Cl$2fpIOAb8z{XCKhi*3 zb#CR*nreqnPgxi2Si{D9Whc|YZ;AYaUs-FZeV--@KR0fg&|TuCWYR#ncyQoIg-vkE zaru*DOg!slY7I-&Z%8>tUCTZd>(Rhg8CSM>#DDFtoX*|%Hom;gUD3DTqH8OB4=iTz zb^by&meZH(wHMyaE4eC?^q4C{^Vm@2iIVj-YfChemJ9XblkV+<~FR1Xu0OZ zDy*_r?1%Jcvx2LqrYm1Br&6?1FLb-^(!1u|@Pt1vP5!pr8n!}}u?o>lGfN4d<>wBi zmh*=78|fAEPDZjvyuNWrEY|&L&9ufgDLHf2(fxOCP@PY0&B}bISn3@vhbv)i(q z?Z+5(!1~eg40d_d^H-DChv%t2yS+s))w9`uN^M{7rxjf8jbRVB2$etfu?p(?U0Ff> ztsj43+mi4?A?o6I@ohhQdJ+US>O1K5hHEnFGxlXDH0bG6CEoMW?#X#7-|Al{$Nz3a z&||9V_ocnztI8rhC{fFHgkB4~DP@t~#DCRyk&V=n#E>(m%KEO~NFUxf&8YLu{8xHi zNZz~q^LYio8d~%wE@57HLqu4FGk4A5WgUk zOlv7jpJ&T!bT)sC?at+E&hY-K!7b@zK zQY%YbF0_00f=%s~H>gb`!_V9jb>g1A+Ql*dT;ncwvvA$&p4S^i9`I*u=-lJo&}G24 z`_q!D7lOOWUY|)&&^K_cUkbCZ$BLUJVy{M9Uo}r0Pc<;RB6L;sy;L-F*PHfjQ=2)~ ztlG~AqUDs{;r1H7 z;k7!x`t|XQMMi~RCb@1!w%w{$-_Tuj>jkG&xx+ci;C;VuuRSj8nHnGFp1Eg9^*Cd2 zF@RHbU-1fY=a83vXT!Lj6_imW($c|{{+j)-x(pwLw@uWYc@rOW_v^5vc-xSXOqjvZ zrzNGHX8DhdUmq@d{Px7#p%=LyWjkcwK2q8E-TzlY;XuK=xG&2E4t^YcpFHzxzUjQh zo1YjiKH2|9R(5ND(Xn})U8k8oTMAWtPdxSsGph2Rf$x>rhSa|KILUw0b)s@5b?>E0 zo8}TrqZGN9-81b6?Nb}eKK1T6|Bm|&$4vItiTy`%l`a;lxgGncwET_F^W#*Z7hqVKUUf-B5MgN`_T>#g*faYdm&M z?Yv|dqEl~GUpRm5F#}%jO6q611Er1mO5eqQ?`5;|XS8p4MB7n%udtd|(Jtj`)YCi4 z$F}mDv4lSn+Odiwbf%klV1E1V>G^zG3HF}PuL;Tf?&beo)FgN^ zE7nPldOGo;bD~a0PxDjSn&%yc6}r_|Q=iV?-eMGNpt!N~x{149^X&^e?zy_8Juf)< zGw`eVZ&n9e21X$!cmpMcje_5CSM?~gcpCm|1N>+z{D|r7Q$ho#Vi<9aB=8o)7873> z%@@Ngh+kfcVT*`g#Kke<3PIo_j#&}N8F9>#_+?levnGBql)#n~ztAL*_$>*<%!OgZ z1>L|4L(HWZMqCXJzGB!aLTb4rW=C9@=s#}edP|u?c?OP2VivNv^=urHzNXM}9r&SA z#aY`aloHz68`-a^LfJhx1k~nMS?J=m_}gq0$`$yTHhDUs`M^O6(~-e7*XCY`&xIe; zw1k&e!Ycr0pAwoTD3ZeF6Q5yacKzNdW;tjY@{(^!qA5a zFWnDAb$$YK%RKppFtqy(pZZ)wxh3sH>)?lI?UunWkVasMH(g#@M!W(F|E%peca|G;@T|7I3YEQ^VQ4jD|8sMrvWdUD{X z1gNsc1c^%ghz)eChhIg#1)V2DXU`=ek~KsY6C~VMQcxsU4(rB`*Bhz(eP8P@W0uogjOtt>%#z#97f(=sU`;vTDgEUle&O`4Ft$xT!a=WDfRmM zu&Bi_sOg)NSC`~3m%~H})?aJAZN_1!V6ahTXN$ufiW!ouzvNJZsRs)TX zuOv|&1r$>@3J`6wLk0gqb8&P{7>PbmK(3$8d-_wDl|o4uooj+VU{J&~hz8bCgrdpG zr09)g26hmphysO*lQc3&(q%c>6sU>)49SXr7v_9sF;rmHI1ndn}L^sZ-C)S&lJK3 zD(A!AB(ne`Zb}7<7eFtRN#Lji*tP(5@8Ue;JZWfky~x~-u7+O`tcLn?m(vDycW5muOcAPd^3T`K z@{_||2d*XFcNXOYb-s+OKujGaoMTSuC-9F#ZHwS$W#Fx3ay)cN@y3sD|Iv;{Z@21>jW82!xo0& zH~W;(a)Gu6WtXZZDBv;}|3g%z6KYZ}- z5}NvUlJ%ViCQo>kOB4E|b|;BiXrgFYzyjvqhYFP7x3*R26!c}0ta~*v;=*Wf4p%UC zm_%P{q84F|{k+c__U+(*hKLYAvZ`spqyT$3u!g@delH+V%|b>2uwM%$pF*5;vxp=; z(!wMOfe9RyIYpvM+9)NNXhT%L6qxH^Vj!@TkrM~!#RU3vFyg9mz^4nQZfKB)!*_)d94E}g0eb(4e1tTHQ@~pv zlLuaUOr&Rt=sp)RQjrOLcmnzmUcf&4l+ea;b~C~;Mf$Kt_Z9;meM}DY>7y)jbY9!G zSlDw0VPe;t6~tpS%ygEO4QQJ(ae&RKa^FOceCq-}}c0SkMB8x!t~fFTpDcD$uLh z?9*}JZHQ?SrDYky(kgBt4b6s_00BS8VfhvW^BSQsg$OaH0Vd$v4@1n)2#xn~Mvyy) zgylfZpZ<0i!i*hnfW=}MFgFH}&xy?Be?MHD6Alhyo!?6a(6pcuB{QN{chh%wV%6%7s*%=5zKx8W*@}i5$>|(}qh)TFw$o zh^YSx@f7cISpNzbf%IduW+hU?Q^CysPdshRj(=DNC zEeL+S1cM)@f)nIJ&c#+OFu7R5;${E%9sXTH+wd0gsxHOE2;Nsh1_58-R5Md?u9*QJ z5O35{syy7c@xtAe#E?5SgJV^nsm%*vG$1;@TH%|oYF^ART zGni}JD=wT>6qNp94Ljgc^#xUfJ~lliaaJ-%a$CV-_R5gOSXiOLJ#h4`B8l#|LX#k^ z^L@=)Fxp+9R{A#aPyLm?6S(Ycb-=S66|s0Z1n%ho*X5Wp!I6LiO)Ejca!j27 z?&E;kYVct>iZJmN&?jALZ(;3?E2}1tg}#qFkHZC(3vfGALl1mCBb%30qY}D!OIR7 z9&p_b`Lh+0m~x-60EfpclE4VwDnsbK9wYDz4pI~^_(Pwy__~3XJ&GxBd#JaN0K`C0 z7YjSc5JP&)Ac-mZDGSi``_v_`%eD0A|DOa5$jvK@2i*9@Whv0@|h6=FQzx z0+y?wx_k0$xS7IW$12p(({Y$pferZDatebYKM77?d=M2C*3?sgU&OQxb}jlUjO!tR z8@gA{uAO!3^lc+da|cz+D)7` z8iZ)qx3Y17nLjiR2T0RSVguV8U?MVezyt`N=d;BytTKZZ-({R*u(Kryb3i^X!iD>& z9JsI;=yJgLh{*ha!=-kdxF;n4N#TbNh4?66f^F%~Fvso74ih56fbS3|D+qE#=Epih zbtkO3NXLe*d(0Df_m1^l7k>T+KN8l6x|o4|EFUIloZF;$|@#?BBhjsxqQu?2(& z|GwFZKDu_sfnvx-h_&Hj>d2!1-FVL<<}u+It@nISMT;RIp!DWU1nNY)=z9yYMr z1$oHJ1!}2GB1xGps3yuk-Z6fI3)-7-wIDXD15YCraH|V1u*(IxW*Aq{cYs7C*B~<% z`!4fWf@TE5Z4mka0c#^!{nwyGMq2|F#CDKqCHx0by9YShfzaP;&^R*^Sgpi=ou}%`UKOE%H|>4u~Y+fCzZ878z=6T=4ij3|9P7EIGQG8tf)n zRb4T0qFIz)t(c02snH7#8E{%X`;^e+l1SEQS2VTHbcJO-+DoE^X}p}^zAJKg4J zo#F+rt9YfsvEwKjE3Tf9{s7zTCfov`A8dY;yfT3M1SUX4?%$V?(c7$gnb0c_uB}6Q z1)eXE(1!IJg+KJXiq{BUQSf6OCQ9h7&gj^C0s0{w?&{L>W>+I#TQ|fjCiPsx8QSX# z2MPMTPrHeDSJ(1#gKuugkV@OTcVCe~y zhk^$h9ZftSdPayuw|St6MLLf1nUQF{2kHg~_rK|ufxX)=Y3`V?3bAr|qMpni!}CoA zAJ6&cj_0MWxK{E2VPKhsL6j+lvP+a8qF%6TFzv$v*yuNs=l1Njqa^E3 zPqgdvZ2h9?A25qV!I=nsKGbO-S%25@!LiT_bq#NLc*PjdMv@MBA;+A@QK<Ucw1z5@7gj2}4Bcf5wXNJWJo@^ve&!s0%H88#qTVgqb~qAoz&0GAQ)LP1u-cSKUm zICxm{Gwgb&VLQW?n0-oUN1x&Rngmx!!7aWaNb`ku$jfuw2i1Y<>KhCK@ttyg0X|HjTkRcsw+o|M;Z)O;k|Q1Rd$5r zANk17_RrDN;I)F_acJlW9KYy|eIo!IHla-&+9v2K0bv0^6A=&rF9ikI!OcymIoojB zT~UH22e|wZjQK-&jU*CT?T<28yDfKB8(b-B!0DYFopS`_{-~D`-~wl$9wLp6Y>rHv zhJ9KHrU3fERazAZ$OK|6VB8<$B?Q(ev?kMF-gAOuIlX|h4ib>n7GMKrn~|mVo1vw= zMu^tC2$r7lhnA1icv29pV>4PC>%UMxauwE~rGP@2Pgjep*@#zS3pxik*#cEuszNYq zGsl+fzia8?7Nurpa3ShvGcJRi+Xj4IrMz&8Ms0yCU~?4UOQ`Q&2& zG`iPNkZf@1U~*61A`rsKCjY@~ATAISk;nH7{=RXOzUvK|3sQjGNlc5d<%k5Oz!FAW z73`w)7N1xm2wp7_lm;7&P@RqO9q0wi2v`a1+KQ|rb`1+02v`cVY(+WYCC>fYK`_Tr zP!*^IAsrS$5Vdx~(M3RF8Cop;ec`9%tk>{Pdfgi|1fkjLD3oP<5{v_)|K&=PV2JLD zAkna3w3n2DqfI+WG%G?-06d+K3R{$Svd0+4?BUYZyWg<68)}Bx_ZazNDVZIwC%6uLQh2FbeO(d&Y7@AUB zhQYE9wvedz9YHn_ABLKFA5Jp3OOoot&>(+AW-UuKteG6tFH2_zb%JE&+J@%4xtTf@ zhvg`g%aLW*Z*NHv(DLc6sDPeSUE*Ht=7Wez&1>m=xvSycGd2%Tpu_9(7Qlc z66X>JKmI6GQT?#;3Cs2w|fOz&)k zz}QA1F~FjRM%JG=JY$F8yS74Hz&so|jM&tbbw#wXHBkS)KeVIaP|lmANdWD&LLA`5 zAKEZZ3tx}qSo^6J&Fj4M8)%0;$kpf>v$q*6(vB^U0#H5Q5coe+B zISn@UQ#hrg7Z}PU1f>4kKIz>F>vKMjfCT|}B+3h_kq};U62UShLOdWK5?MVEJywlx zoX>|bMITRfB7hc!2?5AI4_t^uT~K;`=e|X7q;`OHpy%%tBl(Ys3iAQ>D5T%|$<>~t z(0%N%4Ejl_og~R`9fgSzp*_;jB$5i*WT1Nbb-02Q&PuMs&qu*19grqv`=XFpBIn*> zSD{=kTuae!+r+2>-)Oi5RgFen@;dL3v=S`l39K>wl08HdXH_9OLwYnU=B^f5%#CPd zqG++JMlzHG&_4RSc29|Kdkrg&pODiR6R?nEb&f#^C?E!wMO#Foc`+z4 zR^sSNGZO8GY6-i3;Ap=ciE6~66^hSu0gpjgSTQV&eyu!d4}4?MF3P@GC@Z*{l&y_L zXU@Gis?K^7!HM1M1K&IHpXFx-UU6s>AUY1JG7ca~ zrE#cw^*DMt1V;tIcpTcdvYBb_Pk=$>0y7l7ej%J!l;Fd7uSqA!2N_EYQaT*5)QW%Aw~!olNqFB|(1f zU8q#aU9eQcERtlm3%Pr(THn??@LX77#a!ES0m0OK~lrbe6;ihW-@r z9VcxmBS{OWD4bb;dl(0}QYaH?b2rNjE|IK793sqMCl!sz=~Sqtn-f&hpo<}i>z`RR z*P{)Q&2Z@41=r&A{cxO{6szVE;r>TVzE-`s8BS$gVH45!$q7MH?7Dym2k=Nh{@R)V z4H?5o($NHTUR{HuYZXXTMqY&TpK@Na^ibMh|22o_UG&kgS&0HI7G`JzS z6;#k*f{LS|aXGLrR44<6Yc?Df=2C$8bGDz^Cy4=vf)ke_05gV1N5Kcfd5RBgG0+FjnB6f;wk#3e=c3I!hv7+^oUT z2U8?EoPz4Q?#RqzBN$4K1x8U$V3vwJMr^CP%_B+isYr`wV|jN3 zOjW1g4}G>iNU)MG>Gq~V3(Q$@Sz*Al2jeH&>|g*>uoWzQ8g4MrM`St|&ME|)_n-{R zoBUO62h5EaoITJxbe;fd(E~nFHc+$&8GB(5EU;39B)!~&f@JLitmF_3kkdYMgTz>z zWK~J~v(8C_3fMGBH0%$WhNGMHNK{)_l>MJo#zUL~7?K=5i=a*Hdr`ig;5g;b34?10 z)`Q+v>8oe?S%CdsbfDq07pgkqNRqPlqBy>n*_CBtl{T7M-Oh)GcaW$ z9IM6zHaEjC{sn*NQwUrezsW#zHoi`_G=5C?$@Vt?&0%4R{5z6wcNn}wP*2uG{ntJ=80@hoJ*bsXKhoJ7a7(5#IE zzU#u+Z$qLM2hgzSgQFwx?Py%X4O>_wKJy|51AHCDsNqSHez=RVfzbnq^b?Ypf;`25 z?+r0wpnVV(VRI0|T0RJlhcK~xnD?no=LyUg^>Ey$-|P4ih#eFI`Cp4$H=52=$5Ww_g9*?W7-+diEpU=4@o)OI*d;0|YDsBoCqd zt9b~*rL_dC{7-J6;Z(m5NSEe&F>;AU*wlg3qP`*;(_z#E*kR~91qRTN1LJ7yH*~Ap zVU$i<2D?8=!#twoId}E)gPG(%dKeA;qe)^<*TSfVoO4qBdKr@S<6#tFXEuJyl+%IB z(e}9`^AKV+$U#0>oda!K>4+RJ%76{xSQSXc<2=4tMJ;zC*a5D(65mB^kE*cMvav|w;-e08pT->x=q%{|(yTPp= zyrE?Buz5i3&X#!)^}0HX&I8(4#M%A{oC7!;s3F-xo5fi`YaUvf z#ov=myaVTm9&ij*hRyruDWOHy{>6_SMSDrtD$4Bo;UE+T6Nua&X81RJoATCQ{Gmrt zJj8h9)Ng`=ZVv1b3UvG@ZqM@b0#)b-A{%a(4OdZteN+?9>=fzvtsl+uvjWM7;_Ux9 zZ$Un+-^K2~=o|9UxD@o`fh-f896W=OBul67%E&A~3#iCPi>NWZ$^(LM;@t^PTIshs zrdR;25WaZ>S((9oW(od(ChiwbwKNOES=)a3xly~8my}&!fMG;6AEyn1<#3%A3s-#f zZmASU#T1~(X@xS3hROu08?im;|BQJ#XN0`*-GUg{DD;)QXhdpTRfy`h_K;PoH*D_} zu)XP7BaLxZO?74c&#BI-0tmrw~&iBrY{) z4K%^=))P7nt|w=o652j`3ClzYA+Y2a$^jn7FpMY1gH9*G;pL9rL?N`_?zQm5-$c=b zL)Y?I-FVb_dC!$q%qhYY2`RxOHC;0(@dD1qP3fdmeE*RGzr7`RiPv`s+4HKEN>ihl{2h@6a1lH$!xk!CmVVOlO!Z$XY6mCgl*de)0Pxn z<;UqIuAaeU*zpv0%*y^(G4$kVoG=l+Y zfya=T$ybYzX|ja)2uT4hA3*njDcBF7@lWO=c0>TcmNKmY0^$SMWDzNki1fdN##Ior zN2vgSm8QNFR7|k7s=>A_Wh7GRiH~^P{AI5<#A3E_Ed)KPMl}Dib(CJ>3tal*UouwI zP9}Np>(}3bak*)NiWQ7<6b)OSNL&^X;DuC%ID)W1n$tcbmT&%(1dwVmziBnq`EVm! zY+n^XOHVoIH;@H5uJy61u|hg2$-O)`xZiqcZ5O4bZ#8&R;>%&PdYieuHdQ`~ud#I4RM=hLCsPg_uz;jT|Wa z4QicRP3rfXIAcnTS(bEbL}$7%bDjnK>S<6(x^ za*;XYCr<hvoe8%Eb)} z(uoD*eFmO`aXzI(lJkM0W??bdjJUS0NRoI?SvggLG9Y;3llnTEhM^IgBlO?TICion zN5unstv3SOjV}xtw8}}kCNX#HGwhz`2{F-x7{V_J?m26;-5JMmI_>bm7x3?ycW2qp zfB7&syHej#GgFI!?|lnyReF!}&6K*~rNm&RJ_$}LvcYw#vG3(B<0m&wbWVJAH9w96 zya@`D0<6ph))f4$$`pKB{{qO_1*`r#Moe&FUnC}Lrk~T1Z8q2^=Nm0j&Vt}K`1Bq! zvV7i`P=AIe!qk7_aMwM$p{W`JJitBYr@WlXAmU}h>m`qFB;`OG%JDZI{ax4|E7Aj> zw`OqUg@})mLBVN5Nx8)>N`6w2wGZdqONSr26<9tt^?HGb*)V=axw#9OyW-8+?;TJk z@Oa1cH;+!*tAC}w^8F&i+c<$n5Ypj|ZbZ=I1I2t*C)ZZ2-EMmrU%J){^@f;PvPqHq z6;#OpB$>UW)GAQ1M9&qk&FSeL@Nfa=tg*8TLar-UhCXcW|T1r*LELNsBd3tau1GQ!KrSOG8^wXKyiYp2l5_ z2Dg!4Mo#3P4`xhfPn*RoT`I5(OUTC2XhZMTr%~Nw`aVv%D>m%;bqf}X5G?<^>rkd| zZcI{BYfTslOr|`}d=8oDJ0t?59fr_vz`fOagrEaV0z+YBm@Ryr>)m>KQ;?yLhNFL9IN;KJ|V znx>3SCz~L;eUf9?b9`8E>@Pfn1PCJwe-?l;Hx$E@u{J3{FJ!w;PIzb}kcx+zw9d#F zm=am6gsWBbde4O*qrui0R*WXBAYEW1RoCQTqKz@%zsct1hydX~ryl!#HkUeqxi7`g z<&l6Q>(>^F5h=$h`RM_c!fj60`0j*8d^Qm1vx#RNw;G4O$5ic9Fbl^HeLIQqVla<5 za~6QwKBcAg5Xv#6hd=*k`D<~;r)Ns6fG87yLGM;y=LuYq>oM@Qjw}U{6%rK~OL0>| z;VB$1kC>zBw2FuQ0!-0< zkiBr3VMluHiZ0eXdN?J9;wMe=(BprWs`I6G$==dW@B26QTz)oA*uOcw`W#~+8x+CN zzAiN87AWuu+*dJR&&{d?+Z#l@ifoW$vV>gbbpJ=(q_LkF=udyF6p0 zfmPuE?`)`=0*hKUw!2lx;SaH3NydTn;dLht$3m@zDpokg+Z}$tc~~|UEEG|qcf)VH zY%|f{4P<_#P*l+v(@Dc645Jko>qpLKl^V3=B=!kOqFkwgSufHbCW3eKSWkV;XPYW<-rrz$lp^W|Y&ZcJXOBpKbE~^oWRnzw-8dtDz)d;Jp}R1I6=$4n z`N*{6!8^K(SLc0B#N*MnhyJY2VvNz#b8E~tCrYwT(ABFO?j7ryB~gAnLm*}|r=@m4 zZ@t9knqGVl_I+BCpSEa;e80ZW_m@8^@ODa!7dbWCGTH>**W+z1^q>jHt(^nbYXhtD zo?cKy21~V@f`!u<#!G?ks0Q>Zm08Lui!ZIkMD{4@%H6#RpgjF?alV|dNe z6sT25YQaAVaY}h~KmgO|t`S&zl!|%41JWB;QoSe|z&@GMx}Z6)u{pSx;Th z3DtLA7b=En9n)LkO*w8vIyAFDxy*l1g8S;ro;lm@>3K`TkU7(&sB729kaw}@`SWus zC0RRoBP#&9j`q|2tmQJA*wHAv3Qu$3x=DUbmYN@xpVH1Zvl0=CXlG8Hd0;K1LfR#3 z>9}CcNeJ6Yu3yb!R9SZMXnI{N;{t)oFs41U!r$ZJGD7#W(EW!{1&0fmY9v|f{%Kqe zdefb@ogMF{VzIK!;%Bta7!QNHfdtc?BQ)zmM5=+y<7Jq!C#&Im)Y6ec(f5DyX=K_*Z7$^ zP4PCLjxsXi#9A9^98Xl5GLgWXnb(S9av@Yq8z$;7+;8|5~x-DU1U(-z>onLWV#PU74EK5_Lqa zLv6!o;^L|wm5{sI{+OvnPcadXtV|@2boK35wvLn_m3OdRhfL@q%KZMO+GnAtSmB?o z9?!MJX}X-&qxB-ml*pICD;E4aqqPgs~3Dq~%c|W@+1Eoj|-S-*YW_GbsoDej5t0`kq}v{Rlb0 zkrn7>Hf6Z62(l#Y*plz?E!lKA@fEC|Piul0ocX<>iX}|#$vMPET~*nu|MP7|kV3fI zOn@gdb!b_MaTEk$TCeAs0Y0O{*K7Oc1!2>&z>lu^?Ds+$TPa`j@wxHucR`<3p0eNSD{Xb}9jm5E6y9V3HC1#Zl2sVL8Oq(6Csz#)Noa!BYC5I5 zDsr%5$@fk^^KI@|gqavcT+7z=`oC=ojkXBQgJ}p|tn0>*62=}{B*AchXBr7N!yPFi zMi`#yfDPO}UZ zw~@aJ`Z4dI70kcB4!GL2a;q05f05Yn`)iUXaA1`UgX?;q_*MP+F})Faa~CUjRWFo5+7z;)f#H! ze38!_Is=Swq-22wWq3@9RHDLCY;9Y5>;sa`vg{c$45zgtx!(dByt9fq{Ce+yQl$v# zxZF9oZW8=XWbGJ;E{tQ1Za3rIVol=`5!J}QW2co1ajS~Nwh8Jg=9tSwoP1S|EVWam zmXi<~+bZ!ma&x4@`@Mo`nbao%a9K_CV3z4WEFyB8R80fB67Iikf1y=AqK=bBrG}>| zuC}IH?Ow?%b&bt%`*i>zGZZnmGLV_ky!7pF3NJ4sZYP7ThZ-9CCnXj7?AbQE=cwDb z+`S(AJIFceSjHHIF2LCVB{7c| zvEjRD%je!_@I&5%-cr)(ggQh@n_7VJ8QE@Qz8BXe_R|<*Xnc%_XZa9pWk^fgFQbRK zFIM#Dw`nv9^f6s&(vvHJuVV)mu5}pXb))4=Z@qA_BC=EHxHk-jhOsf|qZm;LAcV_N zV7-MAm(A90`^aXjUW`{K+@juZHy~2JIsvSuLMEy>*h_7V8I8v|=8kX-p833}er{u9 zGlJLfUK!-^tCT6Z-w+r5iFB-dF%G}2Z7ki^f0Io(1z?`u^QwN zl3}vkoEO~&=Ov0|v2VUioyW0IY~#rc^@X3hWHhqymsXTU;X*fqjRH6Gh#izjfBpVq z@g<0`FA|~SJptZH0zXxB8NOeZOM@(_P103~8C(+SjM&%PgzM5S#iTffP>6OBkn#=K zfh9Y8N27&=1md@Mk#{-FNPgM+l~I)TIFptf5$?2=X?OUb?D;kRQzLs_n3w%G$}c-h zJZG=tX}Fx&>DM{2qefBK02o(l;5 zb$UvB9CO?!jz?ks%3DNl!8~FzVi(9U9MEZ(m5?9m_?Pu1Om1!X6X~T`IA-;jZ=T*s zZ=~U+L4zmKk0-Cu;5B>Ig<~o}xAiwm4F<69zWw0+=>~qj6&H53ltY>;i(KW}CQV8B zWAPEfcl{8L{X-7-)#zKx7ITO0&|w_80wlAzZHo2G8_cs%WM!7NRTFWzEof=X2Q-Sg$+4lQXtN`|{5c-X&@Rl}jH6!Lr%ZOOo3 zk#6iZgG=6<^b!p&IN+ig3ocYZ%D=vH@_M)Uj$l>T3 z{giUGr*tXPS8W~DoL>B1*ww8s(1GKrNTJI4Q` z{x!9B`~4H*IC~-BK4dliu^Ffj17T+9pES3yxP|&5v@HZ|>!m;e04C_kBE7gxOTX&= zA;V8z{s9_6RsuwD0Kg!@|CQ|WN$?Sx8voFIu%~T80>6E5jbO&dv_b*^ohbitVWnNk z17XvIN%0Zd%AkP#kpD}d%?2K*{-FuB4PXLaKp(zd9N_s!(6(AaU@^jnPlFaX4Etdb zumRyda&DvH0n-0t(G&n;e8`t=cEZ3<|5zeKfj$3lpFaab|KaB4f$`)YsbP$KJu5;2 z0Hiqor(qJN2~pz1v?V$LYmh#Ih<*hs!l3<6TXa8${%!*U0Hk6A03^wxj2NjD{~6E|HW6%2Hrq_fEV47nD~JJ zfG$KDS}q7PZKDE++~!gQRQ|ZBwvpohQ_y-DaQmOHrviBJub|R;pf~7)#wj?+o%lcf zln{{IpMsetLW>WbRz-u4kfz!Mr2WY6YZI{Izq9%8sOiRlB-9_4!Ud50pR6iKxbItr z3IL39{g*Rl8VlWjmy`1V>_Yv(>A`>qKd!XR7Zt?+@0vRBK#l*3aUcfi{X2tcDi8g?zb=}}2O0ju zF_ePPKUmU4D?#=jBn_1yYU~edy&u%|Z)?UFNE79wS~X%d;?4galr6%Kvo)&xuh>t^ zpgXvazJh27I`zc`08Hio>#Njd(5E!+T@W&OpDStmp1$7204Zhs&Xg@hLZg#!>|xqw zzrmoDx`IA08X_?vB%Q1S(po1Y=l$y@QUE0J&9wgcMVc!e9$LIq(SjofCjoMuxd;8X zBTXMBj&9!p`lSB znhh?RqXyqh*MG_j^LIVU`c=3r#veh&G`aq5q1xZNnCtTDri&+f*taH$2eyBzH6ujs z)n}Uoka9GBw7%#bI5Ql69d)I&TMs(~f95H9BrJ7i5L+vmJ1p3IN9&?u!?}7}j)VZc#B96C0JDB1IJcKc zxvO^W9@d;RFQ-rN*~uA4|BHdatBbSy%V|34yd%ura#zu@@Jl*Ol?Va zd=~3YX*ie~Fp8gv0aT@b+SRut$W_zasfJXH&{9_t&nHl3Gqy%|+!fCUm%@f_w+U4+ zZ1=MRH1(k*t36P@3|4610sHo8&xm?cEAa%G%?Y5Ku7JoWMt_{~;y%%m-8SQ+%2)9~ zieg#=tm;G%m^0}t#5zNENM;yx9GR8JM_gIU|M)&Nq!!(I|H-BHgX{9O4vLrW`w3As zF>iK=3|;=Cz|p3q3nkkNENX9vO5ule0Hwagc>SuuWDFa_ILUzhNRnYHMXaf|m7LTK zv!^^ZG&B^DZkAEpQE!v|Tb~ZCVMdmsT79Ll4@ImN9__qNRpSYw>gp{tCUEv)0be$V z1JZR8p+Wxky!i_e@b|#^^Kp3A?aqnCgM)`RKhQ&Yq^BzpW@SPUoah)IC`zYV&tMvc zyVJXS6sTMJCr%jDDDis{%P&eRYNx?%;O>QiGCj(>O-z2JlBRKDU_c-HYqucJi*Ikw z+r68_d^f2F-OBJ4{~Ie_7P1et)IO`p!m_v9YY{l&o$&R?vS0=QApv1qS3uMtlP8?2 zCEr5;lqe&O(`PO}aMiSwA3NMjCe2Axw;miNE{*d+Iqk!+>%<}J(GemhXY2g&fw>s9 zAr;ycF0!l~l!&6G)eS;}rjV)QKoI;qH5Td)VSYV3tQ*`>0QyhK5|eNsfrL0!qJ%>P z0F&gf9j4zYDpW+KKLisUptCZvv%@1dc~4Mmgp$|zpQe-xUgsP`lo|`#{}h}X1Z?+s z&kYO-(Eo+uUA&99I39(WaVjGv<~X$z{X~kqvoUfEUCyo)C>xPis+4#O^%TbQ=Hn|^ zG?NmLLNR_!zRgzQ+*lQODSg6?L(dVb1iBX8t5tbA5b)DKXopT%b}0v88%&yL%&II9 znXShV2VnDp6J!Hf2)mzm5A$Hkns#1q+E!e^{b&RU&d#yM;w5y&+zN)}cD z^h019ed{M4{<+IcY=L75^q-v#OWprVl_Z^j?isP@n`GV>$=K;s#)dL5J-vgU&fk)> zn%sJ23RtAU(DDJs2}@^ z!yuci#c3O?RWD2a3&9P8*qMoq!BVopJohTCS0!E5+3H4$9KxDOEKX!C2ttVODuKU` zYX)dPd+Q0eP08rgee-k%bP_iCP-lE6{N3lVYf57{tA6G>uu7`QoGrh6;mt#lvEiL>p_7Cb7O5B%d858u7PDD~lNWEv@B)+GYpy%UiK@5-E*IjlBETk>z7q z!6zZsa|Jf%1NwX*qrNA()< zVuN8+i&l9Zt5Oly_X2xo-h;W9@kFCJe}&BtGH#tPas5&ju8cRD>QpdBt>)jPc2|Un zWdNsppI45xQRMg18iHKMt`#~wj^*h?hQ*#na#ncar~oFPH--JmkquDZ*oGtpz}w?y zJY;G0cnt>@dXM2K_9LeZ8tcUAPVVc?B0c>!OJ7XI6@$5LYWL|?(x)LIp*SG>!7l=p z=4}Xt2pdN~9RnOJIB?4Q-LwKWYx>{p>YRiTn;PMY@pOQ!kx-tsGy6vA$j*?j>{bH` z>{m@11nqb*xo=gcw{j6j!P~QrUP zCJ?O`_?pPr9z&X-g8Ge0vYRvYlfV(YVxnVKp(IOCGeaXftrm5H|4p(2SV=2u1K&@S zfDTewk7lPsAdk<<;c~WCXd_mKbQzSdfbz!8|06;XPRz-eEtv5#fS*csSeBzJF!#bx zABttUoo)HxN65&yrtcOmp<5)K#&&i5t(MQP#P@?0VUw-7+pDsx@crj(LSiD~bK>4r z;aBfF;fbnv!DLcCv^mr;a2V7dh=3!2q?8Indf9F)3%{mYsZH+UAGg(-4%dho_!W*b z9LyeKI1YbJvd0w|Sc`-X?OEEv01`@LM{jvn=4e=-!}phWzwmP^-a`~VroRpz{ayO)!$jyjYuH; zxq88S&Z`pqGDx_aP}+6REXo);@loak^AZ~H}SXsD)C2LHuvKrSniPKaZkg>BU0$;+O0rGN9O5#0wxK|+u4 zlXBjNnaU<0Nv9i5M(?QDEn!+)5kMpI4&e@`85n^s&TBe0uCnPd$TM-h#SlL&;fl`J zmnX7%;v~=dOAGb_?%Q*f4S}DfVhkSfC^?Lyd7$E+!YA6kgBP{Ag_`xQ_3INuEM(t* z00N6$8aSukAhft&)llFhkr8IwLX|B-4En9{yC6Jjq51B0;GHyQ1ocyBE^g^Y)!bJ*SXjf)5;`m|H9L6^qGszd zIetY!!`{b+RA%JDhv+QsM-UK2s1?<@F`p8+PuL9~BJLEAvCmVrI|tg3ZyRFYAs*o4 z>X@D|hUF2^6k(noSR&v_}5=imRntO{^3C z?f8v+&Ih)1i=uA~9Gp2EQt`CIEt5^)IF2kWNU7;qejfLqhnm0IfoE&rm?cuj%MkG@ zkCZ9lK*D-9*BpSk_s>!}yZqfGUpY-GSa5VjL$>?p3GMAwS?T!+ZBF8gT+y$CsU)^3 z7NX1Hj2?kUPMf0+AVG!W_{nbmOn@!7Kzu-!I|z)Gy({)5`@2C)N89av2}oP|&Q$i? zaIoD9fg;tY(g?3zDZP9%9|D*K0WFe-2W_m1yVf5(|6RnP+m~B~Qkg?ro1ZhyyWX}l zg`&?<`i}k zIR%SM3oY$WA{Kq=8(a<|Uh*vq+*cOs0qb*w>S4~USy)+ z_mAf-buq46$G`R-=IDcPo{@N! za3233+X~z7Wy6(KvOSd~vmCgC>J4PtS*2KS(0?}AIHkV1!tbG8d?V}>gcnFRdIWS< zw0t||JS|K{a}Pdo5BLps#`e{g zR=KE}FrH>=OopqnB0hXS_ZL4dG;F{3#X0SyHyhTTvK{CX^nkl)o$Q}ZNZAclBnp;; zZ4GiL2`*6F546O0p>#8d@@mY@3ri~L*SpnLhI2twiT(~iJs_||(|bV@x*t92ud5TF`TiSfasWo&mDdT8B$*{V7wj?iD)5pca7rKM1h{D=f z;>S(mq$d^0mVmWC;b=y1Iv)Twrj?!);|X^|LQaCPgX+OIelVe2cZ}{-EchYE3Gq`f zK{Y3m<~t||!hkPy!iqnqF)dqlkgZs?vNAV^no*nnFU6}AzG%*WVkyHYoUiNC8U@o4 z5w=`md9Gf#unfXMAb>qN&B%@n6Im9#=kWB?u^OIG zj*cZ>ufIR5^18nrb+4XakzIQLN@=_j5Aw+e{~A8d7A}y|g=XunCI9zJ3_@QxAbf|* zp~|HV*{lD`Z00>gVYBxl!(YDXun>l|kjApiGOg3+m_u>u$bPz_d_my1wW?bI5%FFG z_n2_w8#9@f__^7nZ0Ic5m`2%B{?fS2R03DnFy0yM_364`2op_Jr{>xnzE_cSP|{(T z2J5eZQLAn6glo4$&~5!evil0@3_0VD6d}m9cA2aVN-Uo(*KB3)zu&LeaM%Q{fJO25 zr&tXAvuZw4_l4#>KDa%OcHJHL;q4c>ShCm8bOt`346X|3gr#M8 zqX;m)kkF7!YB3W;(j%f9$ zf~Z<#;8yG;acmlOHJN7e{STh+6K=)P23G`Ny4A%4Q!8B@J{Oe~0nsZWpVE?9M<9M}tjG?sGAi;;+LL#UBqJ%@V zp8C>Fb9jAaoBKa*{2n(@YYM$0U9w%WUWhU79Qx|pLz{P_$jh*1(`qeX!kyM zCCw~9@{OwWkVMs*pZ3dYb?T+M>fW-#-yjxq-K&+VdWYDiP__CdIz5{BO8OP=pj z;}K{*w@2)b2MFNl+Ik(!qfcK9W}kHp}1& z_0Q*3eqnwG(zGmR3LM;DZm$xVNmKLrWV15v@Eg}qb7b}ZaEz97hm^0baNzdX_;BKL zleG9pJb1sFuZ7is8;X{oheX8PHjxnJ%~Yc-pybOyi0@Zz94ssi!j-$hNo-#PrD-ba z%QYy2!4HZ){WT`_@Q8-a2Mxm zbS$65q)R!QkcooHKDW?}m3hSD@r5Oq9VsT9#(jljz#%aI1(qtL?g%Qy_T1 z@}zYgxV{n_!WsG%Cv(;wog5z7-XPQp98rRR2y6FM0eZVI998DTF7|CCOo$AOL67%7 zAMvH!V@&{ZKRzf6___WEoihTZv1oxSv)`cCFnKwqUaiK<2Q0uv)ZTz`F2-6LI#f^eOa?7?BNoz;`PFQw zt&)4vC&yr{Tb`Gw6#0S|POUn#(e;#R)1d{yWIFMsd<`#cqo{6w$<4~7#*k$SY8!m9 zF0HqBg#Ty4*rxmf`Ztqn(f@Dw^1q2l+LwI@;xraX2!u3cj{oLSgf5VXACr<&2#C;s zQzIq>h|+(vonl@&gS!hf?Ee?f?ef0m301p7Y=ECvz( zVWpu-Lim16R)RXQ=-NpE01SHofHrM_9fBYYi3cAQJR@l9xjvfm-*|;;-U5ja(~Lhd zt5KyhuBKQ>!LH7n+nSIVmm(aU3^f3#%vPlF_0QIFH$b_yYh7^4)g-ZhI;SJPqppKJ zvhc`EZCPWnLSw1C+pepc=H^D-{I}{1w|B!Z@n(kR!tacqlEnw!8Jv0=meIFo>ZWb( zKPgo(!RO~SnKm`w-CB;hVina=>fl^7Q#I1uy){~WeQ)ekutE*5*#B4=9EOHnZzeY` zUUrh@ty7+IMcmEjXM*I#o|mRn1&ZRnr2E zro3jFG`HUecbcuExb%+QzP=+EFjbZuND=AEfVsY;|J*)R<<`;SRJ(5vcDxmv;`Z?< z+;#v&_0OL&FekM*=oQzAJSBo%q6vIFG&GtI&i5FmrYdxQLv`zzOCsz9CiiD#`ns*- zJ=4tW3qs|MDV5zligx^F-bS~a*$?lB&7^trDP;a)XZw6zUC{}z{QC>DoJd+9Q)6q^ z1z5prdiw^C%8d0%+7O&ZzIFB6xua4-|D;>-`+r$hQRy5ovT<1BC>VNTcVWCdn|jn{ zZTcsal@3*0sJ#ors8GU-_uV7pxDp-xB# zfRpFAbId`ipNcydPxG8cZo~ zIgTG=CKB+u+7o^GH4s0hb|10UYZryT(-O_z(|Y4{bHzL+OjHII{QC#48sNkE6P)&x zT+N`US-}9F!S3H|)>}S~f~;C_Ph@lmXq?sSqEPCTvOLW*>R850&1cg4p%ZYUo@0KV z5m4P!I@LueZ=9cn9%^0RiJetCaBTlm$9Gm>eEp8?a`uMJd{Q%(p*!_gi*2K1SsI7S zn#QqeU84e!4D>_p)MQZ%zh}kG0jx3zFDHsqy(r+vJ*l3{I1!LRYqV^G9o^H@BbnI3 zD5GSD{bV+JYl}@Q-)@siTlpiyM^QMpz-Q!9Zqwri+AMlOcxqCuC%c08s6x=XMb+M; zU|$obDw6L>O0Yt9hJ2?iY}XjCeAK-tE=EX6#0{Kp=`3r@rai|8k3jU@>&{2j) z*Kg5CO|QmGocnt2?G6{)rC@NRRZGd4-H7cA^`mgR?#qz8(_pNWbp)e}8fUn>-&Rmi zdSJ&HV8`J8e)(jJa4;fJjXz{?JkZ{+c7P(4&O)yN637FUsi09XwRmACHi|Q*oP|vZ zH%1}pmAd)o=Bt3{X;i|jJQxM{r!1|)Y}Rr}g#x)qS41Fzd`^EaLjwbF2yTMRGZ4k`A2KNa=D$EudW7vzq)}1*Tv=b;*T2t}P%jV1x6;FQo1n8o<&vLr z#WVU<3P@6-%oT#wMBwv{Tnfh;J9)3Sh2exaG8Pr~c&d2?)+7hg+8bzln+j2%Cj(HY zOQh0Xq&*aDfZib<^^ubsV;uS%xpI+bEFC0X$kW^({L zD9oCqrKoB2T4f7qXD)%^sdlLah-ZFIHb)MVzk9!D%&>wC3-;^?cKUwb2rMC3f0iHJ z-gg>k6l*|TFqPX>*9fodfu4d~BjHl)pY{qCw8rEq{gi~LP6puh+8`1zC84>gZ;U=c zzdM#74E%azwDB35Y7Wkzv=j9E=jai`H1yZ7vXhNWDSvK_T}Pc?J7aBFxSadTgcVh` z53*d(SEMImnP73*HE4^_Z+K9I#QkCS}8mzBRtp zc5Y2U7rMZ)Wvqa8U}jGa{5OBsjJC`J96C7_DQI>_1&r1DpzpynB72lE|nDqWDqeT6`}AM5X=;_x)qUM{CZEZ+n1P8v_1HVoue;NF9} zU@2X~*DJ9!9B;Lh>N6*^1t)uhUpav`zJ1umMBpMCdP{5dEKf%Imw8A9Q4)r~L7rQb zAZTIcS(o}9ENDjx*@)OmP5A62g!KK;*cVhj1{o;BuU`Y1un#rrH(^`Q5#=Y%U(qm6 zr{p3gBUjovALi+wqx;4-lP9pXj zf)9>wJ*YB3m@s^ELUtP|7WP?}mZ}=CN4J-HLU;CbGJ9$Z%SS$E*zy9+lQ?jGf|us< zt)44BnCNMMGW8W6yOMiRt0}n4NLok@4g3k>uHOe~#~+~3EHCmN_zQ!+5T{i-pD=72#y38sNc zCGiL1D%153IWZRQ+Lw|h{U)C5hY1GFtu8;pDqt@LQx4mGwmcFOf94&dL{D2?llKGTb5;1$$ z_peKGt=+j7iE1)y%+oQDyFeairDV4{RJbvTQMaMFUK(;alg*9&%f)cGyEA>^W$knn%A!2y+9~)G6$ZYPPR5&F+KFPlfk zfj%a2Y)(D3T{YcDhZN~5BdO3L@r3%Bpai;T#<-wa0rc{p*>jmmITX9peXx9R`Pk(^ zFS1$|*ZgQEjxzt1bzSvzIw`K7Pz+geLxTKe{`WYrCd@3}2&N==;Gbq8OvctmDCu8D zYv1^U1y2Jc+uahhmq41$Crr1Y#j20~!YFV06vX!AOYL$H6qI<}yp+@np2Hk{QKTf$ zvc|g5U2x?kA@bf`Ql&Y+P)*T>0gDOjM9fBXAS;z)=+2~sEJF&pjilZ$>b!JD@&Vs} zLSO*>RY@XAu_lpp!E6!~^gjgjvWtQ>nHEkLVPL+WRiB=CczAii``EE71;B*gx6UG; zb_-3fdlan?G52-8r_}Kd=QQ}xJ)n;y$sS6O)*#Nh#c_%1NZQZoO1ia|lN`@$99Nb& zT*tZHVSby@_tTj8Ij~J4XwohJ-Q&B@iPA(Iyy-y17QQf&02p^n!G!9EpKfK|jSJjL zz^QiPfiQ%3`0p*N=>|imjn%@G!WGxr=%|j)X{{Y?D|!`4_o#K%bXZ=;SabID1T1|n zK>VV=k;EJ1+-LTT?(OgKqO^T>odf!e7ufagRLFk#TpPs=AQFXDm~N1ql^pvg znZCfWkJA9nn!q*j)Kq2KYma*xddn{@c8%#o=&MhSyS;LzU2=g1b35m>muq{z{P2C( z8JGASy})d!HCJdRo7M-*C&09Fja3tsEw&{Mnh6#UsY=FZb>Ndhf4L#vaKHoYk34nU z?F3$W@yUmMG%p^N?ee*yl|XJA^lOoTDgjp)RwI=rUhsLq;FS`@!3@oRUD^!QORkKw zF?h8lHl#nPteOu4TYaj2GG#~|4OZ21-+KNK;2u};$J`Q=gDDb1&x#*KMXKYy<0Yo2egP(eCwxC_tr7E{WUaShF2+f-2< zpA^U8Bv_5`lKi*LO>f2)i=YaJu{5lN!3pid*7J%Kf%;SEfB!C%Sd+nhj-e{KUehuX zjpMSn?XfcT<~gMt&pCl@ML52E`~_9C4v-C+ndjS zj{x|2qetz^w$iQb^N%+C8^%BoPykW+05#F+7g_yyO39m#-d|w4stUG5ovjf!sYc{G ziT&-*sTd;>x_LJDbswxYq(Ii*B;KUM%ehD{`~l&fknukpqM3R@oVYvt(6r&7kL(YI zMGS&wF)f!BGau<_3&obDit?xJ3F?8{KO4a7d4bM`#4c9~*&GB1{x{cO1zdAXLqMrV zgNRTL?bCkU+v=eFnzJUCVuwR}u71RJE42ey8hSD+!EuA!BeRpC{+=~?o?y{LR6Qyd z8BoJtO=dQE4DVH*6j4U6pS#EWqqUCBocgu?kXleJGx&74Op?EA{#|8h36 zU#6tE)vc1GrOPkt2Ud7j!>K7FJPSRhNU~Dq`MSB8l*uQq*FRwLm&K_|tbNWr$dbh>vQ;>>H|JiJt6yS=rm+;oD*a6(&M6 z*sF#htJVza^0q{;S?=*oG&a(w@Bg?SSw4clX^b`HW;&Cu-&T8S5s*j=5TIYF)g~5vu zr4IeKfe5FFV8!SNTDJ_(+#te3;L(9D{#O(vk-AG>#pOxEI-EIZ0uF) zNBXN0aTD$eoj&}l%AN$G4l}yKq41f72TC6umK&GMEK(^g$^d1 zLZ!La`Effv@Jy3ugpp?gw;t(sVZiyNstLKjUO6~8Yd;1_yCeY-;n%Ma;cU*Fik}QT zB8w^K-(9;_9`SNb-th9NY zV0&dgs=l`P%g(0!(_DnWVzi5!-C{Q-@*auzLsqAHi z-SB0xubZ&__oQLdgVLqB!!aN2Ef-CG!Sx$M`M42SkO{*2Yu}GW;O{Q=KgWn|+WB7V z4&(g7(k(`pizT&H^L@Yk`js5d0chi}WNF4!k2Fpa(zlGrSCsd`4+T9zZ$i<{8}L*K zz#aUc5-Iv;H~UCUL^S^zgNC?V>z#B6S4~ensa4IRvsJSSy585<6h|SHG))UKki?pv z+HYczeve{pe8uago$9ZVg&{TKc=E&KcTmo}iCPkMVv-N^EmbV`)GuDr{haTS-1cOH z$1(cNeUf$5Fa4+XMSS_KJ}o#pj^Y@-@B$Z<>X%pi@l5i?b8mMF}K)rT@CG#f^6|_W|w+cA5~0?4l1*4LjQ_7s`Hej z4n3s^`kR>8?dk?%JHq~{qM3C4^FA%+({oq-Ikzf6;Xl#VvzhYoNRj|r`p$aM@MIqx zlkB@S#9k4}yv{^Lcyo(Ec&(vTbHvdcVnZcZFHpxhB=>@(r1#k~>r3Ix(r|FAhfi7d z!{2}7bSEF|M~p<*K6PtmPnHaV7GnBx0zV+(KyY#Y?L7}A_}E<+E+F=4aN@8s>-8?O z(+hUVuM970a#g?moIZ&W^eAStJ@$O32)-?q({vSq-O92MvyypdCRWl%X5zv>td+`q zKdkzRt&vTqGFnD4wp=CV_Ky^!COv&JqsgHCN>h!>a;+DAy2b2M;Z>t4~H|irxG{yt+Z}!rMW1BLgG- z>q7S1a86WI&yFQSMWM9*fsW5>nlj?arP;d67g-;PH9i{$BIxpbw*Th&z8u-H;kjDTp>YxpkkqAj?K*SSs063p7XoM@uBt zm%j<4-coO!t@WpHMNf;HN-SUDJYwKp=9(Wmu?@UoUwF6Waym~KRlBC4LYZDe?z z*Ucpz78YccyBlsI>@5E(@*ymtXOu~n!&zso?op5aMOBh0FNc`dJ@Q7j@eZSrVvRTT zm5=u_T1#2xvpopnjLf2t@jP#K|2l>~j#a+Ha7yD|E*wVD*XZ|g?*b?BANkgfwz)6+ zNxlBvarg&M%cJI?x!~psKfCYoO%j6xU3b;__hzmXrfghkefkO0$m)l@7Id2Y^IHL`%2Uw2# z@2BdStsr@_X7kk%a6cGi6@>C(UT39HHgGR9p+wF!Cdd&hf2bPoq7Aod9|RArUwX`&r;&RVC^VL2c51iH%F~uk#v#wHYcVIR1ed_`fw;Fs_D-68xNM2Yy zY6(P}G^XqFD9R6`^$EiW{rCRyhWtCz^5uDS0Vje;&wvSeobx|-{w!gIO4^Y-7%@Bl zCyHBGKuX}eTru(Cx9=A0`FB^Cwe?z$|N1RYe)>l<=lZIjU$aLaR$B%={xUCC3 zntk#6b6QVIz@J?#>?_dUXjSvo{Zxn2OKTp$d z-l*SfUktvp6M`C)e5(`Q3yOI?r&bT!4Bfac1GiL|=}>kBDgxVc`=`crLZ&vyU%wIF z)m;unFCYEFK|IuDNl9CkaUpo*piN?r-~TI?sm-G9%{ueTg%&PXl)HE2X&E79e8Efv zvPQo|KoVd?7Z2R7HH_9bI!*4?KH_4M*U@x7WBK4EkZimW2K#hx9Y1}P0rmX0iGsq; zC#Oa+W7{fI^~U~Z$X@B2RS^}`PqSS?mv4lZ-&m)|C{aLD&R;#cWG#h_=&wp=e?>-Z zJGR+JIdRPWI@@4`B-H&vrg2ATgME^znfgnB=+HV?~cqmJu2V=<;A+jZVi^Hx}uVRI(WcOsSMMff_`tdTCw^Nqx*VPaImLV^N zwE@S0;J--TfvW%3nXPYQhBida|6LrHCOf=BcJYyM;&YetwGH@pp`I~9ej-*TeYNml zV;yP17W;NudM=pr63rReUBJu_N=jQIuX((-4` z!sKbg@3ft5*!GX9NlLUJi_JtV9;Y-V;E%f|=64!nH`y{)6?MH1!?a@x69~EWkB>WB zPeXKi7_F|U( zJQ%Y{=KBG!@G_oGG5xE*92tbRRcxjfJq9}wQ91inrPpgyem%>$&z4`J*_mpt$M^^~ z8s$?vjc?S1u9P0HbyPo^JkTzix+ph1_0%<=d3{X@er07sq(Wuu|8DN)%DJ7`KMZ6i z<+J!0hu$CLpzd*f5WEn5}p4X&f7iR zw!982_jh9Xyi6)jpv8IpiWl`y_1C5BQqG1iv@&9Tr|aQ#u{jE>LVZ$bp#)!EAOGU` zTi(6ru5?O0(e+Zp|bD<^Cz>Bi~D~ z_lZpC^Oi?C)TB4r{-K94T{}<5hKgmY-z5E;iZP!>ni)wB+VEuU9`&_3mmx~s*O4>q zpKm<{`UReC-X!?o-wl^DT=eSG2F3Fl)@a75d)hFBvam7B`v3g%AVG+YIKO2TXoa;V zeNZfYaOLc8QI%3VhoMQeet6l8Fv_dQOQ&)bglnj8KH9j z!iV==YZ8o~u7p`|wEZaXmCe=u9gh|WM1Hi#@!lMoR?1Hlo<+~BSM+|`)o2lFk8CfH zSXk9^mgUVnk~6XAGAnL**CyEs4!HWXQ$)#rjOW;bIcV3`a8BAQK{IO|r+M_`+9zDe z&AyGv(XpS4+}ikrs<1Jbx#o{I5g=b!96UwHe^1AJOX$bnb{{Q7Lp+4RivYbTaNn`{BC0Myhc1Z0OGBJd{02vK>5QcmLq!uql zA~V2iVnUztky{wxudkiVdIk!#gWiPJ9G}4MA8!HpIdog$zRenhFQ}$LPd;xqy#K$ek}bW+6LXr?||VPw$Rf zNo{BQL&y=PCz7!xAF1c%BP!)rSqv`o{4NJaFAGm?OvSOMRw4= z$;mGt+le;Ysnb4asY3&g`)6^(qMdgk-^o}cYLz2q^Y1dUKflj9=Pq#(PggZ<_ka8c zEAuhMlXYpHIDyczWD&?;I=sSH>yT>N>eo4sIIXq}HbF&=?6WSBJb zTS29^WX>XZ8tQIX0OkjAA?KwH}?Nw=JA4 z@g0`#qy`dQN=2)uTJ|M2bIF_bU8a`LZ6vSUXMI!`68l?Ijgt2Gdfp;BtoBGP!^wO( zWweucAWUt*OW~0eBNV81g0PNb>qk;?vs;GxLF-}rh zFg<~72#*ELkCK}8nO*Y0dgj5w~2$bGR^q$yU8ER>`8|gN16Snw!$hFXw(XW7-}gbCaJ&R z__>p=N)Yyo34l}faoan83B1`ZOxO-9&!(PvPh6%L!94jn+0Y7=CnCdA*ASb8GL-Q< zRz}PrnP+)tLV8|RVOW)#T(=<6ujntFnjxI)0X2&A`J)zZ+Yt2VgNNP2CwyH#{hOxL zV1yc_56H9Y9pIK+v5yvz?Hm6{B!oD~=b}%%ASnxB?-2$II*0W%ym9o`Q|Tkzg;2{a zx6-TK26m;!^MTzVUJpHNR;G`;y>t(>&qqIbyovnfQnqe*n|aw-iC;$YZ@*rPK=mT(X5<)gua zWto*Vla>Tn89NTm)VF+`x(s3(Un{5{C>$ecMNZd)Z)&THlc3WpuUBrc9PzE{rV3;2tyV*SyHW zMZD(c%16;zI@(0rLiI#D8_mgH?|r#PoJv;&7do54zmLasY@-9dBaore?5X%w8X$_b z{w#H3C*I-4Rn8sC!xMuNd{uiH9KA`R^*YP{L_1hvfRxQk6@(eG`5q&e1^1<;sV>X? zKM(JVQl$>_RzU(QANE~~g0J}eh(`U8V1^KTS>lD9=q zn6`^PfFrZj5BS=i6ggLp8^+&v48D$d>cX&>6iC8El=NspYShPU>?t&_+uvhy0Dl%7 zLbVTIxcjDezg)$aWBGxV=d*9x?hm5OC80e+tGGJV0iCpbdK7GLlcGT+BRAARs(xg8 zG3|;=xzveL0Mp=XgkUDEfBn@2d2e{Fe$nBvD(ZP}-9{ zwr^HJlP=rQec&QzWjRtV zB(PB(cM{-EJeWwo%1)Nl%{Q|Mvmqwj2aj+L2gl9vq&cX{EMEg za4QB!kkkiuYGJ3v3hc+z#|nuIGgvl9^=P#mhSu*6_BMwK9J$B)0}y#N0h?Coi|J7` z1nqzL?+l`qUMmqYD{6*K3Cp7Xx>I}Mb>Geh!gIhK&7H`V4BK;FkXG+<6~NVc+v?}W zsb$J!7e6`n$2Y{})ZIIUZ*D}>Gl#3TxyBK*gS~tz zb4`MJJAL!-13mM;hVc?P%dRnzAhFg&*|&0JnP!xcR3l&gyATWTeA4cRsQis@+VLIY zjMcTXMOIHeJ_D_1|G?P#}>Q=f;)xFsng2#!%H5nqs)d`#(HCnwzQJMB^V zu>lQ8A(GCjU7MKyRban%(!3RW+|z*$rSKKB%K1%k?b4A){U)&CbO}WVJpYA5F0pE| zmzFSnrRTyHDVm%VcXw_)29VnM+yOn}P6f$B3~@B%{)BN(UO&U7pUK zwGUk)R<1#jaZ4mw#(A;zvxNAU zvah}>J$;Ui?VdbD;0~nA7%*+Co+FZjps$c>tuqM4wrLhZTvk71w|{Z9!uOBr7>T-u zr(WLPf+`>D!?sR1J;STipX(S2#fdv1Kar5gB!^yA;>OWBQvLqIYARz<9e7{jwvUt- zi%+iS`c#_fULrkhQX~95W`pIL^%n2C zeo;z<+zFRX`o7EFouQr)1HYyt`TH9w+ju;@&%3iqb4>@KXIykTPb13j8M!ENI^^}CjAWfoOIUB4n}vc{i_>JS`JJKua)=%FPdrjVIq?Br`h;Z=j` zD@eOf?ph!}`qQ9%TffE!Yk|sFNT*uGUWa&wcKnqaR@s`5XSpeh;}NH51#e!xV?t<6 zw-OF=XF4swa_{a_&nZ7{z)pV|OCO)cAtFc~Y+r7U9^u>e)1`Wfx5UDzry4n_zvQD$ zgJ<_H69*YWdX6T0WHehhwf0uR?KiUkKRdzSJ%ZQq#3yRe4^i`8;Gds-BpqT;&_-|z z+@YA?pzAap^X_%k#!~YnnBPVhzO9KMN%L29EKkVbFul@Eo}ueqx7{lqSfAYQ_EI+k z)rRi9+3g~Q5}?*>Bx1-V{aqgr?{h!-Vz{x_@suPL9ZM@G^Yn)9`z+3kVgq!v-Wrc|jXEj;p zpyE(F(`a1>CR?=fWeGmsoQUPk)^)vNgbVhM69er6!RyOvzdIVAo*3G|GN(aq6J~UW zc%Ba{7hl|u6}t|GZ*z>^_>`q%5>`pnrQF-Cf2Ba`xIo#Rb3|Hy1<9m9Wd}o}3QEX+ z;x@Dp8K&019x1ZU51r3%jM933Bu5ZTo<_5ni0L70^rFcHNAA+6soL3|r*lR6XM2Hh zD~?a{d(2F_YpS%XMSMe(ooLYL1K9)aMw3>?+_9~q35i&Vinxz^Fd>7MLP0oLRZ;HUaYLJ7IYptnQVt-z1d!CThws`*P+;II~oj_)N+ilzY zZ*Ut2g5y4AzA}TaZlKXmLgIB)S;Q}|F??+h9J%U$d3F<%yTsI8_vRp%8;BPzo_kFU z`bb?lOiex>o{)WLo1s;-oYTuu{D8nHv^QM)Jb9h(UWrVjIgC;^zuFTlXG}r3_O=4m=;u1sSW}t@ zt#{jy(-+G!^fWSWl*qYGHsj-k&xQxRhBItYf34%4PNEn8=&40}H6!(_d%f7J@t zptx{gY)LZGY;1zkGRX#t1V+n^8cLW`u38)V}{mcQc{0XeV$L}A1h2A7xlM^7g zo8tDuwE3tuo`8l*1*Ipv+xYTOqqE@ZY1>Yw*MZI(ho`Vweay}%pdP=aK>xYlZ&?Zy zr?3wF_;L4fr&n&q!aa`om5yi6y(oIt-!r|=m>$N<@1p^s3d|n6ZmMbw|L7K6+jR~I zbsGQf)Qkstb0$8)Y_{lr7Mo#s)K>gzokr;46Y}V%Z&Fd}97{BOr=vrEA1YRFT+e&G zh##f{#U+vE^dCOCfrD_eUH^!m(9iz+e=>g@s8Kn9zvU1*R6|twA8ng~d=yDE1O!h) zNX;4SkM`qhDhwz+3+ij&f4)5@sx9!}e~Jqg35X2L^Pye>mDGMJqFMuZq8g|rcyNNz zQ~lSnuv%#f2JlL1^@>y=D48j$DiDLQ@kYf1q6I#mQ8R(DplDPifCps^>Nf!Dibd@Q zpvZXC3n13C z19UzPqA~$FNX#RsxA0E-QPfjFGm=SEAVCRQG>z&BpwY~sM*W8dDW5}~{|^Wcn@5HH z(4b-S=$~K1Dd5pz48Y}q%rjz$!J9~!Fm{2$d=CN`Ie;oTLKu#m@LpUCj3b~{;=L1w z3=q_k3dNvrgEKIHi3v4-i4_6JegwuE8ikL7s$xTcfRttmw($=*ZjBNSv_=V+=Q$i> z(+4-S4-VN&;e7=Rjj4ub4CfEAQ^x~}@vW=lS#$q~22l*e6NTe{3dFMjOu7fg(*xvv z@ewZoP^&!@?;jkk?K2)H4jej4z!R5%L(j(WOaQcqpLkmTH9=N>;t9k1NPpoeRl-9y z2KCRxBVa`oZD4UMDhOXNJ{dIp1AaQdi76PL8VKk~NPL~DgrSMSiZe36(IDvtu+Wfa z2tE|ReIANm1vt)HB)%IEywfVehr;>c6yra`gF_5bw~0*IWy@XvuT7{fBY8bI*U_n<%2&CXuAR#o;1b|_UqzMcu;L1St32XtK8B7Tpq~L9fnFJOra7em~KnR!= z?=gZ}CU_giDZvU5c*D9Un5KufZBP+L0UEf{5~ha0+c5G7+kv*B0>TY|M`an||Ee*T zRSiyoB$pjh^QWb z=E#U5P~e{zis~k42!>532_%67L=5@6Nr(w)x23{@CQ=hc0#i!IK!gbh$;m;)50E$K zBWeO5YB8dJK!}$rjHn0~ZlvBMqB}Y`)Ky1B0^rRz6WIVNyzC_U0^p5}5xtXv;|byt ze+QQOfhchiGJFLDJpQJkg2k%;!eZ5Ikc~}3atOW{F$T0#l9&fj_e_Sk1yHC)miQB3 z7GXK!_wdR6FH4gY()orMpnLp=*c!ml(;{{O_z##7lL6kp;Y?f%$Qk8DEDPZM^d=q# zsDJe%_68h_G>JGInDpFq;u;|KdLV=lMh#nn&4`dLK9~((lo4Y>{R@e?0WxJp#9n|x zv#Xo)y~GFz*@_4VbP$U|VhYHk8xe$-;xY=O%>{ z#eib=5+?z3k~2u0iwL*UcI=G>9vcF}a3lf(?SDkcAw2FdqVI=^$=TqxjXov5K!mTO z^jN-{6b1x@dLKyM@&8A(^qjbi0gk8#B3T5K6QCwZ0~n3dli8^TKs??_|Gx-5Y*T{clY)WoPA_@%9wjwzJh{)TJz}EkNc-r

IKT; z_VZQhnBgHHOsM_mC*)9HX^7m18_U^Q>^5Fs_1WMmK)bK^J86+SG7V0;%0>M5J@SioG*??f_#{WTz_8=d?neZItHDh2N z>W>WJ`3NF|n0()8_kEkeFnj1cgM1VQkwJ^xK#73KCHz3Uz!Wln1jPWV z{rC)u1wL@La@P370V^MjMFOdXz!b8J2T1_fMG(+E5nPCGWuS3@OSY=kX)+9Zi3-Ay zL`)1Z{{cdSrdNQ9(D7l?8lyiMOW5lpAe??dgnX=kQM3603ebnUZItSy(ke`R4AlP& zM+V`5mG*)X;gIeF7Jk4beFS!vM`F@@K!AT#r0>w-gJrzMQ|DlV5m5khy$ykxE1rpT z1CVM1bqQe%<_|KMfbOX*q-%ireA!7E0R~?=NdISC8*WlJzzPd|q(Oj77)p_v0PLG& zN&i>cH%Nh$33$9)pzj+mg}r%Em4FaHU=#W$6jot*RF||4AMU@1(H_o+F!o)ra?Ny* zT3u3dXo)rHBOpPkJ!vlBAgaEkp@7YwD#%fsU@xJXNB}Da7m}brFGEO!0TFy7NO6EA z93D$r42aJcPkIU*upQIXz*U9xSIG?ubG}WMP20 z+eyj70smH{AR7i20@*|sJ1fi--UR>G6p*z@*xB{+ITi88?5fNi#?nd#4QT8V~mTE4FHeoX&Pi$0K-B}G64p-$V={I2Y^)u63HY0 z!B8P&lh5G2iydT-@HWU&FIgbG>1`j`1n`EBYnyBiIMmTj$jSl2KTgS*ff-slBNK-| zm_f<$$YtSHftV1J-~UGhVnjl&38z*~LS6|N*NlvO5Z=T=PVNMs6G%D(xi-A%gn_&P zpkKmFo(e!O*~kL{XYJ%Cp9YQ^$ZK+4K!p=!atr|PPL*5={t*7(c1{B^Wh2LiSelRn z&nkW<AQATUuWjDYvbwsaIR z@B!C!6h?giK@eR{3ORUFmL|nG2o5>hQYZp0+vrB|zsD;PcZxuuts#Jd0Pw(}V2T>x z+zCvla0R40&Y!aMu7orQE|N#w17Qp`Y8$kR(g{Z|9eR4n4>@>h1=8V z-vm7}><{9C8YE7Fk`6+b4ckF3{!rio5~H9|8Uy-R;Zqs_Hg2b&3Y4hM-2B4N`JVGZ z5KE~@AcL}$xL`F`E+y~m>swBDG2{rchmF$`pXVP)4Y|H&=mv>hV}%h^;OLKd zUw3HJg*`SOB0`FSxJv$07I)0X9EZM~yDNUWZbLAa<(^Hkqj!QTy>H2?{1*gtGE_ZC zytgIAb|y3GQ)RfD6POYGcr#v|n82>8JKdzDP;TyBBnKwqfT#zW%ug~GgfHvbWR8yw zhraWa{BS*Uux}#XLdE^M&hvwx2cP=rtA`{Fnfpz)Av=2>rP*r zQSEM46(HKMFpO7h5?}^tI6`!F)Gwl&psE{v7*7W?TGHCR^0(Em^y+$gAvlrw{N~R~ zA}9aNa`W%cYD~n>GtC?1TaSFqp4PdQAR|(!|?bsMOvJ7Lv_!;SYH4f8)mA@>@N9A+i2k%BBgs zqa6F1n!|o1h5f=|ea~#}pp=`=1az`exYuP_pgUWj1o9$+TGbV9n>Eby>zit*3X;?HZ!y`Vv|}DuPfWM(Ha8~hfEHHbDSXvX*qN>2$nL{W z(Q{d&JDhZ+%2`9>GYzn+oazM;8}zOxsY`zF#xtoKLTe?x#N?(S^&syvBLFDdhK zG5?Es|7%tnEd{3oBlcU0G~_gxTCfx(erK zA4+Koc1Ig3F0(o@#np^MC&yQk1Lv#)wy(_J3X`;MM(1e6Vi6#p9h_20 zJ?vHfv2Icar?)Ged>!Lj%_~NYCdzb4;}q`VZ}&1BDBmk76AZWy&Bw@OS@rv^Pl46A zlID;O?M9inK3QWMFrdvoNtF*ojwf}Wp0Ix5lj^H$quIG5iN>O%7D}=E{vk};c!h3) zvQ=O)HojtSIy{5do7!p0oP<1UdYQ@0NJ=-#qdS@sj5COrW~GdQkEF({veI=@CT|BB zrQ>8~Pj*om6+mvSO9^_*4RS=^^-n7}SR`n5DE z{OdJ^_Hg2kZ4L|D{NyXj9Pwzw1hnuqY|Hk8z&{z(1jC*VzYMAUk-iEz#sm$85UQ7_ z5ZEL>2P^8FN>0nuG6!x{qn2mJQnKK@%k$LJC_4|G!Iqdss~w5!_oAvJtsBxL74Xo_ z%aj*%F--j6Yh9Z=Op2WKLzpL=@0vSnC;R@Bx!)n@TGbii#HhNGEka&E+mn>v{MFXJ zDqErbmI^hB)zR!#c*v4)(#LD0jv;H7v5gNzx!}0CI$D(TGNy#TyLn9aEF;A`8Zu7@ zdzc#7sOn3E^Qahs(rstoW^OnN8Sc0=hDk*vP7TSP2%s%#=<3PQQvuxsNB9JX=eoTH z_KL1Y=yaj~!VC%gj(TpYBsyR0*3J~vSSuj`4OSp=p*eoH3$x1;3mqd({ct3BwWG||zBL9a;Q;AE&ST)mrw(E#b zMURNxH|TCVN4vb)jWF)Jz2Rb&Rc?>w#*I$XFVOL}Y3}rK%fy!ZGy%n!ZoJhI zTs~vbO7FSQvwW=C`4cz^nT*$Imu$J4)m%eyui4I* zGVAEBEi>C4lSW-I{CB%Vz`Y$$7TKa2O z<{MAXHvo1^uJG+{^ZV@E-gEJO2$U>^MUPuJBsYn6Bb>pY@bFe|yW_snbRubdg+FEZ zsKA3uA1jzTg(A+Lj*cpCCU&rxJm>aB`_d~r1G^%jOVt-pC;c1TTDn;<<#=TtD?PX) zyCD+wz*_!Gy>zaGhF_i5qRfgWn?Yl$_6z9|9pnq9{p^dIe^l&e*9>gz#TD5drHKNW z*$o>XA>sjnTpQ?fyy$${thJhN+BXFh4%HOWvsOsM?LNOJY)?I_sw2XEeokZP#FIP8 z8;1N@6UBNWbtQ>A%w93Bixw) zum}EWzvto5%a@z!K6k~y-_t>5{#DSpri^{}jX>h%C&x*$td&;WHN~Hu zf0>{nW+o?z+HF9pJ{Pw8MT9lC=)?!l>wMa{X$MK3tFLO37#dpQ!%NW7->Bc9o;8=gbDU166)vw(2!lvJTr`!pS$Q%Xn}vSa;lDr z4)^GOUu+CX*Y+xq&JlX}Yf5pR&+=G=!W&ZbBAt_ky`F*yg7TLb#Y5TMzS8-ysw>9w zqS20F`h!LzXocYI59Y>q_2HRO*ya-&$p*ww8^v5=rrzm(#?TV(cejWXi7hIk%P!(d zi)b2*h*}=?@tb^J&R!ScIbiVBi5PkYsyoTEp?SS|&vA*Zkx|9QBoFNreZug0;&iFE z`==ZUnLd$a$ytu4@wIm6;qQzk3T_$`Io;l>cy_SL6YLLmab)Eys)*J4CNsp)GyJev z<6U5-AYQ|gYI--`h+5Hvmsx=06FOi#Oi16suODM!Oae`74_3B%7^ec)F$CW}?eSVC zDj#gSqt<8s2(S$NYUN5e@$f3by2n~`J%FUJ6&0$j5i!)u|2L7|%2F#g%cpbv1p~hs zQRnCueeU>gOAGMO2*t2$+?*NF`N%~Ik~;W0=Y1vkk?xaZ|KQ!FoBINJ;duMK*AEOd z@OID&qwoGkVKTbmAQwKk$)Bk~rv$y2<9?Xy;gw+02Nbbqj{WMXwq;U1A?vTc>0R#* zk!-(4RqN%Rs~-u{$46k9J!HMych(R1?u!4+jG1gJ5}c=Fi8T2qSl*-@F-sz3fVV%z zWD3_DgGnD&E0*i&)(G)eqT`V{Uv4n>XyDc}jU4*7x~vwJDKWo&!3aLC2KF5v$p22$ zeUPz!p@;udBG$054t*^@Qizi~@S=9qJins4I`+7Aatn0uCaM&JYM{8EP{aiP*n}0= z)Z#%0`CZiT{aR3xWLLIyV86B!-O+O?obX|?n{$|T z-|K5M1|Jm_eAiq;*qTHRq`lW_ZVU4$!>~8l>Z989FM8bAu}-EQgJi!AnoedF#>|cv zdZ$tzB}79l+oiq!g9Y>ks=(Vbdn6kAZ`qe{ft9gJuZg?|2MMgCD)}|XcP_0+2bH2= zh{JDLH1j1WMZZ*tYi9fC{zok4LR%gS#NR$86Qm$S1`o~#Sez% z6a#`yL~QC+I-X`pgkC&!w+rX45tjG9+d~Dx59f2ssSYA>54d60qnqDQD}CdhJ0%VD z$J9`&d;i(^gq#5uX_W)>Q*?ov4r$+*NCc*cfcAP@=1?b_sq2 zyJ3Il5u0G2@6&mGvnmm(D(EXY^Z3qO4r1$ft3(O!v?zZ&FQI!FxfFg`I{G!XGbH*l zPRl|o{t+_795KM3lm)5wkC1h_wqw&1oZ8VD%6he}QLz6oT_x|!7Rnr}J}8@16|m|prqW@{G3JVNrW2Un+Wmm@az3?>`3}0*P3g0 zP$uef6vx(Vuv-T0Beoo1j4d8<^lGR%qa85K@<)(0mSGj?I7wKGGWz}l7ORR=TRBx4M z*+2W;zgy(e4wxnN3+a%CbZb%afYp@6dJr`Yhu)xj)(D#htwiatg+1Q*bg1%VcVOzW z8Kmt!8FIY!ll^#hs6&IGJ5+Th8?y~+hZNxF_-52mDjREHF zwTJWpjG&#={Blis$C)TM!Y@>bF$WREil4lj620hmPfUh3^p-j_lC6d=%E5g%<+m$4 zH%{W>G;znfTO*kxp4qX9R1qh_ZBV*xPZ8F*DN~s@c9B8L<`0a!{ox-Zv4aAB+pHtF zYt+Wm4!qvX?5cF-5_{GbGI#h|?*T=ZKD3I;p#^+9Aj489`^&b8NT%1|-YOLKF|oSA zBn`=`iESqOj@NNVzq%`7$*W+}`?&8)`%3r~SWMjlC4-kb94q446dUz<2*jO<$u^~n zyvjBD`2HcJ41?=xMx`;mEv`5YZSP|!tzUgQ4x_Boy9{HM1eHIo&+>;;t?8RBs*F_f zs%uxqw%=@O_bWqNvBxcwsQ42LIsPP;nVgwvIoCP62Lv`Xwo#3BD7%2^7^b%)dS%M6 z+b)Nkl`vHnD{&?p{fK@Ta*zt;a+Zg!BC5%g8-7K6?zXrr^Zh50-PU#p2gkei+h+t@ zoF5l^8Xaf${GBjaq1w6&HNk9<H3A?52s1ndYBFsyLZlNr8%@$ zQ$$aBJ)D!HxA@aoL)=;5P!^rYuj$t9QDYnF9OK48cHQ3^jO)jXxl`xcWb<9}cCQf4 zGQYNZO!f4Wk+!?u_Y5;|dt)6%qNjc!5K8NItS{VtuVf4@}n!v*H2RSUKgFoC9~F&T5%=ry0r z>o43o50HrttTgxEItk4}N4)e7U3=B|;k%X*4{uVez(hMSukh1Uz9Z5Iy{n;!wFg^IsXuJOQ%e4-Wby!=gQ5T>RPsO>dyzToupH@~4UR zG`_kzTymJVXt?!}D+~>%SID#d;20aTnKFy_sw59!CB}XY+uGZ)U8arY?A=mvr{2oy zn|S>23_8yuSEw@F4!gvV<(Qk{(j}C_4ruA;Iq9KA!ggefT?gNOWlzche^+V$re(mc z{$9H4aAaOz9X!h_gCF*Ev^^s1g4_2vU{P_q$ePE048<&RezSCrms+ika)Hr+!$oK9 z<=D5@Vvwyf9A-K8CTFiBE+$+86>LYwOh8HTJv=n9Rfnlb>wF#WuG&=KBKXtwJe1mvaqb<~VaMmp)X&#HLH_%0bE~k0#FZ*UJXR$q;<|Pf!2*=U`;(K|a%dd0# ztWxLg+7M-ZN_JeExcP=V6g;}dDcVg>NU%O73H7DMlX#QkO!AO61N%_>NZJ|!i=ZRUmSB|D~tI#=O*GH6r{vZznV03Oi0N-^Win{zm{4=-`0eUmNJ zCcHNtuQZm!7KYywPN9FeqANo%+6P(J@@UB)6pITQ5-Xf=IBsj#Lv)JhPP6=zE-gaR z`%+Sf8@(s;l6ZwvqAgu{0-3u-xHd7G|A6%aW9s_yVyMg#!PlQp#=kXBK#Bs8wD(`2*iLFYW92mIyCv zI`T!Sk^fYB)gGP4OhXJ)Th(B$hjL&x$pIE(#2YZ`+DAqyJb4dauHV5@tTi)t_%x9h zT$0rNdN@I@^=#BEdR~OySCMxo5^oOB%s5$A7cUgZ!UEU+iN=_nOnXTe zs5ciwrGb8H)$TCJ7C=*7)D&CKrHe0+oM}VJ?zv@_Dq5>x7KS@ zMW^HS{za$lb!TLw?bR~$@T2Y38XUO3c4pk@c5kx2IUiwECQ{^XZC6HB^sx-RfxW@D%CYN4P zd|%?}3WC*ryVeC}6@suQfv*u%J;ek~X|!S~KzL#L46?4ho}Ru&hnno@=9YaKwm9hj zIDqo3azc(+zC@1T$DJvG3TOGUNC}tA%z-zk&~5+5&4)!)@j{}KppdB182MQKetfGy zf0v>cWhGM12=IP+PN$zZtx%bY;rEj#60iY}6Px6><37M`RYK`B zF&E3=V(XVP8rR<-w2Z%!zfP3Ap1&pX&E_thkX&yu0`lJAL}7zhpd0Z#^}4G;X_5(x zd8&Mi??n-PgDv5NuBKh5*v4)u1u^P}S9yg}1EZR!?Q5OiF%Ue$>w!{)%Zj-q5$Uv} z@QANAt8V}t@Ri3CL6Xu#BNe_ySZoWf^98yXv@Qks5HI&FZ!n10J1-xRk+&;~%M*U` z^nW1$a1lkG(ld=7b?#B*D@g>{?)pm&W7fq<-MT`Lb38rb1vMa*vrenn>XeSi|GeC` z(4%@z)%jXe^X84eBw{qEE>H^K$xiHNQ3g^)KWn!z>v}{% z-p}2?wqjcArbL>6uj}{q;ydxj;4B-TP2(e=nC0b>%C%5lb6WknLbkGw`c_VBjCQG8 zk8fSfGzY1Q2>R&i-Ljl;)y4b5=_?vukYd$&<5Z?cy)Sion*ht}sYxj?7E-BUjf0Y`S{Bb`) z+`o_g#Jh4^OaA_&A*iC zQvP=|X+8hbGU=Uy+YkEB%n6mnLghdA;Nx=-ME;As{GZSA^mJVHf0vid<7xSQmJ5CE zyYPQ0@A`v>-YA`g8}aWgTgmy)iJ1yMjU&tRC?o$?k_hV)_54)&mlaR!zbK6fqaimE z38F>*yF7M3Yh?2i`)7WdcliHHIa1-LI#m(wF~UE~>MkxHS@}eY7oTSk{4eDhztG_t zwHt6x|ABl-vQ7Ux&&Kd3T%Ui>9=Y{@MkyK z<@~=VWN#!S#r`*vPm1XMpZAj|$$r6sPRfe=^jK{4;zs{l3CRPzR{3+vN}tDPe)9hx zD@F|9TK==V3OHekKc6sv9_3$w&;O?edl1(S;7pzu>%MdAva-{SyW90Ql~hp_Oc5R~ zQ;MaJ|DDU4C`~;KA7D}O{m0dArm=4BX8r&KfpB-gt=lBB^wyXBl1cm*GK_)wAChRe zF>k6|5HZtBFPpd>^TK{G?ekyUKh7N;UZt5p<4hFQ^KlVXO>^qD##)4_=*leGVC$>9 zfdI*DR^-eI8kMcJXVwmwz}TI}01!T1GI_AKY74XGas)j1#Y#FuwDM_g5;$h7Q3?ke zgBmFk&SK>+Mxfw$e9yBn?TQJKCO`?M(-50O+4ghLufOR1@u#3M!Ue913O^ZLQs%G~ zVjsFW1>^bK`BgICH52SSzebK9=>EOh0;1J|L=Yo|9A5VY|27qoPj=gVC-{Z)UbwS7 z=Qr2rHG0HC!n`DJWj$V5Q5d7OM8$)(vvst0dz(U#7l#eUl*y+HN=rb+J5-%7U78S% z@0@gvOe;COMVYrP)q_ySYnAK_4~I}i9$?_P zo*LRUbn)Um?RBU_vdYdJ8^_%Q*LRBDW^v;n2Hs0%yU% z<%HBGT|<_rp^+ja^9ViREg{^m01OOhY1_g`qd8ZTCCo+OF(dfA+giC{7fKqj8RpJED;vikPPGPEk&#%lgr0Kx|Gdiy*K zmTzmd$~mqXkUe1>&St{5d*|UTGZjiu(Z*dimsBXhl6g&-4b^KBRa4Bv{~8=NlC8oX zk8q2~Q}=mrv?6s1jK6}5jHgDt=M*A~&`Ql5bTlYdF&RW#qSGVX1Q3M7x?Q^GvF9MD zGZ#|0gkL!K_lZl%0@oS1#>);FS{yFk4<=LhqJGXb`E~aB1$yDyk!v3WUP?v!%znox z2#I87gZ#CVlbv43hDs^KR3dm(1KtdSmpS$5wwT+j=XVs6DKME3tv}PvY78qPra=_I zS9yR(l6%4^lURg~2dEraXc)LYq#r3@fjllwf#jb^YpK9duqwYR1@;(Rnz5a-#z{+ z7wT0Pihy0DgH|tSfNH?^RWjre!S5xYYDP0G_x{^!baw5g4S@1Qd1g%tkbCyRIBpiR zZ74D^dU>^Wl65jYxci1TD0Y_fXd^RfC z05g*wb%CHU3AF!&%|SD+^iPn@&lY_1Hb4J0K#yNu#ZUVEu6x3Ikpp)tcN#(^gG6I5p4&P22k8I3_;5!grS$p?YyWIU z34-^3zI_=CP3;}%O#!Rl){IJGBJxV2b?r+Hxqp|d+&e18I-)wb7NLkG$;z`qsod&vqiJMp{4a+S-PwCSJ168*o^N1};C6AV&FC zB%O9r3lDL#Ti7hx4eG#y;n(w+_aXv23`xlEwjsN#J_=s~ih;Mbr$8Lm2POok?r+Xe z;%TcoWt6#^LYyPuV0Q0!=tJfHtfYiilAv!9>K`!h_A`CG9N1s{hW(9+ZQ%{zJEDmT z&v@nSUYiptRRj-*4)x$6_6%Bl6yTr5POLsy)>=59!M2iT_Hv2J2EzK!Bm0wWvqmjh zzq0FTl5OWJ{{^}&`iBGh5oES?SccQ632{50>y#*Clr2P_3e0_Bk-84HP|2o3YWjY4 z>V!kmhL+_~TFZ4IKngK->5iBF4NDtAn>tsLEu#~Wy9oBuqi-UC{n@^=ZE zAhEB0Wm|*PwFBWRwRIZRxoflbSm_Qko7L(PtB~&Rbhz_^7)7_>JkB*IQyZDZFk$|C zFe;s`f`8N_;Ba_P>v3Gw)xD}<&>Xf&E=bA#LNuwU!+EZ5ubz z?_DMtFZ8-pJL{4cDhLpba$Kz~#BzDmt73D?6V)z7EJl9%izoV#wh1n0ykhU@66-CB zdQaemr((5tUJj+6*c#lt&6FglrJ(hSGZN4O!H|F}IA4lkJOYeTAKYtdzwO>zY_W?= z&pN}ZB0H6Hs?}^qDR*q@qKD`={+2TYkl&HraupOvBWZRbKFE(WAgb?D*Zur=1$?r| z(R{SH`FU|1HNpnxckZt6Uoj0|nsZJ{)f(`iUE`)Pjwh7y8XVR78FTlpZHl}ll>?5M zK#hTQ7Uy}K{u|pI@nq}jM2OD^PE#2f|gC6sJkhHX^H9;8^eyzte) zZXZD-yzz=(YL0Io2#8BL0rchR?KS!ssC`%(NNTZ|pc)S4mVB+2r6_mb&LITto4UYw zG=nZe+sq4@!BL+gJCiYF1&8X~*EDA!8hx7nHhc7IixAA^TrA(D$SSZ{>)C$RQXm5j z!NC~of}4wCp@fR!6biSY2z4<%H_FlItICe-6Pv|ud3qh>=b6b!wWc+tYCR}b5{md` zT#R$QFGpwG+Xg@GhA(Y`eA(p(u!ao%F6BEol(lc0VTUddCt*c}|CWb-i}ejCgu+2N zq;8WpZrrcWKoCyBh}yT*Z-!qLoLmw1pznGdf_yS-LkM@>oN!Gv6qjdeXIv2mej_kR zh$7F)*%t7@SS_U>cGZLPm>S+m{Td;xdIg(yl+!w zNB8e2@7FmG^O=z283?|Bv||Jm&N1r`bdAw7vknIhAp@rxGhRmC z4B7U!?U1!rIrq!xhk}c!TazJpLrDGe6&~YhyFoL@U*QSV7W_M3o;oil5Q*efBZ%Y< z#N(onodnn&D#lY|DZsscLUmZ=p}|#n=?KJ|6Myj$Kt%5EPUn|i4~PKuD*=gr_L%!s*)vqDU1dw_JWHKTiYmB*4^JTgkD6x!FH zy$I)_JgX%%kFCISSVZYL%*ZMV7oAEk!^NSKwulAJS_}Dlu6OpZ)tBOPu9Ez+e{81IDpFVO6$Y>R2_F zP)1&QrR!Q+h_e7QF2p-$Rz8oXQp8-+ruEdm!0LV4*iVB4UfM`^Bx0qES)0-MrB#ji zvf-+-kQ^(TfUl;YEzInkkLS7Xx`T^EJ;v&-P{Stfh?bZxrZY|S--`RSySz6HCVRkE z&`8&D)nK`ZS9QUk@?!96@1OlG_u|MYU+l-u%_s2@GDm=ziuO|1PRN@1%_wbDrh?iF zY&Qh`4bsQX#b(!y*0Z8LbpXaDX@D&~dI4lIQbRqsUbLMSRti=HR>2FeU;#n6;}1Tt z)JeK$Y?{Zgs&&nYPS~44m5=i~PdG$Dma+OJKz=Gkl6D?u_NE1${0WytqLduomFXiC zR^W*`XAl^Hk+-CzpY`bZHo`q}%eP*#++hTQaq2R7x?@o^&+Ff(UHC@u5p0pQLoZm{ zP}gLp!}hGDIoWjR(9)U=3(1ro5E(O)=qZNTTxRD;pU9 zP_(qmXvtU|5V80CA@Wi5o9Rr8oU09A%eyV-VMP14yygY%No?8GT4{)zuYjmgLfoiHD`>PUfO=8N6ljAi@?f>2xYHE_rXD<9^#C3zs0w)hSF z;RV@Q;XM*fG+9aEkd~E3`K!p8Cc|16_I=5vBkxm;z*E-z%k&r2_T7V7Fd7SqY0R$y zR}tO^25~VO_imQ7u!~7?o3W)h{yG%EC6NQOR~SCa-Sk)A-y^rO<>yZ};|?iGST62M z_x$lhW<(1Egm$rL%|0QGq4PTDBj1@`%lc$7a4}QRAQFP+qD3`!^|fcD*`rOT6tFR^ ztdaREXnlGF7{t^H)>C`hhZ;&yns~^eu%jPh9dFKZ*njbtQUnm1JZ%Sw?7jJSVkD;1HrC?I0`dji+;3+al zn6FY*l6co8_krgk)2K0n^4z=`Pd4Z1VdscinDO-#t{2 zLfe)`RD10_zrEH`KQ4^q+Nre&j+^Md6w9rixwCo;S8X?{WE~AlL)qvEfad;{)&R{B z>X!D@?$czFh_5KFY8~EdGu?DPoT}I6VftiOf^zcYG&0Q zgNf?(^jy5e8YIZ+`=wzt)Wo8~h5nP6+;Yx+q0U}Gj%UHiLX(q=WFU z!?{sTM`w-a(15a=@N6#(z)CmXWo4N0)x3g63nQQDHNBza^My?ya zJsQ(byHT%@$7~MpGjaoEm~p1*_*CRa<^X5zccUz4c)B7`=e9z(CQBl0h8<71tYl?~Ouy9xJS`wSwfC=^X1OxA6$9l)zSKZGH#ZA$o&AGkhkr!w! zd#PP|+mA7#Ub>2`{wOF`vk6r%26pTH=UEkGuxoFk|7d5IcK3gvY zv1YcNvm0%^#y}77pat6#QCBWyYfV>=IGyKD!_#S7CVhjV0gh~{bsZf1daNOWHf|SO z(7xsQw-bb_@k2VB7VyJ1mcI`C^)(mvx#zHU9=+@`;U{NNn9k=*3;8k>zX z3_UKRfqJND0NQNIUrr|vT?WQv`q>pozto{t+EZ+d9ub`e{X$GpJJ-)hNsT2AJK47y)O#H23Pp*LGHD`a=WJT= zpDI_S)W!FjixW5;?%Zfw7;9BLD#j->HOGud=X{td1yJU>rj-K>bJRdbX zSxyAmC6W0#dFEeO7h=orHgac;izC|HtPVL+04eK1@9J1jy_x$H;O0aM-@bV6*?`0O z=5U~gd`6kE*=7iMuuQ`7>-vwnA9>^ubZijiE%gowP2MSyH3TjMT{>=XHrht$P+!U( zW^Q&OzP&S6JYs#EG`Zo<; z3}`%UbB$9UxwApga{qmg!w`h%}-_8yjz)UD;0IVrhZ68ZPKTuo)6 zM*+f~usn}zzhhUO1TTuj@j;C z9Xub*FJ&2zaxMuq8Zc0EE|bLi;kq$ru!X&2p}es4otT}$SNk4`->K0Caq(7WGy%WR z3U(Cd{+6_21!FhRoApR{n865jSFXDSzsS$<2O&CA{WL7m>2f$rcBpM&tH@S6cRRT$ zRnbWM-R7GSSTFi*%PEormNR%b?*l*(&O1w@@qwN>R0U0o$*|-bT;#=#P_i|MHORyM z0uNrO`o7hORt$Axn~L49)i$sv>vA)llaVz6eY@3`RZ=R+(A~^s|I$NPEW+h50P2nd zJtLFOiFqhq9b<)jST4Ie0WI||3x0E`l6^x>Y906&+j43=0Y0F53@KVM))I(Yekcvr zraRHw5j>T({m|Vo?=GCtB8$=w(pa8iY0xxhkW;yab{za{1%xN|z2%XB59)XUrduA3 zaOVKNr!%qLgaa|+ScDHV8-@6xEDH4C=L!l)eKz8$R(=P5xMK(TtLX*dqUK}a08~ry6-YY$Lh&MdeBVmJKvDS&R2gS7?I@n;kw@4&`ze=ULt zBQye@sF^T7E5!FnBYFyzr}J>ipkIq)556)UJ;R#3nsbT1>t|zyIjA3cwAK?k4*c+X z+?ys$-nLCQ4xUR?vKD~-!xy?Q_HP0`?2ZzT4ImuQ?}Xg1OmaK|&nEny*f>pyt&T=3 z;f7oL5ei`xhah^w?VY!+22RG2feji7_JSOokKOD5ZYAEb^n3fN71>x7g8UC&;(BvC z4iMq{<_-h$!tvNz^eF{S5D&0B|C(ju8u?ldTB8s@SK7qRtv!FAw9kYpb>D`^gC&UapO@{TS08K%@G1`<&UKzkGLdq$`^ljkJ968p^wS& zW9^sm#t{~NS7Td$p`iRW>wXE*9(4zu0cY!&vUreEIbhJ_X!*VtIO-;}F=|l>p~!g?CZ-QNi(YDsrUPgBhA?SSkRO3lN|c8#DFA<1$gKPEpY zGp;>&=kWW3EhN(_`#ECCC*W?AOEf;8t5Hv%W6d#w5_I(03C zZ+~DUp1;e9=8G?XYEZ`*I*E^NsZ>F z41r$rF>|ngTyHEwF28frYVeZtX5HC2gez@CfpwL{T(5FbCqr~N`+~??5mKofA}wnj z?A*YD42|Q}S?DDqk`2@96l3Ub*i+(nJEU#W1#4kp6^+dQ5`p)3;fa6t^~!Jtvd)76 z*|qBRvAa8P*m%QV4_oZPh%{OV0jC~bq@$_9b0^@HPj|<=0)Ezvo}8G=8>C&_P}pu5 zY@-Hww3gHJ4&>AL@KN5e zpCi>-wW%c%s?V?#wa5Q-1j1Z6-ZCBnoKWWAr~^B-U*W%YJ!Ybj)|%sKA)Zf-uSIB# zusr{5li}%01i==KsFk;t41Kl0{Q+^(HV4X=rbk1rzFl@CQff**QR_L_S32V@n6NQt z-|I(jQLm^NJm`qiHBsSeMfh@0YG|`@R%gbgvRDtTKgAqG&}HxaUF0RP$h5Et&@yn& ziNAT|gna8hs&u+`#dyuYDxB5SL&5pFLcuI+&)j1OUMCj{YH#PJW@3Fw$ZaDK)At#mN(l6cz83W793ZgL;q9Tm4uA6%;~L4 zDI#WF8)M! z>cD3SjQMWe%65v@3qK%dV+NDduP-7cqTOVHUdT zb88)d!u}(QAF+GgjhApywSE5b_D`8WrJQ&t-Cclz@=d|z&6C^ck3r)FsumV-YGR&~P-2PLg2wmBe`12S~^&UGUwXjZKV8r^6M|dEo9rmufi?%<<0G(c)RkzqxEv- zPT>aaV`oj%o=TkiZ(|l_WAJ6X%yXDhI`B>AD_O8O28TV_8&#h*#Vt&uq=*lEUoU;& zC!|+%g{hx0&S-uspf#OwKkJ>QBD$e_Kbx*}PcG?ByP#F1IYV-(v#9sxF`00%18%>j z)lx;ptbEviKIF0Rb2Pct!T&(3p1dBn?znta5M1~b{+_J!C6giZ%ND!}ar}CNL2dl9 zCEhI@Gehr^X^O5u)lLU*20VsZGeTs{N6lg-6p-rKj@$|f6jY9BkG;vDrs}smx;KX~ zP(5vbaYiUAI2OIz-pPi0!n$6_nrjSU3>UEW8Ns#Gd;J;#7Y1r9c>Ul>#N@1?X!{_1 z%vu(^1cu~uIq@nH?o2R0dbk2&(O}WuP3J?rF428xI26EDbJmY+VJ4^~bP1aQ>Q=d) zWM21vkbi&%2!yH8vdXzWo`y_oS4Zt)$V8;D*s-LdP(gG_izXQ7;*7rtB6@=2)g&LV ze|@4ld-ey~XC6Puir@SRc7l#ZJ2Sjs#KFT7DnRA7UvN&Ez1}<^K?_}mhL5v zimEd-teBublK4DzE-ZFMNGTB3tLQ*v|5CS;)%o55YAiBW8Ia$8lcxm*w>k{6-TJ(X z4Fhi@5))Xmy-aoP;ie|{AXf+aC&KX;zB5)Tu_KWwhP|A^1_w{2X`mmnwmG2D|zAVoAG)wDZQ=%Lr~4HJ4DC<)K^Qnh8brWH^eo;ORM#U$^*i z8^`mmuE?CUW*?`R(HDS;)<0TvsgSf7*fu1a)R5btMCz*z`I1=J>m3-0)=Z5WnO3@> z;);hXO>a(hY2$>Ad?5-E_->c%j(Njd3W>NLO%H5txrk)_aW7NLW~I_1ijQPBorQ-$ z)bDnQ@-sg_8L`=>v$ve!L4%z+XI;}Su*}evyk3zJFIhiS-72~LCC}SMedt@1*CRyN zRZ@HDnD;A6$|-H77xoZYE9Z`yhb?eCX2zB4)suwELju0J&f z=143W+PuGwf(PSAjQ0MS?DJ@f1&1L*1Kd~hx+j#@(M(P|Qu zG~HX*!c6y!gL_grJ~uv{wI`x$Y(&?G@^w8;ZqS3T)S`X2`=dfLcccl)qPzobn0c+a zVe&6t0*c#?U@@HFz^)whtH2fNhpf5ayMMyUJui)Ulgi$clTO%C2q@JV?LiZCrV6 z01WC|$g7O3=O9TfUMl#X&7VI+^0>l-(5P|G+YMcsctkmvI>$zl`R6f3`6nJ?SooZ2 zX}JL$*}Ci+f^!85{k3J>Yfm8nu}e>_mDRa*&;ea-*3oZ=^9jf8t)m8t6(0+H(j|b# zXm+8$wOXJqIbjw`*+yfPb7MDrOr=0m4YNn_n&sBhDCuzD@CB5&cI&9f5)2uRrnh;O zOk$N*h@P$LNc-Ffdt*{!3F9s0RVD(?OLd~bB&AOq;`_N3KjCRvh8I6@$z*8oG7=MC z^$LC3EfJASzELdla*SbxTtR_RzmQI95PG7xzoa0S%M$l9-MuQ!WPZR3^pu!|@C}B@ z!P&^G3fhJgou^Ai=}l=>1#389v=#G>i`zvm%(TqLu~Cu!u5oCSmA zM`qm%le4oPpu4Z(ChP*(H`MI+xv|lmPhHAwl`?EzY_|w^zPs?Z9r$Mf!k7HoM?LcwogKh0jXVah7=E78sR=S?n zx^xq#by*xA?t8jAcH_nVwA4Ajlk<=A9_`n8#{D2ZrCV(yBaSM-eO-;rJ;zyn{oTYC*xMk(Wbw)}|d zBk;B?{i^NC{kWyHpG5BzqCCXg8Vw`73MIdfT=UD5g?7p)W|M>SB+xbG8J4V@d!m$jfX{Rd z4LT>RuiG9)o`=964j_b+_JtXh4BlRyeYDOwNT@t@;u(?7tS-N+N5|x@6Ec*5IQTj` zsmyiy{M7^!ZaO;S9O@7_IQ_juy(aU)WDE2HL06| zDL4RqxkcgHRu$G{uLZ_hHya^Cg5U>|!+_z(uTxsc*Nt;HA#Ot)nVG5MwhDB%df;naeXrdF~iU0I^LZ#c1@pd4iGVj}SMQcZyU z-!2Jf;!#a~&DK9{!dTzB`b>!i76uJ486h7-Ugx+ukv7VB5HW*vC87yj2je~S#9btv zMM|EgexS+`Ys?Kkcir-E;BQ0>pm~^$G!0#B@Q67{Jo186(26-IEPtb_7X>s~bad$A z409LPb!z40j9}dSNM_|Vo|c6lc|AXa)Q)DX&%53TFP}6qQLk1~s+OOHA zqCOwgu02MCf2-ppV&v<8;nua+3KDL)v@T zaV679R>oGKqeE94A37*g9?51bcF!a7C397?tl|}fvi)f+5|HEW>oVD>GK-M_mRv_k#UG` zzprLGd$K|If8ncCKZ6wedJ6Tt0~4zHrNf#;pCyOu2iqn#{-Ei)XHzy0FSqVO@3-7l zVlDDZr6QRgT0GEa_qBV3_vumL(#TDQNZaGD1=H{nXpz`_RmW1NWm*Bid1*F62AaK< z(+)01FZ4Uu{oRjX;j(eZtf0#xk#UD>$q$)6s!VlRjUHtUYAt2DTdFsA zfeWRclq%F!A3G+t$qY{+Dkh3v!GX}k{ed#c{`2#0tSLX5ij^b8NwcAA@q{CJH4>^-N~A6SkW>k?Mdrqlh4$d>?9-=f$A|JG|$ZOn#JdOFX9 zqWZ-$LAsf@49ST6Aw!HInbb6$GUm?JkBt$o#YX9#$}dvv_1tGWH#tlX+;CSC3y2VB z6&2cWy#Ylh!|ImJ)dJp868GBrZx_4r4>e%YpzV761+V((7zm_67b>X1Vgap~*N#6srG?)F75#;zzqwihM%bk~yU^YuYEQ-S> zVhJUP?2pT;MMjEE>rHv62`LNMgV1~fb68e!a|pMkyqE#AgXxS|JOy_yluWV^cn+pl z%uA2LPKtL3vX}?JTcHIa%&%%A=I=KL+yEM{wlfuNP6`zlJ=LGFz#2-QSKx~*Yu(cg z$|^{Wfs;P!X0}iO{S|w&Sa}<%Q?zHMcXs&r_g^|qtIpGdajt(mt6E>5IDcW)9hXE5 z5m>qR_M5t3Ss9n@9)d7DIJ(kX;7r z%1zfu5ol=_v#UY(>*hCH^4Lu4*!0&ZH)c(jeRum<-z z&6A?-%ZuDH(IfraUc2^ORn%&jO>BYcD7Pq_kfT!lOghFlY>T|jSeKq-xaHevx+v5> zX_R-yTR+&?+GXjLv$=CHt8Nc_Lr5EE+Ni*RDtAg?g0*b^2du>9U+(YYzN>9>PSV(#&RVoY9MJb*54L^cq>Bm~@+ zUr)rww=D`Z>hou4elKFRtLup4gwbrNOf$Xt)Se7 zhl9ttv&N^DX~V8JZC&nk>?2@9X_MZje=IcsU(8HFovX_~w8f8QcBGz=tg^lf2_QP< z`$RTsV@Hj~L{y$%piIp}7!n$TRDKW;o?RQ_O{$&PIOH9Y>U?3#k$$wbxlGl3?MAf4 zWwdB)FmmTM>$BXb+0)Bm6Zs}`>$~VezyN0lMduh1FSrfmh$r%KwZ4T0 zG%AXWbK_?pF&!i_&ib0Fuio#T-YUplraBsb6*q>>r9Qv%UXfpA7)jf6r-#sh=EzGw z4h}2u7mTrs*k64}0|^_qs+C9C8^GDYE*&%T&^}rHh%Q{==%1t=J1ecOmO~LIe8hv) z{*H@i*<0#Bx2T6-X%jwI$0==XgZ?fAjC<_`O#H$ZHRiWByO@pq^0#Irr3746UTj2e zeBNZX$3KYU7}Li7YO!S2yIQv3M7)?nn_+n9qF$)sodw%F z#O^1y4cojZHHqZlbqQt`R zY(*_~Cli>_SnEFLn)6dp3GQ&7PjUiQO@aa*eOX`D1+s+;%gdvg_f9mD^{%Rsp*~%| zf}rP%3dM6H=?m`u5_A~*MDrr<(RNAqSB9q-rE(ZM z!2|^OTmkjujn{{}?$Y}jp3mQ*HCroQl7^jZ_2qSn+#vE4Xfy;aGu7qfUB|JK9JTk0 z#`5+u%aKMG|IV|@-!^4H9E)&hleEH%486Fx)mPmkMB>pzcZIce!yUu6q-33?EN)SU zfg9?j4ucURUU2r=p0|id*rhB@>TkSUeenwQU(#&YAJZCLx(^&5EXZJ?uwVZaTL|-7$f}Rfmfr$B_hS<5V1M1va5#3=p z;^jOhw5SWcGxqV;FsHn=fUT&ViaY1GdQGO; z1w7jcS++d`7Ty&A&e;?EX)+A)^^e!j3=kq3jn1)V8Hp}vhjc46eN9POdRT?k@-5?& zxf<37`$uXCWq1S6y$AK1Na%Y@%h2p zjLVuM&}do--bG|?kuGB@RO#-J$!#FZMd?$ViS;RFO&IoR(yjEc%k zCaShf0ZQ+n%n*0^)Ur#?eTgR_yNET<_r&1NX_Nq4EjKZxwj0?#FaJ^OyIxWcUQ(I! zpI{tP+h~xWu=HXaE9w!_$2}QapUao{3UuB51rWh-s)u80^HeYNT445p3 zt;|Yp^pxa1$w_{fRUnE;9=TUOW>*VA-v&Ls*GNtrN5jjBkK~QU`wDLI5_7%j~(-nIZsV)u6*fyyww%skJj1@RAk?*=L zUM~K8Ns$gRy+1cKrZBqh{2_J-mW?xtYNmxyebOp#M}mh}dyC%4`-zasL70nbX9)Ua zL2evpGNP3Cw?W308h+sa9>HW4un|PBSr82J{dAb!3vr+la5x3cjKA#;jbC~a_WYBErIIlarK zjxq3g7O@>+R*@MKW`WKRz}%8pSoXwj@5#~%pOY@WVb-aBn$xJDux{f=SVMaNH$1ClFErr^DLCKa}kqCf<@`t9w^4d3~t8|r7PB8nQ#gCIKUsPaa=lJZ!p z9MYVYjGzaCSMjr*4QqoJFN=T2@*$F+zetBw?}L&F#ykViJ+a(%dl52x$l0#Pn@vel5dPt9$G`3Xj-HPmx}Rz0Tg29|eO zT0Yc!I)_ipB6@CPpnf;AhUrofz>!nIE%NKU_Y@vMOOX^LeNN+kBpukf=#qaxM3g2O z_QpAtLtG@&*P{`M{g%iy8+}?+T(&liWbk(2T4UWUtT5QiBX6xq)5igC6r^ z1wLngR7kY1wsuQMX`!?~EfiXmXevSrQKWu^LJBEs zi4vlcl%-9`68`7jJ9<6!zn{1Ce$V%uIdkUBnYs5qzh_?SH)NsMfBVv$T*F9B^Rw>pk_ripFU7A7MsOM~=$0#QuhTZUHkVZc zmbs^npLN#FKBqC<{F$-z{8)ATHc>l~PMc@PhEBt40@+zLo))753kVgi4|8FZ6JW+R~sM4MrJHcZHwrRLl6}*LwPH zlE|xSb;GSI{I`_@9E8+Lb8lT@Yz^Qa$`ia*Z=l%tI&6Q5TG`Obj>;5$*T!(k#XUQc zx9~04y?NH)jd)Gss^N_NlArbp-rD*~v1~N@R0Y?nkETwg$y#euR>fBYr+snnDwFTq zv}o3$XZHHjLJB{0%O0s&H1osH6@`tiQLVG}+NU;O6xI3N@3d2`ujWl;aOce1-x#br z&I+AmmoNDdcrJPgqvZYQHa)iBjT^F`olTun(PE#=awqFZQa~SS_LWW6khthz=+wid z-e7y<%jJ>LrR~md+ghbI#eX_onEvZ~!twce&(uFXc@d*|JNH^63!7|JeM;C+{L1eg zN1K|QV%XbX%{kwucIR=~B`?lL{o5pm+J_l^@AnNp;#tkQnQfbU#J=hP=c9`b_fF^B z^Nh9EA-(5)L(0^MGzITEBE{+8>VwbX?fZERyrN}OenlOALj=wJ-k9)a*5dz!vg|`2 zYoCz_KJroiMS<+BN>L9vw*Ag0Z}!S%q!35qjF)d2>VF<3&N3AwOEhR<#+77!>b_iIfIAidGC^M|9#c5>NDeyZ77L5fO>r0<@WCGHCVs3 zK6mblwauJeMo#kD2gf{36qM@DI}LCbXz7~RobSJwvB7cfEuvq-S}fo6<`OfA-Ks~UZi@bdV{skvu{y?7ah z{3g|3kN=MUd{T)q=F>X6>TdF^19A7(_-|av-gjGg>kn5;Br4&2x?r2Fw%KNG4>N|* z(4Ta}rya^(U7O>7+MPbVxxHi|huX?CO?R$d`1+*zBg4D89lne29K6HNs-&>#r;myH z-RoD}qU>zU8CoCz6YVZ_IF~y2{8C-T=H90NiqFWg2!ubeH~myGbu_W+q#~RNJfix3 zB(w1G7KZ!{^*IS~dygDlR`~e)yoaG(7n^i9EM0CmHtoC8!!TDjx9pDos6MaD72Z6; zgP(odd6L&X*tSD#METfe6~E`4y7^K&@~U5Y`inm_ZX5Jh4q%wyGaT5UvRbzNtc!oL z7)ypZ$1?4FPrbbsYk#_*j@~0(4&8>LWy^!D0NmtD^7cAuz7D~7Ax{+@l{;un( z$0_}eN}s@oRTbN=IcLc9FIUNE*we5_&a(CU&B3VXl3@Fv&20&qf?G{wEw;#yH9V0% zQ@7%lpUfknio)333*4(R&Yds2uXE_!Y9-|(vn$U_w(c-VE&b7~aHCI~;UoWWarbwn z$MdXHQ@QS2xV07SPw30P|8tWAq4sS0{Vo11>yq6Z^Ys_kD(O1=D$H|!KFIo#3S|-9F8%OQ6G7-OTE8Z zt?P2+A2eOCW^AY;y4!Q5w8_WHJ_d)g(=CPj?WQC1s!#5$xO`FAt7D+Evr_liyOIY6 zN;kL%UX*35EC_m7QFY{y@_~z7;a@a_D&)9d*k@LO;muGc%>*&KYN{e`58nToa5fiqsbWnKzL z-gvQ$rZ(=cH2tP{^OQ>U-iFwyrMGx*D6Xsh+_=Z1J7K+_GVxOGo}a~=DUI>v!3~z_ z0x@U(T~`#fFN<8-TmG^jqvX<#r05eDZY)3y9)e(0RJX@|cXjxFv@FZCH+)c%%k9=y zfg^jIr>lO{&Q&wFxc$|7_sbMf(Jytw9PUqlm+H*iwOV-4EazC-QRlfj*S1)#m48Oq zyQ%Fik560h<>1^bj{G{oE}z$)W~(!n-+Z+H)|<3=KJUUZ*U>MRw#x)BeZL*~7stf! zFx@8g*wECQQRwdYs!!E=cI9W+t=$1R?_4CJ!@W3l3{;Oe{6PqG5fS~-u5!<;!18~{r-#PT`vWd_s_W&A(3zAxn*_jl{;GTeXj+K z;MDNNHvDE$gAIAj9V;o|?LFpJizN{e#<+!hcmdjZR$^Ub~est4Qpiwg17K zyuSLwYbcxTM+Pb`PahXef3ZH9f zO!nfi$Pb}y2U;J5v+%aq@BBDLR8!2YYX04~bz3;1Y7bZ^9ne~8v_c@?^Qog!mUBky z&sUc|iO8q5Cys>^=bA=8Wee6YMo;V9UE#dfT%ap>fuXj+uyB9awXD(*@qOiqZ`qy> zJiI7fvS#+euMxL@h?akL=WY7jo?&RQ@}|6b(clX%$2Aw{uMkRD5^TL?nPABs5s&;A zVbyaozgB))q_Od6(yjN&BbV7vu&$a>xjC%oe2)2GZj^11)uaEs8}7s zDIJ-;cX(=!+|4-41IyR#Tda4c*jRLU=BY1U^``=rxLwnhYaLi`SGKroR5DBO{{F|MTw2hHbHAC_9n zp&yiL!r7NzxYK{1d_FukyQKTvujSp(+Y-L^bFK7lY;SwlUs>=(jipmsv7@JKYwZE2 zpVm;IO@5wWt*L+ugFM40IT-^1hZPCj$z51>T`-o?q+);z)p3Shk z?6YXkW1Z60&3Us>f2<{I^P8X(qm`Zo(L^DK^{{XE`RG>l4XJL$S5^!p|MWW+QuN~6 z6bDqxPslQgm!w?Jy|~8uM1=k0cQFzIcF}8>cyHYJp0}=CjWH0FQmMP^(vTby+d*qo=+36hdfgb0^ z+$RR7t-}M;WBK190j*EE0{i`*-7AaSExxT}%84m=OKn_# z%$_;#rO(Jfa7$QDq?egbL+4r^pQiyT=RT$$xHPIg!+Fl8Z;B&Vp1a(;)n{1zvH9Ve z*h>exZEwMSoH^&+=~;~P(UAu3ukQy2uNT|vK3cnYWB(nw0l`@!Zns?e?~jOQnp~e# zpgbq4ULxWIm5ed@O^t(%1qYkkzO|L9+s`|RkRsD20lwU_9r)g z^W0tAU+;vie%)Phuo$&Y_jYQYYkxDz!gBCYGmq~0s=_lV7&!)V4-a3H= zLsqg?M|1T{F6H+Knw8b@B)LdbH>#6ga+N+ zsP*oi@9C-ER6fS}T;i8EetjWJ?B1$JpC2E7_jQh!R_XTPw+(B0g=IvSBp%G=dGw0G z%X&1=gYQ*{{*}jNXJ0?9$onZMw_>E#$ZtkW*|MuVGwR+1A9nOqI&8zXtNKf{QFryF z%^R29sCV3@S1cxV=AP;Mi_cB#LwfCHWuBfM$k=;rZ4Xbk&WM)SP-8=eOQv(n`Rj(; zSid*_7f-ATjbcq)ziQuyoASdtaat~~f5q;XW9T~Z*(C-HplS{d{vd0n`QP185;5%X(fNA7hTToH6BUG2u4;w4WL0$!L5 z46HTrRbrS0aa~!eEjLTY_rh?f(&LzQ%F~Y-sCP*G6dbq_=xw{uiQ6vZiDL8mu1t}T zlg&SQ`nj+ z^%Nddv0u$tv86Ywi0`%4DuYPd(v$3LGrJjnV!jLaHCXCeE0~%|=A=9hU(1>u&>E7z z++nA2Uxh>Yt^TGc#ul+L?hyMIX-c)VH;Z0&FQ`lOF^`wc{2t75ZprVwb!&L|lV1nT zi&iTNh-E?hJFe#?jj0*VJ5?5_nbamvL z{nLQyANK33?{tSr$~Ly`dDVIGa^?%UM6NB@Ur9*DJMLBTv1JKsXY5RfP*Hcj{{7@1 z#h*KM!k(Fg_jVWGy;m+6Yg4fNy>q4Ar#5b$9nMn(FNUy-{EQE5EEWvE*3)g&yE|!Y zeE*ob`HM*=e+-K~ZZ#;0Qbs@t~h}T9V*30+c+!g*arxZ!cxGicKT(+VjBxSkFlqIJM z#b3;gk(;vS!jW6f4-+%}l=6jC4%Nk7tNs!HW%{+^ta<&<{6|i0cI%k8%f7}=WVu29 zH0ejz6!ctzva@TK2REE@;ufE_Qff?oWKr&=sCbFMR7~^ZHrX;v2ksy}xwv&H$ZXs$ric>I0-VZa-goWZnU>4~sasgii@s zS)Tj|-<~%0C09K$y(ilQYGXGHJfJL5xRN&^e0wHj!%T-aGP-*v$Yf#Y>udtA;FCwTKr z=Q}YRSNglPzU&YE!KU}k`1juH+j4sEPZtzzSzzL7sl-OOkBB7gdZNLn+y^ISu!T0s zO_k%TxyKt}+55}63E7G_&P^BDu|6p0RJ@adU0U`>gZP#=_M3|1Z|<4ra+B*6+enh= zTm!d`LOOguiN#30H z<8|KoY|-;ycefT_IlMY(KM$Wdx5$hqB_l$kX6b=Kg8>E+A+4TMJ?33L#8QSGZ|2h0e9Inbuj9Mpw$}pb>?mh1~=BR0_8pC(d?S)H|QA@5AYt?!wNe@s)yCO}!19S2li+DS38F-M&9Rn0Ig&inBTFSLb|X_EXd2ruR?SHhg+8#%fe= z(D=PCMfKLxmyh^H8Ncj(xb@dR>5I0wb;s1C^_HnIzr-0^?zi#NJx92Wd*%oEJoq~M zh->Ek@Hez;d1(@A-Y(U(P)`KTY$aZE2nHI`7N| zpVAFcrAX@0Z2RTjB|@c#vUmJ!d;jS<|JUDNm*qx2&@r!f9w}j%uVQcvCmIYcsd1e) z`Xi)$rG}>XCf$)rDXHVL5yxlK#D{DVLh)_0hGxnme&@~MqqVW+AAKWZt~*5Teeyd< zX5W<;m*un46C6jza^KE#S7N!EWgU{D`OvV#NGgESOZO`p`PRBXUTLpifSszq=(hdG zc>|}mJ-X(QaFS&@XUy5?4U9oz?kA3*-+O+uwXe~Mx4f^cntDP*sbxuId+m02SGJYb zQWuEs_?w44Y`LpZTM(;NoZ`zoBJz=8}=i#+|qT-=X z;)%GTilmF&^5z%wA763(IuvqY!8W}bvzns~tB%bT^eAVHD5V$QIHLAl`ueJTlNWjS zQ$i0M__g49O?;V(@tbW`i`^XFw2h2y@wk!QcvQVY=-8{@9!s@u-O~?MPMYKR_@vc2 zBSzo;_%lt3p`Xp}e7sS`UJ>4zSU()}T~6nZ*a6{>CkLbo%c04Bqbs6*9gdJ!W;y%07YRZlc#*wl~|)2@ff9liFaU zk!Zd9^L~Bzyz9*~J?zzdc4mGpY57AQ!_LYk#ttVzvrJ(j&!y99Q(E=|{@;4|{!92y z((wxp8J8xgvzpOEX+od+W>7vE!i4(6MuwPA{jo=epbiE^jWUE8g-8;FDfNd7L9C$u zC?beu)E`3xR-Y}4$q}-IEhTy&i^jG!=c2OVi!x2&RAe}+Z~Q8Z)vc$L-0QiKrUF49x2dlHjk7$U1lkV-P#6fP zw#)r2KAjR`VPT&~f8*OL{yECM#f5* zzlKCTcxiOM5+O!;J{I9)UjyGudJ7__@Yiklw=nji7)@>eOD)Vju2c%KYJo2mRb`@{ zm!zpPlnH4CaxA<*ioRaQ$HEc@-^L368@~!;3y_yGL7mQya+JX#iy5?PlQMQlPEM=l zKD43_3Clc;!n0_qoC+aH`Tl#Qhs6+lFD(Isr#RjQ>ATzlnrgRzl?VNpLrkT-(()6r zdk%fw&jp7&Gx04^o(eIOO1?)b;FXs#ttlywSnQN@GfLyC-+ z)1u?51a;ymswB}mD;gaHl=4`#F8X5ud`s=Jxf4BD6+lvDkg*yeLOJ^*r|p+G9PpbY zIgvUK1*lI8M*B}yVuvm-QG ztBwcZaWFg_g7UByqRTvf`UPls8X=EFr{T1kISmX{71HSDX}Gay5^XA?(aLFr6xAoU zQ~3f#5d3bKYRnn*9~zoQ%%)-#UXpu5A4bUul7N|XxQw<(x=Gm!h>r^5B#DL@D*Y7hiU)xd*$8vY+!sSJtA zpiT`!l;$LM3z!Gu>S31rbYxA1$WH`5`+k z!ho8osajx0%7HYaiW;>DajJK%4(qjCz+G-A9vV!l?JH^O^qII88_xs-3@;junTf-5 z%ps{j9~8-Nkyd0fP`4SeYC~4tor$w5$jm)-0V@kj6QnDo!}wJgYm})?=u-Z~Cw{dk z0!|r(5nLL-3S%$$(B?L26Vep*kE-2i2cQ+f=t3HfUxl%UeQD}_Up8UHr;Ase4s`m# z4KU5cK~I6d3Wc$H0W_aa2aisg(G1z;K$C}}tofG*{}#q-2hmiIAT|l)tAmSLgbsw} zdI&8V3Sr}(G(JrqU(1>3T+NwEDqo#Ucmv4RM(iJg54Du#8E zidiKoJHm@)!+$OD%mvs?9K)t_u{Uc;SZOv5XU-?MP}W>RlIm2+w*InIh*$wcOou7! zw*Jc>nu`ZZa2}XirvHyA>v`DJVXg;(5s(NykO;GxOnor?hkw?HT@3lo$3Y!3+4pN0 zCP)C}DRcH*G9Fi=kHD6j4^8)*j-!0Yb3U%35u}K*loHLPl7;GAT$Q-SWtjD%e)JIo zf1N6f{Y7(?k@x~UG+GP5#F#fb+Paxt{(mN(d~$JI3$;FB&BXelu$3L9`m!re+OYQL zd@B!vI6Q|gk>4%+_bQCN9EZ9W5HqL|6kG`Ht>4X#1PjX9xsiqe)>~)*%|>^la|VPm66nEdR9*1C zzg?OSR-0dL6YJR>iocL*_yZVVm-|gMtcJ7=aW36d{PXey?n%4z zJVP*coP&exg&KNeNJx`yWEuhbL<*xzjPRszCE+4N3?DNhsFUtdEeUrmp7VSjHiF7(WRpihoa$bgWg2B1ev zVI$&ejF<6UTf!bag$_$VtycM)G4O9;>;p`#G{yzjB6Oi~D@>VDScjPNt0#oE%4>qN zLeT`;`W#B5jwZP0z9d=`L8He^aLJz{J2&_XRD!p#^CQpW_RWlAVfxx%^H_4Xd<&S6+q znKv;<57N}Ri*Z%7T?_`+XVGYHHrP&EjGH}1iazAxW{+|3qmI9#0a7GziWVs?!DYj6 z37|QrvBDC(5SvRUcnm=x%3w1}Zd?Ceg|Y4DXsTy92hXJBdqf)JzC<&UE_3iAfu-0n zxuqZ)yiJSjmf~H^LB~9^2B;IJ(3axgM&RGVSm7R$Dv5I77ZsmQvAJ75!>%(`b)rj> zeI!+r8j(L_Gs}kQW@ee;!nVQ;&@Ur28fk_%G7eYobe)Brh6`9^j(Fi8ntIO+H~#*| zdVfuL3eh!ZqMxaJ$Zi?0SR<&8J;uu zNHj);Mt?6O#HbRkbM{r84kWu1SY;lJuduxd)8v$=_U2UoJw_?HG{FP4@o15gXAMQAZ&ce185?^bRC}SaGWRs8o;E0Gjqm%_nrz0- zgCR26m{*+gJ2Vx#Pj6#mt-+(ScgIm~RAc?O@ROpvrhi0)4Ix9t>))e_nCHgq$LOdH zOogpBxNOJTfR*AF5~ZJbJS2eytt23YMx=3S6Sk1=qA1q}rl5^2zN5_@V}F_snGpmF z33D@t<=~(i8<-n8wz!=#Fu=Z13B$jXr%XW~Z1KSS02y1J5-PU?&t}^Fo!~cLQe^0_j%cqiT2_)}` zhvfHAzP3#r#U$Di zPNVHkc+!n5|NdJOTIYm`$DCV25lF}xmv=*F(A^P@);JRy)CkRaEbeIzYTFr`83Ide;*E%Z72Z@Y zS_P(k8U;{)6F9wZJvQC%Q>i9=mW74?GW!J61;AB*Bp^?@L3!6iniz}}M1%VTVJa5j zjFMgra9K2ltybfGuz^ADHFSF;j&i|t{SV#y_e39ofWOSjY1MtJluf9m~ zrIGJiT&gQmn)aQ8fF!{~WoGKk%QW@MU#gVca~Vf)eHE+`D&r9$<40U0sd`k~<(AJs zG=RYjcqqlpI(L_3$sku3TmYwQqyEc4`)}sN&CumWy6rPActib0>eK-Nh=4O#W)55F zbJXQR%%bD8bbFvWq@_&>t{ia0++;9mlA<@O6DBTlU z>vY3q^#_R-8`7wbJFXT7_P=?m0P}s_u8C_!AErjQ<52O03w)bKuIBK#2}WxCDvVva zlD7J4m5?BM0JmLKB)XTH%ndbSVTqPxoiJdft~8(Uz#HA@p1tp8fX%DBCr)vYsHiG)Rh+k&7qJ zSx+?e-2p|&oBD~*InZ~KI+rRAcR-hY8=M9>Ob9EZVlP}@!W;u1u8f2Hc*{B=JY%vH zmq0o4!hHYR32*fRJBN-5qgQu@Wzd5YxX4tJ@XCA)kG~(_TL-ZIX$&t}hey?ogy)xF z_|Q6>0;MEe_8*4duEQ1OCkZ=NVR-0@@DyYK^^xipxrJk2f1Q?P)`EyEos=k>6cu6- zdbA$b`}aCtBIKG*zV~9DkuG;AGpZ;IcJ5T&TjusfTLGp;`1Fa~=XbF?0UNOMdpAIH z`YpmpxKmgHUE6?1KIOn6*%6qgMKJm>NaI&w?A$gqv;kLtrHwGM-Y-c&8m-!hmr8#? z*;8LpCKb`yjksf1N!YVX7(F^KB8i4K;`^D6bAx)iA=A|%)0t~m)*FhefXuuJL8@b` zcoSUrLh6d|o0z&)AB2&8jR=9#fJ@DYp3Av@LlCfjSmBs^5G{<9d|-3X>W#;B$Q!zG zMwCWXH{os=ZUS_J1dRr4!rXl%Iw(V<)thiKKeXgO{{U`BXTlbcxf5p;Xe!@kT(kN& z?Tr5c{S|{kz`W{So=#H*7m9KskIne$NZ@8@uwRE3o!E@0+f@=hJ)cI0z&4dTd|LoL zwTniLx8Sjy^HMA85;)e7%fbQ~F@6=s+8-dPQs_g5h{*ruN(pJj=08%Tf%Jl<3yU4R9D+^k_*EFYv67_HPp{m3K=)xa?ZKuRY?d03#QSgmC>z5I1-PMrdOdy> z#;(0j^WXXq^3*t=N?Xqz1dX^gPpp2me4^wy%b~?v@i4933TYKGUzAQ@h@A*Jz7?n3 zIZ~!z@IToURAVU0i)?&w+5GpwPUcy~ScH^(@oJbQ!;)(XsdOIZ9<%30rX))qrTOCY zD26B3tT$1hFQJIu`r;}>K1*bqZ%b3>`{5z9@dGphqlCSvBs%W**KbnPZAUABh##dn zz^}b#*zJE04wW4bB)Nz?xg*_ega(xjZrh;u+k8dQu%D>XKf_DHQadPE3UzJ6xidz> zZow2RiAF<2g(vO)?EQhF5lT@c&~AU+=VK&XkdEQU{)7lMh?@f*7=}UlsDh9&J2^88 zb7cc?b$d6LB|#Pb8`amz?KC9uvF}v$QAk|ideTp z`MMi~XXcp|nHvv7MR^UqQ)RN-Et!qx8t5RZHGklS&o!KzycgS%Z>Q|zL%2S0isw7S|n>F#)lYM zc$9oe(IHD(q-!lU6_x(gqMij;In$z%9k?0ZU{Lr@p#)kSjF&DK66Ie*bJPE##Uy&d zlSEa}`(V6F-SkD;=M+p|Zio}}%h^sZl1hJa$UOvfn>Wz9M?>(;s3g(OO*Hx)dPfypF-1Mxho&m{it(c?0(UxaCs;Whhf;SE>QtP);!oGXr<+*rf)VEV zVX+(H97c$u&IB>u|Gjo#4TZGYm4an5`$0A)jjdafGV^RKdzm99fcAxASvDyfEWols z32`pe6pFW`-C={32aYrI{UvGs zrZ7T|3ebutmtGu(!0>>1=DY2;GBh@u9HSDVY+p|E)@1_ z<5yv9#Ri&c;VmwPo<-rb>h1fiz61)i72KIJ2ft+#&6kSCwLm8t8npEzQ8DBhO$bxT zX7RJ`bqwUB6BI6H1H%3!bsFXD6;iihC#~xqCeAf!AXpi|C}&V@&RXXcq#Y18@g?NI z86Qtq=vOto6N0Hbeig==?xOTi2m{{&B$3vdQ)%=H0}ty;%~xCN;q9`D`9xo5XVO%* z7~EOu7%-rBnnqW|5VBNfk6JklALM3Xc>zhrJVn?_Xlhmro+9O>fyV!6wCgYWgG2*) zQFQf$6pr4bX zy)ezm2IOTWgb@*k=Z!`jh=!DC(b_niFkPH4SRTNnHHPcIok-D7INk9yJ zgkMxRd|vsBr9iS}z*J%OTLUIM>A`gk34UY}k3F!B2P-3*w1^RpcQM%{>J5{WRQRbU zF}Xr?T3=1jfcO-_YcS?VZf^}}HHlq#W1zJQj4d;zQP*8~p$->$sxSZzg3VEzz`V^g z8nYYcL^g>&3!%|%p%RlTNEgZQjHMaE30O}f0Z`6F8eI!0<&MB@@wn|!Jq*Dm=8+Ri zp{WH4IGd|T1Jy7cNGIR_HE?fr-h6YIPlsT`&urko44OJS5gV{b1OvK9X*4_$*RbW= zURQ|0M>W2En7EL(T%)N|_YhOi-9&sx)lOPb=^q!-Z*MjB0J`NXjs6~$;F+{Stj_xR zcp>DOKjZ-Of*r$#gj1kT`Fn6Ok&i{$MmT8n;~t!-ZZ}Gw21C6#2|vumR*RFS&fbgX zmgQbB@C>8dxFsjQ+D|7LYJ9X_?OvQ5g1f(H?tt}&fNdUgFpmq-RBkxxkD89p8uZ^&Dw2UyByOk%DxBu&sTo{|#JP z4mbGBy=I+7Q>Q24UYjI=0loP&8UWv+MrGJA?@7BB$guZdgxP?a%{Y}4T}>i{sE##~ zR%Y4JqCa4pvSRS(s(1y=m%t+vBeOY>q>7>6L6Y3aHW_!uJsGSxrPHGQ$@tSgr6hXb z5RG;u6C#xDf(}*ryWrgwxMyIF`lf1?v~6wuv#lLVwtKni}$tGVI!8w6!bJbB`d zp_ihHp{P1ZK6E_=`}L5tQu&(p;71bj!4B(4sQ1LY-aJfGe}95uCO}Gh9Bnj z=)JHM@)MPkMFIP?zh5RL@ z6p>9DhTTZ`k^}`yAUi239+dM}cAAu_D^ixkQMHm3{iux^wmL!HvM31JA_yAu!V#&6 zgwpZ*AmenfFjqwi*-w*_Mj`3A8ty0IhiVk8fbOSbUtW-~wH{`R9Ke%p#sPp;%`ohF z02>J-;m8#jE<1p;tdWEhEiufMf!XBE5qrH01xq343~bw%gt=U$knmP1ada#L*Pu!g zzT-~85~v@HQ1Rya^g!dHV4}z$n=lEK5f z3y8FzX?JgHtgqkS4{8jQ^UmA!S4RrI210%z%z-Qrsx%5H8BOq-^Qc|H%X< z?ahWr%2+RDS{$il;y293nE;D?#&A$3t|j|O_}U)~U(3XIw+%f{KgmNCQFEJk1Cz-v zO-E3hQ(6E?A^b3PV3$@kh=sUs_@C)y@yGbw{>edq<(9N(Tsq}w(umx@<*?w%Kbqllb;9nt8Gk+Q- z4nt!@0q|@5wYdS1JDrzh&vmkJPufo`f>~X#X`ksS>d@$HaXbsH72e5?$ zmGP@EcBBvoGVPKy7joEvliTGm1bKhazp|O9|Ls>9N%&UrKfX8r5O$%v?NMgA}(;pz%ZZLywPJ;)T|~!o-YGH}xBG~>3P!U%TVSq2lgyv?dKHQ*Rt2s2NKd616}!eJno`((9%rd~USyR`D4S)vCFvN;Sg zGgVlhq|zT7RXR=(0;MK0ECE{rX3mV7~3ntzdCKiZHserZD3c>@ohT~UZ zthyl~Vin z<26Q1YUe#BtDUidE%Ny1ZCLZcDXVppX*T)zQ=tJQW7YP_jBEM$=HofZkdB|sc(x1D zU?zONfpT+o#dQ5WFks#={h8f7mM~e@V-M)^uExcwtN@yb+cTLap9HTA1qv}ut`KPM zd;edW6!n6hD$eO7tprV`y(%P>C|~6r&Gy-XpMT(9X9-hC>vJZn*`B~^XIcrjcu+Hi zoLtPLwljCKTFnVUk!p>PMd4m8sLh7?zlcd~?eWQKx+n4G!S*C{V`sr+#)HM8LdY0i zN>JASeTTqoebvdyx`IX6x^@v*&$uv|k-HWi@iIRGK0I$c_!lxkh4ec`b6Eul!pEL zU#Aiv?q)Dtrc4@BZ%ousMb@ma_>-G1N>?zF`ly9-x y2^is4s6ui~twq)`X{Z+y3Y12KfU>pdooz+ys)OH_*7RsSEZky;S| From 4f9754b4472f61f3b4d4902014da1b78973d06b0 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:32:44 +0300 Subject: [PATCH 032/351] Update `test_repl.py` to 3.14.6 (#8164) * Update `test_repl.py` to 3.14.6 * mark failing tests * Mark failing test correctly --- Lib/test/test_repl.py | 392 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 387 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_repl.py b/Lib/test/test_repl.py index 03bf8d8b548..211d1783842 100644 --- a/Lib/test/test_repl.py +++ b/Lib/test/test_repl.py @@ -1,14 +1,35 @@ """Test the interactive interpreter.""" -import sys import os -import unittest +import select import subprocess +import sys +import unittest +from contextlib import contextmanager +from functools import partial from textwrap import dedent -from test.support import cpython_only, SuppressCrashReport +from test import support +from test.support import ( + cpython_only, + has_subprocess_support, + os_helper, + SuppressCrashReport, + SHORT_TIMEOUT, +) from test.support.script_helper import kill_python +from test.support.import_helper import import_module + +try: + import pty +except ImportError: + pty = None -def spawn_repl(*args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **kw): + +if not has_subprocess_support: + raise unittest.SkipTest("test module requires subprocess") + + +def spawn_repl(*args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, custom=False, isolated=True, **kw): """Run the Python REPL with the given arguments. kw is extra keyword args to pass to subprocess.Popen. Returns a Popen @@ -22,7 +43,14 @@ def spawn_repl(*args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **kw): # path may be used by Py_GetPath() to build the default module search # path. stdin_fname = os.path.join(os.path.dirname(sys.executable), "") - cmd_line = [stdin_fname, '-E', '-i'] + cmd_line = [stdin_fname] + # Isolated mode implies -EPs and ignores PYTHON* variables. + if isolated: + cmd_line.append('-I') + # Don't re-run the built-in REPL from interactive mode + # if we're testing a custom REPL (such as the asyncio REPL). + if not custom: + cmd_line.append('-i') cmd_line.extend(args) # Set TERM=vt100, for the rationale see the comments in spawn_python() of @@ -36,10 +64,47 @@ def spawn_repl(*args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **kw): stdout=stdout, stderr=stderr, **kw) + +spawn_asyncio_repl = partial(spawn_repl, "-m", "asyncio", custom=True) + + +@contextmanager +def temp_pythonstartup(*, source: str, histfile: str = ".pythonhist"): + """Create environment variables for a PYTHONSTARTUP script in a temporary directory.""" + with os_helper.temp_dir() as tmpdir: + filename = os.path.join(tmpdir, "pythonstartup.py") + with open(filename, "w") as f: + f.write(source) + yield { + "PYTHONSTARTUP": filename, + "PYTHON_HISTORY": os.path.join(tmpdir, histfile) + } + + +def run_on_interactive_mode(source): + """Spawn a new Python interpreter, pass the given + input source code from the stdin and return the + result back. If the interpreter exits non-zero, it + raises a ValueError.""" + + process = spawn_repl() + process.stdin.write(source) + output = kill_python(process) + + if process.returncode != 0: + raise ValueError("Process didn't exit properly.") + return output + + +@support.force_not_colorized_test_class class TestInteractiveInterpreter(unittest.TestCase): @cpython_only + # Python built with Py_TRACE_REFS fail with a fatal error in + # _PyRefchain_Trace() on memory allocation error. + @unittest.skipIf(support.Py_TRACE_REFS, 'cannot test Py_TRACE_REFS build') def test_no_memory(self): + import_module("_testcapi") # Issue #30696: Fix the interactive interpreter looping endlessly when # no memory. Check also that the fix does not break the interactive # loop when an exception is raised. @@ -92,6 +157,23 @@ def test_multiline_string_parsing(self): output = kill_python(p) self.assertEqual(p.returncode, 0) + @cpython_only + def test_lexer_buffer_realloc_with_null_start(self): + # gh-144759: NULL pointer arithmetic in the lexer when start and + # multi_line_start are NULL (uninitialized in tok_mode_stack[0]) + # and the lexer buffer is reallocated while parsing long input. + long_value = "a" * 2000 + user_input = dedent(f"""\ + x = f'{{{long_value!r}}}' + print(x) + """) + p = spawn_repl() + p.stdin.write(user_input) + output = kill_python(p) + self.assertEqual(p.returncode, 0) + self.assertIn(long_value, output) + + @unittest.expectedFailureIf(sys.platform == "android", "TODO: RUSTPYTHON; AssertionError: 101 != 0") def test_close_stdin(self): user_input = dedent(''' import os @@ -107,6 +189,306 @@ def test_close_stdin(self): self.assertEqual(process.returncode, 0) self.assertIn('before close', output) + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 101 != 0 + def test_interactive_traceback_reporting(self): + user_input = "1 / 0 / 3 / 4" + p = spawn_repl() + p.stdin.write(user_input) + output = kill_python(p) + self.assertEqual(p.returncode, 0) + + traceback_lines = output.splitlines()[-6:-1] + expected_lines = [ + "Traceback (most recent call last):", + " File \"\", line 1, in ", + " 1 / 0 / 3 / 4", + " ~~^~~", + "ZeroDivisionError: division by zero", + ] + self.assertEqual(traceback_lines, expected_lines) + + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 101 != 0 + def test_interactive_traceback_reporting_multiple_input(self): + user_input1 = dedent(""" + def foo(x): + 1 / x + + """) + p = spawn_repl() + p.stdin.write(user_input1) + user_input2 = "foo(0)" + p.stdin.write(user_input2) + output = kill_python(p) + self.assertEqual(p.returncode, 0) + + traceback_lines = output.splitlines()[-8:-1] + expected_lines = [ + ' File "", line 1, in ', + ' foo(0)', + ' ~~~^^^', + ' File "", line 2, in foo', + ' 1 / x', + ' ~~^~~', + 'ZeroDivisionError: division by zero' + ] + self.assertEqual(traceback_lines, expected_lines) + + @unittest.expectedFailure # TODO: RUSTPYTHON + def test_pythonstartup_error_reporting(self): + # errors based on https://github.com/python/cpython/issues/137576 + + def make_repl(env): + return subprocess.Popen( + [os.path.join(os.path.dirname(sys.executable), ''), "-i"], + executable=sys.executable, + text=True, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + ) + + # case 1: error in user input, but PYTHONSTARTUP is fine + with os_helper.temp_dir() as tmpdir: + script = os.path.join(tmpdir, "pythonstartup.py") + with open(script, "w") as f: + f.write("print('from pythonstartup')\n") + + env = os.environ.copy() + env['PYTHONSTARTUP'] = script + env["PYTHON_HISTORY"] = os.path.join(tmpdir, ".pythonhist") + p = make_repl(env) + p.stdin.write("1/0") + output = kill_python(p) + expected = dedent(""" + Traceback (most recent call last): + File "", line 1, in + 1/0 + ~^~ + ZeroDivisionError: division by zero + """) + self.assertIn("from pythonstartup", output) + self.assertIn(expected, output) + + # case 2: error in PYTHONSTARTUP triggered by user input + with os_helper.temp_dir() as tmpdir: + script = os.path.join(tmpdir, "pythonstartup.py") + with open(script, "w") as f: + f.write("def foo():\n 1/0\n") + + env = os.environ.copy() + env['PYTHONSTARTUP'] = script + env["PYTHON_HISTORY"] = os.path.join(tmpdir, ".pythonhist") + p = make_repl(env) + p.stdin.write('foo()') + output = kill_python(p) + expected = dedent(""" + Traceback (most recent call last): + File "", line 1, in + foo() + ~~~^^ + File "%s", line 2, in foo + 1/0 + ~^~ + ZeroDivisionError: division by zero + """) % script + self.assertIn(expected, output) + + @unittest.expectedFailure # TODO: RUSTPYTHON + def test_runsource_show_syntax_error_location(self): + user_input = dedent("""def f(x, x): ... + """) + p = spawn_repl() + p.stdin.write(user_input) + output = kill_python(p) + expected_lines = [ + ' def f(x, x): ...', + ' ^', + "SyntaxError: duplicate argument 'x' in function definition" + ] + self.assertEqual(output.splitlines()[4:-1], expected_lines) + + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 101 != 0 + def test_interactive_source_is_in_linecache(self): + user_input = dedent(""" + def foo(x): + return x + 1 + + def bar(x): + return foo(x) + 2 + """) + p = spawn_repl() + p.stdin.write(user_input) + user_input2 = dedent(""" + import linecache + print(linecache._interactive_cache[linecache._make_key(foo.__code__)]) + """) + p.stdin.write(user_input2) + output = kill_python(p) + self.assertEqual(p.returncode, 0) + expected = "(30, None, [\'def foo(x):\\n\', \' return x + 1\\n\', \'\\n\'], \'\')" + self.assertIn(expected, output, expected) + + def test_asyncio_repl_reaches_python_startup_script(self): + with os_helper.temp_dir() as tmpdir: + script = os.path.join(tmpdir, "pythonstartup.py") + with open(script, "w") as f: + f.write("print('pythonstartup done!')\n") + env = os.environ.copy() + env["PYTHON_HISTORY"] = os.path.join(tmpdir, ".asyncio_history") + env["PYTHONSTARTUP"] = script + p = spawn_asyncio_repl(isolated=False, env=env) + output = kill_python(p) + self.assertEqual(p.returncode, 0) + self.assertIn("pythonstartup done!", output) + + def test_asyncio_repl_respects_isolated_mode(self): + with os_helper.temp_dir() as tmpdir: + script = os.path.join(tmpdir, "pythonstartup.py") + with open(script, "w") as f: + f.write("print('should not print')\n") + env = os.environ.copy() + env["PYTHON_HISTORY"] = os.path.join(tmpdir, ".asyncio_history") + env["PYTHONSTARTUP"] = script + p = spawn_asyncio_repl(isolated=True, env=env) + output = kill_python(p) + self.assertEqual(p.returncode, 0) + self.assertNotIn("should not print", output) + + @unittest.skipUnless(pty, "requires pty") + def test_asyncio_repl_is_ok(self): + m, s = pty.openpty() + cmd = [sys.executable, "-I", "-m", "asyncio"] + env = os.environ.copy() + proc = subprocess.Popen( + cmd, + stdin=s, + stdout=s, + stderr=s, + text=True, + close_fds=True, + env=env, + ) + os.close(s) + os.write(m, b"await asyncio.sleep(0)\n") + os.write(m, b"exit()\n") + output = [] + while select.select([m], [], [], SHORT_TIMEOUT)[0]: + try: + data = os.read(m, 1024).decode("utf-8") + if not data: + break + except OSError: + break + output.append(data) + os.close(m) + try: + exit_code = proc.wait(timeout=SHORT_TIMEOUT) + except subprocess.TimeoutExpired: + proc.kill() + exit_code = proc.wait() + + self.assertEqual(exit_code, 0, "".join(output)) + + +@support.force_not_colorized_test_class +class TestInteractiveModeSyntaxErrors(unittest.TestCase): + + @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: Process didn't exit properly. + def test_interactive_syntax_error_correct_line(self): + output = run_on_interactive_mode(dedent("""\ + def f(): + print(0) + return yield 42 + """)) + + traceback_lines = output.splitlines()[-4:-1] + expected_lines = [ + ' return yield 42', + ' ^^^^^', + 'SyntaxError: invalid syntax' + ] + self.assertEqual(traceback_lines, expected_lines) + + +class TestAsyncioREPL(unittest.TestCase): + def test_multiple_statements_fail_early(self): + user_input = "1 / 0; print(f'afterwards: {1+1}')" + p = spawn_asyncio_repl() + p.stdin.write(user_input) + output = kill_python(p) + self.assertIn("ZeroDivisionError", output) + self.assertNotIn("afterwards: 2", output) + + def test_toplevel_contextvars_sync(self): + user_input = dedent("""\ + from contextvars import ContextVar + var = ContextVar("var", default="failed") + var.set("ok") + """) + p = spawn_asyncio_repl() + p.stdin.write(user_input) + user_input2 = dedent(""" + print(f"toplevel contextvar test: {var.get()}") + """) + p.stdin.write(user_input2) + output = kill_python(p) + self.assertEqual(p.returncode, 0) + expected = "toplevel contextvar test: ok" + self.assertIn(expected, output, expected) + + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 101 != 0 + def test_toplevel_contextvars_async(self): + user_input = dedent("""\ + from contextvars import ContextVar + var = ContextVar('var', default='failed') + """) + p = spawn_asyncio_repl() + p.stdin.write(user_input) + user_input2 = "async def set_var(): var.set('ok')\n" + p.stdin.write(user_input2) + user_input3 = "await set_var()\n" + p.stdin.write(user_input3) + user_input4 = "print(f'toplevel contextvar test: {var.get()}')\n" + p.stdin.write(user_input4) + output = kill_python(p) + self.assertEqual(p.returncode, 0) + expected = "toplevel contextvar test: ok" + self.assertIn(expected, output, expected) + + def test_quiet_mode(self): + p = spawn_repl("-q", "-m", "asyncio", custom=True) + output = kill_python(p) + self.assertEqual(p.returncode, 0) + self.assertEqual(output[:3], ">>>") + + @support.force_not_colorized + @support.subTests( + ("startup_code", "expected_error"), + [ + ("some invalid syntax\n", "SyntaxError: invalid syntax"), + ("1/0\n", "ZeroDivisionError: division by zero"), + ], + ) + def test_pythonstartup_failure(self, startup_code, expected_error): + startup_env = self.enterContext( + temp_pythonstartup(source=startup_code, histfile=".asyncio_history")) + + p = spawn_repl( + "-qm", "asyncio", + env=os.environ | startup_env, + isolated=False, + custom=True) + p.stdin.write("print('user code', 'executed')\n") + output = kill_python(p) + self.assertEqual(p.returncode, 0) + + tb_hint = f'File "{startup_env["PYTHONSTARTUP"]}", line 1' + self.assertIn(tb_hint, output) + self.assertIn(expected_error, output) + + self.assertIn("user code executed", output) + if __name__ == "__main__": unittest.main() From 89c46bf47c26ab43e4642f36c8021e15da742220 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:32:57 +0300 Subject: [PATCH 033/351] Update `test_tempfile.py` to 3.14.6 (#8165) --- Lib/test/test_tempfile.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_tempfile.py b/Lib/test/test_tempfile.py index 0e6b9bced3f..42d1cf2832c 100644 --- a/Lib/test/test_tempfile.py +++ b/Lib/test/test_tempfile.py @@ -332,7 +332,9 @@ def test_read_only_directory(self): with _inside_empty_temp_dir(): probe = os.path.join(tempfile.tempdir, 'probe') if os.name == 'nt': - cmd = ['icacls', tempfile.tempdir, '/deny', 'Everyone:(W)'] + # Use security identifier *S-1-1-0 instead + # of localized "Everyone" to not depend on the locale. + cmd = ['icacls', tempfile.tempdir, '/deny', '*S-1-1-0:(W)'] stdout = None if support.verbose > 1 else subprocess.DEVNULL subprocess.run(cmd, check=True, stdout=stdout) else: @@ -355,7 +357,9 @@ def test_read_only_directory(self): self.make_temp() finally: if os.name == 'nt': - cmd = ['icacls', tempfile.tempdir, '/grant:r', 'Everyone:(M)'] + # Use security identifier *S-1-1-0 instead + # of localized "Everyone" to not depend on the locale. + cmd = ['icacls', tempfile.tempdir, '/grant:r', '*S-1-1-0:(M)'] subprocess.run(cmd, check=True, stdout=stdout) else: os.chmod(tempfile.tempdir, oldmode) @@ -1747,7 +1751,7 @@ def test_cleanup_with_symlink_to_a_directory(self): d2.cleanup() @unittest.skipIf(sys.platform == "win32", "TODO: RUSTPYTHON; flaky, sometimes pollute env on CI") - @unittest.expectedFailureIf(sys.platform in ("android", "linux"), "TODO: RUSTPYTHON; FileNotFoundError: [Errno 2] No such file or directory: ''") + @unittest.expectedFailureIf(sys.platform in ('android', 'linux'), "TODO: RUSTPYTHON; FileNotFoundError: [Errno 2] No such file or directory: ''") @os_helper.skip_unless_symlink def test_cleanup_with_symlink_modes(self): # cleanup() should not follow symlinks when fixing mode bits (#91133) From 23e27236d8422f5547e0607af2de1c297da3a5ec Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:33:13 +0300 Subject: [PATCH 034/351] Update `test_ssl.py` to 3.1.46 (#8166) * Update `test_ssl.py` to 3.14.6 * Add `Lib/test/ssltests.py` * Mark failing test correctly --- Lib/test/ssltests.py | 37 +++++++ Lib/test/test_ssl.py | 254 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 269 insertions(+), 22 deletions(-) create mode 100644 Lib/test/ssltests.py diff --git a/Lib/test/ssltests.py b/Lib/test/ssltests.py new file mode 100644 index 00000000000..ee03aed5cca --- /dev/null +++ b/Lib/test/ssltests.py @@ -0,0 +1,37 @@ +# Convenience test module to run all of the OpenSSL-related tests in the +# standard library. + +import ssl +import sys +import subprocess + +TESTS = [ + 'test_asyncio', 'test_ensurepip.py', 'test_ftplib', 'test_hashlib', + 'test_hmac', 'test_httplib', 'test_imaplib', + 'test_poplib', 'test_ssl', 'test_smtplib', 'test_smtpnet', + 'test_urllib2_localnet', 'test_venv', 'test_xmlrpc' +] + +def run_regrtests(*extra_args): + print(ssl.OPENSSL_VERSION) + args = [ + sys.executable, + '-Werror', '-bb', # turn warnings into exceptions + '-m', 'test', + ] + if not extra_args: + args.extend([ + '-r', # randomize + '-w', # re-run failed tests with -v + '-u', 'network', # use network + '-u', 'urlfetch', # download test vectors + '-j', '0' # use multiple CPUs + ]) + else: + args.extend(extra_args) + args.extend(TESTS) + result = subprocess.call(args) + sys.exit(result) + +if __name__ == '__main__': + run_regrtests(*sys.argv[1:]) diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 7cfbe0c97dc..10fb5c80b9b 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -1,5 +1,6 @@ # Test the support for SSL and sockets +import contextlib import sys import unittest import unittest.mock @@ -47,9 +48,20 @@ PROTOCOLS = sorted(ssl._PROTOCOL_NAMES) HOST = socket_helper.HOST +IS_AWS_LC = "AWS-LC" in ssl.OPENSSL_VERSION IS_OPENSSL_3_0_0 = ssl.OPENSSL_VERSION_INFO >= (3, 0, 0) PY_SSL_DEFAULT_CIPHERS = sysconfig.get_config_var('PY_SSL_DEFAULT_CIPHERS') +HAS_KEYLOG = hasattr(ssl.SSLContext, 'keylog_filename') +requires_keylog = unittest.skipUnless( + HAS_KEYLOG, 'test requires OpenSSL 1.1.1 with keylog callback') +CAN_SET_KEYLOG = HAS_KEYLOG and os.name != "nt" +requires_keylog_setter = unittest.skipUnless( + CAN_SET_KEYLOG, + "cannot set 'keylog_filename' on Windows" +) + + PROTOCOL_TO_TLS_VERSION = {} for proto, ver in ( ("PROTOCOL_SSLv3", "SSLv3"), @@ -258,26 +270,67 @@ def utc_offset(): #NOTE: ignore issues like #1647654 ) -def test_wrap_socket(sock, *, - cert_reqs=ssl.CERT_NONE, ca_certs=None, - ciphers=None, certfile=None, keyfile=None, - **kwargs): - if not kwargs.get("server_side"): - kwargs["server_hostname"] = SIGNED_CERTFILE_HOSTNAME - context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - else: +def make_test_context( + *, + server_side=False, + check_hostname=None, + cert_reqs=ssl.CERT_NONE, + ca_certs=None, certfile=None, keyfile=None, + ciphers=None, + min_version=None, max_version=None, +): + if server_side: context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - if cert_reqs is not None: + else: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + + if check_hostname is None: if cert_reqs == ssl.CERT_NONE: context.check_hostname = False + else: + context.check_hostname = check_hostname + + if cert_reqs is not None: context.verify_mode = cert_reqs + if ca_certs is not None: context.load_verify_locations(ca_certs) if certfile is not None or keyfile is not None: context.load_cert_chain(certfile, keyfile) + if ciphers is not None: context.set_ciphers(ciphers) - return context.wrap_socket(sock, **kwargs) + + if min_version is not None: + context.minimum_version = min_version + if max_version is not None: + context.maximum_version = max_version + + return context + + +def test_wrap_socket( + sock, + *, + server_side=False, + check_hostname=None, + cert_reqs=ssl.CERT_NONE, + ca_certs=None, certfile=None, keyfile=None, + ciphers=None, + min_version=None, max_version=None, + **kwargs, +): + context = make_test_context( + server_side=server_side, + check_hostname=check_hostname, + cert_reqs=cert_reqs, + ca_certs=ca_certs, certfile=certfile, keyfile=keyfile, + ciphers=ciphers, + min_version=min_version, max_version=max_version, + ) + if not server_side: + kwargs.setdefault("server_hostname", SIGNED_CERTFILE_HOSTNAME) + return context.wrap_socket(sock, server_side=server_side, **kwargs) USE_SAME_TEST_CONTEXT = False @@ -317,6 +370,20 @@ def testing_context(server_cert=SIGNED_CERTFILE, *, server_chain=True): return client_context, server_context, hostname +def do_ssl_object_handshake(sslobject, outgoing, max_retry=25): + """Call do_handshake() on the sslobject and return the sent data. + + If do_handshake() fails more than *max_retry* times, return None. + """ + data, attempt = None, 0 + while not data and attempt < max_retry: + with contextlib.suppress(ssl.SSLWantReadError): + sslobject.do_handshake() + data = outgoing.read() + attempt += 1 + return data + + class BasicSocketTests(unittest.TestCase): def test_constants(self): @@ -698,6 +765,7 @@ def test_dealloc_warn(self): support.gc_collect() self.assertIn(r, str(cm.warning.args[0])) + @unittest.expectedFailureIf(sys.platform == "android", "TODO: RUSTPYTHON; TypeError: path should be string, bytes, os.PathLike or integer, not NoneType") def test_get_default_verify_paths(self): paths = ssl.get_default_verify_paths() self.assertEqual(len(paths), 6) @@ -1035,7 +1103,6 @@ def test_hostname_checks_common_name(self): with self.assertRaises(AttributeError): ctx.hostname_checks_common_name = True - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: not found in {, , } @ignore_deprecation def test_min_max_version(self): ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) @@ -1089,7 +1156,12 @@ def test_min_max_version(self): ctx.maximum_version = ssl.TLSVersion.MINIMUM_SUPPORTED self.assertIn( ctx.maximum_version, - {ssl.TLSVersion.TLSv1, ssl.TLSVersion.TLSv1_1, ssl.TLSVersion.SSLv3} + { + ssl.TLSVersion.TLSv1, + ssl.TLSVersion.TLSv1_1, + ssl.TLSVersion.TLSv1_2, + ssl.TLSVersion.SSLv3, + } ) ctx.minimum_version = ssl.TLSVersion.MAXIMUM_SUPPORTED @@ -1410,6 +1482,50 @@ def dummycallback(sock, servername, ctx): ctx.set_servername_callback(None) ctx.set_servername_callback(dummycallback) + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Expected 'mock' to not have been called. Called 1 times. + def test_sni_callback_on_dead_references(self): + # See https://github.com/python/cpython/issues/146080. + c_ctx = make_test_context() + c_inc, c_out = ssl.MemoryBIO(), ssl.MemoryBIO() + client = c_ctx.wrap_bio(c_inc, c_out, server_hostname=SIGNED_CERTFILE_HOSTNAME) + + def sni_callback(sock, servername, ctx): pass + sni_callback = unittest.mock.Mock(wraps=sni_callback) + s_ctx = make_test_context(server_side=True, certfile=SIGNED_CERTFILE) + s_ctx.set_servername_callback(sni_callback) + + s_inc, s_out = ssl.MemoryBIO(), ssl.MemoryBIO() + server = s_ctx.wrap_bio(s_inc, s_out, server_side=True) + server_impl = server._sslobj + + # Perform the handshake on the client side first. + data = do_ssl_object_handshake(client, c_out) + sni_callback.assert_not_called() + if data is None: + self.skipTest("cannot establish a handshake from the client") + s_inc.write(data) + sni_callback.assert_not_called() + # Delete the server object before it starts doing its handshake + # and ensure that we did not call the SNI callback yet. + del server + gc.collect() + # Try to continue the server's handshake by directly using + # the internal SSL object. The latter is a weak reference + # stored in the server context and has now a dead owner. + with self.assertRaises(ssl.SSLError) as cm: + server_impl.do_handshake() + # The SNI C callback raised an exception before calling our callback. + sni_callback.assert_not_called() + + # In AWS-LC, any handshake failures reports SSL_R_PARSE_TLSEXT, + # while OpenSSL uses SSL_R_CALLBACK_FAILED on SNI callback failures. + if IS_AWS_LC: + libssl_error_reason = "PARSE_TLSEXT" + else: + libssl_error_reason = "callback failed" + self.assertIn(libssl_error_reason, str(cm.exception)) + self.assertEqual(cm.exception.errno, ssl.SSL_ERROR_SSL) + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: is not None def test_sni_callback_refcycle(self): # Reference cycles through the servername callback are detected @@ -1423,6 +1539,59 @@ def dummycallback(sock, servername, ctx, cycle=ctx): gc.collect() self.assertIs(wr(), None) + @unittest.skipUnless(support.Py_GIL_DISABLED, + "test is only useful if the GIL is disabled") + @threading_helper.requires_working_threading() + def test_sni_callback_race(self): + # Replacing sni_callback while handshakes are in-flight must not + # crash (use-after-free on the callback in free-threaded builds). + client_ctx, server_ctx, hostname = testing_context() + + server_ctx.sni_callback = lambda *a: None + done = threading.Event() + + def do_handshakes(): + while not done.is_set(): + c_in = ssl.MemoryBIO() + c_out = ssl.MemoryBIO() + s_in = ssl.MemoryBIO() + s_out = ssl.MemoryBIO() + client = client_ctx.wrap_bio( + c_in, c_out, server_hostname=hostname) + server = server_ctx.wrap_bio(s_in, s_out, server_side=True) + for _ in range(50): + try: + client.do_handshake() + except ssl.SSLWantReadError: + pass + except ssl.SSLError: + break + if c_out.pending: + s_in.write(c_out.read()) + try: + server.do_handshake() + except ssl.SSLWantReadError: + pass + except ssl.SSLError: + break + if s_out.pending: + c_in.write(s_out.read()) + + def toggle_callback(): + while not done.is_set(): + server_ctx.sni_callback = lambda *a: None + server_ctx.sni_callback = None + + workers = max(4, (os.cpu_count() or 4) * 2) + threads = [threading.Thread(target=do_handshakes) + for _ in range(workers)] + threads.append(threading.Thread(target=toggle_callback)) + + with threading_helper.catch_threading_exception() as cm: + with threading_helper.start_threads(threads): + done.set() + self.assertIsNone(cm.exc_value) + def test_cert_store_stats(self): ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) self.assertEqual(ctx.cert_store_stats(), @@ -1665,6 +1834,39 @@ def test_num_tickest(self): with self.assertRaises(ValueError): ctx.num_tickets = 1 + @support.cpython_only + def test_refcycle_msg_callback(self): + # See https://github.com/python/cpython/issues/142516. + ctx = make_test_context() + def msg_callback(*args, _=ctx, **kwargs): ... + ctx._msg_callback = msg_callback + + @support.cpython_only + @requires_keylog_setter + def test_refcycle_keylog_filename(self): + # See https://github.com/python/cpython/issues/142516. + self.addCleanup(os_helper.unlink, os_helper.TESTFN) + ctx = make_test_context() + class KeylogFilename(str): ... + ctx.keylog_filename = KeylogFilename(os_helper.TESTFN) + ctx.keylog_filename._ = ctx + + @support.cpython_only + @unittest.skipUnless(ssl.HAS_PSK, 'requires TLS-PSK') + def test_refcycle_psk_client_callback(self): + # See https://github.com/python/cpython/issues/142516. + ctx = make_test_context() + def psk_client_callback(*args, _=ctx, **kwargs): ... + ctx.set_psk_client_callback(psk_client_callback) + + @support.cpython_only + @unittest.skipUnless(ssl.HAS_PSK, 'requires TLS-PSK') + def test_refcycle_psk_server_callback(self): + # See https://github.com/python/cpython/issues/142516. + ctx = make_test_context(server_side=True) + def psk_server_callback(*args, _=ctx, **kwargs): ... + ctx.set_psk_server_callback(psk_server_callback) + class SSLErrorTests(unittest.TestCase): @@ -4922,10 +5124,6 @@ def test_internal_chain_server(self): self.assertEqual(res, b'\x02\n') -HAS_KEYLOG = hasattr(ssl.SSLContext, 'keylog_filename') -requires_keylog = unittest.skipUnless( - HAS_KEYLOG, 'test requires OpenSSL 1.1.1 with keylog callback') - class TestSSLDebug(unittest.TestCase): def keylog_lines(self, fname=os_helper.TESTFN): @@ -5164,15 +5362,27 @@ def non_linux_skip_if_other_okay_error(self, err): return # Expect the full test setup to always work on Linux. if (isinstance(err, ConnectionResetError) or (isinstance(err, OSError) and err.errno == errno.EINVAL) or - re.search('wrong.version.number', str(getattr(err, "reason", "")), re.I)): + re.search( + # Matches the following error messages: + # '[SSL: WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1123)' + # '[SSL: RECORD_LAYER_FAILURE] record layer failure (_ssl.c:1109)' + # '[SSL: HTTP_REQUEST] http request (_ssl.c:1143)' + r'wrong.version.number|record.layer.failure|http.request', + str(getattr(err, "reason", "")), + re.IGNORECASE, + ) + ): # On Windows the TCP RST leads to a ConnectionResetError # (ECONNRESET) which Linux doesn't appear to surface to userspace. # If wrap_socket() winds up on the "if connected:" path and doing - # the actual wrapping... we get an SSLError from OpenSSL. Typically - # WRONG_VERSION_NUMBER. While appropriate, neither is the scenario - # we're specifically trying to test. The way this test is written - # is known to work on Linux. We'll skip it anywhere else that it - # does not present as doing so. + # the actual wrapping... we get an SSLError from OpenSSL. This is + # typically WRONG_VERSION_NUMBER. The same happens on iOS, but + # RECORD_LAYER_FAILURE or HTTP_REQUEST is the error. + # + # While appropriate, these scenarios aren't what we're specifically + # trying to test. The way this test is written is known to work on + # Linux. We'll skip it anywhere else that it does not present as + # doing so. try: self.skipTest(f"Could not recreate conditions on {sys.platform}:" f" {err=}") From 4d3ddac7c31fb358d448bd42b66fe82fb7d81e19 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:33:51 +0300 Subject: [PATCH 035/351] Update some tests to 3.14.6 (#8167) * Add `test_fileutils.py` from 3.14.6 * Align patch for `test_zipimport_support.py` * Update `test_bigmem.py` to 3.14.6 * Update `test_exception_group.py` * Align patches for `test_baseexception.py` * Update `test_exceptions.py` * Update `test_dtrace.py` * Remove patch from `string_tests.py` --- Lib/test/string_tests.py | 5 +- Lib/test/test_baseexception.py | 6 +-- Lib/test/test_bigmem.py | 61 +++++++++++++++--------- Lib/test/test_dtrace.py | 35 +------------- Lib/test/test_exception_group.py | 75 +++++++++++++++++++++++++++++- Lib/test/test_exceptions.py | 25 ++++++++++ Lib/test/test_fileutils.py | 30 ++++++++++++ Lib/test/test_zipimport_support.py | 5 +- 8 files changed, 176 insertions(+), 66 deletions(-) create mode 100644 Lib/test/test_fileutils.py diff --git a/Lib/test/string_tests.py b/Lib/test/string_tests.py index 08926bf88f8..0c159e02fb9 100644 --- a/Lib/test/string_tests.py +++ b/Lib/test/string_tests.py @@ -482,11 +482,8 @@ def test_expandtabs(self): self.checkequal(' a\n b', ' \ta\n\tb', 'expandtabs', 1) self.checkraises(TypeError, 'hello', 'expandtabs', 42, 42) - # TODO: RUSTPYTHON; expandtabs overflow checks - # if sys.maxsize < (1 << 32) and struct.calcsize('P') == 4: - # # This test is only valid when sizeof(int) == sizeof(void*) == 4. - if sys.maxsize < (1 << 32) and struct.calcsize('P') == 4 and False: + if sys.maxsize < (1 << 32) and struct.calcsize('P') == 4: self.checkraises(OverflowError, '\ta\n\tb', 'expandtabs', sys.maxsize) diff --git a/Lib/test/test_baseexception.py b/Lib/test/test_baseexception.py index 5870dc7f9da..0c206c4e3bd 100644 --- a/Lib/test/test_baseexception.py +++ b/Lib/test/test_baseexception.py @@ -79,10 +79,8 @@ def test_inheritance(self): # Underscore-prefixed (private) exceptions don't need to be documented exc_set = set(e for e in exc_set if not e.startswith('_')) - # RUSTPYTHON specific - exc_set.discard("JitError") - # XXX: RUSTPYTHON; IncompleteInputError will be officially introduced in Python 3.15 - exc_set.discard("IncompleteInputError") + exc_set.discard("JitError") # XXX: RUSTPYTHON specific + exc_set.discard("IncompleteInputError") # XXX: RUSTPYTHON; IncompleteInputError will be officially introduced in Python 3.15 self.assertEqual(len(exc_set), 0, "%s not accounted for" % exc_set) interface_tests = ("length", "args", "str", "repr") diff --git a/Lib/test/test_bigmem.py b/Lib/test/test_bigmem.py index 8f528812e35..ea76b1282ba 100644 --- a/Lib/test/test_bigmem.py +++ b/Lib/test/test_bigmem.py @@ -9,7 +9,12 @@ """ from test import support -from test.support import bigmemtest, _1G, _2G, _4G +from test.support import bigmemtest, _1G, _2G, _4G, import_helper +# _testcapi = import_helper.import_module('_testcapi') +try: # TODO: RUSTPYTHON + import _testcapi +except ImportError: + _testcapi = None import unittest import operator @@ -784,17 +789,14 @@ def test_title(self, size): def test_swapcase(self, size): self._test_swapcase(size) - # TODO: RUSTPYTHON - @unittest.expectedFailure - @bigmemtest(size=_2G, memuse=2) - def test_isspace(self, size): - super().test_isspace(size) + @unittest.expectedFailure # TODO: RUSTPYTHON + def test_isspace(self): + return super().test_isspace() + + @unittest.expectedFailure # TODO: RUSTPYTHON + def test_istitle(self): + return super().test_istitle() - # TODO: RUSTPYTHON - @unittest.expectedFailure - @bigmemtest(size=_2G, memuse=2) - def test_istitle(self, size): - super().test_istitle(size) class BytearrayTest(unittest.TestCase, BaseStrTest): @@ -821,17 +823,13 @@ def test_swapcase(self, size): test_hash = None test_split_large = None - # TODO: RUSTPYTHON - @unittest.expectedFailure - @bigmemtest(size=_2G, memuse=2) - def test_isspace(self, size): - super().test_isspace(size) + @unittest.expectedFailure # TODO: RUSTPYTHON + def test_isspace(self): + return super().test_isspace() - # TODO: RUSTPYTHON - @unittest.expectedFailure - @bigmemtest(size=_2G, memuse=2) - def test_istitle(self, size): - super().test_istitle(size) + @unittest.expectedFailure # TODO: RUSTPYTHON + def test_istitle(self): + return super().test_istitle() class TupleTest(unittest.TestCase): @@ -1280,6 +1278,27 @@ def test_dict(self, size): d[size] = 1 +class ImmortalityTest(unittest.TestCase): + + @bigmemtest(size=_2G, memuse=pointer_size * 9/8) + def test_stickiness(self, size): + """Check that immortality is "sticky", so that + once an object is immortal it remains so.""" + if size < _2G: + # Not enough memory to cause immortality on overflow + return + o1 = o2 = o3 = o4 = o5 = o6 = o7 = o8 = object() + l = [o1] * (size-20) + self.assertFalse(_testcapi.is_immortal(o1)) + for _ in range(30): + l.append(l[0]) + self.assertTrue(_testcapi.is_immortal(o1)) + del o2, o3, o4, o5, o6, o7, o8 + self.assertTrue(_testcapi.is_immortal(o1)) + del l + self.assertTrue(_testcapi.is_immortal(o1)) + + if __name__ == '__main__': if len(sys.argv) > 1: support.set_memlimit(sys.argv[1]) diff --git a/Lib/test/test_dtrace.py b/Lib/test/test_dtrace.py index a63978fd1bd..ba2fa99707c 100644 --- a/Lib/test/test_dtrace.py +++ b/Lib/test/test_dtrace.py @@ -8,7 +8,7 @@ import unittest from test import support -from test.support import findfile +from test.support import findfile, MS_WINDOWS if not support.has_subprocess_support: @@ -103,6 +103,7 @@ class SystemTapBackend(TraceBackend): COMMAND = ["stap", "-g"] +@unittest.skipIf(MS_WINDOWS, "Tests not compliant with trace on Windows.") class TraceTests: # unittest.TestCase options maxDiff = None @@ -159,43 +160,11 @@ class DTraceNormalTests(TraceTests, unittest.TestCase): backend = DTraceBackend() optimize_python = 0 - @unittest.expectedFailureIfWindows('TODO: RUSTPYTHON') - def test_function_entry_return(self): - return super().test_function_entry_return() - - @unittest.expectedFailureIfWindows('TODO: RUSTPYTHON') - def test_verify_call_opcodes(self): - return super().test_verify_call_opcodes() - - @unittest.expectedFailureIfWindows('TODO: RUSTPYTHON') - def test_gc(self): - return super().test_gc() - - @unittest.expectedFailureIfWindows('TODO: RUSTPYTHON') - def test_line(self): - return super().test_line() - class DTraceOptimizedTests(TraceTests, unittest.TestCase): backend = DTraceBackend() optimize_python = 2 - @unittest.expectedFailureIfWindows('TODO: RUSTPYTHON') - def test_function_entry_return(self): - return super().test_function_entry_return() - - @unittest.expectedFailureIfWindows('TODO: RUSTPYTHON') - def test_verify_call_opcodes(self): - return super().test_verify_call_opcodes() - - @unittest.expectedFailureIfWindows('TODO: RUSTPYTHON') - def test_gc(self): - return super().test_gc() - - @unittest.expectedFailureIfWindows('TODO: RUSTPYTHON') - def test_line(self): - return super().test_line() - class SystemTapNormalTests(TraceTests, unittest.TestCase): backend = SystemTapBackend() diff --git a/Lib/test/test_exception_group.py b/Lib/test/test_exception_group.py index 507bbc2ecbc..1e1c43a6bf4 100644 --- a/Lib/test/test_exception_group.py +++ b/Lib/test/test_exception_group.py @@ -1,4 +1,4 @@ -import collections.abc +import collections import types import unittest from test.support import skip_emscripten_stack_overflow, skip_wasi_stack_overflow, exceeds_recursion_limit @@ -194,6 +194,79 @@ class MyEG(ExceptionGroup): "MyEG('flat', [ValueError(1), TypeError(2)]), " "TypeError(2)])")) + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Tuples differ: ('test', (ValueError(1), TypeError(2))) != ('test', []) + def test_exceptions_mutation(self): + class MyEG(ExceptionGroup): + pass + + excs = [ValueError(1), TypeError(2)] + eg = MyEG('test', excs) + + self.assertEqual(repr(eg), "MyEG('test', [ValueError(1), TypeError(2)])") + excs.clear() + + # Ensure that clearing the exceptions sequence doesn't change the repr. + self.assertEqual(repr(eg), "MyEG('test', [ValueError(1), TypeError(2)])") + + # Ensure that the args are still as passed. + self.assertEqual(eg.args, ('test', [])) + + excs = (ValueError(1), KeyboardInterrupt(2)) + eg = BaseExceptionGroup('test', excs) + + # Ensure that immutable sequences still work fine. + self.assertEqual( + repr(eg), + "BaseExceptionGroup('test', (ValueError(1), KeyboardInterrupt(2)))" + ) + + # Test non-standard custom sequences. + excs = collections.deque([ValueError(1), TypeError(2)]) + eg = ExceptionGroup('test', excs) + + self.assertEqual( + repr(eg), + "ExceptionGroup('test', deque([ValueError(1), TypeError(2)]))" + ) + excs.clear() + + # Ensure that clearing the exceptions sequence doesn't change the repr. + self.assertEqual( + repr(eg), + "ExceptionGroup('test', deque([ValueError(1), TypeError(2)]))" + ) + + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: TypeError not raised + def test_repr_raises(self): + class MySeq(collections.abc.Sequence): + def __init__(self, raises): + self.raises = raises + + def __len__(self): + return 1 + + def __getitem__(self, index): + if index == 0: + return ValueError(1) + raise IndexError + + def __repr__(self): + if self.raises: + raise self.raises + return None + + seq = MySeq(None) + with self.assertRaisesRegex( + TypeError, + r"__repr__ returned non-string \(type NoneType\)" + ): + ExceptionGroup("test", seq) + + seq = MySeq(ValueError) + with self.assertRaises(ValueError): + BaseExceptionGroup("test", seq) + + def create_simple_eg(): excs = [] diff --git a/Lib/test/test_exceptions.py b/Lib/test/test_exceptions.py index 7e79732a3b9..621c9d82336 100644 --- a/Lib/test/test_exceptions.py +++ b/Lib/test/test_exceptions.py @@ -2528,6 +2528,31 @@ def test_incorrect_constructor(self): args = ("bad.py", 1, 2, "abcdefg", 1) self.assertRaises(TypeError, SyntaxError, "bad bad", args) + @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 2 is not None + def test_syntax_error_memory_leak(self): + # gh-146250: memory leak with re-initialization of SyntaxError + e = SyntaxError("msg", ("file.py", 1, 2, "txt", 2, 3)) + e.__init__("new_msg", ("new_file.py", 2, 3, "new_txt", 3, 4)) + self.assertEqual(e.msg, "new_msg") + self.assertEqual(e.args, ("new_msg", ("new_file.py", 2, 3, "new_txt", 3, 4))) + self.assertEqual(e.filename, "new_file.py") + self.assertEqual(e.lineno, 2) + self.assertEqual(e.offset, 3) + self.assertEqual(e.text, "new_txt") + self.assertEqual(e.end_lineno, 3) + self.assertEqual(e.end_offset, 4) + + e = SyntaxError("msg", ("file.py", 1, 2, "txt", 2, 3)) + e.__init__("new_msg", ("new_file.py", 2, 3, "new_txt")) + self.assertEqual(e.msg, "new_msg") + self.assertEqual(e.args, ("new_msg", ("new_file.py", 2, 3, "new_txt"))) + self.assertEqual(e.filename, "new_file.py") + self.assertEqual(e.lineno, 2) + self.assertEqual(e.offset, 3) + self.assertEqual(e.text, "new_txt") + self.assertIsNone(e.end_lineno) + self.assertIsNone(e.end_offset) + class TestInvalidExceptionMatcher(unittest.TestCase): def test_except_star_invalid_exception_type(self): diff --git a/Lib/test/test_fileutils.py b/Lib/test/test_fileutils.py new file mode 100644 index 00000000000..ff13498fbfe --- /dev/null +++ b/Lib/test/test_fileutils.py @@ -0,0 +1,30 @@ +# Run tests for functions in Python/fileutils.c. + +import os +import os.path +import unittest +from test.support import import_helper + +# Skip this test if the _testcapi module isn't available. +_testcapi = import_helper.import_module('_testinternalcapi') + + +class PathTests(unittest.TestCase): + + def test_capi_normalize_path(self): + if os.name == 'nt': + raise unittest.SkipTest('Windows has its own helper for this') + else: + from test.test_posixpath import PosixPathTest as posixdata + tests = posixdata.NORMPATH_CASES + for filename, expected in tests: + if not os.path.isabs(filename): + continue + with self.subTest(filename): + result = _testcapi.normalize_path(filename) + self.assertEqual(result, expected, + msg=f'input: {filename!r} expected output: {expected!r}') + + +if __name__ == "__main__": + unittest.main() diff --git a/Lib/test/test_zipimport_support.py b/Lib/test/test_zipimport_support.py index c9cd182d6b7..23bbd6c88c5 100644 --- a/Lib/test/test_zipimport_support.py +++ b/Lib/test/test_zipimport_support.py @@ -37,9 +37,8 @@ def _run_object_doctest(obj, module): from test.support.rustpython import DocTestChecker # TODO: RUSTPYTHON finder = doctest.DocTestFinder(verbose=verbose, recurse=False) - # TODO: RUSTPYTHON - # runner = doctest.DocTestRunner(verbose=verbose) - runner = doctest.DocTestRunner(verbose=verbose, checker=DocTestChecker()) + runner = doctest.DocTestRunner(verbose=verbose) + runner = doctest.DocTestRunner(verbose=verbose, checker=DocTestChecker()) # TODO: RUSTPYTHON # Use the object's fully qualified name if it has one # Otherwise, use the module's name try: From dda5516449ce1284279da02dedfcb05a99233e85 Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:34:14 +0200 Subject: [PATCH 036/351] Add allocator functions to c-api (#8168) --- .cspell.dict/cpython.txt | 1 + Cargo.lock | 1 + crates/capi/Cargo.toml | 1 + crates/capi/src/lib.rs | 1 + crates/capi/src/pymem.rs | 46 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 50 insertions(+) create mode 100644 crates/capi/src/pymem.rs diff --git a/.cspell.dict/cpython.txt b/.cspell.dict/cpython.txt index 84265bda609..e5b31b57f15 100644 --- a/.cspell.dict/cpython.txt +++ b/.cspell.dict/cpython.txt @@ -192,6 +192,7 @@ pyerrors Pyfunc pylifecycle pymain +pymem pyrepl pystate PYTHONTRACEMALLOC diff --git a/Cargo.lock b/Cargo.lock index 743e293f161..bd10622e601 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3447,6 +3447,7 @@ version = "0.5.0" dependencies = [ "bitflags 2.13.0", "itertools 0.14.0", + "libc", "num-complex", "pyo3", "rustpython-pylib", diff --git a/crates/capi/Cargo.toml b/crates/capi/Cargo.toml index 601989faa1d..878620ebe23 100644 --- a/crates/capi/Cargo.toml +++ b/crates/capi/Cargo.toml @@ -14,6 +14,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] bitflags = { workspace = true } itertools = { workspace = true } +libc = { workspace = true } num-complex = { workspace = true } rustpython-vm = { workspace = true, features = ["threading", "compiler", "importlib", "host_env"] } rustpython-stdlib = {workspace = true, features = ["threading"] } diff --git a/crates/capi/src/lib.rs b/crates/capi/src/lib.rs index fb3bc687f4d..638a78d6327 100644 --- a/crates/capi/src/lib.rs +++ b/crates/capi/src/lib.rs @@ -26,6 +26,7 @@ pub mod object; pub mod pycapsule; pub mod pyerrors; pub mod pylifecycle; +pub mod pymem; pub mod pystate; pub mod refcount; pub mod setobject; diff --git a/crates/capi/src/pymem.rs b/crates/capi/src/pymem.rs new file mode 100644 index 00000000000..6c8b66f5d3e --- /dev/null +++ b/crates/capi/src/pymem.rs @@ -0,0 +1,46 @@ +use core::ffi::c_void; + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMem_Malloc(n: usize) -> *mut c_void { + unsafe { libc::malloc(if n == 0 { 1 } else { n }) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMem_Calloc(nelem: usize, elsize: usize) -> *mut c_void { + unsafe { + libc::calloc( + if nelem == 0 || elsize == 0 { 1 } else { nelem }, + if nelem == 0 || elsize == 0 { 1 } else { elsize }, + ) + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMem_Realloc(ptr: *mut c_void, new_size: usize) -> *mut c_void { + unsafe { libc::realloc(ptr, if new_size == 0 { 1 } else { new_size }) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMem_Free(ptr: *mut c_void) { + unsafe { libc::free(ptr) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMem_RawMalloc(n: usize) -> *mut c_void { + unsafe { libc::malloc(n) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMem_RawCalloc(nelem: usize, elsize: usize) -> *mut c_void { + unsafe { libc::calloc(nelem, elsize) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMem_RawRealloc(ptr: *mut c_void, new_size: usize) -> *mut c_void { + unsafe { libc::realloc(ptr, new_size) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMem_RawFree(ptr: *mut c_void) { + unsafe { libc::free(ptr) } +} From c876709388ac81d8abcf0ce17b78ff999ed3a9ae Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:35:04 +0200 Subject: [PATCH 037/351] Add more mapping functions to c-api (#8169) --- crates/capi/src/abstract_/mapping.rs | 157 +++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/crates/capi/src/abstract_/mapping.rs b/crates/capi/src/abstract_/mapping.rs index 18b188613dc..143d5a97744 100644 --- a/crates/capi/src/abstract_/mapping.rs +++ b/crates/capi/src/abstract_/mapping.rs @@ -1,4 +1,15 @@ use crate::{PyObject, pystate::with_vm}; +use core::ffi::{CStr, c_char, c_int}; +use rustpython_vm::AsObject; + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMapping_Check(obj: *mut PyObject) -> c_int { + with_vm(|_vm| { + let obj = unsafe { &*obj }; + Ok(obj.mapping_unchecked().check()) + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyMapping_Size(obj: *mut PyObject) -> isize { with_vm(|vm| { @@ -7,6 +18,11 @@ pub unsafe extern "C" fn PyMapping_Size(obj: *mut PyObject) -> isize { }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMapping_Length(obj: *mut PyObject) -> isize { + unsafe { PyMapping_Size(obj) } +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyMapping_Keys(obj: *mut PyObject) -> *mut PyObject { with_vm(|vm| { @@ -37,6 +53,147 @@ pub unsafe extern "C" fn PyMapping_Items(obj: *mut PyObject) -> *mut PyObject { }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMapping_GetItemString( + obj: *mut PyObject, + key: *const c_char, +) -> *mut PyObject { + with_vm(|vm| { + let obj = unsafe { &*obj }; + let key = unsafe { CStr::from_ptr(key) } + .to_str() + .map_err(|_| vm.new_value_error("mapping key must be valid UTF-8"))?; + obj.get_item(key, vm) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMapping_GetOptionalItem( + obj: *mut PyObject, + key: *mut PyObject, + result: *mut *mut PyObject, +) -> c_int { + with_vm(|vm| { + unsafe { + *result = core::ptr::null_mut(); + } + let obj = unsafe { &*obj }; + let key = unsafe { &*key }; + + match obj.get_item(key, vm) { + Ok(value) => { + unsafe { + *result = value.into_raw().as_ptr(); + } + Ok(true) + } + Err(err) if err.fast_isinstance(vm.ctx.exceptions.key_error) => Ok(false), + Err(err) => Err(err), + } + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMapping_GetOptionalItemString( + obj: *mut PyObject, + key: *const c_char, + result: *mut *mut PyObject, +) -> c_int { + with_vm(|vm| { + unsafe { + *result = core::ptr::null_mut(); + } + let obj = unsafe { &*obj }; + let key = unsafe { CStr::from_ptr(key) } + .to_str() + .map_err(|_| vm.new_value_error("mapping key must be valid UTF-8"))?; + + match obj.get_item(key, vm) { + Ok(value) => { + unsafe { + *result = value.into_raw().as_ptr(); + } + Ok(true) + } + Err(err) if err.fast_isinstance(vm.ctx.exceptions.key_error) => Ok(false), + Err(err) => Err(err), + } + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMapping_HasKey(obj: *mut PyObject, key: *mut PyObject) -> c_int { + with_vm(|vm| { + let obj = unsafe { &*obj }; + let key = unsafe { &*key }; + obj.get_item(key, vm).is_ok() + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMapping_HasKeyString(obj: *mut PyObject, key: *const c_char) -> c_int { + with_vm(|vm| { + let obj = unsafe { &*obj }; + if let Ok(key) = unsafe { CStr::from_ptr(key) }.to_str() { + obj.get_item(key, vm).is_ok() + } else { + false + } + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMapping_HasKeyWithError( + obj: *mut PyObject, + key: *mut PyObject, +) -> c_int { + with_vm(|vm| { + let obj = unsafe { &*obj }; + let key = unsafe { &*key }; + + match obj.get_item(key, vm) { + Ok(_) => Ok(true), + Err(err) if err.fast_isinstance(vm.ctx.exceptions.key_error) => Ok(false), + Err(err) => Err(err), + } + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMapping_HasKeyStringWithError( + obj: *mut PyObject, + key: *const c_char, +) -> c_int { + with_vm(|vm| { + let obj = unsafe { &*obj }; + let key = unsafe { CStr::from_ptr(key) } + .to_str() + .map_err(|_| vm.new_value_error("mapping key must be valid UTF-8"))?; + + match obj.get_item(key, vm) { + Ok(_) => Ok(true), + Err(err) if err.fast_isinstance(vm.ctx.exceptions.key_error) => Ok(false), + Err(err) => Err(err), + } + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMapping_SetItemString( + obj: *mut PyObject, + key: *const c_char, + value: *mut PyObject, +) -> c_int { + with_vm(|vm| { + let obj = unsafe { &*obj }; + let key = unsafe { CStr::from_ptr(key) } + .to_str() + .map_err(|_| vm.new_value_error("mapping key must be valid UTF-8"))?; + let value = unsafe { &*value }.to_owned(); + obj.set_item(key, value, vm) + }) +} + #[cfg(false)] mod tests { use pyo3::prelude::*; From 3b4c5f65dd02022601f98b95fab9697451af4046 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:22:08 +0900 Subject: [PATCH 038/351] Align compiler and AST behavior with CPython (#8138) * Align compiler and AST behavior with CPython Update the compiler pipeline, AST module support, and related VM compile/eval paths toward CPython 3.14 behavior. Use upstream Ruff parser crates directly and keep RustPython-specific syntax preflight handling in the compile path. Refresh opcode metadata, snapshots, and targeted tests for the aligned bytecode and AST behavior. * fix * Fix PR 8138 CI failures * Fix match mapping wildcard scan complexity * fixes * Use Ruff runtime AST fields * Minimize residual patch diff * refactor error handling * Remove _ast conversion context wrappers * Clean up _ast conversion glue and compiler warning escalation Rename the Node conversion parameter `ctx` to `vm` throughout the _ast module. Remove the unused compile_program, compile_program_single, compile_block_expression, and compile_expression forwarders; compile_top stays as the entry point. Stash an escalated compiler SyntaxWarning so a non-SyntaxWarning category propagates unchanged instead of being rewritten to SyntaxError, matching PyErr_ExceptionMatches(SyntaxWarning) in compiler_warn. Drop the now-unused CompileWarningError::into_codegen_error. Replace the PositionalArguments two-variant enum with a struct holding a shared range and a PositionalArgumentsKind enum. Assisted-by: Claude * Represent compiler flags as a CompilerFlags bitflags Replace the loose PY_CF_*/CO_* i32 constants in compile_mode with a CompilerFlags bitflags covering the PyCF_* flags and __future__ CO_FUTURE_* bits. The Python-visible ast.PyCF_* attribute values are derived from it via bits(); parser input modes stay plain consts. builtins::compile and the compile_string flag handling test flags through CompilerFlags::contains instead of raw bit math. Assisted-by: Claude * Fix PR 8138 test regressions * Fix PR 8138 CI failures * Avoid shell interpolation in Linux deps action * Fix review findings in compiler/AST changes - frame.rs: compute the MATCH_CLASS class name lazily so a successful match no longer takes the type name lock or allocates; the __match_args__-not-a-tuple branch reuses it instead of recomputing. - _ast/constant.rs: add the missing leading space to the with_recursion label ("maximum recursion depth exceeded during compilation"). - sys.set_int_max_str_digits: restore the "maxdigits must be 0 or larger than {n}" error text. - codegen: set CO_MAXBLOCKS to 20 in both ir.rs and compile.rs. - _ast/exception.rs: extract the duplicated excepthandler node guard into ensure_excepthandler_node. - codegen: correct the validate_keywords doc comment. - Drop redundant "CPython" qualifiers from comments added in this branch. Assisted-by: Claude --- .cspell.json | 3 + .github/actions/install-linux-deps/action.yml | 39 +- Cargo.lock | 33 +- Cargo.toml | 19 +- Lib/test/test_ast/test_ast.py | 10 - Lib/test/test_audit.py | 1 - Lib/test/test_builtin.py | 3 - Lib/test/test_cmd_line_script.py | 1 - Lib/test/test_codeop.py | 1 - Lib/test/test_compile.py | 12 - Lib/test/test_exceptions.py | 1 - Lib/test/test_fstring.py | 5 - Lib/test/test_future_stmt/test_future.py | 5 - Lib/test/test_genexps.py | 2 +- Lib/test/test_global.py | 4 - Lib/test/test_grammar.py | 11 - Lib/test/test_listcomps.py | 1 - Lib/test/test_named_expressions.py | 5 - Lib/test/test_patma.py | 14 - Lib/test/test_pdb.py | 6 +- Lib/test/test_peepholer.py | 1 - Lib/test/test_pep646_syntax.py | 2 +- Lib/test/test_pydoc/test_pydoc.py | 1 - Lib/test/test_pyrepl/test_interact.py | 7 - Lib/test/test_pyrepl/test_pyrepl.py | 10 - Lib/test/test_pyrepl/test_reader.py | 1 - Lib/test/test_repl.py | 1 - Lib/test/test_symtable.py | 1 - Lib/test/test_syntax.py | 307 +- Lib/test/test_sys_setprofile.py | 1 - Lib/test/test_type_comments.py | 15 - Lib/test/test_type_params.py | 3 - Lib/test/test_unpack_ex.py | 8 +- crates/capi/src/ceval.rs | 34 +- crates/capi/src/unicodeobject.rs | 52 +- crates/codegen/src/compile.rs | 8056 ++++++++++++----- crates/codegen/src/error.rs | 60 +- crates/codegen/src/ir.rs | 438 +- crates/codegen/src/lib.rs | 86 +- crates/codegen/src/preprocess.rs | 788 +- crates/codegen/src/symboltable.rs | 3237 ++++--- crates/codegen/src/unparse.rs | 106 +- crates/compiler-core/src/bytecode.rs | 7 + .../compiler-core/src/bytecode/instruction.rs | 25 +- crates/compiler-core/src/bytecode/oparg.rs | 7 +- crates/compiler/src/lib.rs | 5313 ++++++++++- crates/stdlib/src/_opcode.rs | 4 +- ...k_attribute_and_subscript_expressions.snap | 3 +- ...n_stdlib___opcode__tests__const_no_op.snap | 3 +- ...nt_true_if_pass_keeps_line_anchor_nop.snap | 3 +- ...ython_stdlib___opcode__tests__if_ands.snap | 3 +- ...thon_stdlib___opcode__tests__if_mixed.snap | 3 +- ...python_stdlib___opcode__tests__if_ors.snap | 3 +- ...tdlib___opcode__tests__nested_bool_op.snap | 3 +- crates/vm/Cargo.toml | 1 + crates/vm/src/builtins/type.rs | 78 +- crates/vm/src/eval.rs | 2 +- crates/vm/src/exceptions.rs | 18 +- crates/vm/src/frame.rs | 92 +- crates/vm/src/import.rs | 4 +- crates/vm/src/object/core.rs | 3 + crates/vm/src/protocol/callable.rs | 6 +- crates/vm/src/stdlib/_abc.rs | 21 +- crates/vm/src/stdlib/_ast.rs | 2146 ++++- crates/vm/src/stdlib/_ast/argument.rs | 133 +- crates/vm/src/stdlib/_ast/basic.rs | 10 +- crates/vm/src/stdlib/_ast/constant.rs | 537 +- crates/vm/src/stdlib/_ast/elif_else_clause.rs | 53 +- crates/vm/src/stdlib/_ast/exception.rs | 99 +- crates/vm/src/stdlib/_ast/expression.rs | 1312 ++- crates/vm/src/stdlib/_ast/module.rs | 167 +- crates/vm/src/stdlib/_ast/node.rs | 61 +- crates/vm/src/stdlib/_ast/operator.rs | 176 +- crates/vm/src/stdlib/_ast/other.rs | 29 +- crates/vm/src/stdlib/_ast/parameter.rs | 232 +- crates/vm/src/stdlib/_ast/pattern.rs | 555 +- crates/vm/src/stdlib/_ast/pyast.rs | 104 +- crates/vm/src/stdlib/_ast/python.rs | 276 +- crates/vm/src/stdlib/_ast/repr.rs | 36 +- crates/vm/src/stdlib/_ast/statement.rs | 1771 ++-- crates/vm/src/stdlib/_ast/string.rs | 557 +- crates/vm/src/stdlib/_ast/type_ignore.rs | 33 +- crates/vm/src/stdlib/_ast/type_parameters.rs | 208 +- crates/vm/src/stdlib/_ast/validate.rs | 447 +- crates/vm/src/stdlib/_symtable.rs | 2 + crates/vm/src/stdlib/builtins.rs | 713 +- crates/vm/src/stdlib/sys.rs | 55 +- crates/vm/src/stdlib/sys/monitoring.rs | 4 +- crates/vm/src/vm/compile.rs | 967 +- crates/vm/src/vm/compile_mode.rs | 83 + crates/vm/src/vm/interpreter.rs | 6 +- crates/vm/src/vm/mod.rs | 73 +- crates/vm/src/vm/python_run.rs | 18 +- crates/vm/src/vm/thread.rs | 1 + crates/vm/src/vm/vm_new.rs | 106 +- crates/wasm/Cargo.toml | 1 + crates/wasm/src/lib.rs | 6 +- crates/wasm/src/vm_class.rs | 71 +- examples/hello_embed.rs | 2 +- examples/mini_repl.rs | 2 +- examples/parse_folder.rs | 2 +- extra_tests/snippets/builtin_compile.py | 101 + ruff.toml | 3 + src/shell.rs | 19 +- .../generate_rs_opcode_metadata.py | 1 + 105 files changed, 23195 insertions(+), 6976 deletions(-) create mode 100644 crates/vm/src/vm/compile_mode.rs diff --git a/.cspell.json b/.cspell.json index 21199c0c5f5..af2f1401d95 100644 --- a/.cspell.json +++ b/.cspell.json @@ -66,8 +66,11 @@ "deoptimize", "emscripten", "excs", + "flufl", "fnfe", + "fsdefault", "ifexp", + "implicits", "interps", "jitted", "jitting", diff --git a/.github/actions/install-linux-deps/action.yml b/.github/actions/install-linux-deps/action.yml index 7900060fb29..46ce74d50e4 100644 --- a/.github/actions/install-linux-deps/action.yml +++ b/.github/actions/install-linux-deps/action.yml @@ -39,11 +39,36 @@ runs: - name: Install Linux dependencies shell: bash if: ${{ runner.os == 'Linux' }} - run: > - sudo apt-get update + env: + GCC_MULTILIB: ${{ inputs.gcc-multilib }} + MUSL_TOOLS: ${{ inputs.musl-tools }} + CLANG: ${{ inputs.clang }} + GCC_AARCH64_LINUX_GNU: ${{ inputs.gcc-aarch64-linux-gnu }} + run: | + if ! sudo apt-get update; then + echo "::warning::apt-get update failed; disabling nonessential Microsoft apt sources and retrying" + for source in /etc/apt/sources.list.d/*microsoft* /etc/apt/sources.list.d/*azure-cli*; do + if [ -e "$source" ]; then + sudo mv "$source" "$source.disabled" + fi + done + sudo apt-get update + fi - sudo apt-get install --no-install-recommends - ${{ fromJSON(inputs.gcc-multilib) && 'gcc-multilib' || '' }} - ${{ fromJSON(inputs.musl-tools) && 'musl-tools' || '' }} - ${{ fromJSON(inputs.clang) && 'clang' || '' }} - ${{ fromJSON(inputs.gcc-aarch64-linux-gnu) && 'gcc-aarch64-linux-gnu linux-libc-dev-arm64-cross libc6-dev-arm64-cross' || '' }} + packages=() + if [[ "$GCC_MULTILIB" == "true" ]]; then + packages+=(gcc-multilib) + fi + if [[ "$MUSL_TOOLS" == "true" ]]; then + packages+=(musl-tools) + fi + if [[ "$CLANG" == "true" ]]; then + packages+=(clang) + fi + if [[ "$GCC_AARCH64_LINUX_GNU" == "true" ]]; then + packages+=(gcc-aarch64-linux-gnu linux-libc-dev-arm64-cross libc6-dev-arm64-cross) + fi + + if ((${#packages[@]})); then + sudo apt-get install --no-install-recommends "${packages[@]}" + fi diff --git a/Cargo.lock b/Cargo.lock index bd10622e601..e6a4194a056 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3631,9 +3631,8 @@ dependencies = [ [[package]] name = "rustpython-ruff_python_ast" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f021ff72cabf5e2cd6d8ec8813d376a8445a228dc610ab56c27bd9054cda70d4" +version = "0.15.9" +source = "git+https://github.com/RustPython/ruff.git?tag=0.15.19-rustpython#3c1ab2dd9987f190f3df94d28452ed5e65e106af" dependencies = [ "aho-corasick", "bitflags 2.13.0", @@ -3650,9 +3649,8 @@ dependencies = [ [[package]] name = "rustpython-ruff_python_parser" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01e6ee78bd9671fb5766664b2695fe1f2a92a961f4d9101646c570d8acdb1e0b" +version = "0.15.9" +source = "git+https://github.com/RustPython/ruff.git?tag=0.15.19-rustpython#3c1ab2dd9987f190f3df94d28452ed5e65e106af" dependencies = [ "bitflags 2.13.0", "bstr", @@ -3671,9 +3669,8 @@ dependencies = [ [[package]] name = "rustpython-ruff_python_trivia" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79e7cfd1056f3a02ff0d2d0e4474286ca963260782f878b7b81c1dd87432e682" +version = "0.15.9" +source = "git+https://github.com/RustPython/ruff.git?tag=0.15.19-rustpython#3c1ab2dd9987f190f3df94d28452ed5e65e106af" dependencies = [ "itertools 0.14.0", "rustpython-ruff_source_file", @@ -3683,9 +3680,8 @@ dependencies = [ [[package]] name = "rustpython-ruff_source_file" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "948107aad62ddb12a11fc7bf68a49e52a0b0a3737d415a2505e54f5a9edac737" +version = "0.15.9" +source = "git+https://github.com/RustPython/ruff.git?tag=0.15.19-rustpython#3c1ab2dd9987f190f3df94d28452ed5e65e106af" dependencies = [ "memchr", "rustpython-ruff_text_size", @@ -3693,9 +3689,8 @@ dependencies = [ [[package]] name = "rustpython-ruff_text_size" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8291ee0f5a779e54ccd4e0151a0c426f8b49a123f99b5b6545db17ccdd4277aa" +version = "0.15.9" +source = "git+https://github.com/RustPython/ruff.git?tag=0.15.19-rustpython#3c1ab2dd9987f190f3df94d28452ed5e65e106af" dependencies = [ "get-size2", ] @@ -3855,6 +3850,7 @@ dependencies = [ "static_assertions", "strum", "strum_macros", + "thin-vec", "thiserror", "timsort", "wasm-bindgen", @@ -3881,6 +3877,7 @@ dependencies = [ "js-sys", "rustpython-common", "rustpython-pylib", + "rustpython-ruff_text_size", "rustpython-stdlib", "rustpython-vm", "serde-wasm-bindgen", @@ -4363,6 +4360,12 @@ version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +[[package]] +name = "thin-vec" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" + [[package]] name = "thiserror" version = "2.0.18" diff --git a/Cargo.toml b/Cargo.toml index 677de28ef1c..4781c95e101 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -183,19 +183,11 @@ rustpython-sre_engine = { path = "crates/sre_engine", version = "0.5.0" } rustpython-wtf8 = { path = "crates/wtf8", version = "0.5.0" } rustpython-doc = { path = "crates/doc", version = "0.5.0" } -# Use RustPython-packaged Ruff crates from the published fork while keeping -# existing crate names in the codebase. -ruff_python_parser = { package = "rustpython-ruff_python_parser", version = "0.15.8" } -ruff_python_ast = { package = "rustpython-ruff_python_ast", version = "0.15.8" } -ruff_text_size = { package = "rustpython-ruff_text_size", version = "0.15.8" } -ruff_source_file = { package = "rustpython-ruff_source_file", version = "0.15.8" } -# To update ruff crates, comment out the above lines and uncomment the following lines to pull directly from the Ruff repository at the specified commit hash. -# Ruff tag 0.15.8 is based on commit c2a8815842f9dc5d24ec19385eae0f1a7188b0d9 -# at the time of this capture. We use the commit hash to ensure reproducible builds. -# ruff_python_parser = { git = "https://github.com/astral-sh/ruff.git", rev = "c2a8815842f9dc5d24ec19385eae0f1a7188b0d9" } -# ruff_python_ast = { git = "https://github.com/astral-sh/ruff.git", rev = "c2a8815842f9dc5d24ec19385eae0f1a7188b0d9" } -# ruff_text_size = { git = "https://github.com/astral-sh/ruff.git", rev = "c2a8815842f9dc5d24ec19385eae0f1a7188b0d9" } -# ruff_source_file = { git = "https://github.com/astral-sh/ruff.git", rev = "c2a8815842f9dc5d24ec19385eae0f1a7188b0d9" } +# Use the RustPython Ruff fork for RustPython public `_ast` metadata. +ruff_python_parser = { package = "rustpython-ruff_python_parser", git = "https://github.com/RustPython/ruff.git", tag = "0.15.19-rustpython" } +ruff_python_ast = { package = "rustpython-ruff_python_ast", git = "https://github.com/RustPython/ruff.git", tag = "0.15.19-rustpython" } +ruff_text_size = { package = "rustpython-ruff_text_size", git = "https://github.com/RustPython/ruff.git", tag = "0.15.19-rustpython" } +ruff_source_file = { package = "rustpython-ruff_source_file", git = "https://github.com/RustPython/ruff.git", tag = "0.15.19-rustpython" } der = { version = "0.8", features = ["alloc", "oid", "pem", "zeroize"] } phf = { version = "0.13.1", default-features = false, features = ["macros"]} @@ -311,6 +303,7 @@ tcl-sys = { git = "https://github.com/arihant2math/tkinter.git", tag = "v0.2.0" textwrap = { version = "0.16.2", default-features = false } termios = "0.3.3" thiserror = "2.0" +thin-vec = "0.2.14" timsort = "0.1.2" tk-sys = { git = "https://github.com/arihant2math/tkinter.git", tag = "v0.2.0" } icu_casemap = "2" diff --git a/Lib/test/test_ast/test_ast.py b/Lib/test/test_ast/test_ast.py index 00283ca05a0..31fd6296451 100644 --- a/Lib/test/test_ast/test_ast.py +++ b/Lib/test/test_ast/test_ast.py @@ -150,7 +150,6 @@ def test_parse_invalid_ast(self): self.assertRaises(TypeError, ast.parse, ast.Constant(42), optimize=optval) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: compile() unrecognized flags def test_optimization_levels__debug__(self): cases = [(-1, '__debug__'), (0, '__debug__'), (1, False), (2, False)] for (optval, expected) in cases: @@ -586,7 +585,6 @@ def test_invalid_sum(self): compile(m, "", "exec") self.assertIn("but got expr()", str(cm.exception)) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: expected str for name def test_invalid_identifier(self): m = ast.Module([ast.Expr(ast.Name(42, ast.Load()))], []) ast.fix_missing_locations(m) @@ -1365,7 +1363,6 @@ def test_replace_ignore_known_custom_instance_fields(self): self.assertIs(repl.ctx, context) self.assertRaises(AttributeError, getattr, repl, 'extra') - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: "Name\.__replace__\ missing\ 1\ keyword\ argument:\ 'id'\." does not match "replace() does not support Name objects" def test_replace_reject_missing_field(self): # case: warn if deleted field is not replaced node = ast.parse('x').body[0].value @@ -1700,7 +1697,6 @@ def check_text(code, empty, full, **kwargs): full="Module(body=[Import(names=[alias(name='_ast', asname='ast')]), ImportFrom(module='module', names=[alias(name='sub')], level=0)], type_ignores=[])", ) - @unittest.expectedFailure # TODO: RUSTPYTHON; ? ^^^^^^^^^ ^^^^^^^^^ def test_copy_location(self): src = ast.parse('1 + 1', mode='eval') src.body.right = ast.copy_location(ast.Constant(2), src.body.right) @@ -1737,7 +1733,6 @@ def test_fix_missing_locations(self): "end_col_offset=0), lineno=1, col_offset=0, end_lineno=1, end_col_offset=0)])" ) - @unittest.expectedFailure # TODO: RUSTPYTHON; ? ^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ def test_increment_lineno(self): src = ast.parse('1 + 1', mode='eval') self.assertEqual(ast.increment_lineno(src, n=3), src) @@ -1959,7 +1954,6 @@ def test_literal_eval_syntax_errors(self): (\ \ ''') - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: required field "lineno" missing from alias def test_bad_integer(self): # issue13436: Bad error message with invalid numeric values body = [ast.ImportFrom(module='time', @@ -3259,7 +3253,6 @@ class MyAttrs(ast.AST): r"MyAttrs.__init__ got an unexpected keyword argument 'c'."): obj = MyAttrs(c=3) - @unittest.expectedFailure # TODO: RUSTPYTHON; DeprecationWarning not triggered def test_fields_and_types_no_default(self): class FieldsAndTypesNoDefault(ast.AST): _fields = ('a',) @@ -3273,7 +3266,6 @@ class FieldsAndTypesNoDefault(ast.AST): obj = FieldsAndTypesNoDefault(a=1) self.assertEqual(obj.a, 1) - @unittest.expectedFailure # TODO: RUSTPYTHON; DeprecationWarning not triggered def test_incomplete_field_types(self): class MoreFieldsThanTypes(ast.AST): _fields = ('a', 'b') @@ -3293,7 +3285,6 @@ class MoreFieldsThanTypes(ast.AST): self.assertEqual(obj.a, 1) self.assertEqual(obj.b, 2) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: Expected type 'str' but 'bytes' found. def test_malformed_fields_with_bytes(self): class BadFields(ast.AST): _fields = (b'\xff'*64,) @@ -3713,7 +3704,6 @@ def assert_ast(self, code, non_optimized_target, optimized_target): f"{ast.dump(optimized_tree)}", ) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: compile() unrecognized flags def test_folding_format(self): code = "'%s' % (a,)" diff --git a/Lib/test/test_audit.py b/Lib/test/test_audit.py index d01d36ad3db..690a6e7434e 100644 --- a/Lib/test/test_audit.py +++ b/Lib/test/test_audit.py @@ -77,7 +77,6 @@ def test_monkeypatch(self): def test_open(self): self.do_test("test_open", os_helper.TESTFN) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_cantrace(self): self.do_test("test_cantrace") diff --git a/Lib/test/test_builtin.py b/Lib/test/test_builtin.py index 163ebcfb5bd..10783cf33e2 100644 --- a/Lib/test/test_builtin.py +++ b/Lib/test/test_builtin.py @@ -486,7 +486,6 @@ def test_compile_top_level_await_no_coro(self): msg=f"source={source} mode={mode}") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_compile_top_level_await(self): """Test whether code with top level await can be compiled. @@ -627,7 +626,6 @@ def test_compile_async_generator(self): exec(co, glob) self.assertEqual(type(glob['ticker']()), AsyncGeneratorType) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: <_ast.Name object at 0xb40000731e3d1360> is not an instance of def test_compile_ast(self): args = ("a*__debug__", "f.py", "exec") raw = compile(*args, flags = ast.PyCF_ONLY_AST).body[0] @@ -1020,7 +1018,6 @@ def test_exec_redirected(self): finally: sys.stdout = savestdout - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: Unexpected keyword argument closure def test_exec_closure(self): def function_without_closures(): return 3 * 5 diff --git a/Lib/test/test_cmd_line_script.py b/Lib/test/test_cmd_line_script.py index 8b8c452f676..16df318ae8e 100644 --- a/Lib/test/test_cmd_line_script.py +++ b/Lib/test/test_cmd_line_script.py @@ -645,7 +645,6 @@ def test_syntaxerror_indented_caret_position(self): self.assertNotIn("\f", text) self.assertIn("\n 1 + 1 = 2\n ^^^^^\n", text) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_syntaxerror_multi_line_fstring(self): script = 'foo = f"""{}\nfoo"""\n' with os_helper.temp_dir() as script_dir: diff --git a/Lib/test/test_codeop.py b/Lib/test/test_codeop.py index 12976122241..2e1568d5ea2 100644 --- a/Lib/test/test_codeop.py +++ b/Lib/test/test_codeop.py @@ -279,7 +279,6 @@ def test_filename(self): self.assertNotEqual(compile_command("a = 1\n", "abc").co_filename, compile("a = 1\n", "def", 'single').co_filename) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 0 != 2 def test_warning(self): # Test that the warning is only returned once. with warnings_helper.check_warnings( diff --git a/Lib/test/test_compile.py b/Lib/test/test_compile.py index 4d117be1b88..a6542b396cc 100644 --- a/Lib/test/test_compile.py +++ b/Lib/test/test_compile.py @@ -209,7 +209,6 @@ def test_literals_with_leading_zeroes(self): self.assertEqual(eval("0o777"), 511) self.assertEqual(eval("-0o0000010"), -8) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: SyntaxError not raised def test_int_literals_too_long(self): n = 3000 source = f"a = 1\nb = 2\nc = {'3'*n}\nd = 4" @@ -283,7 +282,6 @@ def test_none_assignment(self): self.assertRaises(SyntaxError, compile, stmt, 'tmp', 'single') self.assertRaises(SyntaxError, compile, stmt, 'tmp', 'exec') - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: SyntaxError not raised by compile def test_import(self): succeed = [ 'import sys', @@ -348,7 +346,6 @@ def test_lambda_consts(self): l = lambda: "this is the only const" self.assertEqual(l.__code__.co_consts, ("this is the only const",)) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: SyntaxError not raised by compile def test_encoding(self): code = b'# -*- coding: badencoding -*-\npass\n' self.assertRaises(SyntaxError, compile, code, 'tmp', 'exec') @@ -465,7 +462,6 @@ def test_condition_expression_with_dead_blocks_compiles(self): # See gh-113054 compile('if (5 if 5 else T): 0', '', 'exec') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_condition_expression_with_redundant_comparisons_compiles(self): # See gh-113054, gh-114083 exprs = [ @@ -580,7 +576,6 @@ def test_compile_redundant_jump_after_convert_pseudo_ops(self): compile(ast.fix_missing_locations(tree), "", "exec") - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: at 0xb77555080 file "1", line 1> != at 0xb77554f00 file "3", line 1> def test_compile_ast(self): fname = __file__ if fname.lower().endswith('pyc'): @@ -696,7 +691,6 @@ def test_single_statement(self): self.compile_single("class T:\n pass") self.compile_single("c = '''\na=1\nb=2\nc=3\n'''") - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: SyntaxError not raised by compile_single def test_bad_single_statement(self): self.assertInvalidSingle('1\n2') self.assertInvalidSingle('def f(): pass') @@ -708,7 +702,6 @@ def test_bad_single_statement(self): self.assertInvalidSingle('x = 5 # comment\nx = 6\n') self.assertInvalidSingle("c = '''\nd=1\n'''\na = 1\n\nb = 2\n") - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: b'source code cannot contain null bytes' not found in b'OSError: stream did not contain valid UTF-8\n' def test_particularly_evil_undecodable(self): # Issue 24022 src = b'0000\x00\n00000000000\n\x00\n\x9e\n' @@ -719,7 +712,6 @@ def test_particularly_evil_undecodable(self): res = script_helper.run_python_until_end(fn)[0] self.assertIn(b"source code cannot contain null bytes", res.err) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: b'source code cannot contain null bytes' not found in b'OSError: stream did not contain valid UTF-8\n' def test_yet_more_evil_still_undecodable(self): # Issue #25388 src = b"#\x00\n#\xfd\n" @@ -756,7 +748,6 @@ def check_limit(prefix, repeated, mode="single"): # check_limit("a", " if a else a") # check_limit("if a: pass", "\nelif a: pass", mode="exec") - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: "cannot contain null" does not match "invalid syntax (, line 1)" def test_null_terminated(self): # The source code is null-terminated internally, but bytes-like # objects are accepted, which could be not terminated. @@ -1673,7 +1664,6 @@ class WeirdDict(dict): self.assertRaises(NameError, ns['foo']) - @unittest.expectedFailure # TODO: RUSTPYTHON; + [3, 5, 3, 5] def test_compile_warnings(self): # Each invocation of compile() emits compiler warnings, even if they # have the same message and line number. @@ -1691,7 +1681,6 @@ def test_compile_warnings(self): self.assertEqual([wm.lineno for wm in caught], [3, 5] * 2) - @unittest.expectedFailure # TODO: RUSTPYTHON; + [5, 9] def test_compile_warning_in_finally(self): # Ensure that warnings inside finally blocks are # only emitted once despite the block being @@ -1742,7 +1731,6 @@ def test_compile_warning_in_finally(self): self.assertEqual(wm.category, SyntaxWarning) self.assertIn("\"is\" with 'int' literal", str(wm.message)) - @unittest.expectedFailure # TODO: RUSTPYTHON @support.subTests('src', [ textwrap.dedent(""" def f(): diff --git a/Lib/test/test_exceptions.py b/Lib/test/test_exceptions.py index 621c9d82336..7ab4c810a08 100644 --- a/Lib/test/test_exceptions.py +++ b/Lib/test/test_exceptions.py @@ -231,7 +231,6 @@ def check(self, src, lineno, offset, end_lineno=None, end_offset=None, encoding= line = line.removeprefix('\ufeff') self.assertIn(line, cm.exception.text) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_error_offset_continuation_characters(self): check = self.check check('"\\\n"(1 for c in I,\\\n\\', 2, 2) diff --git a/Lib/test/test_fstring.py b/Lib/test/test_fstring.py index f4fca1caec7..e35d5118f18 100644 --- a/Lib/test/test_fstring.py +++ b/Lib/test/test_fstring.py @@ -701,7 +701,6 @@ def test_double_braces(self): ["f'{ {{}} }'", # dict in a set ]) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_compile_time_concat(self): x = 'def' self.assertEqual('abc' f'## {x}ghi', 'abc## defghi') @@ -816,7 +815,6 @@ def build_fstr(n, extra=''): s = "f'{1}' 'x' 'y'" * 1024 self.assertEqual(eval(s), '1xy' * 1024) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_format_specifier_expressions(self): width = 10 precision = 4 @@ -947,7 +945,6 @@ def test_parens_in_expressions(self): ["f'{3)+(4}'", ]) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_newlines_before_syntax_error(self): self.assertAllRaise(SyntaxError, "f-string: expecting a valid expression after '{'", @@ -1031,7 +1028,6 @@ def test_misformed_unicode_character_name(self): r"'\N{GREEK CAPITAL LETTER DELTA'", ]) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_backslashes_in_expression_part(self): self.assertEqual(f"{( 1 + @@ -1732,7 +1728,6 @@ def test_with_an_underscore_and_a_comma_in_format_specifier(self): with self.assertRaisesRegex(ValueError, error_msg): f'{1:_,}' - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: "f-string: expecting a valid expression after '{'" does not match "invalid syntax (?, line 1)" def test_syntax_error_for_starred_expressions(self): with self.assertRaisesRegex(SyntaxError, "can't use starred expression here"): compile("f'{*a}'", "?", "exec") diff --git a/Lib/test/test_future_stmt/test_future.py b/Lib/test/test_future_stmt/test_future.py index faa5f4cc683..8d2050a3936 100644 --- a/Lib/test/test_future_stmt/test_future.py +++ b/Lib/test/test_future_stmt/test_future.py @@ -81,7 +81,6 @@ def test_future_multiple_features(self): ): from test.test_future_stmt import test_future_multiple_features # noqa: F401 - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 1 != 24 def test_unknown_future_flag(self): code = """ from __future__ import nested_scopes @@ -135,14 +134,12 @@ def test_multiple_import_statements_on_same_line(self): """ self.assertSyntaxError(code, offset=54) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 1 != 24 def test_future_import_star(self): code = """ from __future__ import * """ self.assertSyntaxError(code, message='future feature * is not defined', offset=24) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_future_import_braces(self): code = """ from __future__ import braces @@ -188,7 +185,6 @@ def test_syntactical_future_repl(self): out = kill_python(p) self.assertNotIn(b'SyntaxError: invalid syntax', out) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_future_dotted_import(self): with self.assertRaises(ImportError): exec("from .__future__ import spam") @@ -480,7 +476,6 @@ def bar(): self.assertEqual(foo.__code__.co_cellvars, ()) self.assertEqual(foo().__code__.co_freevars, ()) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: SyntaxError not raised def test_annotations_forbidden(self): with self.assertRaises(SyntaxError): self._exec_future("test: (yield)") diff --git a/Lib/test/test_genexps.py b/Lib/test/test_genexps.py index fde12f13cdc..17d2d137074 100644 --- a/Lib/test/test_genexps.py +++ b/Lib/test/test_genexps.py @@ -159,7 +159,7 @@ ... SyntaxError: cannot assign to generator expression - >>> (y for y in (1,2)) += 10 # TODO: RUSTPYTHON # doctest: +EXPECTED_FAILURE + >>> (y for y in (1,2)) += 10 Traceback (most recent call last): ... SyntaxError: 'generator expression' is an illegal expression for augmented assignment diff --git a/Lib/test/test_global.py b/Lib/test/test_global.py index 1f55dfbe1ac..11d0bd54e8b 100644 --- a/Lib/test/test_global.py +++ b/Lib/test/test_global.py @@ -28,7 +28,6 @@ def setUp(self): ### Syntax error cases as covered in Python/symtable.c ###################################################### - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 12 != 5 def test_name_param(self): prog_text = """\ def fn(name_param): @@ -36,7 +35,6 @@ def fn(name_param): """ check_syntax_error(self, prog_text, lineno=2, offset=5) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 12 != 5 def test_name_after_assign(self): prog_text = """\ def fn(): @@ -45,7 +43,6 @@ def fn(): """ check_syntax_error(self, prog_text, lineno=3, offset=5) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 12 != 5 def test_name_after_use(self): prog_text = """\ def fn(): @@ -54,7 +51,6 @@ def fn(): """ check_syntax_error(self, prog_text, lineno=3, offset=5) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 12 != 5 def test_name_annot(self): prog_text_3 = """\ def fn(): diff --git a/Lib/test/test_grammar.py b/Lib/test/test_grammar.py index cf90de7b115..19440b10115 100644 --- a/Lib/test/test_grammar.py +++ b/Lib/test/test_grammar.py @@ -114,7 +114,6 @@ def test_underscore_literals(self): # Sanity check: no literal begins with an underscore self.assertRaises(NameError, eval, "_0") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_bad_numerical_literals(self): check = self.check_syntax_error check("0b12", "invalid digit '2' in binary literal") @@ -137,7 +136,6 @@ def test_bad_numerical_literals(self): check("1e2_", "invalid decimal literal") check("1e+", "invalid decimal literal") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_end_of_numerical_literals(self): def check(test, error=False): with self.subTest(expr=test): @@ -251,7 +249,6 @@ def test_eof_error(self): compile(s, "", "exec") self.assertIn("was never closed", str(cm.exception)) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: SyntaxError not raised @skip_wasi_stack_overflow() def test_max_level(self): # Macro defined in Parser/lexer/state.h @@ -298,7 +295,6 @@ def one(): my_lst[one()-1]: int = 5 self.assertEqual(my_lst, [5]) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_var_annot_syntax_errors(self): # parser pass check_syntax_error(self, "def f: int") @@ -751,7 +747,6 @@ def test_expr_stmt(self): # Check the heuristic for print & exec covers significant cases # As well as placing some limits on false positives - @unittest.expectedFailure # TODO: RUSTPYTHON def test_former_statements_refer_to_builtins(self): keywords = "print", "exec" # Cases where we want the custom error @@ -1165,7 +1160,6 @@ def continue_in_finally_after_return2(x): """, True) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_yield(self): # Allowed as standalone statement def g(): yield 1 @@ -1205,7 +1199,6 @@ def g(): rest = 4, 5, 6; yield 1, 2, 3, *rest # Check annotation refleak on SyntaxError check_syntax_error(self, "def g(a:(yield)): pass") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_yield_in_comprehensions(self): # Check yield in comprehensions def g(): [x for x in [(yield 1)]] @@ -1302,7 +1295,6 @@ def test_assert_failures(self): else: self.fail("AssertionError not raised by 'assert False'") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_assert_syntax_warnings(self): # Ensure that we warn users if they provide a non-zero length tuple as # the assertion test. @@ -1317,7 +1309,6 @@ def test_assert_syntax_warnings(self): compile('assert x, "msg"', '', 'exec') compile('assert False, "msg"', '', 'exec') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_assert_warning_promotes_to_syntax_error(self): # If SyntaxWarning is configured to be an error, it actually raises a # SyntaxError. @@ -1496,7 +1487,6 @@ def test_comparison(self): if 1 not in (): pass if 1 < 1 > 1 == 1 >= 1 <= 1 != 1 in 1 not in x is x is not x: pass - @unittest.expectedFailure # TODO: RUSTPYTHON def test_comparison_is_literal(self): def check(test, msg): self.check_syntax_warning(test, msg) @@ -1526,7 +1516,6 @@ def check(test, msg): compile('True is x', '', 'exec') compile('... is x', '', 'exec') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_warn_missed_comma(self): def check(test): self.check_syntax_warning(test, msg) diff --git a/Lib/test/test_listcomps.py b/Lib/test/test_listcomps.py index 5dbc130b4c5..5e09fad72d8 100644 --- a/Lib/test/test_listcomps.py +++ b/Lib/test/test_listcomps.py @@ -219,7 +219,6 @@ class i: [__conditional_annotations__ for x in y] """ self._check_in_scopes(code, raises=NameError) - @unittest.expectedFailure # TODO: RUSTPYTHON; SyntaxError: compiler_make_closure: cannot find '__conditional_annotations__' in parent vars def test_references___conditional_annotations___nested(self): code = """ class i: [lambda: __conditional_annotations__ for x in y] diff --git a/Lib/test/test_named_expressions.py b/Lib/test/test_named_expressions.py index 4f92176b301..2e0643484fc 100644 --- a/Lib/test/test_named_expressions.py +++ b/Lib/test/test_named_expressions.py @@ -4,35 +4,30 @@ class NamedExpressionInvalidTest(unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON; wrong error message def test_named_expression_invalid_01(self): code = """x := 0""" with self.assertRaisesRegex(SyntaxError, "invalid syntax"): exec(code, {}, {}) - @unittest.expectedFailure # TODO: RUSTPYTHON; wrong error message def test_named_expression_invalid_02(self): code = """x = y := 0""" with self.assertRaisesRegex(SyntaxError, "invalid syntax"): exec(code, {}, {}) - @unittest.expectedFailure # TODO: RUSTPYTHON; wrong error message def test_named_expression_invalid_03(self): code = """y := f(x)""" with self.assertRaisesRegex(SyntaxError, "invalid syntax"): exec(code, {}, {}) - @unittest.expectedFailure # TODO: RUSTPYTHON; wrong error message def test_named_expression_invalid_04(self): code = """y0 = y1 := f(x)""" with self.assertRaisesRegex(SyntaxError, "invalid syntax"): exec(code, {}, {}) - @unittest.expectedFailure # TODO: RUSTPYTHON; wrong error message def test_named_expression_invalid_06(self): code = """((a, b) := (1, 2))""" diff --git a/Lib/test/test_patma.py b/Lib/test/test_patma.py index 8d359a646d9..5a06972fdde 100644 --- a/Lib/test/test_patma.py +++ b/Lib/test/test_patma.py @@ -82,7 +82,6 @@ class S4(collections.UserList, dict, C): self.assertEqual(self.check_mapping_then_sequence(S3()), "seq") self.assertEqual(self.check_mapping_then_sequence(S4()), "seq") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_late_registration_mapping(self): class Parent: pass @@ -106,7 +105,6 @@ class GrandchildPost(ChildPost): self.assertEqual(self.check_mapping_then_sequence(ChildPost()), "map") self.assertEqual(self.check_mapping_then_sequence(GrandchildPost()), "map") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_late_registration_sequence(self): class Parent: pass @@ -2246,7 +2244,6 @@ def f(w): self.assertEqual(f(None), {}) self.assertEqual(f((1, 2)), {}) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_patma_210(self): def f(w): match w: @@ -2955,7 +2952,6 @@ def test_invalid_syntax_2(self): pass """) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_invalid_syntax_3(self): self.assert_syntax_error(""" match ...: @@ -3075,7 +3071,6 @@ def test_name_capture_makes_remaining_patterns_unreachable_4(self): pass """) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_patterns_may_only_match_literals_and_attribute_lookups_0(self): self.assert_syntax_error(""" match ...: @@ -3083,7 +3078,6 @@ def test_patterns_may_only_match_literals_and_attribute_lookups_0(self): pass """) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_patterns_may_only_match_literals_and_attribute_lookups_1(self): self.assert_syntax_error(""" match ...: @@ -3126,7 +3120,6 @@ def test_real_number_multiple_ops(self): pass """) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_real_number_wrong_ops(self): for op in ["*", "/", "@", "**", "%", "//"]: with self.subTest(op=op): @@ -3202,7 +3195,6 @@ def test_mapping_pattern_duplicate_key(self): pass """) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_mapping_pattern_duplicate_key_edge_case0(self): self.assert_syntax_error(""" match ...: @@ -3210,7 +3202,6 @@ def test_mapping_pattern_duplicate_key_edge_case0(self): pass """) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_mapping_pattern_duplicate_key_edge_case1(self): self.assert_syntax_error(""" match ...: @@ -3225,8 +3216,6 @@ def test_mapping_pattern_duplicate_key_edge_case2(self): pass """) - - @unittest.expectedFailure # TODO: RUSTPYTHON def test_mapping_pattern_duplicate_key_edge_case3(self): self.assert_syntax_error(""" match ...: @@ -3258,7 +3247,6 @@ def test_accepts_positional_subpatterns_1(self): self.assertEqual(x, range(10)) self.assertIs(y, None) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_got_multiple_subpatterns_for_attribute_0(self): class Class: __match_args__ = ("a", "a") @@ -3273,7 +3261,6 @@ class Class: self.assertIs(y, None) self.assertIs(z, None) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_got_multiple_subpatterns_for_attribute_1(self): class Class: __match_args__ = ("a",) @@ -3379,7 +3366,6 @@ class A: class TestValueErrors(unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON def test_mapping_pattern_checks_duplicate_key_1(self): class Keys: KEY = "a" diff --git a/Lib/test/test_pdb.py b/Lib/test/test_pdb.py index 8b2806781af..97f084088c5 100644 --- a/Lib/test/test_pdb.py +++ b/Lib/test/test_pdb.py @@ -2165,7 +2165,7 @@ def test_pdb_await_support(): >>> def test_function(): ... asyncio.run(main(), loop_factory=asyncio.EventLoop) - >>> with PdbTestInput([ # TODO: RUSTPYTHON # doctest: +ELLIPSIS +EXPECTED_FAILURE + >>> with PdbTestInput([ # doctest: +ELLIPSIS ... 'x = await task', ... 'p x', ... 'x = await test()', @@ -2280,7 +2280,7 @@ def test_pdb_await_contextvar(): >>> def test_function(): ... asyncio.run(main(), loop_factory=asyncio.EventLoop) - >>> with PdbTestInput([ # TODO: RUSTPYTHON # doctest: +EXPECTED_FAILURE + >>> with PdbTestInput([ ... 'p var.get()', ... 'print(await get_var())', ... 'print(await asyncio.create_task(set_var(100)))', @@ -2768,7 +2768,7 @@ def test_pdb_multiline_statement(): >>> def test_function(): ... import pdb; pdb.Pdb(nosigint=True, readrc=False).set_trace() - >>> with PdbTestInput([ # TODO: RUSTPYTHON # doctest: +NORMALIZE_WHITESPACE +EXPECTED_FAILURE + >>> with PdbTestInput([ # doctest: +NORMALIZE_WHITESPACE ... 'def f(x):', ... ' return x * 2', ... '', diff --git a/Lib/test/test_peepholer.py b/Lib/test/test_peepholer.py index eb251568767..14657dd2e77 100644 --- a/Lib/test/test_peepholer.py +++ b/Lib/test/test_peepholer.py @@ -157,7 +157,6 @@ def test_pack_unpack(self): self.assertNotInBytecode(code, 'UNPACK_SEQUENCE') self.check_lnotab(code) - @unittest.expectedFailure # TODO: RUSTPYTHON; LOAD_CONST count mismatch in long-tuple branch def test_constant_folding_tuples_of_constants(self): for line, elem in ( ('a = 1,2,3', (1, 2, 3)), diff --git a/Lib/test/test_pep646_syntax.py b/Lib/test/test_pep646_syntax.py index 8034bb9e935..d79196219fe 100644 --- a/Lib/test/test_pep646_syntax.py +++ b/Lib/test/test_pep646_syntax.py @@ -312,7 +312,7 @@ >>> f4.__annotations__ {'args': StarredB, 'arg1': } - >>> def f5(*args: *b = (1,)): pass # TODO: RUSTPYTHON # doctest: +EXPECTED_FAILURE + >>> def f5(*args: *b = (1,)): pass Traceback (most recent call last): ... SyntaxError: invalid syntax diff --git a/Lib/test/test_pydoc/test_pydoc.py b/Lib/test/test_pydoc/test_pydoc.py index 46f8ba60f8b..2a96ef4dd71 100644 --- a/Lib/test/test_pydoc/test_pydoc.py +++ b/Lib/test/test_pydoc/test_pydoc.py @@ -932,7 +932,6 @@ def test_synopsis(self): synopsis = pydoc.synopsis(TESTFN, {}) self.assertEqual(synopsis, 'line 1: h\xe9') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_source_synopsis(self): def check(source, expected, encoding=None): if isinstance(source, str): diff --git a/Lib/test/test_pyrepl/test_interact.py b/Lib/test/test_pyrepl/test_interact.py index e4f90db3304..65b1eed5bdd 100644 --- a/Lib/test/test_pyrepl/test_interact.py +++ b/Lib/test/test_pyrepl/test_interact.py @@ -117,7 +117,6 @@ def f(x, x): ... SyntaxError: duplicate argument 'x' in function definition""" self.assertIn(r, f.getvalue()) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_runsource_shows_syntax_error_for_failed_compilation(self): console = InteractiveColoredConsole() source = "print('Hello, world!'" @@ -133,7 +132,6 @@ def test_runsource_shows_syntax_error_for_failed_compilation(self): console.runsource(source) mock_showsyntaxerror.assert_called_once() - @unittest.expectedFailure # TODO: RUSTPYTHON def test_runsource_survives_null_bytes(self): console = InteractiveColoredConsole() source = "\x00\n" @@ -155,7 +153,6 @@ def test_no_active_future(self): self.assertFalse(result) self.assertEqual(f.getvalue(), "{'x': }\n") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_future_annotations(self): console = InteractiveColoredConsole() source = dedent("""\ @@ -210,7 +207,6 @@ def test_multiline_single_assignment(self): console = InteractiveColoredConsole(namespace, filename="") self.assertFalse(_more_lines(console, code)) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_multiline_single_block(self): namespace = {} code = dedent("""\ @@ -227,7 +223,6 @@ def test_multiple_statements_single_line(self): console = InteractiveColoredConsole(namespace, filename="") self.assertFalse(_more_lines(console, code)) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_multiple_statements(self): namespace = {} code = dedent("""\ @@ -237,7 +232,6 @@ def test_multiple_statements(self): console = InteractiveColoredConsole(namespace, filename="") self.assertTrue(_more_lines(console, code)) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_multiple_blocks(self): namespace = {} code = dedent("""\ @@ -285,7 +279,6 @@ def test_incomplete_statement(self): class TestWarnings(unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON def test_pep_765_warning(self): """ Test that a SyntaxWarning emitted from the diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 74735ef3c84..1bf3f9715b4 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -466,7 +466,6 @@ def prepare_reader(self, events): reader = ReadlineAlikeReader(console=console, config=config) return reader - @unittest.expectedFailure # TODO: RUSTPYTHON def test_auto_indent_default(self): # fmt: off input_code = ( @@ -486,7 +485,6 @@ def test_auto_indent_default(self): output = multiline_input(reader) self.assertEqual(output, output_code) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_auto_indent_continuation(self): # auto indenting according to previous user indentation # fmt: off @@ -514,7 +512,6 @@ def test_auto_indent_continuation(self): output = multiline_input(reader) self.assertEqual(output, output_code) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_auto_indent_prev_block(self): # auto indenting according to indentation in different block # fmt: off @@ -546,7 +543,6 @@ def test_auto_indent_prev_block(self): output2 = multiline_input(reader) self.assertEqual(output2, output_code) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_auto_indent_multiline(self): # fmt: off events = itertools.chain( @@ -586,7 +582,6 @@ def test_auto_indent_multiline(self): output = multiline_input(reader) self.assertEqual(output, output_code) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_auto_indent_with_comment(self): # fmt: off events = code_to_events( @@ -605,7 +600,6 @@ def test_auto_indent_with_comment(self): output = multiline_input(reader) self.assertEqual(output, output_code) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_auto_indent_with_multicomment(self): # fmt: off events = code_to_events( @@ -680,7 +674,6 @@ def test_get_line_buffer_returns_str(self): wrapper = _ReadlineWrapper(f_in=None, f_out=None, reader=reader) self.assertIs(type(wrapper.get_line_buffer()), str) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_multiline_edit(self): events = itertools.chain( code_to_events("def f():\n...\n\n"), @@ -744,7 +737,6 @@ def test_history_navigation_with_up_arrow(self): self.assertEqual(output, "1+1") self.assert_screen_equal(reader, "1+1", clean=True) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_history_with_multiline_entries(self): code = "def foo():\nx = 1\ny = 2\nz = 3\n\ndef bar():\nreturn 42\n\n" events = list(itertools.chain( @@ -1426,7 +1418,6 @@ def test_paste_mid_newlines(self): output = multiline_input(reader) self.assertEqual(output, code) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_paste_mid_newlines_not_in_paste_mode(self): # fmt: off code = ( @@ -1448,7 +1439,6 @@ def test_paste_mid_newlines_not_in_paste_mode(self): output = multiline_input(reader) self.assertEqual(output, expected) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_paste_not_in_paste_mode(self): # fmt: off input_code = ( diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index 33ef95accbd..51644ec7ce4 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -180,7 +180,6 @@ def test_up_arrow_after_ctrl_r(self): reader, _ = handle_all_events(events) self.assert_screen_equal(reader, "") - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Lists differ def test_newline_within_block_trailing_whitespace(self): # fmt: off code = ( diff --git a/Lib/test/test_repl.py b/Lib/test/test_repl.py index 211d1783842..c80db832387 100644 --- a/Lib/test/test_repl.py +++ b/Lib/test/test_repl.py @@ -437,7 +437,6 @@ def test_toplevel_contextvars_sync(self): expected = "toplevel contextvar test: ok" self.assertIn(expected, output, expected) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 101 != 0 def test_toplevel_contextvars_async(self): user_input = dedent("""\ from contextvars import ContextVar diff --git a/Lib/test/test_symtable.py b/Lib/test/test_symtable.py index f36bbcaea1f..b55adab6baf 100644 --- a/Lib/test/test_symtable.py +++ b/Lib/test/test_symtable.py @@ -198,7 +198,6 @@ class SymtableTest(unittest.TestCase): T = find_block(GenericMine, "T") U = find_block(GenericMine, "U") - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: != 'type alias' def test_type(self): self.assertEqual(self.top.get_type(), "module") self.assertEqual(self.Mine.get_type(), "class") diff --git a/Lib/test/test_syntax.py b/Lib/test/test_syntax.py index 0934f22d470..5013eb096f5 100644 --- a/Lib/test/test_syntax.py +++ b/Lib/test/test_syntax.py @@ -59,15 +59,15 @@ Traceback (most recent call last): SyntaxError: cannot assign to __debug__ ->>> def __debug__(): pass # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> def __debug__(): pass Traceback (most recent call last): SyntaxError: cannot assign to __debug__ ->>> async def __debug__(): pass # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> async def __debug__(): pass Traceback (most recent call last): SyntaxError: cannot assign to __debug__ ->>> class __debug__: pass # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> class __debug__: pass Traceback (most recent call last): SyntaxError: cannot assign to __debug__ @@ -75,7 +75,7 @@ Traceback (most recent call last): SyntaxError: cannot delete __debug__ ->>> f() = 1 # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> f() = 1 Traceback (most recent call last): SyntaxError: cannot assign to function call here. Maybe you meant '==' instead of '='? @@ -83,11 +83,11 @@ Traceback (most recent call last): SyntaxError: assignment to yield expression not possible ->>> del f() # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> del f() Traceback (most recent call last): SyntaxError: cannot delete function call ->>> a + 1 = 2 # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> a + 1 = 2 Traceback (most recent call last): SyntaxError: cannot assign to expression here. Maybe you meant '==' instead of '='? @@ -120,7 +120,7 @@ This test just checks a couple of cases rather than enumerating all of them. ->>> (a, "b", c) = (1, 2, 3) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> (a, "b", c) = (1, 2, 3) Traceback (most recent call last): SyntaxError: cannot assign to literal @@ -168,15 +168,15 @@ Traceback (most recent call last): SyntaxError: expected 'else' after 'if' expression ->>> x = 1 if 1 else pass # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> x = 1 if 1 else pass Traceback (most recent call last): SyntaxError: expected expression after 'else', but statement is given ->>> x = pass if 1 else 1 # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> x = pass if 1 else 1 Traceback (most recent call last): SyntaxError: expected expression before 'if', but statement is given ->>> x = pass if 1 else pass # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> x = pass if 1 else pass Traceback (most recent call last): SyntaxError: expected expression before 'if', but statement is given @@ -200,15 +200,15 @@ Traceback (most recent call last): SyntaxError: assignment to yield expression not possible ->>> a, b += 1, 2 # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> a, b += 1, 2 Traceback (most recent call last): SyntaxError: 'tuple' is an illegal expression for augmented assignment ->>> (a, b) += 1, 2 # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> (a, b) += 1, 2 Traceback (most recent call last): SyntaxError: 'tuple' is an illegal expression for augmented assignment ->>> [a, b] += 1, 2 # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> [a, b] += 1, 2 Traceback (most recent call last): SyntaxError: 'list' is an illegal expression for augmented assignment @@ -243,7 +243,7 @@ Traceback (most recent call last): SyntaxError: cannot assign to expression ->>> for i < (): pass # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> for i < (): pass Traceback (most recent call last): SyntaxError: invalid syntax @@ -285,11 +285,11 @@ Comprehensions without 'in' keyword: ->>> [x for x if range(1)] # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> [x for x if range(1)] Traceback (most recent call last): SyntaxError: 'in' expected after for-loop variables ->>> tuple(x for x if range(1)) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> tuple(x for x if range(1)) Traceback (most recent call last): SyntaxError: 'in' expected after for-loop variables @@ -301,7 +301,7 @@ Traceback (most recent call last): SyntaxError: cannot assign to expression ->>> [x for a, b, (c + 1, d()) if y] # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> [x for a, b, (c + 1, d()) if y] Traceback (most recent call last): SyntaxError: 'in' expected after for-loop variables @@ -316,11 +316,11 @@ Comprehensions creating tuples without parentheses should produce a specialized error message: ->>> [x,y for x,y in range(100)] # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> [x,y for x,y in range(100)] Traceback (most recent call last): SyntaxError: did you forget parentheses around the comprehension target? ->>> {x,y for x,y in range(100)} # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> {x,y for x,y in range(100)} Traceback (most recent call last): SyntaxError: did you forget parentheses around the comprehension target? @@ -385,7 +385,7 @@ # But prefixes of soft keywords should # still raise specialized errors ->>> (mat x) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> (mat x) Traceback (most recent call last): SyntaxError: invalid syntax. Perhaps you forgot a comma? @@ -413,7 +413,7 @@ Traceback (most recent call last): SyntaxError: invalid syntax ->>> def f(*None): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> def f(*None): ... pass Traceback (most recent call last): SyntaxError: invalid syntax @@ -423,7 +423,7 @@ Traceback (most recent call last): SyntaxError: invalid syntax ->>> def foo(/,a,b=,c): +>>> def foo(/,a,b=,c): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE ... pass Traceback (most recent call last): SyntaxError: at least one argument must precede / @@ -468,12 +468,12 @@ Traceback (most recent call last): SyntaxError: var-positional argument cannot have default value ->>> def foo(a,**b=3): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> def foo(a,**b=3): ... pass Traceback (most recent call last): SyntaxError: var-keyword argument cannot have default value ->>> def foo(a,**b: int=3): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> def foo(a,**b: int=3): ... pass Traceback (most recent call last): SyntaxError: var-keyword argument cannot have default value @@ -523,22 +523,22 @@ Traceback (most recent call last): SyntaxError: * argument may appear only once ->>> def foo(a=1,/*,b,c): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> def foo(a=1,/*,b,c): ... pass Traceback (most recent call last): SyntaxError: expected comma between / and * ->>> def foo(a=1,d=,c): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> def foo(a=1,d=,c): ... pass Traceback (most recent call last): SyntaxError: expected default value expression ->>> def foo(a,d=,c): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> def foo(a,d=,c): ... pass Traceback (most recent call last): SyntaxError: expected default value expression ->>> def foo(a,d: int=,c): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> def foo(a,d: int=,c): ... pass Traceback (most recent call last): SyntaxError: expected default value expression @@ -571,7 +571,7 @@ Traceback (most recent call last): SyntaxError: / must be ahead of * ->>> lambda a=1,/*,b,c: None # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> lambda a=1,/*,b,c: None Traceback (most recent call last): SyntaxError: expected comma between / and * @@ -579,7 +579,7 @@ Traceback (most recent call last): SyntaxError: var-positional argument cannot have default value ->>> lambda a,**b=3: None # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> lambda a,**b=3: None Traceback (most recent call last): SyntaxError: var-keyword argument cannot have default value @@ -619,11 +619,11 @@ Traceback (most recent call last): SyntaxError: * argument may appear only once ->>> lambda a=1,d=,c: None # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> lambda a=1,d=,c: None Traceback (most recent call last): SyntaxError: expected default value expression ->>> lambda a,d=,c: None # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> lambda a,d=,c: None Traceback (most recent call last): SyntaxError: expected default value expression @@ -641,7 +641,7 @@ ... a, # type: int ... ): ... pass -... ''', type_comments=True) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +... ''', type_comments=True) Traceback (most recent call last): SyntaxError: bare * has associated type comment @@ -784,7 +784,7 @@ ... 290, 291, 292, 293, 294, 295, 296, 297, 298, 299) # doctest: +ELLIPSIS (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ..., 297, 298, 299) ->>> f(lambda x: x[0] = 3) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> f(lambda x: x[0] = 3) Traceback (most recent call last): SyntaxError: expression cannot contain assignment, perhaps you meant "=="? @@ -796,25 +796,25 @@ The grammar accepts any test (basically, any expression) in the keyword slot of a call site. Test a few different options. ->>> f(x()=2) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> f(x()=2) Traceback (most recent call last): SyntaxError: expression cannot contain assignment, perhaps you meant "=="? ->>> f(a or b=1) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> f(a or b=1) Traceback (most recent call last): SyntaxError: expression cannot contain assignment, perhaps you meant "=="? ->>> f(x.y=1) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> f(x.y=1) Traceback (most recent call last): SyntaxError: expression cannot contain assignment, perhaps you meant "=="? ->>> f((x)=2) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> f((x)=2) Traceback (most recent call last): SyntaxError: expression cannot contain assignment, perhaps you meant "=="? ->>> f(True=1) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> f(True=1) Traceback (most recent call last): SyntaxError: cannot assign to True ->>> f(False=1) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> f(False=1) Traceback (most recent call last): SyntaxError: cannot assign to False ->>> f(None=1) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> f(None=1) Traceback (most recent call last): SyntaxError: cannot assign to None >>> f(__debug__=1) @@ -826,42 +826,42 @@ >>> x.__debug__: int Traceback (most recent call last): SyntaxError: cannot assign to __debug__ ->>> f(a=) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> f(a=) Traceback (most recent call last): SyntaxError: expected argument value expression ->>> f(a, b, c=) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> f(a, b, c=) Traceback (most recent call last): SyntaxError: expected argument value expression ->>> f(a, b, c=, d) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> f(a, b, c=, d) Traceback (most recent call last): SyntaxError: expected argument value expression ->>> f(*args=[0]) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> f(*args=[0]) Traceback (most recent call last): SyntaxError: cannot assign to iterable argument unpacking ->>> f(a, b, *args=[0]) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> f(a, b, *args=[0]) Traceback (most recent call last): SyntaxError: cannot assign to iterable argument unpacking ->>> f(**kwargs={'a': 1}) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> f(**kwargs={'a': 1}) Traceback (most recent call last): SyntaxError: cannot assign to keyword argument unpacking ->>> f(a, b, *args, **kwargs={'a': 1}) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> f(a, b, *args, **kwargs={'a': 1}) Traceback (most recent call last): SyntaxError: cannot assign to keyword argument unpacking More set_context(): ->>> (x for x in x) += 1 # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> (x for x in x) += 1 Traceback (most recent call last): SyntaxError: 'generator expression' is an illegal expression for augmented assignment ->>> None += 1 # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> None += 1 Traceback (most recent call last): SyntaxError: 'None' is an illegal expression for augmented assignment >>> __debug__ += 1 Traceback (most recent call last): SyntaxError: cannot assign to __debug__ >>> f() += 1 # TODO: RUSTPYTHON; Raises an exception # doctest: +SKIP -Traceback (most recent call last): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +Traceback (most recent call last): SyntaxError: 'function call' is an illegal expression for augmented assignment @@ -957,7 +957,7 @@ elif can't come after an else. - >>> if a % 2 == 0: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> if a % 2 == 0: ... pass ... else: ... pass @@ -1185,7 +1185,7 @@ Traceback (most recent call last): SyntaxError: expected ':' - >>> with (blech as something) + >>> with (blech as something) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE ... pass Traceback (most recent call last): SyntaxError: expected ':' @@ -1195,12 +1195,12 @@ Traceback (most recent call last): SyntaxError: expected ':' - >>> with (blech, block as something) + >>> with (blech, block as something) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE ... pass Traceback (most recent call last): SyntaxError: expected ':' - >>> with (blech, block as something, bluch) + >>> with (blech, block as something, bluch) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE ... pass Traceback (most recent call last): SyntaxError: expected ':' @@ -1313,39 +1313,39 @@ Parenthesized arguments in function definitions - >>> def f(x, (y, z), w): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> def f(x, (y, z), w): ... pass Traceback (most recent call last): SyntaxError: Function parameters cannot be parenthesized - >>> def f((x, y, z, w)): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> def f((x, y, z, w)): ... pass Traceback (most recent call last): SyntaxError: Function parameters cannot be parenthesized - >>> def f(x, (y, z, w)): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> def f(x, (y, z, w)): ... pass Traceback (most recent call last): SyntaxError: Function parameters cannot be parenthesized - >>> def f((x, y, z), w): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> def f((x, y, z), w): ... pass Traceback (most recent call last): SyntaxError: Function parameters cannot be parenthesized - >>> lambda x, (y, z), w: None # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> lambda x, (y, z), w: None Traceback (most recent call last): SyntaxError: Lambda expression parameters cannot be parenthesized - >>> lambda (x, y, z, w): None # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> lambda (x, y, z, w): None Traceback (most recent call last): SyntaxError: Lambda expression parameters cannot be parenthesized - >>> lambda x, (y, z, w): None # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> lambda x, (y, z, w): None Traceback (most recent call last): SyntaxError: Lambda expression parameters cannot be parenthesized - >>> lambda (x, y, z), w: None # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> lambda (x, y, z), w: None Traceback (most recent call last): SyntaxError: Lambda expression parameters cannot be parenthesized @@ -1361,7 +1361,7 @@ >>> try: ... pass - ... except TypeError as __debug__: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + ... except TypeError as __debug__: ... pass Traceback (most recent call last): SyntaxError: cannot assign to __debug__ @@ -1410,28 +1410,28 @@ Better error message for using `except as` with not a name: - >>> try: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> try: ... pass ... except TypeError as obj.attr: ... pass Traceback (most recent call last): SyntaxError: cannot use except statement with attribute - >>> try: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> try: ... pass ... except TypeError as obj[1]: ... pass Traceback (most recent call last): SyntaxError: cannot use except statement with subscript - >>> try: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> try: ... pass ... except* TypeError as (obj, name): ... pass Traceback (most recent call last): SyntaxError: cannot use except* statement with tuple - >>> try: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> try: ... pass ... except* TypeError as 1: ... pass @@ -1440,18 +1440,18 @@ Regression tests for gh-133999: - >>> try: pass - ... except TypeError as name: raise from None # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> try: pass # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + ... except TypeError as name: raise from None Traceback (most recent call last): SyntaxError: invalid syntax - >>> try: pass - ... except* TypeError as name: raise from None # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> try: pass # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + ... except* TypeError as name: raise from None Traceback (most recent call last): SyntaxError: invalid syntax - >>> match 1: - ... case 1 | 2 as abc: raise from None # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> match 1: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + ... case 1 | 2 as abc: raise from None Traceback (most recent call last): SyntaxError: invalid syntax @@ -1464,7 +1464,7 @@ Traceback (most recent call last): SyntaxError: invalid syntax - >>> dict(x=34, (x for x in range 10), 1); x $ y + >>> dict(x=34, (x for x in range 10), 1); x $ y # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE Traceback (most recent call last): SyntaxError: invalid syntax @@ -1474,27 +1474,27 @@ Incomplete dictionary literals - >>> {1:2, 3:4, 5} # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> {1:2, 3:4, 5} Traceback (most recent call last): SyntaxError: ':' expected after dictionary key - >>> {1:2, 3:4, 5:} # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> {1:2, 3:4, 5:} Traceback (most recent call last): SyntaxError: expression expected after dictionary key and ':' - >>> {1: *12+1, 23: 1} # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> {1: *12+1, 23: 1} Traceback (most recent call last): SyntaxError: cannot use a starred expression in a dictionary value - >>> {1: *12+1} # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> {1: *12+1} Traceback (most recent call last): SyntaxError: cannot use a starred expression in a dictionary value - >>> {1: 23, 1: *12+1} # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> {1: 23, 1: *12+1} Traceback (most recent call last): SyntaxError: cannot use a starred expression in a dictionary value - >>> {1:} # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> {1:} Traceback (most recent call last): SyntaxError: expression expected after dictionary key and ':' @@ -1506,7 +1506,7 @@ # Ensure that the error is not raised for invalid expressions - >>> {1: 2, 3: foo(,), 4: 5} # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> {1: 2, 3: foo(,), 4: 5} Traceback (most recent call last): SyntaxError: invalid syntax @@ -1516,48 +1516,48 @@ Specialized indentation errors: - >>> while condition: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> while condition: ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'while' statement on line 1 - >>> for x in range(10): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> for x in range(10): ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'for' statement on line 1 - >>> for x in range(10): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> for x in range(10): ... pass ... else: ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'else' statement on line 3 - >>> async for x in range(10): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> async for x in range(10): ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'for' statement on line 1 - >>> async for x in range(10): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> async for x in range(10): ... pass ... else: ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'else' statement on line 3 - >>> if something: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> if something: ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'if' statement on line 1 - >>> if something: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> if something: ... pass ... elif something_else: ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'elif' statement on line 3 - >>> if something: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> if something: ... pass ... elif something_else: ... pass @@ -1566,33 +1566,33 @@ Traceback (most recent call last): IndentationError: expected an indented block after 'else' statement on line 5 - >>> try: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> try: ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'try' statement on line 1 - >>> try: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> try: ... something() ... except: ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'except' statement on line 3 - >>> try: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> try: ... something() ... except A: ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'except' statement on line 3 - >>> try: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> try: ... something() ... except* A: ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'except*' statement on line 3 - >>> try: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> try: ... something() ... except A: ... pass @@ -1601,7 +1601,7 @@ Traceback (most recent call last): IndentationError: expected an indented block after 'finally' statement on line 5 - >>> try: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> try: ... something() ... except* A: ... pass @@ -1610,57 +1610,57 @@ Traceback (most recent call last): IndentationError: expected an indented block after 'finally' statement on line 5 - >>> with A: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> with A: ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'with' statement on line 1 - >>> with A as a, B as b: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> with A as a, B as b: ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'with' statement on line 1 - >>> with (A as a, B as b): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> with (A as a, B as b): ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'with' statement on line 1 - >>> async with A: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> async with A: ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'with' statement on line 1 - >>> async with A as a, B as b: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> async with A as a, B as b: ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'with' statement on line 1 - >>> async with (A as a, B as b): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> async with (A as a, B as b): ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'with' statement on line 1 - >>> def foo(x, /, y, *, z=2): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> def foo(x, /, y, *, z=2): ... pass Traceback (most recent call last): IndentationError: expected an indented block after function definition on line 1 - >>> def foo[T](x, /, y, *, z=2): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> def foo[T](x, /, y, *, z=2): ... pass Traceback (most recent call last): IndentationError: expected an indented block after function definition on line 1 - >>> class Blech(A): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> class Blech(A): ... pass Traceback (most recent call last): IndentationError: expected an indented block after class definition on line 1 - >>> class Blech[T](A): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> class Blech[T](A): ... pass Traceback (most recent call last): IndentationError: expected an indented block after class definition on line 1 - >>> class C(__debug__=42): ... # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> class C(__debug__=42): ... Traceback (most recent call last): SyntaxError: cannot assign to __debug__ @@ -1668,23 +1668,23 @@ ... def __new__(*args, **kwargs): ... pass - >>> class C(metaclass=Meta, __debug__=42): # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> class C(metaclass=Meta, __debug__=42): ... pass Traceback (most recent call last): SyntaxError: cannot assign to __debug__ - >>> match something: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> match something: ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'match' statement on line 1 - >>> match something: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> match something: ... case []: ... pass Traceback (most recent call last): IndentationError: expected an indented block after 'case' statement on line 2 - >>> match something: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> match something: ... case []: ... ... ... case {}: @@ -1981,23 +1981,23 @@ Traceback (most recent call last): SyntaxError: cannot assign to t-string expression here. Maybe you meant '==' instead of '='? ->>> (x, y, z=3, d, e) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> (x, y, z=3, d, e) Traceback (most recent call last): SyntaxError: invalid syntax. Maybe you meant '==' or ':=' instead of '='? ->>> [x, y, z=3, d, e] # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> [x, y, z=3, d, e] Traceback (most recent call last): SyntaxError: invalid syntax. Maybe you meant '==' or ':=' instead of '='? ->>> [z=3] # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> [z=3] Traceback (most recent call last): SyntaxError: invalid syntax. Maybe you meant '==' or ':=' instead of '='? ->>> {x, y, z=3, d, e} # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> {x, y, z=3, d, e} Traceback (most recent call last): SyntaxError: invalid syntax. Maybe you meant '==' or ':=' instead of '='? ->>> {z=3} # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> {z=3} Traceback (most recent call last): SyntaxError: invalid syntax. Maybe you meant '==' or ':=' instead of '='? @@ -2009,35 +2009,35 @@ Traceback (most recent call last): SyntaxError: trailing comma not allowed without surrounding parentheses ->>> import a from b # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> import a from b Traceback (most recent call last): SyntaxError: Did you mean to use 'from ... import ...' instead? ->>> import a.y.z from b.y.z # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> import a.y.z from b.y.z Traceback (most recent call last): SyntaxError: Did you mean to use 'from ... import ...' instead? ->>> import a from b as bar # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> import a from b as bar Traceback (most recent call last): SyntaxError: Did you mean to use 'from ... import ...' instead? ->>> import a.y.z from b.y.z as bar # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> import a.y.z from b.y.z as bar Traceback (most recent call last): SyntaxError: Did you mean to use 'from ... import ...' instead? ->>> import a, b,c from b # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> import a, b,c from b Traceback (most recent call last): SyntaxError: Did you mean to use 'from ... import ...' instead? ->>> import a.y.z, b.y.z, c.y.z from b.y.z # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> import a.y.z, b.y.z, c.y.z from b.y.z Traceback (most recent call last): SyntaxError: Did you mean to use 'from ... import ...' instead? ->>> import a,b,c from b as bar # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> import a,b,c from b as bar Traceback (most recent call last): SyntaxError: Did you mean to use 'from ... import ...' instead? ->>> import a.y.z, b.y.z, c.y.z from b.y.z as bar # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> import a.y.z, b.y.z, c.y.z from b.y.z as bar Traceback (most recent call last): SyntaxError: Did you mean to use 'from ... import ...' instead? @@ -2061,19 +2061,19 @@ Traceback (most recent call last): SyntaxError: cannot assign to __debug__ ->>> import a as b.c # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> import a as b.c Traceback (most recent call last): SyntaxError: cannot use attribute as import target ->>> import a.b as (a, b) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> import a.b as (a, b) Traceback (most recent call last): SyntaxError: cannot use tuple as import target ->>> import a, a.b as 1 # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> import a, a.b as 1 Traceback (most recent call last): SyntaxError: cannot use literal as import target ->>> import a.b as 'a', a # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> import a.b as 'a', a Traceback (most recent call last): SyntaxError: cannot use literal as import target @@ -2081,7 +2081,7 @@ Traceback (most recent call last): SyntaxError: cannot use attribute as import target ->>> from a import b as 1 # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> from a import b as 1 Traceback (most recent call last): SyntaxError: cannot use literal as import target @@ -2103,11 +2103,11 @@ Traceback (most recent call last): SyntaxError: cannot use tuple as import target ->>> from a import b, с as d[e] # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> from a import b, с as d[e] Traceback (most recent call last): SyntaxError: cannot use subscript as import target ->>> from a import с as d[e], b # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE +>>> from a import с as d[e], b Traceback (most recent call last): SyntaxError: cannot use subscript as import target @@ -2239,7 +2239,7 @@ Invalid pattern matching constructs: - >>> match ...: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> match ...: ... case 42 as _: ... ... Traceback (most recent call last): @@ -2251,13 +2251,13 @@ Traceback (most recent call last): SyntaxError: cannot use expression as pattern target - >>> match ...: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> match ...: ... case 42 as a.b: ... ... Traceback (most recent call last): SyntaxError: cannot use attribute as pattern target - >>> match ...: # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> match ...: ... case 42 as (a, b): ... ... Traceback (most recent call last): @@ -2307,7 +2307,7 @@ Traceback (most recent call last): ... SyntaxError: invalid syntax - >>> A[:(*b)] # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> A[:(*b)] Traceback (most recent call last): ... SyntaxError: cannot use starred expression here @@ -2326,7 +2326,7 @@ Traceback (most recent call last): ... SyntaxError: invalid syntax - >>> A[(*b):] # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> A[(*b):] Traceback (most recent call last): ... SyntaxError: cannot use starred expression here @@ -2636,26 +2636,26 @@ def f(x: *b) Traceback (most recent call last): SyntaxError: cannot assign to __debug__ - >>> class A[__debug__]: pass # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> class A[__debug__]: pass Traceback (most recent call last): SyntaxError: cannot assign to __debug__ - >>> class A[T]((x := 3)): ... # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> class A[T]((x := 3)): ... Traceback (most recent call last): ... SyntaxError: named expression cannot be used within the definition of a generic - >>> class A[T]((yield 3)): ... # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> class A[T]((yield 3)): ... Traceback (most recent call last): ... SyntaxError: yield expression cannot be used within the definition of a generic - >>> class A[T]((await 3)): ... # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> class A[T]((await 3)): ... Traceback (most recent call last): ... SyntaxError: await expression cannot be used within the definition of a generic - >>> class A[T]((yield from [])): ... # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> class A[T]((yield from [])): ... Traceback (most recent call last): ... SyntaxError: yield expression cannot be used within the definition of a generic @@ -2664,23 +2664,23 @@ def f(x: *b) Traceback (most recent call last): SyntaxError: iterable argument unpacking follows keyword argument unpacking - >>> f(**x, *) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> f(**x, *) Traceback (most recent call last): SyntaxError: Invalid star expression - >>> f(x, *:) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> f(x, *:) Traceback (most recent call last): SyntaxError: Invalid star expression - >>> f(x, *) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> f(x, *) Traceback (most recent call last): SyntaxError: Invalid star expression - >>> f(x = 5, *) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> f(x = 5, *) Traceback (most recent call last): SyntaxError: Invalid star expression - >>> f(x = 5, *:) # TODO: RUSTPYTHON; Wrong error message # doctest: +EXPECTED_FAILURE + >>> f(x = 5, *:) Traceback (most recent call last): SyntaxError: Invalid star expression """ @@ -2702,7 +2702,6 @@ def check_warning(self, code, errtext, filename="", mode="exec"): with self.assertWarnsRegex(SyntaxWarning, errtext): compile(code, filename, mode) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: SyntaxWarning not triggered def test_return_in_finally(self): source = textwrap.dedent(""" def f(): @@ -2737,7 +2736,6 @@ def f(): """) self.check_warning(source, "'return' in a 'finally' block") - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: SyntaxWarning not triggered def test_break_and_continue_in_finally(self): for kw in ('break', 'continue'): @@ -2807,7 +2805,6 @@ def _check_error(self, code, errtext, else: self.fail("compile() did not raise SyntaxError") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_expression_with_assignment(self): self._check_error( "print(end1 + end2 = ' ')", @@ -2821,7 +2818,6 @@ def test_curly_brace_after_primary_raises_immediately(self): def test_assign_call(self): self._check_error("f() = 1", "assign") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_assign_del(self): self._check_error("del (,)", "invalid syntax") self._check_error("del 1", "cannot delete literal") @@ -2955,13 +2951,11 @@ def test_generator_in_function_call(self): "Generator expression must be parenthesized", lineno=1, end_lineno=1, offset=11, end_offset=53) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_except_then_except_star(self): self._check_error("try: pass\nexcept ValueError: pass\nexcept* TypeError: pass", r"cannot have both 'except' and 'except\*' on the same 'try'", lineno=3, end_lineno=3, offset=1, end_offset=8) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_except_star_then_except(self): self._check_error("try: pass\nexcept* ValueError: pass\nexcept TypeError: pass", r"cannot have both 'except' and 'except\*' on the same 'try'", @@ -3109,7 +3103,6 @@ def func2(): """ self._check_error(code, "expected ':'") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_invalid_line_continuation_error_position(self): self._check_error(r"a = 3 \ 4", "unexpected character after line continuation character", @@ -3129,7 +3122,6 @@ def test_invalid_line_continuation_left_recursive(self): self._check_error("A.\u03bc\\\n", "unexpected EOF while parsing") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_error_parenthesis(self): for paren in "([{": self._check_error(paren + "1 + 2", f"\\{paren}' was never closed") @@ -3155,7 +3147,6 @@ def test_error_parenthesis(self): s = b'# coding=latin\n(aaaaaaaaaaaaaaaaa\naaaaaaaaaaa\xb5' self._check_error(s, r"'\(' was never closed") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_error_string_literal(self): self._check_error("'blech", r"unterminated string literal \(.*\)$") @@ -3169,7 +3160,6 @@ def test_error_string_literal(self): self._check_error("'''blech", "unterminated triple-quoted string literal") self._check_error('"""blech', "unterminated triple-quoted string literal") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_invisible_characters(self): self._check_error('print\x17("Hello")', "invalid non-printable character") self._check_error(b"with(0,,):\n\x01", "invalid non-printable character") @@ -3252,7 +3242,6 @@ def test_deep_invalid_rule(self): with self.assertRaises(SyntaxError): compile(source, "", "exec") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_except_stmt_invalid_as_expr(self): self._check_error( textwrap.dedent( @@ -3270,7 +3259,6 @@ def test_except_stmt_invalid_as_expr(self): end_offset=22 + len("obj.attr"), ) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_match_stmt_invalid_as_expr(self): self._check_error( textwrap.dedent( @@ -3287,7 +3275,6 @@ def test_match_stmt_invalid_as_expr(self): end_offset=15 + len("obj.attr"), ) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_ifexp_else_stmt(self): msg = "expected expression after 'else', but statement is given" @@ -3308,7 +3295,6 @@ def test_ifexp_else_stmt(self): ]: self._check_error(f"x = 1 if 1 else {stmt}", msg) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_ifexp_body_stmt_else_expression(self): msg = "expected expression before 'if', but statement is given" @@ -3319,7 +3305,6 @@ def test_ifexp_body_stmt_else_expression(self): ]: self._check_error(f"x = {stmt} if 1 else 1", msg) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_ifexp_body_stmt_else_stmt(self): msg = "expected expression before 'if', but statement is given" for lhs_stmt, rhs_stmt in [ diff --git a/Lib/test/test_sys_setprofile.py b/Lib/test/test_sys_setprofile.py index 813adff2a32..d0d2b0c3e01 100644 --- a/Lib/test/test_sys_setprofile.py +++ b/Lib/test/test_sys_setprofile.py @@ -169,7 +169,6 @@ def g(p): (1, 'return', g_ident), ]) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_exception_propagation(self): def f(p): 1/0 diff --git a/Lib/test/test_type_comments.py b/Lib/test/test_type_comments.py index 0deb25f16d3..d827ac27108 100644 --- a/Lib/test/test_type_comments.py +++ b/Lib/test/test_type_comments.py @@ -252,7 +252,6 @@ def parse_all(self, source, minver=lowest, maxver=highest, expected_regex=""): def classic_parse(self, source): return ast.parse(source) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'FunctionDef' object has no attribute 'type_comment' def test_funcdef(self): for tree in self.parse_all(funcdef): self.assertEqual(tree.body[0].type_comment, "() -> int") @@ -261,7 +260,6 @@ def test_funcdef(self): self.assertEqual(tree.body[0].type_comment, None) self.assertEqual(tree.body[1].type_comment, None) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: SyntaxError not raised : feature_version=(3, 4) def test_asyncdef(self): for tree in self.parse_all(asyncdef, minver=5): self.assertEqual(tree.body[0].type_comment, "() -> int") @@ -274,12 +272,10 @@ def test_asyncvar(self): with self.assertRaises(SyntaxError): self.classic_parse(asyncvar) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: SyntaxError not raised : feature_version=(3, 4) def test_asynccomp(self): for tree in self.parse_all(asynccomp, minver=6): pass - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: SyntaxError not raised : feature_version=(3, 4) def test_matmul(self): for tree in self.parse_all(matmul, minver=5): pass @@ -288,37 +284,31 @@ def test_fstring(self): for tree in self.parse_all(fstring): pass - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: SyntaxError not raised : feature_version=(3, 4) def test_underscorednumber(self): for tree in self.parse_all(underscorednumber, minver=6): pass - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: SyntaxError not raised : feature_version=(3, 4) def test_redundantdef(self): for tree in self.parse_all(redundantdef, maxver=0, expected_regex="^Cannot have two type comments on def"): pass - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'FunctionDef' object has no attribute 'type_comment' def test_nonasciidef(self): for tree in self.parse_all(nonasciidef): self.assertEqual(tree.body[0].type_comment, "() -> àçčéñt") - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'For' object has no attribute 'type_comment' def test_forstmt(self): for tree in self.parse_all(forstmt): self.assertEqual(tree.body[0].type_comment, "int") tree = self.classic_parse(forstmt) self.assertEqual(tree.body[0].type_comment, None) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'With' object has no attribute 'type_comment' def test_withstmt(self): for tree in self.parse_all(withstmt): self.assertEqual(tree.body[0].type_comment, "int") tree = self.classic_parse(withstmt) self.assertEqual(tree.body[0].type_comment, None) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'With' object has no attribute 'type_comment' def test_parenthesized_withstmt(self): for tree in self.parse_all(parenthesized_withstmt): self.assertEqual(tree.body[0].type_comment, "int") @@ -327,14 +317,12 @@ def test_parenthesized_withstmt(self): self.assertEqual(tree.body[0].type_comment, None) self.assertEqual(tree.body[1].type_comment, None) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: None != 'int' def test_vardecl(self): for tree in self.parse_all(vardecl): self.assertEqual(tree.body[0].type_comment, "int") tree = self.classic_parse(vardecl) self.assertEqual(tree.body[0].type_comment, None) - @unittest.expectedFailure # TODO: RUSTPYTHON; + (11, ' whatever')] def test_ignores(self): for tree in self.parse_all(ignores): self.assertEqual( @@ -350,7 +338,6 @@ def test_ignores(self): tree = self.classic_parse(ignores) self.assertEqual(tree.type_ignores, []) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: SyntaxError not raised : feature_version=(3, 4) def test_longargs(self): for tree in self.parse_all(longargs, minver=8): for t in tree.body: @@ -381,7 +368,6 @@ def test_longargs(self): self.assertIsNone(arg.type_comment, "%s(%s:%r)" % (t.name, arg.arg, arg.type_comment)) - @unittest.expectedFailure # TODO: RUSTPYTHON; Tests for inappropriately-placed type comments. def test_inappropriate_type_comments(self): """Tests for inappropriately-placed type comments. @@ -416,7 +402,6 @@ def test_non_utf8_type_comment_with_ignore_cookie(self): _testcapi.Py_CompileStringExFlags( b"def a(f=8, #type: \x80\n\x80", "", 256, flags) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: mode must be "exec", "eval", "ipython", or "single" def test_func_type_input(self): def parse_func_type_input(source): diff --git a/Lib/test/test_type_params.py b/Lib/test/test_type_params.py index c63ea2d291c..65261fcb6ed 100644 --- a/Lib/test/test_type_params.py +++ b/Lib/test/test_type_params.py @@ -683,7 +683,6 @@ def foo[U: T](self): ... self.assertIs(X.foo.__type_params__[0].__bound__, float) self.assertIs(X.Alias.__value__, float) - @unittest.expectedFailure # TODO: RUSTPYTHON; + global def test_binding_uses_global(self): ns = run_code(""" x = "global" @@ -1076,7 +1075,6 @@ async def coroutine[B](): class TypeParamsTypeVarTupleTest(unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: "cannot use bound with TypeVarTuple" does not match "invalid syntax (, line 1)" def test_typevartuple_01(self): code = """def func1[*A: str](): pass""" check_syntax_error(self, code, "cannot use bound with TypeVarTuple") @@ -1100,7 +1098,6 @@ def func1[*A](): class TypeParamsTypeVarParamSpecTest(unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: "cannot use bound with ParamSpec" does not match "invalid syntax (, line 1)" def test_paramspec_01(self): code = """def func1[**A: str](): pass""" check_syntax_error(self, code, "cannot use bound with ParamSpec") diff --git a/Lib/test/test_unpack_ex.py b/Lib/test/test_unpack_ex.py index 13b789f52dc..a5a20025930 100644 --- a/Lib/test/test_unpack_ex.py +++ b/Lib/test/test_unpack_ex.py @@ -168,7 +168,7 @@ ... SyntaxError: iterable unpacking cannot be used in comprehension - >>> {**{} for a in [1]} # TODO: RUSTPYTHON # doctest: +EXPECTED_FAILURE + >>> {**{} for a in [1]} Traceback (most recent call last): ... SyntaxError: dict unpacking cannot be used in dict comprehension @@ -356,7 +356,7 @@ ... SyntaxError: can't use starred expression here - >>> (*x),y = 1, 2 # TODO: RUSTPYTHON # doctest:+ELLIPSIS +EXPECTED_FAILURE + >>> (*x),y = 1, 2 Traceback (most recent call last): ... SyntaxError: cannot use starred expression here @@ -366,12 +366,12 @@ ... SyntaxError: cannot use starred expression here - >>> z,(*x),y = 1, 2, 4 # TODO: RUSTPYTHON # doctest:+ELLIPSIS +EXPECTED_FAILURE + >>> z,(*x),y = 1, 2, 4 Traceback (most recent call last): ... SyntaxError: cannot use starred expression here - >>> z,(*x) = 1, 2 # TODO: RUSTPYTHON # doctest:+ELLIPSIS +EXPECTED_FAILURE + >>> z,(*x) = 1, 2 Traceback (most recent call last): ... SyntaxError: cannot use starred expression here diff --git a/crates/capi/src/ceval.rs b/crates/capi/src/ceval.rs index d28dad4d6df..867c39e0388 100644 --- a/crates/capi/src/ceval.rs +++ b/crates/capi/src/ceval.rs @@ -1,17 +1,13 @@ use crate::pystate::with_vm; +use crate::unicodeobject::decode_fsdefault_and_size; use core::ffi::{CStr, c_char, c_int}; use core::ptr::NonNull; use rustpython_vm::builtins::{PyCode, PyDict}; -use rustpython_vm::compiler::Mode; use rustpython_vm::function::ArgMapping; use rustpython_vm::scope::Scope; +use rustpython_vm::version; use rustpython_vm::{AsObject, PyObject, TryFromObject}; -const PY_SINGLE_INPUT: c_int = 256; -const PY_FILE_INPUT: c_int = 257; -const PY_EVAL_INPUT: c_int = 258; -const PY_FUNC_TYPE_INPUT: c_int = 345; - #[unsafe(no_mangle)] pub unsafe extern "C" fn Py_CompileString( code: *const c_char, @@ -19,27 +15,11 @@ pub unsafe extern "C" fn Py_CompileString( start: c_int, ) -> *mut PyObject { with_vm(|vm| { - let code = unsafe { CStr::from_ptr(code) }.to_str().map_err(|_| { - vm.new_system_error("Py_CompileString called with non UTF-8 code string") - })?; - let filename = unsafe { CStr::from_ptr(filename) } - .to_str() - .map_err(|_| vm.new_system_error("Py_CompileString called with non UTF-8 filename"))?; - - let mode = match start { - PY_SINGLE_INPUT => Mode::Single, - PY_FILE_INPUT => Mode::Exec, - PY_EVAL_INPUT => Mode::Eval, - PY_FUNC_TYPE_INPUT => Mode::BlockExpr, - _ => { - return Err( - vm.new_system_error("Invalid start argument passed to Py_CompileString") - ); - } - }; - - vm.compile(code, mode, filename) - .map_err(|err| vm.new_syntax_error(&err, Some(code))) + let code = unsafe { CStr::from_ptr(code) }.to_bytes(); + let filename_size = unsafe { CStr::from_ptr(filename) }.to_bytes().len(); + let filename = decode_fsdefault_and_size(vm, filename, filename_size)?; + let filename = filename.to_string_lossy(); + vm.compile_string_object_with_flags(code, &filename, start, 0, version::MINOR as c_int, -1) }) } diff --git a/crates/capi/src/unicodeobject.rs b/crates/capi/src/unicodeobject.rs index acc6e392c53..33d46692602 100644 --- a/crates/capi/src/unicodeobject.rs +++ b/crates/capi/src/unicodeobject.rs @@ -4,8 +4,8 @@ use core::ffi::{CStr, c_char, c_int}; use core::ptr::NonNull; use core::slice; use core::str; -use rustpython_vm::PyObjectRef; -use rustpython_vm::builtins::PyStr; +use rustpython_vm::builtins::{PyStr, PyStrRef}; +use rustpython_vm::{PyObjectRef, PyResult, VirtualMachine}; define_py_check!(fn PyUnicode_Check, types.str_type); define_py_check!(exact fn PyUnicode_CheckExact, types.str_type); @@ -113,26 +113,42 @@ pub unsafe extern "C" fn PyUnicode_DecodeFSDefaultAndSize( .try_into() .map_err(|_| vm.new_system_error("size must be non-negative"))?; - let bytes = if s.is_null() { - if size != 0 { - return Err(vm.new_system_error( - "PyUnicode_DecodeFSDefaultAndSize called with null data and non-zero size", - )); - } - &[][..] - } else { - unsafe { slice::from_raw_parts(s.cast::(), size) } - }; + decode_fsdefault_and_size(vm, s, size) + }) +} - vm.state.codec_registry.decode_text( - vm.ctx.new_bytes(bytes.to_vec()).into(), - vm.fs_encoding().as_str(), - Some(vm.fs_encode_errors().to_owned()), - vm, - ) +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_DecodeFSDefault(s: *const c_char) -> *mut PyObject { + with_vm(|vm| { + let size = unsafe { CStr::from_ptr(s) }.to_bytes().len(); + decode_fsdefault_and_size(vm, s, size) }) } +pub(crate) fn decode_fsdefault_and_size( + vm: &VirtualMachine, + s: *const c_char, + size: usize, +) -> PyResult { + let bytes = if s.is_null() { + if size != 0 { + return Err(vm.new_system_error( + "PyUnicode_DecodeFSDefaultAndSize called with null data and non-zero size", + )); + } + &[][..] + } else { + unsafe { slice::from_raw_parts(s.cast::(), size) } + }; + + vm.state.codec_registry.decode_text( + vm.ctx.new_bytes(bytes.to_vec()).into(), + vm.fs_encoding().as_str(), + Some(vm.fs_encode_errors().to_owned()), + vm, + ) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyUnicode_EncodeFSDefault(unicode: *mut PyObject) -> *mut PyObject { with_vm(|vm| { diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index a178a23bd2f..711ac392694 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -10,43 +10,40 @@ #![deny(clippy::cast_possible_truncation)] use crate::{ - IndexMap, IndexSet, ToPythonName, - error::{CodegenError, CodegenErrorType, InternalError, PatternUnreachableReason}, + IndexMap, IndexSet, ToPythonName, ast_constant_value_to_constant_data, + error::{CodegenError, CodegenErrorType, InternalError}, ir::{self, Block, BlockIdx, Blocks}, preprocess, symboltable::{self, CompilerScope, Symbol, SymbolFlags, SymbolScope, SymbolTable}, unparse::UnparseExpr, }; use alloc::borrow::Cow; -use core::mem; +use core::{mem, slice}; use malachite_bigint::BigInt; use num_complex::Complex; use num_traits::{Num, ToPrimitive, Zero}; use ruff_python_ast as ast; use ruff_text_size::{Ranged, TextRange, TextSize}; - use rustpython_compiler_core::{ Mode, OneIndexed, PositionEncoding, SourceFile, SourceLocation, bytecode::{ self, AnyInstruction, AnyOpcode, Arg as OpArgMarker, BinaryOperator, BuildSliceArgCount, - CodeFlags, CodeObject, ComparisonOperator, ConstantData, ConvertValueOparg, Instruction, - IntrinsicFunction1, Invert, LoadAttr, LoadSuperAttr, MakeFunctionFlag, MakeFunctionFlags, - OpArg, OpArgType, Opcode, PseudoInstruction, PseudoOpcode, SpecialMethod, UnpackExArgs, - oparg, + CodeObject, ComparisonOperator, ConstantData, ConvertValueOparg, Instruction, + IntrinsicFunction1, Invert, LoadAttr, LoadSuperAttr, OpArg, OpArgType, PseudoInstruction, + SpecialMethod, UnpackExArgs, oparg, }, }; +use rustpython_literal::{ + complex as literal_complex, + escape::{AsciiEscape, UnicodeEscape}, + float as literal_float, +}; use rustpython_wtf8::Wtf8Buf; /// Extension trait for `ast::Expr` to add constant checking methods trait ExprExt { /// Returns true if the expression is a constant literal with no side effects. fn is_constant(&self) -> bool; - - /// Check if a slice expression has all constant elements - fn is_constant_slice(&self) -> bool; - - /// Check if we should use BINARY_SLICE/STORE_SLICE optimization - fn should_use_slice_optimization(&self) -> bool; } impl ExprExt for ast::Expr { @@ -56,30 +53,15 @@ impl ExprExt for ast::Expr { Self::NumberLiteral(_) | Self::StringLiteral(_) | Self::BytesLiteral(_) + | Self::Constant(_) | Self::NoneLiteral(_) | Self::BooleanLiteral(_) | Self::EllipsisLiteral(_) ) } - - fn is_constant_slice(&self) -> bool { - match self { - Self::Slice(s) => { - let lower_const = s.lower.as_deref().is_none_or(|e| e.is_constant()); - let upper_const = s.upper.as_deref().is_none_or(|e| e.is_constant()); - let step_const = s.step.as_deref().is_none_or(|e| e.is_constant()); - lower_const && upper_const && step_const - } - _ => false, - } - } - - fn should_use_slice_optimization(&self) -> bool { - !self.is_constant_slice() && matches!(self, Self::Slice(s) if s.step.is_none()) - } } -const MAXBLOCKS: usize = 20; +const CO_MAXBLOCKS: usize = 21; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FBlockType { @@ -142,6 +124,44 @@ pub struct FBlockInfo { pub(crate) type InternalResult = Result; type CompileResult = Result; +pub type SyntaxWarningHandler<'a> = + dyn FnMut(SourceLocation, String) -> Result<(), CodegenError> + 'a; + +fn warn_ast_preprocess_syntax( + source_file: &SourceFile, + handler: &mut SyntaxWarningHandler<'_>, + range: TextRange, + message: String, +) -> CompileResult<()> { + let location = source_file + .to_source_code() + .source_location(range.start(), PositionEncoding::Utf8); + handler(location, message) +} + +fn checked_future_features( + ast: &ruff_python_ast::Mod, + source_file: &SourceFile, +) -> CompileResult { + preprocess::checked_future_features(ast).map_err(|err| { + let location = source_file + .to_source_code() + .source_location(err.range.start(), PositionEncoding::Utf8); + let error = match err.kind { + preprocess::FutureFeatureErrorKind::InvalidFeature(feature) => { + CodegenErrorType::InvalidFutureFeature(feature) + } + preprocess::FutureFeatureErrorKind::InvalidBraces => { + CodegenErrorType::InvalidFutureBraces + } + }; + CodegenError { + location: Some(location), + error, + source_path: source_file.name().to_owned(), + } + }) +} #[derive(PartialEq, Eq, Clone, Copy)] enum NameUsage { @@ -150,13 +170,14 @@ enum NameUsage { Delete, } /// Main structure holding the state of compilation. -struct Compiler { +struct Compiler<'a> { code_stack: Vec, symbol_table_stack: Vec, source_file: SourceFile, // current_source_location: SourceLocation, current_source_range: TextRange, done_with_future_stmts: DoneWithFuture, + future_features: bytecode::CodeFlags, future_annotations: bool, ctx: CompileContext, opts: CompileOpts, @@ -168,9 +189,9 @@ struct Compiler { /// When > 0, the compiler walks AST (consuming sub_tables) but emits no bytecode. /// Mirrors CPython's `c_do_not_emit_bytecode`. do_not_emit_bytecode: u32, - /// Disable constant tuple/list/set collection folding in contexts where - /// CPython keeps the builder form for later assignment lowering. - disable_const_collection_folding: bool, + /// Mirrors `c_disable_warning` while compiling FINALLY_END copies. + disable_warning: u32, + syntax_warning_handler: Option<&'a mut SyntaxWarningHandler<'a>>, } #[derive(Clone, Copy)] @@ -180,13 +201,36 @@ enum DoneWithFuture { Yes, } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy)] +enum ComprehensionSymbolSource { + Child, + Inlined, +} + +#[derive(Clone, Copy)] +struct SymbolTableCursors { + sub_table: usize, + hidden_annotation_block: usize, + inlined_comprehension_block: usize, +} + +#[derive(Clone, Debug)] pub struct CompileOpts { /// How optimized the bytecode output should be; any optimize > 0 does /// not emit assert statements pub optimize: u8, /// Include column info in bytecode (-X no_debug_ranges disables) pub debug_ranges: bool, + /// Maximum decimal integer literal digits, matching sys.int_info/default. + pub int_max_str_digits: usize, + /// Allow module-level await/async-for/async-with, matching PyCF_ALLOW_TOP_LEVEL_AWAIT. + pub allow_top_level_await: bool, + /// Future compiler flags passed explicitly to compile(), matching cf_flags merge. + pub future_features: bytecode::CodeFlags, + /// Keep single-input blocks incomplete until a terminating newline is seen. + pub dont_imply_dedent: bool, + /// Recursion limit used by compiler tree walks, matching Py_EnterRecursiveCall. + pub recursion_limit: usize, } impl Default for CompileOpts { @@ -194,6 +238,11 @@ impl Default for CompileOpts { Self { optimize: 0, debug_ranges: true, + int_max_str_digits: 4300, + allow_top_level_await: false, + future_features: bytecode::CodeFlags::empty(), + dont_imply_dedent: false, + recursion_limit: 1000, } } } @@ -258,19 +307,69 @@ fn validate_duplicate_params(params: &ast::Parameters) -> Result<(), CodegenErro /// Compile an Mod produced from ruff parser pub fn compile_top( - mut ast: ruff_python_ast::Mod, + ast: ruff_python_ast::Mod, source_file: SourceFile, mode: Mode, opts: CompileOpts, ) -> CompileResult { - preprocess::preprocess_mod(&mut ast); + compile_top_with_syntax_warning_handler(ast, source_file, mode, opts, None) +} + +pub fn compile_top_with_syntax_warning_handler<'a>( + mut ast: ruff_python_ast::Mod, + source_file: SourceFile, + mode: Mode, + mut opts: CompileOpts, + mut syntax_warning_handler: Option<&'a mut SyntaxWarningHandler<'a>>, +) -> CompileResult { + opts.future_features |= checked_future_features(&ast, &source_file)?; + let future_annotations = opts + .future_features + .contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS); + if let Some(handler) = syntax_warning_handler.as_deref_mut() { + preprocess::warn_control_flow_in_finally(&ast, |range, message| { + warn_ast_preprocess_syntax(&source_file, handler, range, message) + })?; + } + if matches!(mode, Mode::Single) + && let ruff_python_ast::Mod::Module(module) = &mut ast + { + preprocess::preprocess_statements( + &mut module.body, + opts.optimize, + future_annotations, + false, + ); + } else { + preprocess::preprocess_mod(&mut ast, opts.optimize, future_annotations, false); + } match ast { ruff_python_ast::Mod::Module(module) => match mode { - Mode::Exec | Mode::Eval => compile_program(&module, source_file, opts), - Mode::Single => compile_program_single(&module, source_file, opts), - Mode::BlockExpr => compile_block_expression(&module, source_file, opts), + Mode::Exec | Mode::Eval => compile_program_with_syntax_warning_handler( + &module, + source_file, + opts, + syntax_warning_handler, + ), + Mode::Single => compile_program_single_with_syntax_warning_handler( + &module, + source_file, + opts, + syntax_warning_handler, + ), + Mode::BlockExpr => compile_block_expression_with_syntax_warning_handler( + &module, + source_file, + opts, + syntax_warning_handler, + ), }, - ruff_python_ast::Mod::Expression(expr) => compile_expression(&expr, source_file, opts), + ruff_python_ast::Mod::Expression(expr) => compile_expression_with_syntax_warning_handler( + &expr, + source_file, + opts, + syntax_warning_handler, + ), } } @@ -280,9 +379,54 @@ pub fn compile_program( source_file: SourceFile, opts: CompileOpts, ) -> CompileResult { - let symbol_table = SymbolTable::scan_program(ast, source_file.clone()) - .map_err(|e| e.into_codegen_error(source_file.name().to_owned()))?; - let mut compiler = Compiler::new(opts, source_file, ""); + compile_program_with_syntax_warning_handler(ast, source_file, opts, None) +} + +fn scan_module_symbols( + ast: &ast::ModModule, + source_file: &SourceFile, + opts: &CompileOpts, +) -> CompileResult { + SymbolTable::scan_program_with_options( + ast, + source_file.clone(), + opts.allow_top_level_await, + opts.future_features + .contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS), + opts.recursion_limit, + ) + .map_err(|e| e.into_codegen_error(source_file.name().to_owned())) +} + +fn scan_expr_symbols( + ast: &ast::ModExpression, + source_file: &SourceFile, + opts: &CompileOpts, +) -> CompileResult { + SymbolTable::scan_expr_with_options( + ast, + source_file.clone(), + opts.allow_top_level_await, + opts.future_features + .contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS), + opts.recursion_limit, + ) + .map_err(|e| e.into_codegen_error(source_file.name().to_owned())) +} + +fn compile_program_with_syntax_warning_handler<'a>( + ast: &ast::ModModule, + source_file: SourceFile, + opts: CompileOpts, + syntax_warning_handler: Option<&'a mut SyntaxWarningHandler<'a>>, +) -> CompileResult { + let symbol_table = scan_module_symbols(ast, &source_file, &opts)?; + let mut compiler = Compiler::new_with_syntax_warning_handler( + opts, + source_file, + "", + syntax_warning_handler, + ); compiler.compile_program(ast, symbol_table)?; let code = compiler.exit_scope(); trace!("Compilation completed: {code:?}"); @@ -295,9 +439,22 @@ pub fn compile_program_single( source_file: SourceFile, opts: CompileOpts, ) -> CompileResult { - let symbol_table = SymbolTable::scan_program(ast, source_file.clone()) - .map_err(|e| e.into_codegen_error(source_file.name().to_owned()))?; - let mut compiler = Compiler::new(opts, source_file, ""); + compile_program_single_with_syntax_warning_handler(ast, source_file, opts, None) +} + +fn compile_program_single_with_syntax_warning_handler<'a>( + ast: &ast::ModModule, + source_file: SourceFile, + opts: CompileOpts, + syntax_warning_handler: Option<&'a mut SyntaxWarningHandler<'a>>, +) -> CompileResult { + let symbol_table = scan_module_symbols(ast, &source_file, &opts)?; + let mut compiler = Compiler::new_with_syntax_warning_handler( + opts, + source_file, + "", + syntax_warning_handler, + ); compiler.compile_program_single(&ast.body, symbol_table)?; let code = compiler.exit_scope(); trace!("Compilation completed: {code:?}"); @@ -309,9 +466,22 @@ pub fn compile_block_expression( source_file: SourceFile, opts: CompileOpts, ) -> CompileResult { - let symbol_table = SymbolTable::scan_program(ast, source_file.clone()) - .map_err(|e| e.into_codegen_error(source_file.name().to_owned()))?; - let mut compiler = Compiler::new(opts, source_file, ""); + compile_block_expression_with_syntax_warning_handler(ast, source_file, opts, None) +} + +fn compile_block_expression_with_syntax_warning_handler<'a>( + ast: &ast::ModModule, + source_file: SourceFile, + opts: CompileOpts, + syntax_warning_handler: Option<&'a mut SyntaxWarningHandler<'a>>, +) -> CompileResult { + let symbol_table = scan_module_symbols(ast, &source_file, &opts)?; + let mut compiler = Compiler::new_with_syntax_warning_handler( + opts, + source_file, + "", + syntax_warning_handler, + ); compiler.compile_block_expr(&ast.body, symbol_table)?; let code = compiler.exit_scope(); trace!("Compilation completed: {code:?}"); @@ -323,9 +493,22 @@ pub fn compile_expression( source_file: SourceFile, opts: CompileOpts, ) -> CompileResult { - let symbol_table = SymbolTable::scan_expr(ast, source_file.clone()) - .map_err(|e| e.into_codegen_error(source_file.name().to_owned()))?; - let mut compiler = Compiler::new(opts, source_file, ""); + compile_expression_with_syntax_warning_handler(ast, source_file, opts, None) +} + +fn compile_expression_with_syntax_warning_handler<'a>( + ast: &ast::ModExpression, + source_file: SourceFile, + opts: CompileOpts, + syntax_warning_handler: Option<&'a mut SyntaxWarningHandler<'a>>, +) -> CompileResult { + let symbol_table = scan_expr_symbols(ast, &source_file, &opts)?; + let mut compiler = Compiler::new_with_syntax_warning_handler( + opts, + source_file, + "", + syntax_warning_handler, + ); compiler.compile_eval(ast, symbol_table)?; let code = compiler.exit_scope(); Ok(code) @@ -353,7 +536,7 @@ macro_rules! emit { }; } -fn eprint_location(zelf: &Compiler) { +fn eprint_location(zelf: &Compiler<'_>) { let start = zelf .source_file .to_source_code() @@ -374,7 +557,7 @@ fn eprint_location(zelf: &Compiler) { /// Better traceback for internal error #[track_caller] -fn unwrap_internal(zelf: &Compiler, r: InternalResult) -> T { +fn unwrap_internal(zelf: &Compiler<'_>, r: InternalResult) -> T { if let Err(ref r_err) = r { eprintln!("=== CODEGEN PANIC INFO ==="); eprintln!("This IS an internal error: {r_err}"); @@ -384,7 +567,7 @@ fn unwrap_internal(zelf: &Compiler, r: InternalResult) -> T { r.unwrap() } -fn compiler_unwrap_option(zelf: &Compiler, o: Option) -> T { +fn compiler_unwrap_option(zelf: &Compiler<'_>, o: Option) -> T { if o.is_none() { eprintln!("=== CODEGEN PANIC INFO ==="); eprintln!("This IS an internal error, an option was unwrapped during codegen"); @@ -454,10 +637,354 @@ enum CollectionType { Set, } +#[derive(Clone, Copy, Eq, PartialEq)] +enum InferredType { + Tuple, + List, + Dict, + Set, + FrozenSet, + Generator, + Function, + Template, + Str, + Bytes, + Int, + Float, + Complex, + Bool, + NoneType, + Ellipsis, + Slice, +} + +impl InferredType { + const fn name(self) -> &'static str { + match self { + Self::Tuple => "tuple", + Self::List => "list", + Self::Dict => "dict", + Self::Set => "set", + Self::FrozenSet => "frozenset", + Self::Generator => "generator", + Self::Function => "function", + Self::Template => "string.templatelib.Template", + Self::Str => "str", + Self::Bytes => "bytes", + Self::Int => "int", + Self::Float => "float", + Self::Complex => "complex", + Self::Bool => "bool", + Self::NoneType => "NoneType", + Self::Ellipsis => "ellipsis", + Self::Slice => "slice", + } + } + + const fn is_long_subclass(self) -> bool { + matches!(self, Self::Int | Self::Bool) + } +} + const STACK_USE_GUIDELINE: u32 = 30; -impl Compiler { - fn new(opts: CompileOpts, source_file: SourceFile, code_name: &str) -> Self { +impl<'warnings> Compiler<'warnings> { + fn constant_truthiness(constant: &ConstantData) -> bool { + match constant { + ConstantData::Tuple { elements } | ConstantData::Frozenset { elements } => { + !elements.is_empty() + } + ConstantData::Integer { value } => !value.is_zero(), + ConstantData::Float { value } => *value != 0.0, + ConstantData::Complex { value } => value.re != 0.0 || value.im != 0.0, + ConstantData::Boolean { value } => *value, + ConstantData::Str { value } => !value.is_empty(), + ConstantData::Bytes { value } => !value.is_empty(), + ConstantData::Code { .. } | ConstantData::Slice { .. } | ConstantData::Ellipsis => true, + ConstantData::None => false, + } + } + + fn infer_type_constant(constant: &ConstantData) -> Option { + match constant { + ConstantData::Tuple { .. } => Some(InferredType::Tuple), + ConstantData::Frozenset { .. } => Some(InferredType::FrozenSet), + ConstantData::Integer { .. } => Some(InferredType::Int), + ConstantData::Float { .. } => Some(InferredType::Float), + ConstantData::Complex { .. } => Some(InferredType::Complex), + ConstantData::Boolean { .. } => Some(InferredType::Bool), + ConstantData::Str { .. } => Some(InferredType::Str), + ConstantData::Bytes { .. } => Some(InferredType::Bytes), + ConstantData::None => Some(InferredType::NoneType), + ConstantData::Ellipsis => Some(InferredType::Ellipsis), + ConstantData::Slice { .. } => Some(InferredType::Slice), + ConstantData::Code { .. } => None, + } + } + + fn infer_type(&self, expr: &ast::Expr) -> Option { + if let Some(constant) = self.ast_constant_value(expr) { + return Self::infer_type_constant(&constant); + } + match expr { + ast::Expr::Tuple(_) => Some(InferredType::Tuple), + ast::Expr::List(_) | ast::Expr::ListComp(_) => Some(InferredType::List), + ast::Expr::Dict(_) | ast::Expr::DictComp(_) => Some(InferredType::Dict), + ast::Expr::Set(_) | ast::Expr::SetComp(_) => Some(InferredType::Set), + ast::Expr::Generator(_) => Some(InferredType::Generator), + ast::Expr::Lambda(_) => Some(InferredType::Function), + ast::Expr::TString(_) => Some(InferredType::Template), + ast::Expr::FString(_) | ast::Expr::StringLiteral(_) => Some(InferredType::Str), + ast::Expr::BytesLiteral(_) => Some(InferredType::Bytes), + ast::Expr::NumberLiteral(number) => match number.value { + ast::Number::Int(_) => Some(InferredType::Int), + ast::Number::Float(_) => Some(InferredType::Float), + ast::Number::Complex { .. } => Some(InferredType::Complex), + }, + ast::Expr::BooleanLiteral(_) => Some(InferredType::Bool), + ast::Expr::NoneLiteral(_) => Some(InferredType::NoneType), + ast::Expr::EllipsisLiteral(_) => Some(InferredType::Ellipsis), + ast::Expr::Slice(_) => Some(InferredType::Slice), + _ => None, + } + } + + fn is_constant_expr(&self, expr: &ast::Expr) -> bool { + if self.ast_constant_value(expr).is_some() { + return true; + } + matches!( + expr, + ast::Expr::StringLiteral(_) + | ast::Expr::BytesLiteral(_) + | ast::Expr::NumberLiteral(_) + | ast::Expr::BooleanLiteral(_) + | ast::Expr::NoneLiteral(_) + | ast::Expr::EllipsisLiteral(_) + ) + } + + fn is_constant_slice(&self, slice: &ast::Expr) -> bool { + match slice { + ast::Expr::Slice(s) => { + let lower_const = s.lower.is_none() + || s.lower.as_deref().is_some_and(|e| self.is_constant_expr(e)); + let upper_const = s.upper.is_none() + || s.upper.as_deref().is_some_and(|e| self.is_constant_expr(e)); + let step_const = + s.step.is_none() || s.step.as_deref().is_some_and(|e| self.is_constant_expr(e)); + lower_const && upper_const && step_const + } + _ => false, + } + } + + fn should_apply_two_element_slice_optimization(&self, slice: &ast::Expr) -> bool { + !self.is_constant_slice(slice) && matches!(slice, ast::Expr::Slice(s) if s.step.is_none()) + } + + fn check_is_arg(&self, expr: &ast::Expr) -> bool { + if let Some(constant) = self.ast_constant_value(expr) { + return matches!( + constant, + ConstantData::None | ConstantData::Boolean { .. } | ConstantData::Ellipsis + ); + } + if let ast::Expr::Tuple(tuple) = expr { + return !tuple.elts.iter().all(|expr| self.is_constant_expr(expr)); + } + if !self.is_constant_expr(expr) { + return true; + } + matches!( + expr, + ast::Expr::NoneLiteral(_) + | ast::Expr::BooleanLiteral(_) + | ast::Expr::EllipsisLiteral(_) + ) + } + + fn warn_syntax(&mut self, range: TextRange, message: String) -> CompileResult<()> { + if self.disable_warning > 0 { + return Ok(()); + } + let Some(handler) = self.syntax_warning_handler.as_deref_mut() else { + return Ok(()); + }; + let location = self + .source_file + .to_source_code() + .source_location(range.start(), PositionEncoding::Utf8); + handler(location, message) + } + + fn check_caller(&mut self, func: &ast::Expr) -> CompileResult<()> { + let warns = self.ast_constant_value(func).is_some() + || matches!( + func, + ast::Expr::StringLiteral(_) + | ast::Expr::BytesLiteral(_) + | ast::Expr::NumberLiteral(_) + | ast::Expr::BooleanLiteral(_) + | ast::Expr::NoneLiteral(_) + | ast::Expr::EllipsisLiteral(_) + | ast::Expr::Tuple(_) + | ast::Expr::List(_) + | ast::Expr::ListComp(_) + | ast::Expr::Dict(_) + | ast::Expr::DictComp(_) + | ast::Expr::Set(_) + | ast::Expr::SetComp(_) + | ast::Expr::Generator(_) + | ast::Expr::FString(_) + | ast::Expr::TString(_) + ); + if warns && let Some(inferred) = self.infer_type(func) { + self.warn_syntax( + func.range(), + format!( + "'{}' object is not callable; perhaps you missed a comma?", + inferred.name() + ), + )?; + } + Ok(()) + } + + fn check_compare( + &mut self, + range: TextRange, + left: &ast::Expr, + ops: &[ast::CmpOp], + comparators: &[ast::Expr], + ) -> CompileResult<()> { + let mut left_is_arg = self.check_is_arg(left); + let mut left_expr = left; + for (op, right_expr) in ops.iter().zip(comparators.iter()) { + let right_is_arg = self.check_is_arg(right_expr); + if matches!(op, ast::CmpOp::Is | ast::CmpOp::IsNot) && (!right_is_arg || !left_is_arg) { + let literal = if !left_is_arg { left_expr } else { right_expr }; + if let Some(inferred) = self.infer_type(literal) { + let is_op = matches!(op, ast::CmpOp::Is); + let op = if is_op { "\"is\"" } else { "\"is not\"" }; + let replacement = if is_op { "==" } else { "!=" }; + self.warn_syntax( + range, + format!( + "{op} with '{}' literal. Did you mean \"{replacement}\"?", + inferred.name() + ), + )?; + return Ok(()); + } + } + left_is_arg = right_is_arg; + left_expr = right_expr; + } + Ok(()) + } + + fn constant_warns_as_subscripter(constant: &ConstantData) -> bool { + matches!( + constant, + ConstantData::None + | ConstantData::Ellipsis + | ConstantData::Integer { .. } + | ConstantData::Float { .. } + | ConstantData::Complex { .. } + | ConstantData::Boolean { .. } + | ConstantData::Frozenset { .. } + ) + } + + fn check_subscripter(&mut self, value: &ast::Expr) -> CompileResult<()> { + let warns = self + .ast_constant_value(value) + .is_some_and(|constant| Self::constant_warns_as_subscripter(&constant)) + || matches!( + value, + ast::Expr::NoneLiteral(_) + | ast::Expr::EllipsisLiteral(_) + | ast::Expr::NumberLiteral(_) + | ast::Expr::BooleanLiteral(_) + | ast::Expr::Set(_) + | ast::Expr::SetComp(_) + | ast::Expr::Generator(_) + | ast::Expr::TString(_) + | ast::Expr::Lambda(_) + ); + if warns && let Some(inferred) = self.infer_type(value) { + self.warn_syntax( + value.range(), + format!( + "'{}' object is not subscriptable; perhaps you missed a comma?", + inferred.name() + ), + )?; + } + Ok(()) + } + + fn check_index(&mut self, value: &ast::Expr, slice: &ast::Expr) -> CompileResult<()> { + let Some(index_type) = self.infer_type(slice) else { + return Ok(()); + }; + if index_type.is_long_subclass() || index_type == InferredType::Slice { + return Ok(()); + } + + let constant_warns = self.ast_constant_value(value).is_some_and(|constant| { + matches!( + constant, + ConstantData::Str { .. } | ConstantData::Bytes { .. } | ConstantData::Tuple { .. } + ) + }); + let warns = constant_warns + || matches!( + value, + ast::Expr::StringLiteral(_) + | ast::Expr::BytesLiteral(_) + | ast::Expr::Tuple(_) + | ast::Expr::List(_) + | ast::Expr::ListComp(_) + | ast::Expr::FString(_) + ); + if warns && let Some(value_type) = self.infer_type(value) { + self.warn_syntax( + value.range(), + format!( + "{} indices must be integers or slices, not {}; perhaps you missed a comma?", + value_type.name(), + index_type.name() + ), + )?; + } + Ok(()) + } + + fn check_assert(&mut self, assert_stmt: &ast::StmtAssert) -> CompileResult<()> { + let warns = match &*assert_stmt.test { + ast::Expr::Tuple(tuple) => !tuple.elts.is_empty(), + _ => matches!( + self.ast_constant_value(&assert_stmt.test), + Some(ConstantData::Tuple { ref elements }) if !elements.is_empty() + ), + }; + if warns { + self.warn_syntax( + assert_stmt.range, + "assertion is always true, perhaps remove parentheses?".to_owned(), + )?; + } + Ok(()) + } + + fn new_with_syntax_warning_handler( + opts: CompileOpts, + source_file: SourceFile, + code_name: &str, + syntax_warning_handler: Option<&'warnings mut SyntaxWarningHandler<'warnings>>, + ) -> Self { let module_code = ir::CodeInfo { // CPython convention: top-level module / interactive / // expression code does not carry CO_NEWLOCALS or CO_OPTIMIZED. @@ -467,7 +994,7 @@ impl Compiler { // empty flags. frame.rs:725-731 then binds locals to globals // for module/REPL frames whose `scope.locals` is None - the // correct semantics for `exec(code, globals)` and module init. - flags: CodeFlags::empty(), + flags: bytecode::CodeFlags::empty(), source_path: source_file.name().to_owned(), private: None, blocks: Blocks::from([Block::default()]), @@ -492,7 +1019,7 @@ impl Compiler { }, static_attributes: None, in_inlined_comp: false, - fblock: Vec::with_capacity(MAXBLOCKS), + fblock: Vec::with_capacity(CO_MAXBLOCKS), symbol_table_index: 0, // Module is always the first symbol table nparams: 0, in_conditional_block: 0, @@ -505,6 +1032,7 @@ impl Compiler { // current_source_location: SourceLocation::default(), current_source_range: TextRange::default(), done_with_future_stmts: DoneWithFuture::No, + future_features: opts.future_features, future_annotations: false, ctx: CompileContext { in_class: false, @@ -515,25 +1043,11 @@ impl Compiler { in_annotation: false, interactive: false, do_not_emit_bytecode: 0, - disable_const_collection_folding: false, + disable_warning: 0, + syntax_warning_handler, } } - fn compile_expression_without_const_collection_folding( - &mut self, - expression: &ast::Expr, - ) -> CompileResult<()> { - let previous = self.disable_const_collection_folding; - self.disable_const_collection_folding = true; - let result = self.compile_expression(expression); - self.disable_const_collection_folding = previous; - result.map(|_| ()) - } - - fn is_unpack_assignment_target(target: &ast::Expr) -> bool { - matches!(target, ast::Expr::List(_) | ast::Expr::Tuple(_)) - } - fn compile_module_annotation_setup_sequence( &mut self, body: &[ast::Stmt], @@ -569,8 +1083,12 @@ impl Compiler { mem::replace(&mut code.instr_sequence, saved_instr_sequence); code.current_block = saved_current_block; code.instr_sequence_label_map = saved_instr_sequence_label_map; - code.annotations_instr_sequence = Some(annotations_instr_sequence); debug_assert!(saved_annotations_instr_sequence.is_none()); + if matches!(result, Ok(true)) { + code.annotations_instr_sequence = Some(annotations_instr_sequence); + } else { + code.annotations_instr_sequence = saved_annotations_instr_sequence; + } }; result.map(|_| ()) @@ -607,13 +1125,17 @@ impl Compiler { ) -> CompileResult<()> { // Save full subscript expression range (set by compile_expression before this call) let subscript_range = self.current_source_range; + if matches!(ctx, ast::ExprContext::Load) { + self.check_subscripter(value)?; + self.check_index(value, slice)?; + } // VISIT(c, expr, e->v.Subscript.value) self.compile_expression(value)?; // Handle two-element non-constant slice with BINARY_SLICE/STORE_SLICE let use_slice_opt = matches!(ctx, ast::ExprContext::Load | ast::ExprContext::Store) - && slice.should_use_slice_optimization(); + && self.should_apply_two_element_slice_optimization(slice); if use_slice_opt { match slice { ast::Expr::Slice(s) => self.compile_slice_two_parts(s)?, @@ -659,9 +1181,10 @@ impl Compiler { /// - collection_type: What type of collection to build (tuple, list, set) /// // = starunpack_helper in compile.c - fn starunpack_helper( + fn starunpack_helper_impl( &mut self, elts: &[ast::Expr], + injected_arg: Option<&str>, pushed: u32, collection_type: CollectionType, ) -> CompileResult<()> { @@ -669,46 +1192,23 @@ impl Compiler { let n = elts.len().to_u32(); let seen_star = elts.iter().any(|e| matches!(e, ast::Expr::Starred(_))); - let big = n + pushed > STACK_USE_GUIDELINE; - - // Match CPython's constant ordering by letting the late flowgraph-style - // folding passes introduce tuple-backed constants after their operands - // have first been emitted as constants. - let can_fold_const_collection = false; - if !self.disable_const_collection_folding - && !seen_star - && pushed == 0 - && can_fold_const_collection - && let Some(folded) = self.try_fold_constant_collection(elts, collection_type)? - { - match collection_type { - CollectionType::Tuple => { - self.emit_load_const(folded); - } - CollectionType::List => { - self.set_source_range(collection_range); - emit!(self, Instruction::BuildList { count: 0 }); - self.emit_load_const(folded); - self.set_source_range(collection_range); - emit!(self, Instruction::ListExtend { i: 1 }); - } - CollectionType::Set => { - self.set_source_range(collection_range); - emit!(self, Instruction::BuildSet { count: 0 }); - self.emit_load_const(folded); - self.set_source_range(collection_range); - emit!(self, Instruction::SetUpdate { i: 1 }); - } - } - return Ok(()); - } + let injected_count = u32::from(injected_arg.is_some()); + let big = n + pushed + injected_count > STACK_USE_GUIDELINE; + + // Constant collections are not folded here: the late flowgraph + // optimization passes introduce tuple-backed constants after their + // operands have first been emitted, matching the constant ordering. // If no stars and not too big, compile all elements and build once if !seen_star && !big { for elt in elts { self.compile_expression(elt)?; } - let total_size = n + pushed; + if let Some(injected_arg) = injected_arg { + self.set_source_range(collection_range); + self.load_name(injected_arg)?; + } + let total_size = n + injected_count + pushed; self.set_source_range(collection_range); match collection_type { CollectionType::List => { @@ -729,6 +1229,7 @@ impl Compiler { let mut i = 0u32; if big { + self.set_source_range(collection_range); match collection_type { CollectionType::List => { emit!(self, Instruction::BuildList { count: pushed }); @@ -803,22 +1304,22 @@ impl Compiler { } } - // If we never built sequence (all non-starred), build it now - if !sequence_built { + debug_assert!(sequence_built); + if let Some(injected_arg) = injected_arg { + self.set_source_range(collection_range); + self.load_name(injected_arg)?; self.set_source_range(collection_range); match collection_type { - CollectionType::List => { - emit!(self, Instruction::BuildList { count: i + pushed }); + CollectionType::List | CollectionType::Tuple => { + emit!(self, Instruction::ListAppend { i: 1 }); } CollectionType::Set => { - emit!(self, Instruction::BuildSet { count: i + pushed }); - } - CollectionType::Tuple => { - emit!(self, Instruction::BuildTuple { count: i + pushed }); + emit!(self, Instruction::SetAdd { i: 1 }); } } - } else if collection_type == CollectionType::Tuple { - // For tuples, convert the list to tuple + } + + if collection_type == CollectionType::Tuple { self.set_source_range(collection_range); emit!( self, @@ -831,6 +1332,15 @@ impl Compiler { Ok(()) } + fn starunpack_helper( + &mut self, + elts: &[ast::Expr], + pushed: u32, + collection_type: CollectionType, + ) -> CompileResult<()> { + self.starunpack_helper_impl(elts, None, pushed, collection_type) + } + fn error(&mut self, error: CodegenErrorType) -> CodegenError { self.error_ranged(error, self.current_source_range) } @@ -847,6 +1357,21 @@ impl Compiler { } } + fn error_optional_range( + &mut self, + error: CodegenErrorType, + range: Option, + ) -> CodegenError { + match range { + Some(range) => self.error_ranged(error, range), + None => CodegenError { + error, + location: None, + source_path: self.source_file.name().to_owned(), + }, + } + } + /// Get the SymbolTable for the current scope. fn current_symbol_table(&self) -> &SymbolTable { self.symbol_table_stack @@ -929,37 +1454,117 @@ impl Compiler { )))); } - let idx = current_table.next_sub_table; - current_table.next_sub_table += 1; - let table = current_table.sub_tables[idx].clone(); - + while current_table.next_sub_table < current_table.sub_tables.len() + && current_table.sub_tables[current_table.next_sub_table].typ + == CompilerScope::Annotation + { + current_table.next_sub_table += 1; + } + if current_table.next_sub_table >= current_table.sub_tables.len() { + let name = current_table.name.clone(); + let typ = current_table.typ; + return Err(self.error(CodegenErrorType::SyntaxError(format!( + "no symbol table available in {name} (type: {typ:?})" + )))); + } + + let idx = current_table.next_sub_table; + current_table.next_sub_table += 1; + let table = current_table.sub_tables[idx].clone(); + // Push the next table onto the stack self.symbol_table_stack.push(table); Ok(self.current_symbol_table()) } - /// Push the annotation symbol table from the next sub_table's annotation_block - /// The annotation_block is stored in the function's scope, which is the next sub_table - /// Returns true if annotation_block exists, false otherwise - fn push_annotation_symbol_table(&mut self) -> bool { + fn push_symbol_table_matching( + &mut self, + typ: CompilerScope, + table_name: &str, + ) -> CompileResult<&SymbolTable> { let current_table = self .symbol_table_stack .last_mut() .expect("no current symbol table"); - // The annotation_block is in the next sub_table (function scope) - let next_idx = current_table.next_sub_table; - if next_idx >= current_table.sub_tables.len() { - return false; + while current_table.next_sub_table < current_table.sub_tables.len() + && current_table.sub_tables[current_table.next_sub_table].typ + == CompilerScope::Annotation + { + current_table.next_sub_table += 1; } - let next_table = &mut current_table.sub_tables[next_idx]; - if let Some(annotation_block) = next_table.annotation_block.take() { - self.symbol_table_stack.push(*annotation_block); - true - } else { - false + let start = current_table.next_sub_table; + let Some(idx) = current_table.sub_tables[start..] + .iter() + .position(|table| table.typ == typ && table.name == table_name) + .map(|idx| start + idx) + else { + let name = current_table.name.clone(); + let current_typ = current_table.typ; + return Err(self.error(CodegenErrorType::SyntaxError(format!( + "no matching symbol table {table_name} ({typ:?}) available in {name} (type: {current_typ:?})" + )))); + }; + + let table = current_table.sub_tables[idx].clone(); + current_table.next_sub_table = idx + 1; + self.symbol_table_stack.push(table); + Ok(self.current_symbol_table()) + } + + /// Push the function annotation symbol table. + /// Signature annotation blocks are stored in st_blocks keyed by the + /// arguments AST node. Without future annotations they are also children; + /// with future annotations they are hidden from children and consumed here. + fn push_annotation_symbol_table(&mut self) -> bool { + let Some(annotation_table) = ({ + let current_table = self + .symbol_table_stack + .last_mut() + .expect("no current symbol table"); + + let next_idx = current_table.next_sub_table; + if next_idx < current_table.sub_tables.len() + && current_table.sub_tables[next_idx].typ == CompilerScope::Annotation + { + let next_table = current_table.sub_tables[next_idx].clone(); + current_table.next_sub_table += 1; + Some(next_table) + } else if current_table.next_hidden_annotation_block + < current_table.hidden_annotation_blocks.len() + { + let idx = current_table.next_hidden_annotation_block; + current_table.next_hidden_annotation_block += 1; + Some(current_table.hidden_annotation_blocks[idx].clone()) + } else { + None + } + }) else { + return false; + }; + + self.symbol_table_stack.push(annotation_table); + true + } + + fn next_function_annotation_symbol_table_uses_annotations(&self) -> bool { + let current_table = self + .symbol_table_stack + .last() + .expect("no current symbol table"); + let next_idx = current_table.next_sub_table; + if next_idx < current_table.sub_tables.len() + && current_table.sub_tables[next_idx].typ == CompilerScope::Annotation + { + return current_table.sub_tables[next_idx].annotations_used; } + + let hidden_idx = current_table.next_hidden_annotation_block; + current_table + .hidden_annotation_blocks + .get(hidden_idx) + .is_some_and(|table| table.annotations_used) } /// Push the annotation symbol table for module/class level annotations @@ -979,19 +1584,9 @@ impl Compiler { } } - /// Pop the annotation symbol table and restore it to the function scope's annotation_block + /// Pop the annotation symbol table. fn pop_annotation_symbol_table(&mut self) { - let annotation_table = self.symbol_table_stack.pop().expect("compiler bug"); - let current_table = self - .symbol_table_stack - .last_mut() - .expect("no current symbol table"); - - // Restore to the next sub_table (function scope) where it came from - let next_idx = current_table.next_sub_table; - if next_idx < current_table.sub_tables.len() { - current_table.sub_tables[next_idx].annotation_block = Some(Box::new(annotation_table)); - } + self.symbol_table_stack.pop().expect("compiler bug"); } /// Pop the current symbol table off the stack @@ -1032,28 +1627,22 @@ impl Compiler { return None; } - // 5. Must be inside a function (not at module level or class body) - if !self.ctx.in_func() { - return None; - } - - // 6. "super" must be GlobalImplicit (not redefined locally or at module level) + // 5. "super" must be GlobalImplicit in the current scope. let table = self.current_symbol_table(); if let Some(symbol) = table.lookup("super") && symbol.scope != SymbolScope::GlobalImplicit { return None; } - // Also check top-level scope to detect module-level shadowing. - // Only block if super is actually *bound* at module level (not just used). + // Then check the top-level scope and reject any statically + // visible symbol for "super", not just local bindings. if let Some(top_table) = self.symbol_table_stack.first() - && let Some(sym) = top_table.lookup("super") - && sym.scope != SymbolScope::GlobalImplicit + && top_table.lookup("super").is_some() { return None; } - // 7. Check argument pattern + // 6. Check argument pattern let args = &arguments.args; // No starred expressions allowed @@ -1180,10 +1769,13 @@ impl Compiler { let source_path = self.source_file.name().to_owned(); // Lookup symbol table entry using key (_PySymtable_Lookup) - let Some(ste) = self.symbol_table_stack.get(key) else { - return Err(self.error(CodegenErrorType::SyntaxError( - "unknown symbol table entry".into(), - ))); + let ste = match self.symbol_table_stack.get(key) { + Some(v) => v, + None => { + return Err(self.error(CodegenErrorType::SyntaxError( + "unknown symbol table entry".to_owned(), + ))); + } }; // Use varnames from symbol table (already collected in definition order) @@ -1192,32 +1784,16 @@ impl Compiler { // Build cellvars using dictbytype (CELL scope or COMP_CELL flag, sorted) let mut cellvar_cache = IndexSet::default(); - // CPython ordering: parameter cells first (in parameter order), - // then non-parameter cells (alphabetically sorted) - let cell_symbols: Vec<_> = ste + let mut cell_names: Vec<_> = ste .symbols .iter() .filter(|(_, s)| { s.scope == SymbolScope::Cell || s.flags.contains(SymbolFlags::COMP_CELL) }) - .map(|(name, sym)| (name.clone(), sym.flags)) + .map(|(name, _)| name.clone()) .collect(); - let mut param_cells = Vec::new(); - let mut nonparam_cells = Vec::new(); - for (name, flags) in cell_symbols { - if flags.contains(SymbolFlags::PARAMETER) { - param_cells.push(name); - } else { - nonparam_cells.push(name); - } - } - // param_cells are already in parameter order (from varname_cache insertion order) - param_cells.sort_by_key(|n| varname_cache.get_index_of(n.as_str()).unwrap_or(usize::MAX)); - nonparam_cells.sort(); - for name in param_cells { - cellvar_cache.insert(name); - } - for name in nonparam_cells { + cell_names.sort(); + for name in cell_names { cellvar_cache.insert(name); } @@ -1254,21 +1830,16 @@ impl Compiler { .collect() }) .unwrap_or_default(); - - let mut free_names = ste + let mut free_names: Vec<_> = ste .symbols .iter() .filter(|(_, s)| { - if s.scope == SymbolScope::Free { - return true; - } - - let has_free_class = s.flags.contains(SymbolFlags::FREE_CLASS); - if scope_type == CompilerScope::Class { - has_free_class && self.has_enclosing_non_module_code_scope() - } else { - has_free_class - } + s.scope == SymbolScope::Free + || (scope_type != CompilerScope::Class + && s.flags.contains(SymbolFlags::FREE_CLASS)) + || (scope_type == CompilerScope::Class + && s.flags.contains(SymbolFlags::FREE_CLASS) + && self.has_enclosing_non_module_code_scope()) }) .filter(|(name, symbol)| { if !matches!( @@ -1280,8 +1851,7 @@ impl Compiler { !(annotation_free_names.contains(*name) && symbol.flags.is_empty()) }) .map(|(name, _)| name.clone()) - .collect::>(); - + .collect(); free_names.sort(); for name in free_names { freevar_cache.insert(name); @@ -1289,31 +1859,42 @@ impl Compiler { // Initialize u_metadata fields let (mut flags, posonlyarg_count, arg_count, kwonlyarg_count) = match scope_type { - CompilerScope::Module => (CodeFlags::empty(), 0, 0, 0), - CompilerScope::Class => (CodeFlags::empty(), 0, 0, 0), + CompilerScope::Module => (bytecode::CodeFlags::empty(), 0, 0, 0), + CompilerScope::Class => (bytecode::CodeFlags::empty(), 0, 0, 0), CompilerScope::Function | CompilerScope::AsyncFunction | CompilerScope::Lambda => ( - CodeFlags::NEWLOCALS | CodeFlags::OPTIMIZED, + bytecode::CodeFlags::NEWLOCALS | bytecode::CodeFlags::OPTIMIZED, 0, // Will be set later in enter_function 0, // Will be set later in enter_function 0, // Will be set later in enter_function ), CompilerScope::Comprehension => ( - CodeFlags::NEWLOCALS | CodeFlags::OPTIMIZED, + bytecode::CodeFlags::NEWLOCALS | bytecode::CodeFlags::OPTIMIZED, 0, 1, // comprehensions take one argument (.0) 0, ), - CompilerScope::TypeParams => (CodeFlags::NEWLOCALS | CodeFlags::OPTIMIZED, 0, 0, 0), + CompilerScope::TypeParams => ( + bytecode::CodeFlags::NEWLOCALS | bytecode::CodeFlags::OPTIMIZED, + 0, + 0, + 0, + ), CompilerScope::Annotation => ( - CodeFlags::NEWLOCALS | CodeFlags::OPTIMIZED, + bytecode::CodeFlags::NEWLOCALS | bytecode::CodeFlags::OPTIMIZED, + 1, // format is positional-only + 0, + 0, + ), + CompilerScope::TypeAlias | CompilerScope::TypeVariable => ( + bytecode::CodeFlags::NEWLOCALS | bytecode::CodeFlags::OPTIMIZED, 1, // format is positional-only - 1, // annotation scope takes one argument (format) + 0, 0, ), }; if ste.is_method { - flags |= CodeFlags::METHOD; + flags |= bytecode::CodeFlags::METHOD; } // CPython sets CO_NESTED from symtable's ste_nested, not merely @@ -1327,15 +1908,15 @@ impl Compiler { | CompilerScope::Lambda | CompilerScope::Comprehension | CompilerScope::Annotation + | CompilerScope::TypeAlias + | CompilerScope::TypeVariable | CompilerScope::TypeParams ) { - flags | CodeFlags::NESTED + flags | bytecode::CodeFlags::NESTED } else { flags }; - if self.future_annotations { - flags |= CodeFlags::FUTURE_ANNOTATIONS; - } + flags |= self.future_features; // Get private name from parent scope let private = if !self.code_stack.is_empty() { @@ -1375,7 +1956,7 @@ impl Compiler { None }, in_inlined_comp: false, - fblock: Vec::with_capacity(MAXBLOCKS), + fblock: Vec::with_capacity(CO_MAXBLOCKS), symbol_table_index: key, nparams, in_conditional_block: 0, @@ -1418,7 +1999,10 @@ impl Compiler { let except_handler = None; self.cpython_cfg_builder_addop(ir::InstructionInfo { - instr: Opcode::Resume.into(), + instr: Instruction::Resume { + context: OpArgMarker::marker(), + } + .into(), arg: OpArg::new(oparg::ResumeLocation::AtFuncStart.into()), target: BlockIdx::NULL, location, @@ -1456,7 +2040,15 @@ impl Compiler { // Preserve flags computed from the symbol-table context. info.flags = flags | (info.flags - & (CodeFlags::NESTED | CodeFlags::METHOD | CodeFlags::FUTURE_ANNOTATIONS)); + & (bytecode::CodeFlags::NESTED + | bytecode::CodeFlags::METHOD + | bytecode::CodeFlags::FUTURE_DIVISION + | bytecode::CodeFlags::FUTURE_ABSOLUTE_IMPORT + | bytecode::CodeFlags::FUTURE_WITH_STATEMENT + | bytecode::CodeFlags::FUTURE_PRINT_FUNCTION + | bytecode::CodeFlags::FUTURE_UNICODE_LITERALS + | bytecode::CodeFlags::FUTURE_GENERATOR_STOP + | bytecode::CodeFlags::FUTURE_ANNOTATIONS)); info.metadata.argcount = arg_count; info.metadata.posonlyargcount = posonlyarg_count; info.metadata.kwonlyargcount = kwonlyarg_count; @@ -1466,7 +2058,7 @@ impl Compiler { // compiler_exit_scope fn exit_scope(&mut self) -> CodeObject { - let _table = self.pop_symbol_table(); + self.pop_symbol_table(); // Various scopes can have sub_tables: // - ast::TypeParams scope can have sub_tables (the function body's symbol table) // - Module scope can have sub_tables (for TypeAlias scopes, nested functions, classes) @@ -1479,17 +2071,25 @@ impl Compiler { unwrap_internal(self, stack_top.finalize_code(&self.opts)) } - /// Exit annotation scope - similar to exit_scope but restores annotation_block to parent + fn expose_annotation_format_parameter(code: &mut CodeObject) { + if let Some(first) = code.varnames.first_mut() { + *first = "format".to_owned(); + } + } + + /// Exit a function signature annotation scope. fn exit_annotation_scope(&mut self, saved_ctx: CompileContext) -> CodeObject { self.pop_annotation_symbol_table(); self.ctx = saved_ctx; let pop = self.code_stack.pop(); let stack_top = compiler_unwrap_option(self, pop); - unwrap_internal(self, stack_top.finalize_code(&self.opts)) + let mut code = unwrap_internal(self, stack_top.finalize_code(&self.opts)); + Self::expose_annotation_format_parameter(&mut code); + code } - /// Enter annotation scope using the symbol table's annotation_block. - /// Returns None if no annotation_block exists. + /// Enter a function signature annotation scope. + /// Returns None if no matching annotation symbol table exists. /// On success, returns the saved CompileContext to pass to exit_annotation_scope. fn enter_annotation_scope( &mut self, @@ -1518,12 +2118,12 @@ impl Compiler { lineno.to_u32(), )?; - // Override arg_count since enter_scope sets it to 1 but we need the varnames - // setup to be correct too + // Keep the internal ".format" name; exit_annotation_scope() + // renames it to "format" on the final code object. self.current_code_info() .metadata .varnames - .insert("format".to_owned()); + .insert(".format".to_owned()); // Emit format validation: if format > VALUE_WITH_FAKE_GLOBALS: raise NotImplementedError // VALUE_WITH_FAKE_GLOBALS = 2 (from annotationlib.Format) @@ -1586,12 +2186,15 @@ impl Compiler { fb_datum: FBlockDatum, ) -> CompileResult<()> { let fb_range = self.current_source_range; - let code = self.current_code_info(); - if code.fblock.len() >= MAXBLOCKS { + if self.current_code_info().fblock.len() >= CO_MAXBLOCKS { return Err(self.error(CodegenErrorType::SyntaxError( "too many statically nested blocks".to_owned(), ))); } + if matches!(fb_type, FBlockType::FinallyEnd) { + self.disable_warning += 1; + } + let code = self.current_code_info(); code.fblock.push(FBlockInfo { fb_type, fb_block, @@ -1608,16 +2211,49 @@ impl Compiler { expected_type: FBlockType, expected_block: ir::InstructionSequenceLabel, ) -> FBlockInfo { - let code = self.current_code_info(); - let fblock = code.fblock.pop().expect("fblock stack underflow"); + let fblock = { + let code = self.current_code_info(); + code.fblock.pop().expect("fblock stack underflow") + }; debug_assert_eq!(fblock.fb_type, expected_type); debug_assert_eq!( fblock.fb_block, expected_block, "CPython _PyCompile_PopFBlock asserts the popped fb_block label" ); + if matches!(expected_type, FBlockType::FinallyEnd) { + self.disable_warning -= 1; + } fblock } + /// `_PyCompile_PushFBlock()` call used by + /// `codegen_unwind_fblock_stack()` to restore the copied fblock after + /// recursive unwinding. + fn restore_fblock_info(&mut self, fblock: FBlockInfo) -> CompileResult<()> { + let FBlockInfo { + fb_type, + fb_block, + fb_exit, + fb_range, + fb_datum, + } = fblock; + let code = self.current_code_info(); + if code.fblock.len() >= CO_MAXBLOCKS { + return Err(self.error_ranged( + CodegenErrorType::SyntaxError("too many statically nested blocks".to_owned()), + fb_range, + )); + } + code.fblock.push(FBlockInfo { + fb_type, + fb_block, + fb_exit, + fb_range, + fb_datum, + }); + Ok(()) + } + fn set_unwind_source_range(&mut self, loc: Option) { if let Some(range) = loc { self.set_source_range(range); @@ -1667,10 +2303,32 @@ impl Compiler { } FBlockType::FinallyTry => { - // FinallyTry is now handled specially in unwind_fblock_stack - // to avoid infinite recursion when the finally body contains return/break/continue. - // This branch should not be reached. - unreachable!("FinallyTry should be handled by unwind_fblock_stack"); + // codegen_unwind_fblock(FINALLY_TRY) + self.set_unwind_source_range(*loc); + emit!(self, PseudoInstruction::PopBlock); + self.mark_unwind_no_location(*loc); + + if preserve_tos { + self.push_fblock_labels( + FBlockType::PopValue, + ir::InstructionSequenceLabel::NO_LABEL, + ir::InstructionSequenceLabel::NO_LABEL, + FBlockDatum::None, + )?; + } + + if let FBlockDatum::FinallyBody(ref body) = info.fb_datum { + self.compile_statements(body)?; + } + + if preserve_tos { + self.pop_fblock_label( + FBlockType::PopValue, + ir::InstructionSequenceLabel::NO_LABEL, + ); + } + + *loc = None; } FBlockType::FinallyEnd => { @@ -1802,96 +2460,40 @@ impl Compiler { preserve_tos: bool, stop_at_loop: bool, ) -> CompileResult<(Option, Option)> { - // Collect the info we need, with indices for FinallyTry blocks - #[derive(Clone)] - enum UnwindInfo { - Normal(FBlockInfo), - FinallyTry { - body: Vec, - fblock_idx: usize, - }, - } - let mut unwind_infos = Vec::new(); - let mut loop_fblock = None; - - { - let code = self.current_code_info(); - for i in (0..code.fblock.len()).rev() { - // Check for exception group handler (forbidden) - if matches!(code.fblock[i].fb_type, FBlockType::ExceptionGroupHandler) { - return Err(self.error(CodegenErrorType::BreakContinueReturnInExceptStar)); - } - - // Stop at loop if requested - if stop_at_loop - && matches!( - code.fblock[i].fb_type, - FBlockType::WhileLoop | FBlockType::ForLoop - ) - { - loop_fblock = Some(code.fblock[i].clone()); - break; - } - - if matches!(code.fblock[i].fb_type, FBlockType::FinallyTry) { - if let FBlockDatum::FinallyBody(ref body) = code.fblock[i].fb_datum { - unwind_infos.push(UnwindInfo::FinallyTry { - body: body.clone(), - fblock_idx: i, - }); - } - } else { - unwind_infos.push(UnwindInfo::Normal(code.fblock[i].clone())); - } - } - } - - // Process each fblock let mut unwind_loc = Some(self.current_source_range); - for info in unwind_infos { - match info { - UnwindInfo::Normal(fblock_info) => { - self.unwind_fblock(&fblock_info, preserve_tos, &mut unwind_loc)?; - } - UnwindInfo::FinallyTry { body, fblock_idx } => { - // codegen_unwind_fblock(FINALLY_TRY) - self.set_unwind_source_range(unwind_loc); - emit!(self, PseudoInstruction::PopBlock); - self.mark_unwind_no_location(unwind_loc); - - // Temporarily remove the FinallyTry fblock so nested return/break/continue - // in the finally body won't see it again - let code = self.current_code_info(); - let saved_fblock = code.fblock.remove(fblock_idx); - - // Push PopValue fblock if preserving tos - if preserve_tos { - self.push_fblock_labels( - FBlockType::PopValue, - ir::InstructionSequenceLabel::NO_LABEL, - ir::InstructionSequenceLabel::NO_LABEL, - FBlockDatum::None, - )?; - } - - self.compile_statements(&body)?; - unwind_loc = None; - - if preserve_tos { - self.pop_fblock_label( - FBlockType::PopValue, - ir::InstructionSequenceLabel::NO_LABEL, - ); - } + let loop_fblock = + self.unwind_fblock_stack_inner(preserve_tos, stop_at_loop, &mut unwind_loc)?; + Ok((unwind_loc, loop_fblock)) + } - // Restore the fblock - let code = self.current_code_info(); - code.fblock.insert(fblock_idx, saved_fblock); - } - } + fn unwind_fblock_stack_inner( + &mut self, + preserve_tos: bool, + stop_at_loop: bool, + unwind_loc: &mut Option, + ) -> CompileResult> { + let Some(top) = self.current_code_info().fblock.last().cloned() else { + return Ok(None); + }; + if matches!(top.fb_type, FBlockType::ExceptionGroupHandler) { + return Err(self.error_optional_range( + CodegenErrorType::BreakContinueReturnInExceptStar, + *unwind_loc, + )); + } + if stop_at_loop && matches!(top.fb_type, FBlockType::WhileLoop | FBlockType::ForLoop) { + return Ok(Some(top)); } - Ok((unwind_loc, loop_fblock)) + let copy = self + .current_code_info() + .fblock + .pop() + .expect("fblock stack underflow"); + self.unwind_fblock(©, preserve_tos, unwind_loc)?; + let loop_fblock = self.unwind_fblock_stack_inner(preserve_tos, stop_at_loop, unwind_loc)?; + self.restore_fblock_info(copy)?; + Ok(loop_fblock) } // could take impl Into>, but everything is borrowed from ast structs; we never @@ -1949,7 +2551,12 @@ impl Compiler { // when building qualnames for the contained function/class code object. if matches!( parent_scope, - Some(CompilerScope::TypeParams | CompilerScope::Annotation) + Some( + CompilerScope::TypeParams + | CompilerScope::Annotation + | CompilerScope::TypeAlias + | CompilerScope::TypeVariable, + ) ) || parent.metadata.name.starts_with(" CompileResult<()> { + let future_features = self.future_features; + self.current_code_info().flags |= future_features; + if symbol_table.is_coroutine { + self.current_code_info() + .flags + .insert(bytecode::CodeFlags::COROUTINE); + } self.symbol_table_stack.push(symbol_table); self.emit_resume_for_scope(CompilerScope::Module, 1); @@ -2264,6 +2859,13 @@ impl Compiler { expression: &ast::ModExpression, symbol_table: SymbolTable, ) -> CompileResult<()> { + let future_features = self.future_features; + self.current_code_info().flags |= future_features; + if symbol_table.is_coroutine { + self.current_code_info() + .flags + .insert(bytecode::CodeFlags::COROUTINE); + } self.symbol_table_stack.push(symbol_table); self.emit_resume_for_scope(CompilerScope::Module, 1); @@ -2365,7 +2967,6 @@ impl Compiler { fn emit_no_location_exception_name_cleanup(&mut self, name: &str) -> CompileResult<()> { // CPython codegen_try_except() emits `name = None; del name` // with NO_LOCATION for `except ... as name` cleanup. - self.set_no_location(); self.emit_load_const(ConstantData::None); self.set_no_location(); self.store_name(name)?; @@ -2423,7 +3024,10 @@ impl Compiler { let current_idx = self.symbol_table_stack.len() - 1; let current_table = &self.symbol_table_stack[current_idx]; let is_typeparams = current_table.typ == CompilerScope::TypeParams; - let is_annotation = current_table.typ == CompilerScope::Annotation; + let is_annotation = matches!( + current_table.typ, + CompilerScope::Annotation | CompilerScope::TypeAlias | CompilerScope::TypeVariable + ); let can_see_class = current_table.can_see_class_scope; // First try to find in current table @@ -2462,13 +3066,11 @@ impl Compiler { let current_table = self.current_symbol_table(); if current_table.typ == CompilerScope::Class && !self.current_code_info().in_inlined_comp - && matches!( - (usage, name.as_ref()), - ( - NameUsage::Load, - "__class__" | "__classdict__" | "__conditional_annotations__" - ) | (NameUsage::Store, "__conditional_annotations__") - ) + && ((usage == NameUsage::Load + && (name == "__class__" + || name == "__classdict__" + || name == "__conditional_annotations__")) + || (name == "__conditional_annotations__" && usage == NameUsage::Store)) { Some(SymbolScope::Cell) } else { @@ -2485,7 +3087,10 @@ impl Compiler { let current_table = self.current_symbol_table(); if matches!( current_table.typ, - CompilerScope::Annotation | CompilerScope::TypeParams + CompilerScope::Annotation + | CompilerScope::TypeAlias + | CompilerScope::TypeVariable + | CompilerScope::TypeParams ) { SymbolScope::GlobalImplicit } else if matches!( @@ -2539,7 +3144,7 @@ impl Compiler { // to check classdict first before globals if class_declared_global { NameOp::Global - } else if can_see_class_scope { + } else if can_see_class_scope && usage == NameUsage::Load { NameOp::DictOrGlobals } else if is_function_like { NameOp::Global @@ -2551,7 +3156,7 @@ impl Compiler { // A global declared in the owning class body must bypass the // classdict, but an explicit global inherited from an outer // function still participates in DictOrGlobals lookup. - if can_see_class_scope && !class_declared_global { + if can_see_class_scope && !class_declared_global && usage == NameUsage::Load { NameOp::DictOrGlobals } else { NameOp::Global @@ -2635,21 +3240,10 @@ impl Compiler { NameOp::DictOrGlobals => { // PEP 649: First check classdict (from __classdict__ freevar), then globals let idx = self.get_global_name_index(&name); - match usage { - NameUsage::Load => { - // Load __classdict__ first (it's a free variable in annotation scope) - let classdict_idx = self.get_free_var_index("__classdict__"); - emit!(self, Instruction::LoadDeref { i: classdict_idx }); - emit!(self, Instruction::LoadFromDictOrGlobals { i: idx }); - } - // Store/Delete in annotation scope should use Name ops - NameUsage::Store => { - emit!(self, Instruction::StoreName { namei: idx }); - } - NameUsage::Delete => { - emit!(self, Instruction::DeleteName { namei: idx }); - } - } + debug_assert!(usage == NameUsage::Load); + let classdict_idx = self.get_free_var_index("__classdict__"); + emit!(self, Instruction::LoadDeref { i: classdict_idx }); + emit!(self, Instruction::LoadFromDictOrGlobals { i: idx }); } } @@ -2664,14 +3258,17 @@ impl Compiler { match &statement { // we do this here because `from __future__` still executes that `from` statement at runtime, // we still need to compile the ImportFrom down below - ast::Stmt::ImportFrom(ast::StmtImportFrom { module, names, .. }) - if module.as_ref().map(|id| id.as_str()) == Some("__future__") => - { + ast::Stmt::ImportFrom(ast::StmtImportFrom { + module, + names, + level, + .. + }) if *level == 0 && module.as_ref().map(|id| id.as_str()) == Some("__future__") => { self.compile_future_features(names)? } // ignore module-level doc comments ast::Stmt::Expr(ast::StmtExpr { value, .. }) - if matches!(&**value, ast::Expr::StringLiteral(..)) + if is_docstring_expr(value) && matches!(self.done_with_future_stmts, DoneWithFuture::No) => { self.done_with_future_stmts = DoneWithFuture::DoneWithDoc @@ -2716,24 +3313,14 @@ impl Compiler { names, .. }) => { - let import_star = names.iter().any(|n| &n.name == "*"); + let import_star = names.first().is_some_and(|n| &n.name == "*"); - let from_list = if import_star { - if self.ctx.in_func() { - return Err(self.error_ranged( - CodegenErrorType::FunctionImportStar, - statement.range(), - )); - } - vec![ConstantData::Str { value: "*".into() }] - } else { - names - .iter() - .map(|n| ConstantData::Str { - value: n.name.as_str().into(), - }) - .collect() - }; + let from_list = names + .iter() + .map(|n| ConstantData::Str { + value: n.name.as_str().into(), + }) + .collect(); // from .... import (*fromlist) self.emit_load_const(ConstantData::Integer { @@ -2811,13 +3398,17 @@ impl Compiler { .. }) => { self.enter_conditional_block(); - self.compile_if(test, body, elif_else_clauses, test.range())?; + self.compile_if(test, body, elif_else_clauses, statement.range())?; self.leave_conditional_block(); self.set_source_range(statement.range()); } ast::Stmt::While(ast::StmtWhile { - test, body, orelse, .. - }) => self.compile_while(test, body, orelse)?, + test, + body, + orelse, + range, + .. + }) => self.compile_while(test, body, orelse, *range)?, ast::Stmt::With(ast::StmtWith { items, body, @@ -2909,13 +3500,15 @@ impl Compiler { arguments.as_deref(), false, )?, - ast::Stmt::Assert(ast::StmtAssert { - test, msg, range, .. - }) => { + ast::Stmt::Assert(assert_stmt) => { + let ast::StmtAssert { + test, msg, range, .. + } = assert_stmt; + self.check_assert(assert_stmt)?; // if some flag, ignore all assert statements! if self.opts.optimize == 0 { let after_block = self.new_block(); - self.compile_jump_if(test, true, after_block)?; + self.compile_jump_if_inner(test, true, after_block, Some(*range))?; self.set_source_range(*range); emit!( self, @@ -2973,7 +3566,13 @@ impl Compiler { statement.range(), )); } - let folded_constant = if v.is_constant() { + let debug_constant = matches!( + &**v, + ast::Expr::Name(ast::ExprName { id, ctx, .. }) + if matches!(ctx, ast::ExprContext::Load) + && id.as_str() == "__debug__" + ); + let folded_constant = if self.is_constant_expr(v) || debug_constant { self.try_fold_constant_expr(v)? } else { None @@ -3000,18 +3599,17 @@ impl Compiler { let unwind_loc = self.unwind_fblock_stack(preserve_tos, false)?; if let Some(loc) = unwind_loc { self.set_source_range(loc); - } - match folded_constant { - Some(constant) if unwind_loc.is_none() => { - self.emit_return_const_no_location(constant); - } - Some(constant) => { - self.emit_load_const(constant); - self.emit_return_value(); + match folded_constant { + Some(constant) => self.emit_return_const(constant), + None => { + self.emit_return_value(); + } } - None => { - self.emit_return_value(); - if unwind_loc.is_none() { + } else { + match folded_constant { + Some(constant) => self.emit_return_const_no_location(constant), + None => { + self.emit_return_value(); self.set_no_location(); } } @@ -3038,11 +3636,7 @@ impl Compiler { range, .. }) => { - if targets.len() == 1 && Self::is_unpack_assignment_target(&targets[0]) { - self.compile_expression_without_const_collection_folding(value)?; - } else { - self.compile_expression(value)?; - } + self.compile_expression(value)?; for (i, target) in targets.iter().enumerate() { if i + 1 != targets.len() { @@ -3099,6 +3693,7 @@ impl Compiler { let name_string = name.id.to_string(); if let Some(type_params) = type_params { + self.set_source_range(*range); self.push_symbol_table()?; let key = self.symbol_table_stack.len() - 1; let lineno = self.get_source_line_number().get().to_u32(); @@ -3113,11 +3708,13 @@ impl Compiler { in_async_scope: false, }; + self.set_source_range(*range); self.emit_load_const(ConstantData::Str { value: name_string.clone().into(), }); self.compile_type_params(type_params)?; self.compile_typealias_value_closure(&name_string, value, *range)?; + self.set_source_range(*range); emit!(self, Instruction::BuildTuple { count: 3 }); emit!( self, @@ -3129,15 +3726,19 @@ impl Compiler { let code = self.exit_scope(); self.ctx = prev_ctx; - self.make_closure(code, MakeFunctionFlags::new())?; + self.set_source_range(*range); + self.make_closure(code, bytecode::MakeFunctionFlags::new())?; + self.set_source_range(*range); emit!(self, Instruction::PushNull); emit!(self, Instruction::Call { argc: 0 }); } else { + self.set_source_range(*range); self.emit_load_const(ConstantData::Str { value: name_string.clone().into(), }); self.emit_load_const(ConstantData::None); self.compile_typealias_value_closure(&name_string, value, *range)?; + self.set_source_range(*range); emit!(self, Instruction::BuildTuple { count: 3 }); emit!( self, @@ -3147,9 +3748,15 @@ impl Compiler { ); } + self.set_source_range(*range); self.store_name(&name_string)?; } - ast::Stmt::IpyEscapeCommand(_) => todo!(), + ast::Stmt::IpyEscapeCommand(stmt) => { + return Err(self.error_ranged( + CodegenErrorType::SyntaxError("invalid syntax".to_owned()), + stmt.range, + )); + } } Ok(()) } @@ -3195,21 +3802,10 @@ impl Compiler { } fn enter_function(&mut self, name: &str, parameters: &ast::Parameters) -> CompileResult<()> { - // TODO: partition_in_place - let mut kw_without_defaults = vec![]; - let mut kw_with_defaults = vec![]; - for kwonlyarg in ¶meters.kwonlyargs { - if let Some(default) = &kwonlyarg.default { - kw_with_defaults.push((&kwonlyarg.parameter, default)); - } else { - kw_without_defaults.push(&kwonlyarg.parameter); - } - } - self.push_output( - CodeFlags::NEWLOCALS | CodeFlags::OPTIMIZED, + bytecode::CodeFlags::NEWLOCALS | bytecode::CodeFlags::OPTIMIZED, parameters.posonlyargs.len().to_u32(), - (parameters.posonlyargs.len() + parameters.args.len()).to_u32(), + parameters.args.len().to_u32(), parameters.kwonlyargs.len().to_u32(), name, )?; @@ -3218,18 +3814,17 @@ impl Compiler { .chain(¶meters.posonlyargs) .chain(¶meters.args) .map(|arg| &arg.parameter) - .chain(kw_without_defaults) - .chain(kw_with_defaults.into_iter().map(|(arg, _)| arg)); + .chain(parameters.kwonlyargs.iter().map(|arg| &arg.parameter)); for name in args_iter { self.varname(name.name.as_str()); } if let Some(name) = parameters.vararg.as_deref() { - self.current_code_info().flags |= CodeFlags::VARARGS; + self.current_code_info().flags |= bytecode::CodeFlags::VARARGS; self.varname(name.name.as_str()); } if let Some(name) = parameters.kwarg.as_deref() { - self.current_code_info().flags |= CodeFlags::VARKEYWORDS; + self.current_code_info().flags |= bytecode::CodeFlags::VARKEYWORDS; self.varname(name.name.as_str()); } @@ -3275,7 +3870,7 @@ impl Compiler { let lineno = self.get_source_line_number().get().to_u32(); // Enter scope with the type parameter name - self.enter_scope(name, CompilerScope::Annotation, key, lineno)?; + self.enter_scope(name, CompilerScope::TypeVariable, key, lineno)?; self.current_code_info() .metadata @@ -3306,13 +3901,17 @@ impl Compiler { // Return value self.set_source_range(expr_range); emit!(self, Instruction::ReturnValue); + self.emit_return_const_no_location(ConstantData::None); // Exit scope and create closure let code = self.exit_scope(); self.ctx = prev_ctx; self.set_source_range(expr_range); - self.make_closure(code, MakeFunctionFlags::from([MakeFunctionFlag::Defaults]))?; + self.make_closure( + code, + bytecode::MakeFunctionFlags::from([bytecode::MakeFunctionFlag::Defaults]), + )?; Ok(()) } @@ -3331,7 +3930,7 @@ impl Compiler { self.push_symbol_table()?; let key = self.symbol_table_stack.len() - 1; let lineno = self.get_source_line_number().get().to_u32(); - self.enter_scope(alias_name, CompilerScope::Annotation, key, lineno)?; + self.enter_scope(alias_name, CompilerScope::TypeAlias, key, lineno)?; self.current_code_info() .metadata .varnames @@ -3352,7 +3951,10 @@ impl Compiler { let code = self.exit_scope(); self.ctx = prev_ctx; self.set_source_range(alias_range); - self.make_closure(code, MakeFunctionFlags::from([MakeFunctionFlag::Defaults]))?; + self.make_closure( + code, + bytecode::MakeFunctionFlags::from([bytecode::MakeFunctionFlag::Defaults]), + )?; Ok(()) } @@ -3360,6 +3962,7 @@ impl Compiler { /// Store each type parameter so it is accessible to the current scope, and leave a tuple of /// all the type parameters on the stack. Handles default values per PEP 695. fn compile_type_params(&mut self, type_params: &ast::TypeParams) -> CompileResult<()> { + let mut seen_default = false; // First, compile each type parameter and store it for type_param in &type_params.type_params { match type_param { @@ -3395,6 +3998,7 @@ impl Compiler { } if let Some(default_expr) = default { + seen_default = true; self.compile_type_param_bound_or_default( default_expr, name.as_str(), @@ -3407,6 +4011,13 @@ impl Compiler { func: bytecode::IntrinsicFunction2::SetTypeparamDefault } ); + } else if seen_default { + return Err(self.error_ranged( + CodegenErrorType::SyntaxError(format!( + "non-default type parameter '{name}' follows default type parameter" + )), + *range, + )); } self.set_source_range(*range); @@ -3431,6 +4042,7 @@ impl Compiler { ); if let Some(default_expr) = default { + seen_default = true; self.compile_type_param_bound_or_default( default_expr, name.as_str(), @@ -3443,6 +4055,13 @@ impl Compiler { func: bytecode::IntrinsicFunction2::SetTypeparamDefault } ); + } else if seen_default { + return Err(self.error_ranged( + CodegenErrorType::SyntaxError(format!( + "non-default type parameter '{name}' follows default type parameter" + )), + *range, + )); } self.set_source_range(*range); @@ -3480,6 +4099,14 @@ impl Compiler { func: bytecode::IntrinsicFunction2::SetTypeparamDefault } ); + seen_default = true; + } else if seen_default { + return Err(self.error_ranged( + CodegenErrorType::SyntaxError(format!( + "non-default type parameter '{name}' follows default type parameter" + )), + *range, + )); } self.set_source_range(*range); @@ -3534,7 +4161,6 @@ impl Compiler { if handlers.is_empty() { self.compile_statements(body)?; - self.compile_statements(orelse)?; } else { self.compile_try_except_no_finally(body, handlers, orelse)?; } @@ -3543,7 +4169,7 @@ impl Compiler { self.set_no_location(); self.pop_fblock_label(FBlockType::FinallyTry, body_label); - let sub_table_cursor = self.symbol_table_stack.last().map(|t| t.next_sub_table); + let symbol_table_cursors = self.current_symbol_table_cursors(); self.compile_statements(finalbody)?; emit!( @@ -3552,11 +4178,7 @@ impl Compiler { ); self.set_no_location(); - if let Some(cursor) = sub_table_cursor - && let Some(current_table) = self.symbol_table_stack.last_mut() - { - current_table.next_sub_table = cursor; - } + self.set_symbol_table_cursors(symbol_table_cursors); self.use_cpython_label_block(finally_except_block); emit!( @@ -3620,7 +4242,16 @@ impl Compiler { self.pop_fblock_label(FBlockType::TryExcept, body_label); emit!(self, PseudoInstruction::PopBlock); self.set_no_location(); + + // The symtable stores child scopes in AST visit order + // (body, handlers, orelse), while codegen_try_except() emits orelse + // before the exception handlers. Keep the symbol table in symtable order + // and only move the codegen cursor while compiling orelse. + let handler_symbol_table_cursors = self.current_symbol_table_cursors(); + self.consume_skipped_nested_scopes_in_except_handlers(handlers)?; self.compile_statements(orelse)?; + let after_orelse_symbol_table_cursors = self.current_symbol_table_cursors(); + self.set_symbol_table_cursors(handler_symbol_table_cursors); emit!( self, PseudoInstruction::JumpNoInterrupt { delta: end_block } @@ -3739,13 +4370,14 @@ impl Compiler { self.use_cpython_label_block(next_handler); } + self.set_symbol_table_cursors(after_orelse_symbol_table_cursors); - emit!(self, Instruction::Reraise { depth: 0 }); - self.set_no_location(); self.pop_fblock_label( FBlockType::ExceptionHandler, ir::InstructionSequenceLabel::NO_LABEL, ); + emit!(self, Instruction::Reraise { depth: 0 }); + self.set_no_location(); self.use_cpython_label_block(cleanup_block); emit!(self, Instruction::Copy { i: 3 }); @@ -3801,7 +4433,7 @@ impl Compiler { self.set_no_location(); self.pop_fblock_label(FBlockType::FinallyTry, body_label); - let sub_table_cursor = self.symbol_table_stack.last().map(|t| t.next_sub_table); + let symbol_table_cursors = self.current_symbol_table_cursors(); self.compile_statements(finalbody)?; emit!( @@ -3810,11 +4442,7 @@ impl Compiler { ); self.set_no_location(); - if let Some(cursor) = sub_table_cursor - && let Some(current_table) = self.symbol_table_stack.last_mut() - { - current_table.next_sub_table = cursor; - } + self.set_symbol_table_cursors(symbol_table_cursors); self.use_cpython_label_block(finally_except_block); emit!( @@ -3881,9 +4509,9 @@ impl Compiler { FBlockDatum::None, )?; self.compile_statements(body)?; + self.pop_fblock_label(FBlockType::TryExcept, body_label); emit!(self, PseudoInstruction::PopBlock); self.set_no_location(); - self.pop_fblock_label(FBlockType::TryExcept, body_label); emit!( self, PseudoInstruction::JumpNoInterrupt { delta: else_block } @@ -3938,30 +4566,29 @@ impl Compiler { emit!(self, Instruction::Copy { i: 2 }); } - // Compile exception type + // Compile exception type. The public-AST validator allows a + // NULL type here, so codegen only emits CHECK_EG_MATCH when present. if let Some(exc_type) = type_ { self.compile_expression(exc_type)?; self.set_source_range(*handler_range); - } else { - return Err(self.error(CodegenErrorType::SyntaxError( - "except* must specify an exception type".to_owned(), - ))); } - // Stack: [prev_exc, orig, list, rest, type] - // ADDOP(c, loc, CHECK_EG_MATCH); - emit!(self, Instruction::CheckEgMatch); - // Stack: [prev_exc, orig, list, new_rest, match] + if type_.is_some() { + // Stack: [prev_exc, orig, list, rest, type] + // ADDOP(c, loc, CHECK_EG_MATCH); + emit!(self, Instruction::CheckEgMatch); + // Stack: [prev_exc, orig, list, new_rest, match] - // ADDOP_I(c, loc, COPY, 1); - // ADDOP_JUMP(c, loc, POP_JUMP_IF_NONE, no_match); - emit!(self, Instruction::Copy { i: 1 }); - emit!( - self, - Instruction::PopJumpIfNone { - delta: no_match_block - } - ); + // ADDOP_I(c, loc, COPY, 1); + // ADDOP_JUMP(c, loc, POP_JUMP_IF_NONE, no_match); + emit!(self, Instruction::Copy { i: 1 }); + emit!( + self, + Instruction::PopJumpIfNone { + delta: no_match_block + } + ); + } // Handler matched // Stack: [prev_exc, orig, list, new_rest, match] @@ -4001,9 +4628,9 @@ impl Compiler { self.compile_statements(body)?; // Handler body completed normally + self.pop_fblock_label(FBlockType::HandlerCleanup, cleanup_body_label); emit!(self, PseudoInstruction::PopBlock); self.set_no_location(); - self.pop_fblock_label(FBlockType::HandlerCleanup, cleanup_body_label); // Cleanup name binding if let Some(alias) = name { @@ -4179,7 +4806,7 @@ impl Compiler { parameters: &ast::Parameters, loc: TextRange, ) -> CompileResult { - let mut funcflags = MakeFunctionFlags::new(); + let mut funcflags = bytecode::MakeFunctionFlags::new(); // Handle positional defaults let defaults: Vec<_> = core::iter::empty() @@ -4200,7 +4827,7 @@ impl Compiler { count: defaults.len().to_u32() } ); - funcflags.insert(MakeFunctionFlag::Defaults); + funcflags.insert(bytecode::MakeFunctionFlag::Defaults); } // Handle keyword-only defaults @@ -4227,7 +4854,7 @@ impl Compiler { count: kw_with_defaults.len().to_u32(), } ); - funcflags.insert(MakeFunctionFlag::KwOnlyDefaults); + funcflags.insert(bytecode::MakeFunctionFlag::KwOnlyDefaults); } Ok(funcflags) @@ -4248,7 +4875,7 @@ impl Compiler { self.enter_function(name, parameters)?; self.current_code_info() .flags - .set(CodeFlags::COROUTINE, is_async); + .set(bytecode::CodeFlags::COROUTINE, is_async); // Set up context let prev_ctx = self.ctx; @@ -4267,7 +4894,7 @@ impl Compiler { self.set_qualname(); // Handle docstring - store in co_consts[0] if present - let (doc_info, body) = split_doc_with_range(body, self.opts); + let (doc_info, body) = split_doc_with_range(body, &self.opts); let doc_str = doc_info.as_ref().map(|(doc, _)| doc); if let Some(doc) = &doc_str { // Docstring present: store in co_consts[0] and set HAS_DOCSTRING flag @@ -4277,7 +4904,7 @@ impl Compiler { .insert_full(ConstantData::Str { value: (*doc).to_string().into(), }); - self.current_code_info().flags |= CodeFlags::HAS_DOCSTRING; + self.current_code_info().flags |= bytecode::CodeFlags::HAS_DOCSTRING; } let start_label = self.use_cpython_function_start_label(); @@ -4300,19 +4927,8 @@ impl Compiler { // Compile body statements self.compile_statements(body)?; - // Emit implicit `return None` if the body doesn't end with return. - // Also ensure None is in co_consts even when not emitting return - // (matching CPython: functions without explicit constants always - // have None in co_consts). - match body.last() { - Some(ast::Stmt::Return(_)) => {} - _ => { - self.emit_return_const_no_location(ConstantData::None); - } - } - // Functions with no other constants should still have None in co_consts - if self.current_code_info().metadata.consts.is_empty() { - self.arg_constant(ConstantData::None); + if stop_iteration_block.is_some() { + self.emit_return_const_no_location(ConstantData::None); } // Close StopIteration handler and emit handler code @@ -4329,6 +4945,7 @@ impl Compiler { emit!(self, Instruction::Reraise { depth: 1u32 }); self.set_no_location(); } + self.emit_return_const_no_location(ConstantData::None); // Exit scope and create function object let code = self.exit_scope(); @@ -4347,7 +4964,7 @@ impl Compiler { /// Compile function annotations as a closure (PEP 649) /// Returns true if an __annotate__ closure was created - /// Uses symbol table's annotation_block for proper scoping. + /// Uses the matching annotation symbol table for proper scoping. fn compile_annotations_closure( &mut self, func_name: &str, @@ -4355,21 +4972,11 @@ impl Compiler { returns: Option<&ast::Expr>, func_range: TextRange, ) -> CompileResult { - let has_signature_annotations = parameters - .args - .iter() - .map(|x| &x.parameter) - .chain(parameters.posonlyargs.iter().map(|x| &x.parameter)) - .chain(parameters.vararg.as_deref()) - .chain(parameters.kwonlyargs.iter().map(|x| &x.parameter)) - .chain(parameters.kwarg.as_deref()) - .any(|param| param.annotation.is_some()) - || returns.is_some(); - if !has_signature_annotations { + if !self.next_function_annotation_symbol_table_uses_annotations() { return Ok(false); } - // Try to enter annotation scope - returns None if no annotation_block exists + // Try to enter annotation scope - returns None if no matching symbol table exists. let Some(saved_ctx) = self.enter_annotation_scope(func_name, func_range)? else { return Ok(false); }; @@ -4427,13 +5034,14 @@ impl Compiler { } ); emit!(self, Instruction::ReturnValue); + self.emit_return_const_no_location(ConstantData::None); // Exit the annotation scope and get the code object let annotate_code = self.exit_annotation_scope(saved_ctx); // Make a closure from the code object self.set_source_range(func_range); - self.make_closure(annotate_code, MakeFunctionFlags::new())?; + self.make_closure(annotate_code, bytecode::MakeFunctionFlags::new())?; Ok(true) } @@ -4442,29 +5050,73 @@ impl Compiler { /// (including nested conditional blocks). This preserves the same walk /// order as symbol-table construction so the annotation scope's /// `sub_tables` cursor stays aligned. - fn collect_annotations(body: &[ast::Stmt]) -> Vec<&ast::StmtAnnAssign> { - use ast::visitor::Visitor; - - #[derive(Default)] - struct AnnotationsVisitor<'a> { - annotations: Vec<&'a ast::StmtAnnAssign>, - } - - impl<'a> Visitor<'a> for AnnotationsVisitor<'a> { - fn visit_stmt(&mut self, stmt: &'a ast::Stmt) { + fn collect_annotations( + body: &[ast::Stmt], + parent_scope_type: CompilerScope, + ) -> Vec<(&ast::StmtAnnAssign, bool)> { + fn walk<'a>( + stmts: &'a [ast::Stmt], + out: &mut Vec<(&'a ast::StmtAnnAssign, bool)>, + in_conditional_block: bool, + module_scope: bool, + ) { + for stmt in stmts { match stmt { - ast::Stmt::AnnAssign(ann_assign) => self.annotations.push(ann_assign), - ast::Stmt::ClassDef(_) | ast::Stmt::FunctionDef(_) => {} - _ => ast::visitor::walk_stmt(self, stmt), + ast::Stmt::AnnAssign(stmt) => { + out.push((stmt, module_scope || in_conditional_block)); + } + ast::Stmt::If(ast::StmtIf { + body, + elif_else_clauses, + .. + }) => { + walk(body, out, true, module_scope); + for clause in elif_else_clauses { + walk(&clause.body, out, true, module_scope); + } + } + ast::Stmt::For(ast::StmtFor { body, orelse, .. }) + | ast::Stmt::While(ast::StmtWhile { body, orelse, .. }) => { + walk(body, out, true, module_scope); + walk(orelse, out, true, module_scope); + } + ast::Stmt::With(ast::StmtWith { body, .. }) => { + walk(body, out, true, module_scope); + } + ast::Stmt::Try(ast::StmtTry { + body, + handlers, + orelse, + finalbody, + .. + }) => { + walk(body, out, true, module_scope); + for handler in handlers { + let ast::ExceptHandler::ExceptHandler( + ast::ExceptHandlerExceptHandler { body, .. }, + ) = handler; + walk(body, out, true, module_scope); + } + walk(orelse, out, true, module_scope); + walk(finalbody, out, true, module_scope); + } + ast::Stmt::Match(ast::StmtMatch { cases, .. }) => { + for case in cases { + walk(&case.body, out, true, module_scope); + } + } + _ => {} } } } - - let mut visitor = AnnotationsVisitor::default(); - for stmt in body { - visitor.visit_stmt(stmt); - } - visitor.annotations + let mut annotations = Vec::new(); + walk( + body, + &mut annotations, + false, + parent_scope_type == CompilerScope::Module, + ); + annotations } fn compile_annotation_for_symbol_cursor_only( @@ -4482,20 +5134,21 @@ impl Compiler { loc: Option, ) -> CompileResult { let loc = loc.unwrap_or(self.current_source_range); - let annotations = Self::collect_annotations(body); - let has_simple_annotation = annotations + // Get parent scope type BEFORE pushing annotation symbol table. + let parent_scope_type = self.current_symbol_table().typ; + let annotations = Self::collect_annotations(body, parent_scope_type); + let simple_annotation_count = annotations .iter() - .any(|stmt| stmt.simple && matches!(stmt.target.as_ref(), ast::Expr::Name(_))); + .filter(|(stmt, _)| stmt.simple && matches!(stmt.target.as_ref(), ast::Expr::Name(_))) + .count(); - if !has_simple_annotation { + if simple_annotation_count == 0 { return Ok(false); } // Check if we have conditional annotations let has_conditional = self.current_symbol_table().has_conditional_annotations; - // Get parent scope type BEFORE pushing annotation symbol table - let parent_scope_type = self.current_symbol_table().typ; // Try to push annotation symbol table from current scope if !self.push_current_annotation_symbol_table() { return Ok(false); @@ -4520,11 +5173,12 @@ impl Compiler { lineno.to_u32(), )?; - // Add 'format' parameter to varnames + // Keep the internal ".format" name; the final code object + // exposes this parameter as "format". self.current_code_info() .metadata .varnames - .insert("format".to_owned()); + .insert(".format".to_owned()); // Emit format validation: if format > VALUE_WITH_FAKE_GLOBALS: raise NotImplementedError self.emit_format_validation(); @@ -4532,8 +5186,8 @@ impl Compiler { self.set_source_range(loc); emit!(self, Instruction::BuildMap { count: 0 }); - let mut simple_idx = 0usize; - for stmt in annotations { + let mut conditional_idx = 0usize; + for (stmt, is_conditional) in annotations { let ast::StmtAnnAssign { target, annotation, @@ -4557,16 +5211,17 @@ impl Compiler { continue; } - let not_set_block = has_conditional.then(|| self.new_block()); - let not_set_label = - (!has_conditional).then(|| self.current_code_info().new_instr_sequence_label()); + let not_set_block = (has_conditional && is_conditional).then(|| self.new_block()); + let not_set_label = (!has_conditional || !is_conditional) + .then(|| self.current_code_info().new_instr_sequence_label()); let name = simple_name.expect("missing simple annotation name"); - if has_conditional { + if let Some(not_set_block) = not_set_block { self.set_source_range(*range); self.emit_load_const(ConstantData::Integer { - value: simple_idx.into(), + value: conditional_idx.into(), }); + conditional_idx += 1; if parent_scope_type == CompilerScope::Class { let idx = self.get_free_var_index("__conditional_annotations__"); emit!(self, Instruction::LoadDeref { i: idx }); @@ -4583,7 +5238,7 @@ impl Compiler { emit!( self, Instruction::PopJumpIfFalse { - delta: not_set_block.expect("missing not_set block") + delta: not_set_block } ); } @@ -4596,7 +5251,6 @@ impl Compiler { }); self.set_source_range(loc); emit!(self, Instruction::StoreSubscr); - simple_idx += 1; if let Some(not_set_block) = not_set_block { self.use_cpython_label_block(not_set_block); @@ -4610,6 +5264,7 @@ impl Compiler { self.set_source_range(loc); emit!(self, Instruction::ReturnValue); + self.emit_return_const_no_location(ConstantData::None); // Exit annotation scope - pop symbol table, restore to parent's annotation_block, and get code let annotation_table = self.pop_symbol_table(); @@ -4622,14 +5277,15 @@ impl Compiler { self.ctx = saved_ctx; // Exit code scope let pop = self.code_stack.pop(); - let annotate_code = unwrap_internal( + let mut annotate_code = unwrap_internal( self, compiler_unwrap_option(self, pop).finalize_code(&self.opts), ); + Self::expose_annotation_format_parameter(&mut annotate_code); // Make a closure from the code object self.set_source_range(loc); - self.make_closure(annotate_code, MakeFunctionFlags::new())?; + self.make_closure(annotate_code, bytecode::MakeFunctionFlags::new())?; // Store as __annotate_func__ for classes, __annotate__ for modules let name = if parent_scope_type == CompilerScope::Class { @@ -4665,14 +5321,25 @@ impl Compiler { if is_async { "async def " } else { "def " }, ); + // The symtable visits defaults before decorators, but + // codegen_function() emits decorators first. Keep the symbol table in + // symtable order and only move the codegen cursor while compiling + // decorators. + let defaults_symbol_table_cursors = self.current_symbol_table_cursors(); + self.consume_skipped_nested_scopes_in_parameter_defaults(parameters)?; self.prepare_decorators(decorator_list)?; + let after_decorators_symbol_table_cursors = self.current_symbol_table_cursors(); + self.set_symbol_table_cursors(defaults_symbol_table_cursors); + + // The first decorator line is used for code objects created by + // this definition, but LOC(s) for the surrounding instructions. + let firstlineno_range = decorator_list + .first() + .map_or(stmt_source_range, |decorator| decorator.expression.range()); // compile defaults and return funcflags let funcflags = self.compile_default_arguments(parameters, def_source_range)?; - - // Restore the `def` line range so that enter_function → push_output → get_source_line_number() - // records the `def` keyword's line as co_firstlineno, not the last default-argument line. - self.set_source_range(def_source_range); + self.set_symbol_table_cursors(after_decorators_symbol_table_cursors); let is_generic = type_params.is_some(); let mut num_typeparam_args = 0u32; @@ -4682,20 +5349,22 @@ impl Compiler { if is_generic { // Count args to pass to type params scope - if funcflags.contains(&MakeFunctionFlag::Defaults) { + if funcflags.contains(&bytecode::MakeFunctionFlag::Defaults) { num_typeparam_args += 1; } - if funcflags.contains(&MakeFunctionFlag::KwOnlyDefaults) { + if funcflags.contains(&bytecode::MakeFunctionFlag::KwOnlyDefaults) { num_typeparam_args += 1; } if num_typeparam_args == 2 { + self.set_source_range(def_source_range); emit!(self, Instruction::Swap { i: 2 }); } // Enter type params scope let type_params_name = format!(""); + self.set_source_range(firstlineno_range); self.push_output( - CodeFlags::OPTIMIZED | CodeFlags::NEWLOCALS, + bytecode::CodeFlags::OPTIMIZED | bytecode::CodeFlags::NEWLOCALS, 0, num_typeparam_args, 0, @@ -4712,13 +5381,13 @@ impl Compiler { // Add parameter names to varnames for the type params scope // These will be passed as arguments when the closure is called let current_info = self.current_code_info(); - if funcflags.contains(&MakeFunctionFlag::Defaults) { + if funcflags.contains(&bytecode::MakeFunctionFlag::Defaults) { current_info .metadata .varnames .insert(".defaults".to_owned()); } - if funcflags.contains(&MakeFunctionFlag::KwOnlyDefaults) { + if funcflags.contains(&bytecode::MakeFunctionFlag::KwOnlyDefaults) { current_info .metadata .varnames @@ -4729,6 +5398,7 @@ impl Compiler { self.compile_type_params(type_params.unwrap())?; // Load defaults/kwdefaults with LOAD_FAST + self.set_source_range(def_source_range); for i in 0..num_typeparam_args { let var_num = oparg::VarNum::from(i); emit!(self, Instruction::LoadFast { var_num }); @@ -4736,13 +5406,14 @@ impl Compiler { } // Compile annotations as closure (PEP 649) - let mut annotations_flag = MakeFunctionFlags::new(); + let mut annotations_flag = bytecode::MakeFunctionFlags::new(); if self.compile_annotations_closure(name, parameters, returns, def_source_range)? { - annotations_flag.insert(MakeFunctionFlag::Annotate); + annotations_flag.insert(bytecode::MakeFunctionFlag::Annotate); } - // Compile function body - self.set_source_range(stmt_source_range); + // Compile function body. codegen_function() uses the first + // decorator line for co_firstlineno, but LOC(s) for MAKE_FUNCTION. + self.set_source_range(firstlineno_range); let final_funcflags = funcflags | annotations_flag; self.compile_function_body( name, @@ -4757,9 +5428,11 @@ impl Compiler { if is_generic { // SWAP to get function on top // Stack: [type_params_tuple, function] -> [function, type_params_tuple] + self.set_source_range(def_source_range); emit!(self, Instruction::Swap { i: 2 }); // Call INTRINSIC_SET_FUNCTION_TYPE_PARAMS + self.set_source_range(def_source_range); emit!( self, Instruction::CallIntrinsic2 { @@ -4769,6 +5442,7 @@ impl Compiler { // Return the function object from type params scope emit!(self, Instruction::ReturnValue); + self.set_no_location(); // Set argcount for type params scope self.current_code_info().metadata.argcount = num_typeparam_args; @@ -4779,15 +5453,18 @@ impl Compiler { self.ctx = saved_ctx; // Make closure for type params code - self.make_closure(type_params_code, MakeFunctionFlags::new())?; + self.set_source_range(def_source_range); + self.make_closure(type_params_code, bytecode::MakeFunctionFlags::new())?; if num_typeparam_args > 0 { + self.set_source_range(def_source_range); emit!( self, Instruction::Swap { i: num_typeparam_args + 1 } ); + self.set_source_range(def_source_range); emit!( self, Instruction::Call { @@ -4796,8 +5473,10 @@ impl Compiler { ); } else { // Stack: [closure] + self.set_source_range(def_source_range); emit!(self, Instruction::PushNull); // Stack: [closure, NULL] + self.set_source_range(def_source_range); emit!(self, Instruction::Call { argc: 0 }); } } @@ -4818,36 +5497,31 @@ impl Compiler { /// Determines if a variable should be CELL or FREE type // = get_ref_type fn get_ref_type(&self, name: &str) -> Result { - let table = self.current_symbol_table(); + let table = self.symbol_table_stack.last().unwrap(); // Special handling for __class__, __classdict__, and __conditional_annotations__ in class scope // This should only apply when we're actually IN a class body, // not when we're in a method nested inside a class. if table.typ == CompilerScope::Class - && matches!( - name, - "__class__" | "__classdict__" | "__conditional_annotations__" - ) + && (name == "__class__" + || name == "__classdict__" + || name == "__conditional_annotations__") { return Ok(SymbolScope::Cell); } - - let Some(symbol) = table.lookup(name) else { - return Err(CodegenErrorType::SyntaxError(format!( - "get_ref_type: cannot find symbol '{name}'" - ))); - }; - - Ok(match symbol.scope { - SymbolScope::Cell => SymbolScope::Cell, - SymbolScope::Free => SymbolScope::Free, - _ if symbol.flags.contains(SymbolFlags::FREE_CLASS) => SymbolScope::Free, - _ => { - return Err(CodegenErrorType::SyntaxError(format!( + match table.lookup(name) { + Some(symbol) => match symbol.scope { + SymbolScope::Cell => Ok(SymbolScope::Cell), + SymbolScope::Free => Ok(SymbolScope::Free), + _ if symbol.flags.contains(SymbolFlags::FREE_CLASS) => Ok(SymbolScope::Free), + _ => Err(CodegenErrorType::SyntaxError(format!( "get_ref_type: invalid scope for '{name}'" - ))); - } - }) + ))), + }, + None => Err(CodegenErrorType::SyntaxError(format!( + "get_ref_type: cannot find symbol '{name}'" + ))), + } } /// Loads closure variables if needed and creates a function object @@ -4940,57 +5614,47 @@ impl Compiler { emit!( self, Instruction::SetFunctionAttribute { - flag: MakeFunctionFlag::Closure + flag: bytecode::MakeFunctionFlag::Closure } ); } // Set annotations if present - if flags.contains(&MakeFunctionFlag::Annotations) { + if flags.contains(&bytecode::MakeFunctionFlag::Annotations) { emit!( self, Instruction::SetFunctionAttribute { - flag: MakeFunctionFlag::Annotations + flag: bytecode::MakeFunctionFlag::Annotations } ); } // Set __annotate__ closure if present (PEP 649) - if flags.contains(&MakeFunctionFlag::Annotate) { + if flags.contains(&bytecode::MakeFunctionFlag::Annotate) { emit!( self, Instruction::SetFunctionAttribute { - flag: MakeFunctionFlag::Annotate + flag: bytecode::MakeFunctionFlag::Annotate } ); } // Set kwdefaults if present - if flags.contains(&MakeFunctionFlag::KwOnlyDefaults) { + if flags.contains(&bytecode::MakeFunctionFlag::KwOnlyDefaults) { emit!( self, Instruction::SetFunctionAttribute { - flag: MakeFunctionFlag::KwOnlyDefaults + flag: bytecode::MakeFunctionFlag::KwOnlyDefaults } ); } // Set defaults if present - if flags.contains(&MakeFunctionFlag::Defaults) { - emit!( - self, - Instruction::SetFunctionAttribute { - flag: MakeFunctionFlag::Defaults - } - ); - } - - // Set type_params if present - if flags.contains(&MakeFunctionFlag::TypeParams) { + if flags.contains(&bytecode::MakeFunctionFlag::Defaults) { emit!( self, Instruction::SetFunctionAttribute { - flag: MakeFunctionFlag::TypeParams + flag: bytecode::MakeFunctionFlag::Defaults } ); } @@ -5017,55 +5681,6 @@ impl Compiler { } } - // Python/compile.c find_ann - fn find_ann(body: &[ast::Stmt]) -> bool { - for statement in body { - let res = match &statement { - ast::Stmt::AnnAssign(_) => true, - ast::Stmt::For(ast::StmtFor { body, orelse, .. }) => { - Self::find_ann(body) || Self::find_ann(orelse) - } - ast::Stmt::If(ast::StmtIf { - body, - elif_else_clauses, - .. - }) => { - Self::find_ann(body) - || elif_else_clauses.iter().any(|x| Self::find_ann(&x.body)) - } - ast::Stmt::While(ast::StmtWhile { body, orelse, .. }) => { - Self::find_ann(body) || Self::find_ann(orelse) - } - ast::Stmt::With(ast::StmtWith { body, .. }) => Self::find_ann(body), - ast::Stmt::Match(ast::StmtMatch { cases, .. }) => { - cases.iter().any(|case| Self::find_ann(&case.body)) - } - ast::Stmt::Try(ast::StmtTry { - body, - handlers, - orelse, - finalbody, - .. - }) => { - Self::find_ann(body) - || handlers.iter().any(|h| { - let ast::ExceptHandler::ExceptHandler( - ast::ExceptHandlerExceptHandler { body, .. }, - ) = h; - Self::find_ann(body) - }) - || Self::find_ann(orelse) - || Self::find_ann(finalbody) - } - _ => false, - }; - if res { - return true; - } - } - false - } - /// Compile the class body into a code object // = compiler_class_body fn compile_class_body( @@ -5077,7 +5692,7 @@ impl Compiler { ) -> CompileResult { // 1. Enter class scope let key = self.symbol_table_stack.len(); - self.push_symbol_table()?; + self.push_symbol_table_matching(CompilerScope::Class, name)?; self.enter_scope(name, CompilerScope::Class, key, firstlineno)?; // Set qualname using the new method @@ -5087,7 +5702,7 @@ impl Compiler { self.code_stack.last_mut().unwrap().private = Some(name.to_owned()); // 2. Set up class namespace - let (doc_str, body) = split_doc_with_range(body, self.opts); + let (doc_str, body) = split_doc_with_range(body, &self.opts); let class_body_prefix_range = self.source_line_start_range(firstlineno); self.set_source_range(class_body_prefix_range); @@ -5123,15 +5738,14 @@ impl Compiler { } // Handle class annotation bookkeeping in CPython order. - if Self::find_ann(body) { - if Self::scope_needs_conditional_annotations_cell(self.current_symbol_table()) { - emit!(self, Instruction::BuildSet { count: 0 }); - self.store_name("__conditional_annotations__")?; - } + let annotations_used = self.current_symbol_table().annotations_used; + if Self::scope_needs_conditional_annotations_cell(self.current_symbol_table()) { + emit!(self, Instruction::BuildSet { count: 0 }); + self.store_name("__conditional_annotations__")?; + } - if self.future_annotations { - emit!(self, Instruction::SetupAnnotations); - } + if self.future_annotations && annotations_used { + emit!(self, Instruction::SetupAnnotations); } // Store __doc__ only if there's an explicit docstring. @@ -5140,13 +5754,14 @@ impl Compiler { self.set_source_range(range); self.emit_load_const(ConstantData::Str { value: doc.into() }); self.store_name("__doc__")?; + self.set_no_location(); self.set_source_range(saved_range); } // 3. Compile the class body self.compile_statements(body)?; - if Self::find_ann(body) && !self.future_annotations { + if annotations_used && !self.future_annotations { self.compile_module_annotate(body, Some(class_body_prefix_range))?; } @@ -5211,6 +5826,7 @@ impl Compiler { // Return the class namespace self.emit_return_value(); self.set_no_location(); + self.emit_return_const_no_location(ConstantData::None); // Exit scope and return the code object Ok(self.exit_scope()) @@ -5233,6 +5849,9 @@ impl Compiler { self.prepare_decorators(decorator_list)?; let is_generic = type_params.is_some(); + let firstlineno_range = decorator_list + .first() + .map_or(stmt_source_range, |decorator| decorator.expression.range()); #[expect(clippy::map_unwrap_or, reason = "Changing this will not compile")] let firstlineno = decorator_list .first() @@ -5251,8 +5870,9 @@ impl Compiler { // Step 1: If generic, enter type params scope and compile type params if is_generic { let type_params_name = format!(""); + self.set_source_range(firstlineno_range); self.push_output( - CodeFlags::OPTIMIZED | CodeFlags::NEWLOCALS, + bytecode::CodeFlags::OPTIMIZED | bytecode::CodeFlags::NEWLOCALS, 0, 0, 0, @@ -5283,7 +5903,10 @@ impl Compiler { in_class: true, in_async_scope: false, }; + let pre_class_body_symbol_table_cursors = self.current_symbol_table_cursors(); let class_code = self.compile_class_body(name, body, type_params, firstlineno)?; + let post_class_body_symbol_table_cursors = self.current_symbol_table_cursors(); + self.set_symbol_table_cursors(pre_class_body_symbol_table_cursors); self.ctx = prev_ctx; self.set_source_range(class_source_range); @@ -5295,7 +5918,7 @@ impl Compiler { // Create the class body function with the .type_params closure // captured through the class code object's freevars. - self.make_closure(class_code, MakeFunctionFlags::new())?; + self.make_closure(class_code, bytecode::MakeFunctionFlags::new())?; self.emit_load_const(ConstantData::Str { value: name.into() }); // Create .generic_base after the class function and name are on the @@ -5311,134 +5934,21 @@ impl Compiler { self.set_source_range(class_source_range); self.store_name(".generic_base")?; - // Compile bases and call __build_class__ - // Check for starred bases or **kwargs - let has_starred = arguments.is_some_and(|args| { - args.args - .iter() - .any(|arg| matches!(arg, ast::Expr::Starred(_))) + let (bases, keywords) = arguments.map_or((&[][..], &[][..]), |args| { + (&args.args[..], &args.keywords[..]) }); - let has_double_star = - arguments.is_some_and(|args| args.keywords.iter().any(|kw| kw.arg.is_none())); - - if has_starred { - // Use CallFunctionEx for *bases or **kwargs - // Stack has: [__build_class__, NULL, class_func, name] - // Need to build: args tuple = (class_func, name, *bases, .generic_base) - - // Build a list starting with class_func and name (2 elements already on stack) - emit!(self, Instruction::BuildList { count: 2 }); - - // Add bases to the list - if let Some(arguments) = arguments { - for arg in &arguments.args { - if let ast::Expr::Starred(ast::ExprStarred { value, .. }) = arg { - // Starred: compile and extend - self.compile_expression(value)?; - emit!(self, Instruction::ListExtend { i: 1 }); - } else { - // Non-starred: compile and append - self.compile_expression(arg)?; - emit!(self, Instruction::ListAppend { i: 1 }); - } - } - } - - // Add .generic_base as final element - self.set_source_range(class_source_range); - self.load_name(".generic_base")?; - self.set_source_range(class_source_range); - emit!(self, Instruction::ListAppend { i: 1 }); - - // Convert list to tuple - self.set_source_range(class_source_range); - emit!( - self, - Instruction::CallIntrinsic1 { - func: IntrinsicFunction1::ListToTuple - } - ); - - self.compile_call_function_ex_keywords( - arguments.map_or(&[][..], |args| &args.keywords[..]), - class_source_range, - )?; - emit!(self, Instruction::CallFunctionEx); - } else if has_double_star { - if let Some(arguments) = arguments { - for arg in &arguments.args { - self.compile_expression(arg)?; - } - } - self.set_source_range(class_source_range); - self.load_name(".generic_base")?; - self.set_source_range(class_source_range); - emit!( - self, - Instruction::BuildTuple { - count: 3 + arguments - .map_or(0, |args| u32::try_from(args.args.len()).unwrap()) - } - ); - self.compile_call_function_ex_keywords( - &arguments.unwrap().keywords[..], - class_source_range, - )?; - emit!(self, Instruction::CallFunctionEx); - } else { - // Simple case: no starred bases, no **kwargs - // Compile bases normally - let base_count = if let Some(arguments) = arguments { - for arg in &arguments.args { - self.compile_expression(arg)?; - } - arguments.args.len() - } else { - 0 - }; - - // Load .generic_base as the last base - self.set_source_range(class_source_range); - self.load_name(".generic_base")?; - - let nargs = 2 + u32::try_from(base_count).expect("too many base classes") + 1; - - // Handle keyword arguments (no **kwargs here) - if let Some(arguments) = arguments - && !arguments.keywords.is_empty() - { - let mut kwarg_names = vec![]; - for keyword in &arguments.keywords { - let name = keyword.arg.as_ref().expect( - "keyword argument name must be set (no **kwargs in this branch)", - ); - kwarg_names.push(ConstantData::Str { - value: name.as_str().into(), - }); - self.compile_expression(&keyword.value)?; - } - self.set_source_range(class_source_range); - self.emit_load_const(ConstantData::Tuple { - elements: kwarg_names, - }); - self.set_source_range(class_source_range); - emit!( - self, - Instruction::CallKw { - argc: nargs - + u32::try_from(arguments.keywords.len()) - .expect("too many keyword arguments") - } - ); - } else { - self.set_source_range(class_source_range); - emit!(self, Instruction::Call { argc: nargs }); - } - } + self.codegen_call_helper_impl( + 2, + bases, + keywords, + class_source_range, + None, + Some(".generic_base"), + )?; // Return the created class - self.set_source_range(class_source_range); self.emit_return_value(); + self.set_no_location(); // Exit type params scope and wrap in function let type_params_code = self.exit_scope(); @@ -5446,7 +5956,7 @@ impl Compiler { // Execute the type params function self.set_source_range(class_source_range); - self.make_closure(type_params_code, MakeFunctionFlags::new())?; + self.make_closure(type_params_code, bytecode::MakeFunctionFlags::new())?; self.set_source_range(class_source_range); emit!(self, Instruction::PushNull); self.set_source_range(class_source_range); @@ -5457,7 +5967,7 @@ impl Compiler { emit!(self, Instruction::PushNull); // Create class function with closure - self.make_closure(class_code, MakeFunctionFlags::new())?; + self.make_closure(class_code, bytecode::MakeFunctionFlags::new())?; self.emit_load_const(ConstantData::Str { value: name.into() }); if let Some(arguments) = arguments { @@ -5466,6 +5976,7 @@ impl Compiler { self.set_source_range(class_source_range); emit!(self, Instruction::Call { argc: 2 }); } + self.set_symbol_table_cursors(post_class_body_symbol_table_cursors); } // Step 4: Apply decorators and store (common to both paths) @@ -5484,7 +5995,7 @@ impl Compiler { test: &ast::Expr, body: &[ast::Stmt], elif_else_clauses: &[ast::ElifElseClause], - _stmt_range: TextRange, + stmt_range: TextRange, ) -> CompileResult<()> { let end_block = self.new_block(); let next_block = if elif_else_clauses.is_empty() { @@ -5493,7 +6004,7 @@ impl Compiler { self.new_block() }; - self.compile_jump_if(test, false, next_block)?; + self.compile_jump_if_inner(test, false, next_block, Some(stmt_range))?; self.compile_statements(body)?; let Some((clause, rest)) = elif_else_clauses.split_first() else { @@ -5509,7 +6020,7 @@ impl Compiler { self.use_cpython_label_block(next_block); if let Some(test) = &clause.test { - self.compile_if(test, &clause.body, rest, test.range())?; + self.compile_if(test, &clause.body, rest, clause.range)?; } else { debug_assert!(rest.is_empty()); self.compile_statements(&clause.body)?; @@ -5523,6 +6034,7 @@ impl Compiler { test: &ast::Expr, body: &[ast::Stmt], orelse: &[ast::Stmt], + while_range: TextRange, ) -> CompileResult<()> { self.enter_conditional_block(); @@ -5538,7 +6050,7 @@ impl Compiler { end_label, FBlockDatum::None, )?; - self.compile_jump_if(test, false, anchor_block)?; + self.compile_jump_if_inner(test, false, anchor_block, Some(while_range))?; self.compile_loop_body_statements(body)?; emit!(self, PseudoInstruction::Jump { delta: loop_block }); @@ -5560,9 +6072,19 @@ impl Compiler { is_async: bool, ) -> CompileResult<()> { self.enter_conditional_block(); + let result = self.compile_with_inner(items, body, is_async); + self.leave_conditional_block(); + result + } - // Python 3.12+ style with statement: - // + fn compile_with_inner( + &mut self, + items: &[ast::WithItem], + body: &[ast::Stmt], + is_async: bool, + ) -> CompileResult<()> { + // Python 3.12+ style with statement: + // // BEFORE_WITH # TOS: ctx_mgr -> [__exit__, __enter__ result] // L1: STORE_NAME f # exception table: L1 to L2 -> L3 [1] lasti // L2: ... body ... @@ -5606,7 +6128,9 @@ impl Compiler { emit!(self, Instruction::Copy { i: 1 }); // [cm, cm] if is_async { - if self.ctx.func != FunctionContext::AsyncFunction { + if self.ctx.func != FunctionContext::AsyncFunction + && !self.allows_top_level_await_in_current_context() + { return Err(self.error(CodegenErrorType::InvalidAsyncWith)); } // Load __aexit__ and __aenter__, then call __aenter__ @@ -5691,7 +6215,7 @@ impl Compiler { self.compile_with_body_statements(body)?; } else { self.set_source_range(items[0].context_expr.range()); - self.compile_with(items, body, is_async)?; + self.compile_with_inner(items, body, is_async)?; } // CPython pops the async-with fblock before emitting POP_BLOCK, but @@ -5715,7 +6239,6 @@ impl Compiler { } emit!(self, Instruction::PopTop); // Pop __exit__ result emit!(self, PseudoInstruction::Jump { delta: after_block }); - self.set_no_location(); // ===== Exception handler path ===== // Stack at entry: [..., exit_func, self_exit, lasti, exc] @@ -5745,7 +6268,6 @@ impl Compiler { self.use_cpython_label_block(after_block); - self.leave_conditional_block(); Ok(()) } @@ -5786,10 +6308,12 @@ impl Compiler { } // The thing iterated: - self.compile_for_iterable_expression(iter, is_async)?; + self.compile_expression(iter)?; if is_async { - if self.ctx.func != FunctionContext::AsyncFunction { + if self.ctx.func != FunctionContext::AsyncFunction + && !self.allows_top_level_await_in_current_context() + { return Err(self.error(CodegenErrorType::InvalidAsyncFor)); } self.set_source_range(iter.range()); @@ -5821,6 +6345,7 @@ impl Compiler { self.compile_store(target)?; } else { // Retrieve Iterator + self.set_source_range(iter.range()); emit!(self, Instruction::GetIter); self.use_cpython_label_block(for_block); @@ -5842,12 +6367,19 @@ impl Compiler { emit!(self, PseudoInstruction::Jump { delta: for_block }); self.set_no_location(); + if is_async { + // codegen_async_for() pops the loop fblock before the + // END_ASYNC_FOR exception block. Sync codegen_for() keeps the + // fblock through END_FOR/POP_ITER and pops below. + self.pop_fblock_label(FBlockType::ForLoop, for_label); + } + self.use_cpython_label_block(else_block); // Except block for __anext__ / end of sync for if is_async { // codegen_async_for emits END_ASYNC_FOR at the iterator location, - // then pops the for-loop fblock before the else block. + // after the for-loop fblock has already been popped. let saved_range = self.current_source_range; self.set_source_range(iter.range()); self.emit_end_async_for(end_async_for_target); @@ -5859,9 +6391,8 @@ impl Compiler { self.set_no_location(); emit!(self, Instruction::PopIter); self.set_no_location(); + self.pop_fblock_label(FBlockType::ForLoop, for_label); } - // No PopBlock here - for async, POP_BLOCK is already in for_block - self.pop_fblock_label(FBlockType::ForLoop, for_label); self.compile_statements(orelse)?; self.use_cpython_label_block(after_block); @@ -5873,39 +6404,9 @@ impl Compiler { Ok(()) } - fn compile_for_iterable_expression( - &mut self, - iter: &ast::Expr, - is_async: bool, - ) -> CompileResult<()> { - // Match CPython's iterable lowering for `for`/comprehension fronts: - // a non-starred list literal used only for iteration is emitted as a tuple. - // Skip async-for/async comprehension iteration because GET_AITER expects - // the original object semantics. - if !is_async - && let ast::Expr::List(ast::ExprList { elts, .. }) = iter - && elts.len() <= usize::try_from(STACK_USE_GUIDELINE).unwrap() - && !elts.iter().any(|e| matches!(e, ast::Expr::Starred(_))) - { - for elt in elts { - self.compile_expression(elt)?; - } - self.set_source_range(iter.range()); - emit!( - self, - Instruction::BuildList { - count: u32::try_from(elts.len()).expect("too many elements"), - } - ); - return Ok(()); - } - - self.compile_expression(iter) - } - fn compile_comprehension_iter(&mut self, generator: &ast::Comprehension) -> CompileResult<()> { let saved_range = self.current_source_range; - self.compile_for_iterable_expression(&generator.iter, generator.is_async)?; + self.compile_expression(&generator.iter)?; self.set_source_range(generator.iter.range()); if generator.is_async { emit!(self, Instruction::GetAiter); @@ -5928,24 +6429,6 @@ impl Compiler { } } - fn forbidden_name(&mut self, name: &str, ctx: NameUsage) -> CompileResult { - if ctx == NameUsage::Store && name == "__debug__" { - return Err(self.error(CodegenErrorType::Assign("__debug__"))); - // return Ok(true); - } - if ctx == NameUsage::Delete && name == "__debug__" { - return Err(self.error(CodegenErrorType::Delete("__debug__"))); - // return Ok(true); - } - Ok(false) - } - - fn compile_error_forbidden_name(&mut self, name: &str) -> CodegenError { - self.error(CodegenErrorType::SyntaxError(format!( - "cannot use forbidden name '{name}' in pattern" - ))) - } - /// Ensures that `pc.fail_pop` has at least `n + 1` entries. /// If not, new labels are generated and pushed until the required size is reached. fn ensure_fail_pop(&mut self, pc: &mut PatternContext, n: usize) { @@ -5988,7 +6471,7 @@ impl Compiler { /// Emits the necessary POP instructions for all failure targets in the pattern context, /// then resets the fail_pop vector. - fn emit_and_reset_fail_pop(&mut self, pc: &mut PatternContext) { + fn emit_and_reset_fail_pop(&mut self, pc: &mut PatternContext, loc: TextRange) { // If the fail_pop vector is empty, nothing needs to be done. if pc.fail_pop.is_empty() { debug_assert!(pc.fail_pop.is_empty()); @@ -5999,6 +6482,7 @@ impl Compiler { // CPython emit_and_reset_fail_pop() uses USE_LABEL here. self.use_cpython_label_block(label); // Emit the POP instruction. + self.set_source_range(loc); emit!(self, Instruction::PopTop); } // Finally, use the first label. @@ -6010,7 +6494,7 @@ impl Compiler { } /// Duplicate the effect of Python 3.10's ROT_* instructions using SWAPs. - fn pattern_helper_rotate(&mut self, mut count: usize) { + fn pattern_helper_rotate(&mut self, loc: TextRange, mut count: usize) { // Rotate TOS (top of stack) to position `count` down // This is done by a series of swaps // For count=1, no rotation needed (already at top) @@ -6018,6 +6502,7 @@ impl Compiler { // For count=3, swap TOS with item 2 positions down, then with item 1 position down while count > 1 { // Emit a SWAP instruction with the current count. + self.set_source_range(loc); emit!( self, Instruction::Swap { @@ -6036,32 +6521,30 @@ impl Compiler { /// to the list of captured names. fn pattern_helper_store_name( &mut self, + loc: TextRange, n: Option<&ast::Identifier>, pc: &mut PatternContext, ) -> CompileResult<()> { match n { // If no name is provided, simply pop the top of the stack. None => { + self.set_source_range(loc); emit!(self, Instruction::PopTop); Ok(()) } Some(name) => { - // Check if the name is forbidden for storing. - if self.forbidden_name(name.as_str(), NameUsage::Store)? { - return Err(self.compile_error_forbidden_name(name.as_str())); - } - // Ensure we don't store the same name twice. // TODO: maybe pc.stores should be a set? if pc.stores.contains(&name.to_string()) { - return Err( - self.error(CodegenErrorType::DuplicateStore(name.as_str().to_string())) - ); + return Err(self.error_ranged( + CodegenErrorType::DuplicateStore(name.as_str().to_string()), + loc, + )); } // Calculate how many items to rotate: let rotations = pc.on_top + pc.stores.len() + 1; - self.pattern_helper_rotate(rotations); + self.pattern_helper_rotate(loc, rotations); // Append the name to the captured stores. pc.stores.push(name.to_string()); @@ -6070,30 +6553,51 @@ impl Compiler { } } - fn pattern_unpack_helper(&mut self, elts: &[ast::Pattern]) -> CompileResult<()> { + fn pattern_wildcard_check(pattern: &ast::Pattern) -> bool { + matches!( + pattern, + ast::Pattern::MatchAs(ast::PatternMatchAs { name: None, .. }) + ) + } + + fn pattern_wildcard_star_check(pattern: &ast::Pattern) -> bool { + matches!( + pattern, + ast::Pattern::MatchStar(ast::PatternMatchStar { name: None, .. }) + ) + } + + fn pattern_unpack_helper( + &mut self, + loc: TextRange, + elts: &[ast::Pattern], + ) -> CompileResult<()> { let n = elts.len(); let mut seen_star = false; for (i, elt) in elts.iter().enumerate() { - if elt.is_match_star() { - if !seen_star { - if i >= (1 << 8) || (n - i - 1) >= ((i32::MAX as usize) >> 8) { - todo!(); - // return self.compiler_error(loc, "too many expressions in star-unpacking sequence pattern"); - } - let counts = UnpackExArgs { - before: u8::try_from(i).unwrap(), - after: u8::try_from(n - i - 1).unwrap(), - }; - emit!(self, Instruction::UnpackEx { counts }); - seen_star = true; - } else { - // TODO: Fix error msg - return Err(self.error(CodegenErrorType::MultipleStarArgs)); - // return self.compiler_error(loc, "multiple starred expressions in sequence pattern"); + if elt.is_match_star() && !seen_star { + if i >= (1 << 8) || (n - i - 1) >= ((i32::MAX as usize) >> 8) { + return Err(self.error_ranged( + CodegenErrorType::TooManyExpressionsInStarUnpackingSequencePattern, + loc, + )); } + let counts = UnpackExArgs { + before: u8::try_from(i).unwrap(), + after: u32::try_from(n - i - 1).unwrap(), + }; + self.set_source_range(loc); + emit!(self, Instruction::UnpackEx { counts }); + seen_star = true; + } else if elt.is_match_star() { + return Err(self.error_ranged( + CodegenErrorType::MultipleStarredExpressionsInSequencePattern, + loc, + )); } } if !seen_star { + self.set_source_range(loc); emit!( self, Instruction::UnpackSequence { @@ -6106,12 +6610,13 @@ impl Compiler { fn pattern_helper_sequence_unpack( &mut self, + loc: TextRange, patterns: &[ast::Pattern], _star: Option, pc: &mut PatternContext, ) -> CompileResult<()> { // Unpack the sequence into individual subjects. - self.pattern_unpack_helper(patterns)?; + self.pattern_unpack_helper(loc, patterns)?; let size = patterns.len(); // Increase the on_top counter for the newly unpacked subjects. pc.on_top += size; @@ -6126,6 +6631,7 @@ impl Compiler { fn pattern_helper_sequence_subscr( &mut self, + loc: TextRange, patterns: &[ast::Pattern], star: usize, pc: &mut PatternContext, @@ -6133,35 +6639,32 @@ impl Compiler { // Keep the subject around for extracting elements. pc.on_top += 1; for (i, pattern) in patterns.iter().enumerate() { - let is_true_wildcard = matches!( - pattern, - ast::Pattern::MatchAs(ast::PatternMatchAs { - pattern: None, - name: None, - .. - }) - ); - if is_true_wildcard { + if Self::pattern_wildcard_check(pattern) { continue; } if i == star { // This must be a starred wildcard. - // assert!(pattern.is_star_wildcard()); + debug_assert!(Self::pattern_wildcard_star_check(pattern)); continue; } // Duplicate the subject. + self.set_source_range(loc); emit!(self, Instruction::Copy { i: 1 }); if i < star { // For indices before the star, use a nonnegative index equal to i. + self.set_source_range(loc); self.emit_load_const(ConstantData::Integer { value: i.into() }); } else { // For indices after the star, compute a nonnegative index: // index = len(subject) - (size - i) + self.set_source_range(loc); emit!(self, Instruction::GetLen); + self.set_source_range(loc); self.emit_load_const(ConstantData::Integer { value: (patterns.len() - i).into(), }); // Subtract to compute the correct index. + self.set_source_range(loc); emit!( self, Instruction::BinaryOp { @@ -6170,6 +6673,7 @@ impl Compiler { ); } // Use BINARY_OP/NB_SUBSCR to extract the element. + self.set_source_range(loc); emit!( self, Instruction::BinaryOp { @@ -6181,6 +6685,7 @@ impl Compiler { } // Pop the subject off the stack. pc.on_top -= 1; + self.set_source_range(loc); emit!(self, Instruction::PopTop); Ok(()) } @@ -6209,31 +6714,31 @@ impl Compiler { // If there is no sub-pattern, then it's an irrefutable match. if p.pattern.is_none() { if !pc.allow_irrefutable { - if let Some(_name) = p.name.as_ref() { - // TODO: This error message does not match cpython exactly - // A name capture makes subsequent patterns unreachable. - return Err(self.error(CodegenErrorType::UnreachablePattern( - PatternUnreachableReason::NameCapture, - ))); + if let Some(name) = p.name.as_ref() { + return Err(self.error_ranged( + CodegenErrorType::UnreachableNameCapturePattern(name.to_string()), + p.range, + )); } // A wildcard makes remaining patterns unreachable. - return Err(self.error(CodegenErrorType::UnreachablePattern( - PatternUnreachableReason::Wildcard, - ))); + return Err( + self.error_ranged(CodegenErrorType::UnreachableWildcardPattern, p.range) + ); } // If irrefutable matches are allowed, store the name (if any). - return self.pattern_helper_store_name(p.name.as_ref(), pc); + return self.pattern_helper_store_name(p.range, p.name.as_ref(), pc); } // Otherwise, there is a sub-pattern. Duplicate the object on top of the stack. pc.on_top += 1; + self.set_source_range(p.range); emit!(self, Instruction::Copy { i: 1 }); // Compile the sub-pattern. self.compile_pattern(p.pattern.as_ref().unwrap(), pc)?; // After success, decrement the on_top counter. pc.on_top -= 1; // Store the captured name (if any). - self.pattern_helper_store_name(p.name.as_ref(), pc)?; + self.pattern_helper_store_name(p.range, p.name.as_ref(), pc)?; Ok(()) } @@ -6242,7 +6747,7 @@ impl Compiler { p: &ast::PatternMatchStar, pc: &mut PatternContext, ) -> CompileResult<()> { - self.pattern_helper_store_name(p.name.as_ref(), pc)?; + self.pattern_helper_store_name(p.range, p.name.as_ref(), pc)?; Ok(()) } @@ -6251,21 +6756,19 @@ impl Compiler { fn validate_kwd_attrs( &mut self, attrs: &[ast::Identifier], - _patterns: &[ast::Pattern], + patterns: &[ast::Pattern], ) -> CompileResult<()> { let n_attrs = attrs.len(); for i in 0..n_attrs { let attr = attrs[i].as_str(); - // Check if the attribute name is forbidden in a Store context. - if self.forbidden_name(attr, NameUsage::Store)? { - // Return an error if the name is forbidden. - return Err(self.compile_error_forbidden_name(attr)); - } // Check for duplicates: compare with every subsequent attribute. - for ident in attrs.iter().take(n_attrs).skip(i + 1) { + for (j, ident) in attrs.iter().enumerate().take(n_attrs).skip(i + 1) { let other = ident.as_str(); if attr == other { - return Err(self.error(CodegenErrorType::RepeatedAttributePattern)); + return Err(self.error_ranged( + CodegenErrorType::RepeatedAttributePattern(attr.to_owned()), + patterns[j].range(), + )); } } } @@ -6292,12 +6795,27 @@ impl Compiler { let nargs = patterns.len(); let n_attrs = kwd_attrs.len(); + let n_kwd_patterns = kwd_patterns.len(); + if n_attrs != n_kwd_patterns { + return Err(self.error_ranged( + CodegenErrorType::SyntaxError(format!( + "kwd_attrs ({n_attrs}) / kwd_patterns ({n_kwd_patterns}) length mismatch in class pattern" + )), + p.range, + )); + } // Check for too many sub-patterns. - if nargs > u32::MAX as usize || (nargs + n_attrs).saturating_sub(1) > i32::MAX as usize { - return Err(self.error(CodegenErrorType::SyntaxError( - "too many sub-patterns in class pattern".to_owned(), - ))); + if nargs > i32::MAX as usize + || nargs.saturating_add(n_attrs).saturating_sub(1) > i32::MAX as usize + { + return Err(self.error_ranged( + CodegenErrorType::SyntaxError(format!( + "too many sub-patterns in class pattern {}", + UnparseExpr::new(&match_class.cls, &self.source_file) + )), + p.range, + )); } // Validate keyword attributes if any. @@ -6344,6 +6862,7 @@ impl Compiler { // At this point the TOS is a tuple of (nargs + n_attrs) attributes (or None). pc.on_top += 1; + self.set_source_range(p.range); self.jump_to_fail_pop(pc, JumpOp::PopJumpIfFalse); // Unpack the tuple into (nargs + n_attrs) items. @@ -6354,24 +6873,19 @@ impl Compiler { count: u32::try_from(total).unwrap() } ); - pc.on_top += total; - pc.on_top -= 1; + if total == 0 { + pc.on_top -= 1; + } else { + pc.on_top += total - 1; + } // Process each sub-pattern. for subpattern in patterns.iter().chain(kwd_patterns.iter()) { - // Check if this is a true wildcard (underscore pattern without name binding) - let is_true_wildcard = match subpattern { - ast::Pattern::MatchAs(match_as) => { - // Only consider it wildcard if both pattern and name are None (i.e., "_") - match_as.pattern.is_none() && match_as.name.is_none() - } - _ => subpattern.is_wildcard(), - }; - // Decrement the on_top counter for each sub-pattern pc.on_top -= 1; - if is_true_wildcard { + if Self::pattern_wildcard_check(subpattern) { + self.set_source_range(p.range); emit!(self, Instruction::PopTop); continue; // Don't compile wildcard patterns } @@ -6395,27 +6909,36 @@ impl Compiler { // Validate pattern count matches key count if keys.len() != patterns.len() { - return Err(self.error(CodegenErrorType::SyntaxError(format!( - "keys ({}) / patterns ({}) length mismatch in mapping pattern", - keys.len(), - patterns.len() - )))); + return Err(self.error_ranged( + CodegenErrorType::SyntaxError(format!( + "keys ({}) / patterns ({}) length mismatch in mapping pattern", + keys.len(), + patterns.len() + )), + p.range, + )); } - // Validate rest pattern: '_' cannot be used as a rest target + // `case {**_}:` is rejected before codegen. RustPython's parser + // currently lets it through, so keep the compiler boundary equivalent. if let Some(rest) = star_target && rest.as_str() == "_" { - return Err(self.error(CodegenErrorType::SyntaxError("invalid syntax".to_string()))); + return Err(self.error_ranged( + CodegenErrorType::SyntaxError("invalid syntax".to_string()), + rest.range, + )); } // Step 1: Check if subject is a mapping // Stack: [subject] pc.on_top += 1; + self.set_source_range(p.range); emit!(self, Instruction::MatchMapping); // Stack: [subject, is_mapping] + self.set_source_range(p.range); self.jump_to_fail_pop(pc, JumpOp::PopJumpIfFalse); // Stack: [subject] @@ -6430,64 +6953,40 @@ impl Compiler { // Length check for patterns with keys if size > 0 { // Check if the mapping has at least 'size' keys + self.set_source_range(p.range); emit!(self, Instruction::GetLen); + self.set_source_range(p.range); self.emit_load_const(ConstantData::Integer { value: size.into() }); // Stack: [subject, len, size] + self.set_source_range(p.range); emit!( self, Instruction::CompareOp { opname: ComparisonOperator::GreaterOrEqual } ); + self.set_source_range(p.range); self.jump_to_fail_pop(pc, JumpOp::PopJumpIfFalse); // Stack: [subject] } // Check for overflow (INT_MAX < size - 1) - let size = u32::try_from(size).map_err(|_| { - self.error(CodegenErrorType::SyntaxError( - "too many sub-patterns in mapping pattern".to_string(), - )) - })?; - - // Step 2: If we have keys to match - if size > 0 { - // Validate and compile keys - let mut seen = IndexSet::default(); - for key in keys { - let is_attribute = matches!(key, ast::Expr::Attribute(_)); - let is_literal = matches!( - key, - ast::Expr::NumberLiteral(_) - | ast::Expr::StringLiteral(_) - | ast::Expr::BytesLiteral(_) - | ast::Expr::BooleanLiteral(_) - | ast::Expr::NoneLiteral(_) - ); - let key_repr = if is_literal { - UnparseExpr::new(key, &self.source_file).to_string() - } else if is_attribute { - String::new() - } else { - return Err(self.error(CodegenErrorType::SyntaxError( - "mapping pattern keys may only match literals and attribute lookups" - .to_string(), - ))); - }; - - if !key_repr.is_empty() && seen.contains(&key_repr) { - return Err(self.error(CodegenErrorType::SyntaxError(format!( - "mapping pattern checks duplicate key ({key_repr})" - )))); - } - if !key_repr.is_empty() { - seen.insert(key_repr); - } + if size.saturating_sub(1) > i32::MAX as usize { + return Err(self.error_ranged( + CodegenErrorType::SyntaxError( + "too many sub-patterns in mapping pattern".to_string(), + ), + p.range, + )); + } + let size = size.to_u32(); - self.compile_match_pattern_expr(key)?; - } - self.set_source_range(p.range); + // Step 2: Validate and compile all keys. + let mut seen = Vec::new(); + for key in keys { + self.compile_pattern_mapping_key(&mut seen, p.range, key)?; } + self.set_source_range(p.range); // Stack: [subject, key1, key2, ..., key_n] // Build tuple of keys (empty tuple if size==0) @@ -6500,11 +6999,14 @@ impl Compiler { pc.on_top += 2; // subject and keys_tuple are underneath // Check if match succeeded + self.set_source_range(p.range); emit!(self, Instruction::Copy { i: 1 }); // Stack: [subject, keys_tuple, values_tuple, values_tuple_copy] // Check if copy is None (consumes the copy like POP_JUMP_IF_NONE) + self.set_source_range(p.range); self.emit_load_const(ConstantData::None); + self.set_source_range(p.range); emit!( self, Instruction::IsOp { @@ -6513,14 +7015,18 @@ impl Compiler { ); // Stack: [subject, keys_tuple, values_tuple, bool] + self.set_source_range(p.range); self.jump_to_fail_pop(pc, JumpOp::PopJumpIfFalse); // Stack: [subject, keys_tuple, values_tuple] // Unpack values (the original values_tuple) emit!(self, Instruction::UnpackSequence { count: size }); // Stack after unpack: [subject, keys_tuple, ...unpacked values...] - pc.on_top += size as usize; // Unpacked size values, tuple replaced by values - pc.on_top -= 1; + if size == 0 { + pc.on_top -= 1; + } else { + pc.on_top += size as usize - 1; + } // Step 3: Process matched values for i in 0..size { @@ -6539,15 +7045,19 @@ impl Compiler { // Stack: [subject, keys_tuple] // Build rest dict exactly + self.set_source_range(p.range); emit!(self, Instruction::BuildMap { count: 0 }); // Stack: [subject, keys_tuple, {}] + self.set_source_range(p.range); emit!(self, Instruction::Swap { i: 3 }); // Stack: [{}, keys_tuple, subject] + self.set_source_range(p.range); emit!(self, Instruction::DictUpdate { i: 2 }); // Stack after DICT_UPDATE: [rest_dict, keys_tuple] // DICT_UPDATE consumes source (subject) and leaves dict in place // Unpack keys and delete from rest_dict + self.set_source_range(p.range); emit!(self, Instruction::UnpackSequence { count: size }); // Stack: [rest_dict, k1, k2, ..., kn] (if size==0, nothing pushed) @@ -6556,10 +7066,13 @@ impl Compiler { let mut remaining = size; while remaining > 0 { // Copy rest_dict which is at position (1 + remaining) from TOS + self.set_source_range(p.range); emit!(self, Instruction::Copy { i: 1 + remaining }); // Stack: [rest_dict, k1, ..., kn, rest_dict] + self.set_source_range(p.range); emit!(self, Instruction::Swap { i: 2 }); // Stack: [rest_dict, k1, ..., kn-1, rest_dict, kn] + self.set_source_range(p.range); emit!(self, Instruction::DeleteSubscr); // Stack: [rest_dict, k1, ..., kn-1] (removed kn from rest_dict) remaining -= 1; @@ -6568,22 +7081,216 @@ impl Compiler { // pattern_helper_store_name will handle the rotation correctly // Store the rest dict - self.pattern_helper_store_name(Some(rest_name), pc)?; - - // After storing all values, pc.on_top should be 0 - // The values are rotated to the bottom for later storage - pc.on_top = 0; + self.pattern_helper_store_name(p.range, Some(rest_name), pc)?; } else { // Non-rest pattern: just clean up the stack // Pop them as we're not using them + self.set_source_range(p.range); emit!(self, Instruction::PopTop); // Pop keys_tuple + self.set_source_range(p.range); emit!(self, Instruction::PopTop); // Pop subject } Ok(()) } + fn compile_pattern_mapping_key( + &mut self, + seen: &mut Vec, + pattern_range: TextRange, + key: &ast::Expr, + ) -> CompileResult<()> { + let is_attribute = matches!(key, ast::Expr::Attribute(_)); + let constant = match self.try_compile_match_mapping_key_constant(key)? { + Some(constant) => Some(constant), + None if is_attribute => None, + None => { + if Self::is_unexpected_match_literal_constant(key) { + return Err(self.error_ranged( + CodegenErrorType::SyntaxError( + "unexpected constant inside of a literal pattern".to_string(), + ), + pattern_range, + )); + } + return Err(self.error_ranged( + CodegenErrorType::SyntaxError( + "mapping pattern keys may only match literals and attribute lookups" + .to_string(), + ), + pattern_range, + )); + } + }; + + if let Some(constant) = constant { + if seen + .iter() + .any(|seen| Self::match_mapping_keys_equal(seen, &constant)) + { + let key_repr = Self::match_mapping_key_repr(&constant); + return Err(self.error_ranged( + CodegenErrorType::SyntaxError(format!( + "mapping pattern checks duplicate key ({key_repr})" + )), + pattern_range, + )); + } + seen.push(constant); + } + + self.compile_match_pattern_expr(key) + } + + fn try_compile_match_mapping_key_constant( + &mut self, + key: &ast::Expr, + ) -> CompileResult> { + if let Some(constant) = self.try_fold_match_pattern_const_expr(key)? { + return Ok(Some(constant)); + } + self.try_compile_match_mapping_key_direct_constant(key) + } + + fn try_compile_match_value_constant( + &mut self, + value: &ast::Expr, + ) -> CompileResult> { + if let Some(constant) = self.try_fold_match_pattern_const_expr(value)? { + return Ok(Some(constant)); + } + self.try_compile_match_pattern_direct_literal(value) + } + + fn match_mapping_keys_equal(left: &ConstantData, right: &ConstantData) -> bool { + use ConstantData::{Bytes, Ellipsis, None, Str}; + + if Self::match_mapping_numeric_keys_equal(left, right).unwrap_or(false) { + return true; + } + + match (left, right) { + (Str { value: left }, Str { value: right }) => left == right, + (Bytes { value: left }, Bytes { value: right }) => left == right, + (None, None) | (Ellipsis, Ellipsis) => true, + _ => false, + } + } + + fn match_mapping_key_repr(key: &ConstantData) -> String { + match key { + ConstantData::Integer { value } => value.to_string(), + ConstantData::Float { value } => literal_float::to_string(*value), + ConstantData::Complex { value } => literal_complex::to_string(value.re, value.im), + ConstantData::Boolean { value } => { + if *value { + "True".to_owned() + } else { + "False".to_owned() + } + } + ConstantData::Str { value } => UnicodeEscape::new_repr(value.as_ref()) + .str_repr() + .to_string() + .unwrap_or_else(|| value.to_string()), + ConstantData::Bytes { value } => AsciiEscape::new_repr(value) + .bytes_repr() + .to_string() + .unwrap_or_else(|| format!(r#"b"{}""#, value.escape_ascii())), + ConstantData::None => "None".to_owned(), + ConstantData::Ellipsis => "...".to_owned(), + other => other.to_string(), + } + } + + fn match_mapping_numeric_keys_equal(left: &ConstantData, right: &ConstantData) -> Option { + use ConstantData::{Boolean, Complex, Float, Integer}; + + match (left, right) { + (Integer { value: left }, Integer { value: right }) => Some(left == right), + (Boolean { value: left }, Boolean { value: right }) => Some(left == right), + (Boolean { value }, Integer { value: int }) + | (Integer { value: int }, Boolean { value }) => { + Some(BigInt::from(u8::from(*value)) == *int) + } + (Float { value: left }, Float { value: right }) => Some(left == right), + (Integer { value: int }, Float { value: float }) + | (Float { value: float }, Integer { value: int }) => { + Some(Self::match_mapping_float_integer_equal(*float, int)) + } + (Boolean { value }, Float { value: float }) + | (Float { value: float }, Boolean { value }) => Some( + Self::match_mapping_float_integer_equal(*float, &BigInt::from(u8::from(*value))), + ), + (Complex { value: left }, Complex { value: right }) => { + Some(left.re == right.re && left.im == right.im) + } + (Complex { value: complex }, other) | (other, Complex { value: complex }) => Some( + complex.im == 0.0 + && Self::match_mapping_float_real_constant_equal(complex.re, other) + .unwrap_or(false), + ), + _ => Option::None, + } + } + + fn match_mapping_float_real_constant_equal(float: f64, other: &ConstantData) -> Option { + match other { + ConstantData::Integer { value } => { + Some(Self::match_mapping_float_integer_equal(float, value)) + } + ConstantData::Boolean { value } => Some(Self::match_mapping_float_integer_equal( + float, + &BigInt::from(u8::from(*value)), + )), + ConstantData::Float { value } => Some(float == *value), + _ => None, + } + } + + fn match_mapping_float_integer_equal(float: f64, int: &BigInt) -> bool { + Self::match_mapping_float_to_integer(float).is_some_and(|float_int| &float_int == int) + } + + fn match_mapping_float_to_integer(value: f64) -> Option { + if !value.is_finite() { + return None; + } + if value == 0.0 { + return Some(BigInt::from(0)); + } + + let bits = value.to_bits(); + let negative = (bits >> 63) != 0; + let exponent_bits = i32::try_from((bits >> 52) & 0x7ff).ok()?; + let fraction = bits & ((1_u64 << 52) - 1); + let (mantissa, exponent) = if exponent_bits == 0 { + (fraction, -1074) + } else { + ((1_u64 << 52) | fraction, exponent_bits - 1023 - 52) + }; + + let mut integer = if exponent >= 0 { + BigInt::from(mantissa) << u32::try_from(exponent).ok()? + } else { + let shift = u32::try_from(-exponent).ok()?; + if shift >= u64::BITS { + return None; + } + let mask = (1_u64 << shift) - 1; + if mantissa & mask != 0 { + return None; + } + BigInt::from(mantissa >> shift) + }; + + if negative { + integer = -integer; + } + Some(integer) + } + fn compile_pattern_or( &mut self, p: &ast::PatternMatchOr, @@ -6625,7 +7332,9 @@ impl Compiler { } else { let control_vec = control.as_ref().unwrap(); if n_stores != control_vec.len() { - return Err(self.error(CodegenErrorType::ConflictingNameBindPattern)); + return Err( + self.error_ranged(CodegenErrorType::ConflictingNameBindPattern, p.range()) + ); } else if n_stores > 0 { // Check that the names occur in the same order. for i_control in (0..n_stores).rev() { @@ -6633,7 +7342,10 @@ impl Compiler { // Find the index of `name` in the current stores. let i_stores = pc.stores.iter().position(|n| n == name).ok_or_else(|| { - self.error(CodegenErrorType::ConflictingNameBindPattern) + self.error_ranged( + CodegenErrorType::ConflictingNameBindPattern, + p.range(), + ) })?; if i_control != i_stores { // The orders differ; we must reorder. @@ -6653,7 +7365,7 @@ impl Compiler { // Also perform the same rotation on the evaluation stack. self.set_source_range(alt.range()); for _ in 0..=i_stores { - self.pattern_helper_rotate(i_control + 1); + self.pattern_helper_rotate(alt.range(), i_control + 1); } } } @@ -6663,7 +7375,7 @@ impl Compiler { self.set_source_range(alt.range()); emit!(self, PseudoInstruction::Jump { delta: end }); self.set_source_range(alt.range()); - self.emit_and_reset_fail_pop(pc); + self.emit_and_reset_fail_pop(pc, alt.range()); } // Restore the original pattern context. @@ -6688,11 +7400,14 @@ impl Compiler { for i in 0..n_stores { // Rotate the capture to its proper place. self.set_source_range(p.range()); - self.pattern_helper_rotate(n_rots); + self.pattern_helper_rotate(p.range(), n_rots); let name = &control.as_ref().unwrap()[i]; // Check for duplicate binding. if pc.stores.contains(name) { - return Err(self.error(CodegenErrorType::DuplicateStore(name.to_string()))); + return Err(self.error_ranged( + CodegenErrorType::DuplicateStore(name.to_string()), + p.range(), + )); } pc.stores.push(name.clone()); } @@ -6720,47 +7435,59 @@ impl Compiler { for (i, pattern) in patterns.iter().enumerate() { if pattern.is_match_star() { if star.is_some() { - // TODO: Fix error msg - return Err(self.error(CodegenErrorType::MultipleStarArgs)); + return Err(self.error_ranged( + CodegenErrorType::MultipleStarredNamesInSequencePattern, + p.range, + )); } // star wildcard check - star_wildcard = pattern.as_match_star().is_some_and(|m| m.name.is_none()); + star_wildcard = Self::pattern_wildcard_star_check(pattern); only_wildcard &= star_wildcard; star = Some(i); continue; } // wildcard check - only_wildcard &= pattern.as_match_as().is_some_and(|m| m.name.is_none()); + only_wildcard &= Self::pattern_wildcard_check(pattern); } // Keep the subject on top during the sequence and length checks. pc.on_top += 1; + self.set_source_range(p.range); emit!(self, Instruction::MatchSequence); + self.set_source_range(p.range); self.jump_to_fail_pop(pc, JumpOp::PopJumpIfFalse); if star.is_none() { // No star: len(subject) == size + self.set_source_range(p.range); emit!(self, Instruction::GetLen); + self.set_source_range(p.range); self.emit_load_const(ConstantData::Integer { value: size.into() }); + self.set_source_range(p.range); emit!( self, Instruction::CompareOp { opname: ComparisonOperator::Equal } ); + self.set_source_range(p.range); self.jump_to_fail_pop(pc, JumpOp::PopJumpIfFalse); } else if size > 1 { // Star exists: len(subject) >= size - 1 + self.set_source_range(p.range); emit!(self, Instruction::GetLen); + self.set_source_range(p.range); self.emit_load_const(ConstantData::Integer { value: (size - 1).into(), }); + self.set_source_range(p.range); emit!( self, Instruction::CompareOp { opname: ComparisonOperator::GreaterOrEqual } ); + self.set_source_range(p.range); self.jump_to_fail_pop(pc, JumpOp::PopJumpIfFalse); } @@ -6768,11 +7495,12 @@ impl Compiler { pc.on_top -= 1; if only_wildcard { // ast::Patterns like: [] / [_] / [_, _] / [*_] / [_, *_] / [_, _, *_] / etc. + self.set_source_range(p.range); emit!(self, Instruction::PopTop); } else if star_wildcard { - self.pattern_helper_sequence_subscr(patterns, star.unwrap(), pc)?; + self.pattern_helper_sequence_subscr(p.range, patterns, star.unwrap(), pc)?; } else { - self.pattern_helper_sequence_unpack(patterns, star, pc)?; + self.pattern_helper_sequence_unpack(p.range, patterns, star, pc)?; } Ok(()) } @@ -6785,14 +7513,37 @@ impl Compiler { // Match CPython codegen_pattern_value(): compare, then normalize to bool // before the fail jump. Late IR folding will collapse COMPARE_OP+TO_BOOL // into COMPARE_OP bool(...) when applicable. - self.compile_match_pattern_expr(&p.value)?; + if let Some(constant) = self.try_compile_match_value_constant(&p.value)? { + self.set_source_range(p.value.range()); + self.emit_load_const(constant); + } else if matches!(*p.value, ast::Expr::Attribute(_)) { + self.compile_expression(&p.value)?; + } else { + if Self::is_unexpected_match_literal_constant(&p.value) { + return Err(self.error_ranged( + CodegenErrorType::SyntaxError( + "unexpected constant inside of a literal pattern".to_string(), + ), + p.range, + )); + } + return Err(self.error_ranged( + CodegenErrorType::SyntaxError( + "patterns may only match literals and attribute lookups".to_string(), + ), + p.range, + )); + } + self.set_source_range(p.range); emit!( self, Instruction::CompareOp { opname: bytecode::ComparisonOperator::Equal } ); + self.set_source_range(p.range); emit!(self, Instruction::ToBool); + self.set_source_range(p.range); self.jump_to_fail_pop(pc, JumpOp::PopJumpIfFalse); Ok(()) } @@ -6803,14 +7554,17 @@ impl Compiler { pc: &mut PatternContext, ) { // Load the singleton constant value. + self.set_source_range(p.range); self.emit_load_const(match p.value { ast::Singleton::None => ConstantData::None, ast::Singleton::False => ConstantData::Boolean { value: false }, ast::Singleton::True => ConstantData::Boolean { value: true }, }); // Compare using the "Is" operator. + self.set_source_range(p.range); emit!(self, Instruction::IsOp { invert: Invert::No }); // Jump to the failure label if the comparison is false. + self.set_source_range(p.range); self.jump_to_fail_pop(pc, JumpOp::PopJumpIfFalse); } @@ -6858,22 +7612,13 @@ impl Compiler { cases: &[ast::MatchCase], pattern_context: &mut PatternContext, ) -> CompileResult<()> { - fn is_trailing_wildcard_default(pattern: &ast::Pattern) -> bool { - match pattern { - ast::Pattern::MatchAs(match_as) => { - match_as.pattern.is_none() && match_as.name.is_none() - } - _ => false, - } - } - self.compile_expression(subject)?; let end = self.new_block(); let num_cases = cases.len(); assert!(num_cases > 0); let has_default = - num_cases > 1 && is_trailing_wildcard_default(&cases.last().unwrap().pattern); + num_cases > 1 && Self::pattern_wildcard_check(&cases.last().unwrap().pattern); let case_count = num_cases - usize::from(has_default); for (i, m) in cases.iter().enumerate().take(case_count) { @@ -6891,8 +7636,8 @@ impl Compiler { self.compile_pattern(&m.pattern, pattern_context)?; assert_eq!(pattern_context.on_top, 0); - self.set_source_range(m.pattern.range()); for name in &pattern_context.stores { + self.set_source_range(m.pattern.range()); self.compile_name(name, NameUsage::Store)?; } @@ -6920,7 +7665,7 @@ impl Compiler { emit!(self, PseudoInstruction::Jump { delta: end }); self.set_no_location(); self.set_source_range(m.pattern.range()); - self.emit_and_reset_fail_pop(pattern_context); + self.emit_and_reset_fail_pop(pattern_context, m.pattern.range()); } if has_default { @@ -6932,7 +7677,7 @@ impl Compiler { emit!(self, Instruction::Nop); } if let Some(ref guard) = m.guard { - self.compile_jump_if(guard, false, end)?; + self.compile_jump_if_inner(guard, false, end, Some(m.pattern.range()))?; } self.compile_statements(&m.body)?; } @@ -7037,6 +7782,7 @@ impl Compiler { ) -> CompileResult<()> { // Save the full Compare expression range for COMPARE_OP positions let compare_range = self.current_source_range; + self.check_compare(compare_range, left, ops, comparators)?; let (last_op, mid_ops) = ops.split_last().unwrap(); let (last_comparator, mid_comparators) = comparators.split_last().unwrap(); @@ -7097,6 +7843,7 @@ impl Compiler { target_block: BlockIdx, ) -> CompileResult<()> { let compare_range = self.current_source_range; + self.check_compare(compare_range, left, ops, comparators)?; let (last_op, mid_ops) = ops.split_last().unwrap(); let (last_comparator, mid_comparators) = comparators.split_last().unwrap(); @@ -7134,13 +7881,13 @@ impl Compiler { self.use_cpython_label_block(cleanup); emit!(self, Instruction::PopTop); if !condition { - self.set_no_location(); emit!( self, PseudoInstruction::JumpNoInterrupt { delta: target_block } ); + self.set_no_location(); } self.use_cpython_label_block(end); @@ -7182,7 +7929,9 @@ impl Compiler { ast::Expr::Starred(ast::ExprStarred { value, .. }) => { // *args: *Ts (where Ts is a TypeVarTuple). // Do [annotation_value] = [*Ts]. + let saved_range = self.current_source_range; self.compile_expression(value)?; + self.set_source_range(saved_range); emit!(self, Instruction::UnpackSequence { count: 1 }); Ok(()) } @@ -7197,6 +7946,7 @@ impl Compiler { fn compile_check_annotation_expression(&mut self, expression: &ast::Expr) -> CompileResult<()> { self.compile_expression(expression)?; + self.set_source_range(expression.range()); emit!(self, Instruction::PopTop); Ok(()) } @@ -7269,23 +8019,24 @@ impl Compiler { } else { // PEP 649: Handle conditional annotations if self.current_symbol_table().has_conditional_annotations { - // Allocate an index for every annotation when has_conditional_annotations - // This keeps indices aligned with compile_module_annotate's enumeration - let code_info = self.current_code_info(); - let annotation_index = code_info.next_conditional_annotation_index; - code_info.next_conditional_annotation_index += 1; - - // Determine if this annotation is conditional - // Module and Class scopes both need all annotations tracked let scope_type = self.current_symbol_table().typ; let in_conditional_block = self.current_code_info().in_conditional_block > 0; let is_conditional = - matches!(scope_type, CompilerScope::Module | CompilerScope::Class) - || in_conditional_block; + matches!(scope_type, CompilerScope::Module) || in_conditional_block; - // Only add to __conditional_annotations__ set if actually conditional if is_conditional { - self.load_name("__conditional_annotations__")?; + let code_info = self.current_code_info(); + let annotation_index = code_info.next_conditional_annotation_index; + code_info.next_conditional_annotation_index += 1; + + self.set_source_range(loc); + if matches!(scope_type, CompilerScope::Class) { + let i = self.get_cell_var_index("__conditional_annotations__"); + emit!(self, Instruction::LoadDeref { i }); + } else { + let namei = self.name("__conditional_annotations__"); + emit!(self, Instruction::LoadName { namei }); + } self.emit_load_const(ConstantData::Integer { value: annotation_index.into(), }); @@ -7340,23 +8091,27 @@ impl Compiler { // Scan for star args: for (i, element) in elts.iter().enumerate() { - if let ast::Expr::Starred(_) = &element { - if seen_star { - return Err(self.error(CodegenErrorType::MultipleStarArgs)); - } - - seen_star = true; + if matches!(element, ast::Expr::Starred(_)) && !seen_star { let before = i; let after = elts.len() - i - 1; - let (before, after) = (|| Some((before.to_u8()?, after.to_u8()?)))() - .ok_or_else(|| { - self.error_ranged( - CodegenErrorType::TooManyStarUnpack, - target.range(), - ) - })?; + if before >= (1 << 8) || after >= ((i32::MAX as usize) >> 8) { + return Err(self.error_ranged( + CodegenErrorType::TooManyStarUnpack, + target.range(), + )); + } + let before = before.to_u8().ok_or_else(|| { + self.error_ranged( + CodegenErrorType::TooManyStarUnpack, + target.range(), + ) + })?; + let after = after.to_u32(); let counts = bytecode::UnpackExArgs { before, after }; emit!(self, Instruction::UnpackEx { counts }); + seen_star = true; + } else if matches!(element, ast::Expr::Starred(_)) { + return Err(self.error(CodegenErrorType::MultipleStarArgs)); } } @@ -7427,7 +8182,7 @@ impl Compiler { ctx: _, .. }) => { - let use_slice_opt = slice.should_use_slice_optimization(); + let use_slice_opt = self.should_apply_two_element_slice_optimization(slice); self.compile_expression(value)?; self.set_source_range(target_range); if use_slice_opt { @@ -7461,7 +8216,7 @@ impl Compiler { self.compile_expression(value)?; let attr_range = self.update_start_location_to_match_attr(target_range, target_range, attr); - self.set_source_range(attr_range); + self.set_source_range(target_range); emit!(self, Instruction::Copy { i: 1 }); let idx = self.name(attr); self.set_source_range(attr_range); @@ -7596,6 +8351,7 @@ impl Compiler { comparators, .. }) if ops.len() > 1 => { + self.set_source_range(expression.range()); self.compile_jump_if_compare(left, ops, comparators, condition, target_block) } _ => { @@ -7668,130 +8424,50 @@ impl Compiler { } } - fn compile_dict(&mut self, items: &[ast::DictItem], range: TextRange) -> CompileResult<()> { - let has_unpacking = items.iter().any(|item| item.key.is_none()); - - if !has_unpacking { - // Match CPython's compiler_subdict chunking strategy: - // - n≤15: BUILD_MAP n (all pairs on stack) - // - n>15: BUILD_MAP 0 + MAP_ADD chunks of 17, last chunk uses - // BUILD_MAP n (if ≤15) or BUILD_MAP 0 + MAP_ADD - const STACK_LIMIT: usize = 15; - const BIG_MAP_CHUNK: usize = 17; - - if items.len() <= STACK_LIMIT { - for item in items { - self.compile_expression(item.key.as_ref().unwrap())?; - self.compile_expression(&item.value)?; - } - self.set_source_range(range); - emit!( - self, - Instruction::BuildMap { - count: u32::try_from(items.len()).expect("too many dict items"), - } - ); - } else { - // Split: leading full chunks of BIG_MAP_CHUNK via MAP_ADD, - // remainder via BUILD_MAP n or MAP_ADD depending on size - let n = items.len(); - let remainder = n % BIG_MAP_CHUNK; - let n_big_chunks = n / BIG_MAP_CHUNK; - // If remainder fits on stack (≤15), use BUILD_MAP n for it. - // Otherwise it becomes another MAP_ADD chunk. - let (big_count, tail_count) = if remainder > 0 && remainder <= STACK_LIMIT { - (n_big_chunks, remainder) - } else { - // remainder is 0 or >15: all chunks are MAP_ADD chunks - let total_map_add = if remainder == 0 { - n_big_chunks - } else { - n_big_chunks + 1 - }; - (total_map_add, 0usize) - }; - + fn compile_subdict( + &mut self, + items: &[ast::DictItem], + begin: usize, + end: usize, + range: TextRange, + ) -> CompileResult<()> { + let n = end - begin; + let big = n * 2 > STACK_USE_GUIDELINE as usize; + if big { + self.set_source_range(range); + emit!(self, Instruction::BuildMap { count: 0 }); + } + for item in &items[begin..end] { + self.compile_expression(item.key.as_ref().unwrap())?; + self.compile_expression(&item.value)?; + if big { self.set_source_range(range); - emit!(self, Instruction::BuildMap { count: 0 }); - - let mut idx = 0; - for chunk_i in 0..big_count { - if chunk_i > 0 { - self.set_source_range(range); - emit!(self, Instruction::BuildMap { count: 0 }); - } - let chunk_size = if idx + BIG_MAP_CHUNK <= n - tail_count { - BIG_MAP_CHUNK - } else { - n - tail_count - idx - }; - for item in &items[idx..idx + chunk_size] { - self.compile_expression(item.key.as_ref().unwrap())?; - self.compile_expression(&item.value)?; - self.set_source_range(range); - emit!(self, Instruction::MapAdd { i: 1 }); - } - if chunk_i > 0 { - self.set_source_range(range); - emit!(self, Instruction::DictUpdate { i: 1 }); - } - idx += chunk_size; - } - - // Tail: remaining pairs via BUILD_MAP n + DICT_UPDATE - if tail_count > 0 { - for item in &items[idx..idx + tail_count] { - self.compile_expression(item.key.as_ref().unwrap())?; - self.compile_expression(&item.value)?; - } - self.set_source_range(range); - emit!( - self, - Instruction::BuildMap { - count: tail_count.to_u32(), - } - ); - self.set_source_range(range); - emit!(self, Instruction::DictUpdate { i: 1 }); - } + emit!(self, Instruction::MapAdd { i: 1 }); } - return Ok(()); } + if !big { + self.set_source_range(range); + emit!(self, Instruction::BuildMap { count: n.to_u32() }); + } + Ok(()) + } - // Complex case with ** unpacking: preserve insertion order. - // Collect runs of regular k:v pairs and emit BUILD_MAP + DICT_UPDATE - // for each run, and DICT_UPDATE for each ** entry. + fn compile_dict(&mut self, items: &[ast::DictItem], range: TextRange) -> CompileResult<()> { + let n = items.len(); let mut have_dict = false; - let mut elements: u32 = 0; - - // Flush pending regular pairs as a BUILD_MAP, merging into the - // accumulator dict via DICT_UPDATE when one already exists. - macro_rules! flush_pending { - () => { - #[allow(unused_assignments)] - if elements > 0 { - self.set_source_range(range); - emit!(self, Instruction::BuildMap { count: elements }); + let mut elements = 0usize; + + for (i, item) in items.iter().enumerate() { + if item.key.is_none() { + if elements != 0 { + self.compile_subdict(items, i - elements, i, range)?; if have_dict { self.set_source_range(range); emit!(self, Instruction::DictUpdate { i: 1 }); - } else { - have_dict = true; } + have_dict = true; elements = 0; } - }; - } - - for item in items { - if let Some(key) = &item.key { - // Regular key: value pair - self.compile_expression(key)?; - self.compile_expression(&item.value)?; - elements += 1; - } else { - // ** unpacking entry - flush_pending!(); if !have_dict { self.set_source_range(range); emit!(self, Instruction::BuildMap { count: 0 }); @@ -7800,10 +8476,27 @@ impl Compiler { self.compile_expression(&item.value)?; self.set_source_range(range); emit!(self, Instruction::DictUpdate { i: 1 }); + } else if elements * 2 > STACK_USE_GUIDELINE as usize { + self.compile_subdict(items, i - elements, i + 1, range)?; + if have_dict { + self.set_source_range(range); + emit!(self, Instruction::DictUpdate { i: 1 }); + } + have_dict = true; + elements = 0; + } else { + elements += 1; } } - flush_pending!(); + if elements != 0 { + self.compile_subdict(items, n - elements, n, range)?; + if have_dict { + self.set_source_range(range); + emit!(self, Instruction::DictUpdate { i: 1 }); + } + have_dict = true; + } if !have_dict { self.set_source_range(range); emit!(self, Instruction::BuildMap { count: 0 }); @@ -7882,14 +8575,28 @@ impl Compiler { send_block } + fn ast_constant_value(&self, expr: &ast::Expr) -> Option { + expr.as_constant_expr() + .map(|expr| ast_constant_value_to_constant_data(expr.value.clone())) + } + + fn single_runtime_interpolation( + expr_tstring: &ast::ExprTString, + ) -> Option<(&ast::ConstantValue, Option<&ast::Expr>)> { + let tstring = expr_tstring.as_single_part_tstring()?; + let interpolation = tstring.elements.first()?.as_interpolation()?; + Some(( + interpolation.runtime_str.as_ref()?, + interpolation.runtime_interpolation_format_spec.as_deref(), + )) + } + fn compile_expression(&mut self, expression: &ast::Expr) -> CompileResult<()> { trace!("Compiling {expression:?}"); let range = expression.range(); self.set_source_range(range); - if matches!(expression, ast::Expr::BinOp(_)) - && let Some(constant) = self.try_fold_constant_expr(expression)? - { + if let Some(constant) = self.ast_constant_value(expression) { self.emit_load_const(constant); return Ok(()); } @@ -7917,22 +8624,7 @@ impl Compiler { self.compile_subscript(value, slice, *ctx)?; } ast::Expr::UnaryOp(ast::ExprUnaryOp { op, operand, .. }) => { - if let ( - ast::UnaryOp::Not, - ast::Expr::Compare(ast::ExprCompare { - left, - ops, - comparators, - .. - }), - ) = (op, operand.as_ref()) - && ops.len() == 1 - { - self.set_source_range(range); - self.compile_compare(left, ops, comparators)?; - } else { - self.compile_expression(operand)?; - } + self.compile_expression(operand)?; // Restore full expression range before emitting the operation self.set_source_range(range); @@ -7963,6 +8655,8 @@ impl Compiler { unreachable!("can_optimize_super_call only accepts calls"); }; self.load_args_for_super(&super_type, super_func.range(), value.range())?; + let attr_access_range = + self.update_start_location_to_match_attr(range, range, attr.as_str()); self.set_source_range(range); let idx = self.name(attr.as_str()); match super_type { @@ -7973,6 +8667,8 @@ impl Compiler { self.emit_load_zero_super_attr(idx); } } + self.set_source_range(attr_access_range); + emit!(self, Instruction::Nop); } else { // Normal attribute access self.compile_expression(value)?; @@ -7993,9 +8689,9 @@ impl Compiler { }) => { self.compile_compare(left, ops, comparators)?; } - // ast::Expr::Constant(ExprConstant { value, .. }) => { - // self.emit_load_const(compile_constant(value)); - // } + ast::Expr::Constant(ast::ExprConstant { value, .. }) => { + self.emit_load_const(ast_constant_value_to_constant_data(value.clone())); + } ast::Expr::List(ast::ExprList { elts, range, .. }) => { self.set_source_range(*range); self.starunpack_helper(elts, 0, CollectionType::List)?; @@ -8077,7 +8773,9 @@ impl Compiler { ); } ast::Expr::Await(ast::ExprAwait { value, .. }) => { - if self.ctx.func != FunctionContext::AsyncFunction { + if self.ctx.func != FunctionContext::AsyncFunction + && !self.allows_top_level_await_in_current_context() + { return Err(self.error(CodegenErrorType::InvalidAwait)); } self.compile_expression(value)?; @@ -8162,12 +8860,12 @@ impl Compiler { } self.enter_function(&name, params)?; - let mut func_flags = MakeFunctionFlags::new(); + let mut func_flags = bytecode::MakeFunctionFlags::new(); if have_defaults { - func_flags.insert(MakeFunctionFlag::Defaults); + func_flags.insert(bytecode::MakeFunctionFlag::Defaults); } if have_kwdefaults { - func_flags.insert(MakeFunctionFlag::KwOnlyDefaults); + func_flags.insert(bytecode::MakeFunctionFlag::KwOnlyDefaults); } // Set qualname for lambda @@ -8181,15 +8879,20 @@ impl Compiler { }; self.compile_expression(body)?; - self.set_source_range(body.range()); - self.emit_return_value(); - // _PyCodegen_AddReturnAtEnd() appends a no-location - // return-None epilogue even after lambda's explicit - // RETURN_VALUE. It is later removed as unreachable, but - // remove_unused_consts() keeps None when it was the first - // constant in an otherwise constant-free lambda. - if self.current_code_info().metadata.consts.is_empty() { - self.arg_constant(ConstantData::None); + let is_generator = self + .current_code_info() + .flags + .contains(bytecode::CodeFlags::GENERATOR); + if is_generator { + // codegen_lambda() calls OptimizeAndAssemble with + // addNone=0, so AddReturnAtEnd appends RETURN_VALUE without + // adding None to co_consts. + emit!(self, Instruction::ReturnValue); + self.set_no_location(); + } else { + self.set_source_range(body.range()); + self.emit_return_value(); + self.emit_return_const_no_location(ConstantData::None); } let code = self.exit_scope(); @@ -8207,7 +8910,12 @@ impl Compiler { }) => { self.compile_comprehension( "", - Some(Opcode::BuildList.into()), + Some( + Instruction::BuildList { + count: OpArgMarker::marker(), + } + .into(), + ), generators, &|compiler, collection_add_i| { compiler.compile_comprehension_element(elt)?; @@ -8235,7 +8943,12 @@ impl Compiler { }) => { self.compile_comprehension( "", - Some(Opcode::BuildSet.into()), + Some( + Instruction::BuildSet { + count: OpArgMarker::marker(), + } + .into(), + ), generators, &|compiler, collection_add_i| { compiler.compile_comprehension_element(elt)?; @@ -8262,9 +8975,15 @@ impl Compiler { range, .. }) => { + let key = key.as_ref(); self.compile_comprehension( "", - Some(Opcode::BuildMap.into()), + Some( + Instruction::BuildMap { + count: OpArgMarker::marker(), + } + .into(), + ), generators, &|compiler, collection_add_i| { // changed evaluation order for Py38 named expression PEP 572 @@ -8356,9 +9075,20 @@ impl Compiler { self.set_source_range(target.range()); } ast::Expr::FString(fstring) => { + if let Some(joined_str) = fstring.runtime_joined_str.as_ref() { + return self.compile_runtime_joined_str(fstring, joined_str); + } self.compile_expr_fstring(fstring)?; } ast::Expr::TString(tstring) => { + if let Some(template_str) = tstring.runtime_template_str.as_ref() { + return self.compile_runtime_template_str(tstring, template_str); + } + if let Some(interpolation) = Self::single_runtime_interpolation(tstring) + && self.compile_runtime_interpolation(tstring, interpolation)? + { + return Ok(()); + } self.compile_expr_tstring(tstring)?; } ast::Expr::StringLiteral(string) => { @@ -8393,8 +9123,11 @@ impl Compiler { ast::Expr::EllipsisLiteral(_) => { self.emit_load_const(ConstantData::Ellipsis); } - ast::Expr::IpyEscapeCommand(_) => { - panic!("unexpected ipy escape command"); + ast::Expr::IpyEscapeCommand(expr) => { + return Err(self.error_ranged( + CodegenErrorType::SyntaxError("invalid syntax".to_owned()), + expr.range, + )); } } Ok(()) @@ -8486,17 +9219,13 @@ impl Compiler { emit!(self, Instruction::BuildList { count: 0 }); } - let sub_table_cursor = self.symbol_table_stack.last().map(|t| t.next_sub_table); + let symbol_table_cursors = self.current_symbol_table_cursors(); if let Some(range) = self.cpython_implicit_call_generator_range(generator_expr) { self.compile_expression_with_generator_range(generator_expr, range)?; } else { self.compile_expression(generator_expr)?; } - if let Some(cursor) = sub_table_cursor - && let Some(current_table) = self.symbol_table_stack.last_mut() - { - current_table.next_sub_table = cursor; - } + self.set_symbol_table_cursors(symbol_table_cursors); let loop_block = self.new_block(); let cleanup = self.new_block(); @@ -8515,9 +9244,9 @@ impl Compiler { self.set_source_range(loc); emit!(self, Instruction::ToBool); emit!(self, Instruction::PopJumpIfTrue { delta: loop_block }); - self.set_source_range(loc); emit!(self, Instruction::PopIter); self.set_no_location(); + self.set_source_range(loc); self.emit_load_const(ConstantData::Boolean { value: false }); self.set_source_range(loc); emit!(self, PseudoInstruction::Jump { delta: end }); @@ -8526,9 +9255,9 @@ impl Compiler { self.set_source_range(loc); emit!(self, Instruction::ToBool); emit!(self, Instruction::PopJumpIfFalse { delta: loop_block }); - self.set_source_range(loc); emit!(self, Instruction::PopIter); self.set_no_location(); + self.set_source_range(loc); self.emit_load_const(ConstantData::Boolean { value: true }); self.set_source_range(loc); emit!(self, PseudoInstruction::Jump { delta: end }); @@ -8536,10 +9265,8 @@ impl Compiler { } self.use_cpython_label_block(cleanup); - self.set_source_range(loc); emit!(self, Instruction::EndFor); self.set_no_location(); - self.set_source_range(loc); emit!(self, Instruction::PopIter); self.set_no_location(); match kind { @@ -8568,20 +9295,100 @@ impl Compiler { Ok(()) } + fn can_use_cpython_method_call(&self, value: &ast::Expr, args: &ast::Arguments) -> bool { + let is_import = matches!(value, ast::Expr::Name(ast::ExprName { id, .. }) + if self.is_name_imported(id.as_str())); + if is_import { + return false; + } + + if args.args.len() + args.keywords.len() + usize::from(!args.keywords.is_empty()) + >= STACK_USE_GUIDELINE as usize + { + return false; + } + + !args + .args + .iter() + .any(|arg| matches!(arg, ast::Expr::Starred(_))) + && args.keywords.iter().all(|kw| kw.arg.is_some()) + } + + fn compile_method_call_arguments( + &mut self, + args: &ast::Arguments, + call_range: TextRange, + kw_names_range: TextRange, + ) -> CompileResult<()> { + let implicit_generator_range = if args.args.len() == 1 && args.keywords.is_empty() { + self.cpython_implicit_call_generator_range(&args.args[0]) + } else { + None + }; + for arg in &args.args { + if let Some(range) = implicit_generator_range { + self.compile_expression_with_generator_range(arg, range)?; + } else { + self.compile_expression(arg)?; + } + } + + if args.keywords.is_empty() { + self.set_source_range(call_range); + emit!( + self, + Instruction::Call { + argc: args.args.len().to_u32() + } + ); + return Ok(()); + } + + let mut kwarg_names = Vec::with_capacity(args.keywords.len()); + for keyword in &args.keywords { + kwarg_names.push(ConstantData::Str { + value: keyword.arg.as_ref().unwrap().as_str().into(), + }); + self.compile_expression(&keyword.value)?; + } + self.set_source_range(kw_names_range); + self.emit_load_const(ConstantData::Tuple { + elements: kwarg_names, + }); + self.set_source_range(call_range); + emit!( + self, + Instruction::CallKw { + argc: (args.args.len() + args.keywords.len()).to_u32() + } + ); + Ok(()) + } + fn compile_call(&mut self, func: &ast::Expr, args: &ast::Arguments) -> CompileResult<()> { // Save the call expression's source range so CALL instructions use the // call start line, not the last argument's line. let call_range = self.current_source_range; + self.validate_keywords(&args.keywords)?; let uses_ex_call = self.call_uses_ex_call(args); // Method call: obj → LOAD_ATTR_METHOD → [method, self_or_null] → args → CALL // Regular call: func → PUSH_NULL → args → CALL if let ast::Expr::Attribute(ast::ExprAttribute { value, attr, .. }) = &func { + if !self.can_use_cpython_method_call(value, args) { + self.check_caller(func)?; + self.compile_expression(func)?; + self.set_source_range(func.range()); + emit!(self, Instruction::PushNull); + self.codegen_call_helper(0, args, call_range, None)?; + return Ok(()); + } + // Check for super() method call optimization if let Some(super_type) = self.can_optimize_super_call(value, attr.as_str()) { // super().method() or super(cls, self).method() optimization // CALL path: [global_super, class, self] → LOAD_SUPER_METHOD → [method, self] - // CALL_FUNCTION_EX path: [global_super, class, self] → LOAD_SUPER_ATTR → [attr] let ast::Expr::Call(ast::ExprCall { func: super_func, .. }) = value.as_ref() @@ -8599,41 +9406,21 @@ impl Compiler { func.range(), attr.as_str(), ); - self.set_source_range(attr_access_range); + self.set_source_range(func.range()); let idx = self.name(attr.as_str()); - if uses_ex_call { - self.set_source_range(func.range()); - match super_type { - SuperCallType::TwoArg { .. } => { - self.emit_load_super_attr(idx); - } - SuperCallType::ZeroArg => { - self.emit_load_zero_super_attr(idx); - } + match super_type { + SuperCallType::TwoArg { .. } => { + self.emit_load_super_method(idx); } - // CPython's Attribute_kind super path emits an attr-line - // NOP after LOAD_SUPER_ATTR, even when the call later uses - // CALL_FUNCTION_EX for starred arguments. - self.set_source_range(attr_access_range); - emit!(self, Instruction::Nop); - self.set_source_range(func.range()); - emit!(self, Instruction::PushNull); - self.codegen_call_helper(0, args, call_range, None)?; - } else { - match super_type { - SuperCallType::TwoArg { .. } => { - self.emit_load_super_method(idx); - } - SuperCallType::ZeroArg => { - self.emit_load_zero_super_method(idx); - } + SuperCallType::ZeroArg => { + self.emit_load_zero_super_method(idx); } - // NOP for line tracking at .method( line - self.set_source_range(attr_access_range); - emit!(self, Instruction::Nop); - // CALL at .method( line (not the full expression line) - self.codegen_call_helper(0, args, method_call_range, Some(attr_access_range))?; } + // NOP for line tracking at .method( line + self.set_source_range(attr_access_range); + emit!(self, Instruction::Nop); + // CALL at .method( line (not the full expression line) + self.compile_method_call_arguments(args, method_call_range, attr_access_range)?; } else { self.compile_expression(value)?; let idx = self.name(attr.as_str()); @@ -8648,28 +9435,15 @@ impl Compiler { attr.as_str(), ); self.set_source_range(attr_access_range); - // Imported names and CALL_FUNCTION_EX-style calls use plain - // LOAD_ATTR + PUSH_NULL; other names use method-call mode. - // Check current scope and enclosing scopes for IMPORTED flag. - let is_import = matches!(value.as_ref(), ast::Expr::Name(ast::ExprName { id, .. }) - if self.is_name_imported(id.as_str())); - if is_import || uses_ex_call { - self.emit_load_attr(idx); - emit!(self, Instruction::PushNull); - } else { - self.emit_load_attr_method(idx); - } - if is_import || uses_ex_call { - self.codegen_call_helper(0, args, call_range, None)?; - } else { - self.codegen_call_helper(0, args, method_call_range, Some(attr_access_range))?; - } + self.emit_load_attr_method(idx); + self.compile_method_call_arguments(args, method_call_range, attr_access_range)?; } } else if let Some(kind) = (!uses_ex_call) .then(|| self.detect_builtin_generator_call(func, args)) .flatten() { let skip_normal_call = self.new_block(); + self.check_caller(func)?; self.compile_expression(func)?; self.optimize_builtin_generator_call( kind, @@ -8692,6 +9466,7 @@ impl Compiler { .then(|| self.cpython_sync_genexpr_call_name(func, args)) .flatten() .is_some(); + self.check_caller(func)?; self.compile_expression(func)?; if sync_genexpr_call_name { // CPython `maybe_optimize_function_call()` creates and uses @@ -8703,6 +9478,7 @@ impl Compiler { .use_raw_instr_sequence_label(skip_optimization); unwrap_internal(self, result); } + self.set_source_range(func.range()); emit!(self, Instruction::PushNull); self.codegen_call_helper(0, args, call_range, None)?; let result = self @@ -8724,6 +9500,24 @@ impl Compiler { has_starred || has_double_star || too_big } + /// Reject duplicate keyword-argument names in a call. + fn validate_keywords(&mut self, keywords: &[ast::Keyword]) -> CompileResult<()> { + for (i, keyword) in keywords.iter().enumerate() { + let Some(arg) = &keyword.arg else { + continue; + }; + for other in &keywords[i + 1..] { + if other.arg.as_ref() == Some(arg) { + return Err(self.error_ranged( + CodegenErrorType::SyntaxError(format!("keyword argument repeated: {arg}")), + other.range, + )); + } + } + } + Ok(()) + } + /// Compile subkwargs: emit key-value pairs for BUILD_MAP fn codegen_subkwargs( &mut self, @@ -8739,8 +9533,8 @@ impl Compiler { let big = n * 2 > STACK_USE_GUIDELINE as usize; if big { - self.set_source_range(call_range); emit!(self, Instruction::BuildMap { count: 0 }); + self.set_no_location(); } for kw in &keywords[begin..end] { @@ -8752,8 +9546,8 @@ impl Compiler { self.compile_expression(&kw.value)?; if big { - self.set_source_range(call_range); emit!(self, Instruction::MapAdd { i: 1 }); + self.set_no_location(); } } @@ -8775,15 +9569,33 @@ impl Compiler { call_range: TextRange, kw_names_range: Option, ) -> CompileResult<()> { - let nelts = arguments.args.len(); - let nkwelts = arguments.keywords.len(); + self.codegen_call_helper_impl( + additional_positional, + &arguments.args, + &arguments.keywords, + call_range, + kw_names_range, + None, + ) + } + + fn codegen_call_helper_impl( + &mut self, + additional_positional: u32, + args: &[ast::Expr], + keywords: &[ast::Keyword], + call_range: TextRange, + kw_names_range: Option, + injected_arg: Option<&str>, + ) -> CompileResult<()> { + self.validate_keywords(keywords)?; + + let nelts = args.len(); + let nkwelts = keywords.len(); // Check if we have starred args or **kwargs - let has_starred = arguments - .args - .iter() - .any(|arg| matches!(arg, ast::Expr::Starred(_))); - let has_double_star = arguments.keywords.iter().any(|k| k.arg.is_none()); + let has_starred = args.iter().any(|arg| matches!(arg, ast::Expr::Starred(_))); + let has_double_star = keywords.iter().any(|k| k.arg.is_none()); // Check if exceeds CPython's stack-use guideline. // With CALL_KW, kwargs values go on stack but keys go in a const tuple, @@ -8794,22 +9606,29 @@ impl Compiler { // Simple call path: no * or ** args let implicit_generator_range = if additional_positional == 0 && nelts == 1 && nkwelts == 0 { - self.cpython_implicit_call_generator_range(&arguments.args[0]) + self.cpython_implicit_call_generator_range(&args[0]) } else { None }; - for arg in &arguments.args { + for arg in args { if let Some(range) = implicit_generator_range { self.compile_expression_with_generator_range(arg, range)?; } else { self.compile_expression(arg)?; } } + let injected_count = if let Some(injected_arg) = injected_arg { + self.set_source_range(call_range); + self.load_name(injected_arg)?; + 1 + } else { + 0 + }; if nkwelts > 0 { // Compile keyword values and build kwnames tuple let mut kwarg_names = Vec::with_capacity(nkwelts); - for keyword in &arguments.keywords { + for keyword in keywords { kwarg_names.push(ConstantData::Str { value: keyword.arg.as_ref().unwrap().as_str().into(), }); @@ -8823,24 +9642,23 @@ impl Compiler { }); self.set_source_range(call_range); - let argc = additional_positional + nelts.to_u32() + nkwelts.to_u32(); + let argc = + additional_positional + nelts.to_u32() + injected_count + nkwelts.to_u32(); emit!(self, Instruction::CallKw { argc }); } else { self.set_source_range(call_range); - let argc = additional_positional + nelts.to_u32(); + let argc = additional_positional + nelts.to_u32() + injected_count; emit!(self, Instruction::Call { argc }); } } else { // ex_call path: has * or ** args // Compile positional arguments - if additional_positional == 0 - && nelts == 1 - && matches!(arguments.args[0], ast::Expr::Starred(_)) + if additional_positional == 0 && nelts == 1 && matches!(args[0], ast::Expr::Starred(_)) { // Single starred arg: pass value directly to CallFunctionEx. // Runtime will convert to tuple and validate with function name. - if let ast::Expr::Starred(ast::ExprStarred { value, .. }) = &arguments.args[0] { + if let ast::Expr::Starred(ast::ExprStarred { value, .. }) = &args[0] { self.compile_expression(value)?; } } else { @@ -8850,14 +9668,15 @@ impl Compiler { // LIST_EXTEND, tuple=1)`, even when the only reason for the // ex-call path is too many non-starred positional arguments. self.set_source_range(call_range); - self.starunpack_helper( - &arguments.args, + self.starunpack_helper_impl( + args, + injected_arg, additional_positional, CollectionType::Tuple, )?; } - self.compile_call_function_ex_keywords(&arguments.keywords, call_range)?; + self.compile_call_function_ex_keywords(keywords, call_range)?; self.set_source_range(call_range); emit!(self, Instruction::CallFunctionEx); @@ -9102,30 +9921,87 @@ impl Compiler { { let _ = self.push_symbol_table()?; } - let _ = self.pop_symbol_table(); + self.pop_symbol_table(); Ok(()) } - fn consume_skipped_nested_scopes_in_expr( - &mut self, - expression: &ast::Expr, - ) -> CompileResult<()> { - use ast::visitor::Visitor; + fn consume_function_annotation_symbol_table_if_used(&mut self) -> CompileResult<()> { + if !self.next_function_annotation_symbol_table_uses_annotations() { + return Ok(()); + } + if !self.push_annotation_symbol_table() { + let current_table = self.current_symbol_table(); + return Err(self.error(CodegenErrorType::SyntaxError(format!( + "no annotation symbol table available in {} (type: {:?})", + current_table.name, current_table.typ + )))); + } + self.pop_annotation_symbol_table(); + Ok(()) + } + + fn consume_skipped_nested_scopes_in_expr( + &mut self, + expression: &ast::Expr, + ) -> CompileResult<()> { + use ast::visitor::Visitor; - struct SkippedScopeVisitor<'a> { - compiler: &'a mut Compiler, + struct SkippedScopeVisitor<'a, 'warnings> { + compiler: &'a mut Compiler<'warnings>, error: Option, } - impl SkippedScopeVisitor<'_> { + impl SkippedScopeVisitor<'_, '_> { fn consume_scope(&mut self) { if self.error.is_none() { self.error = self.compiler.consume_next_sub_table().err(); } } + + fn consume_inlined_comprehension_scope(&mut self) -> bool { + if self.error.is_some() { + return false; + } + let Some(current_table) = self.compiler.symbol_table_stack.last_mut() else { + return false; + }; + if current_table.next_inlined_comprehension_block + < current_table.inlined_comprehension_blocks.len() + { + current_table.next_inlined_comprehension_block += 1; + true + } else { + false + } + } + + fn visit_comprehension_tail( + &mut self, + elt1: &ast::Expr, + elt2: Option<&ast::Expr>, + generators: &[ast::Comprehension], + ) { + if let Some(outermost) = generators.first() { + self.visit_expr(&outermost.target); + for if_expr in &outermost.ifs { + self.visit_expr(if_expr); + } + } + for generator in generators.iter().skip(1) { + self.visit_expr(&generator.target); + self.visit_expr(&generator.iter); + for if_expr in &generator.ifs { + self.visit_expr(if_expr); + } + } + if let Some(elt2) = elt2 { + self.visit_expr(elt2); + } + self.visit_expr(elt1); + } } - impl ast::visitor::Visitor<'_> for SkippedScopeVisitor<'_> { + impl ast::visitor::Visitor<'_> for SkippedScopeVisitor<'_, '_> { fn visit_expr(&mut self, expr: &ast::Expr) { if self.error.is_some() { return; @@ -9149,19 +10025,41 @@ impl Compiler { } self.consume_scope(); } - ast::Expr::ListComp(ast::ExprListComp { generators, .. }) - | ast::Expr::SetComp(ast::ExprSetComp { generators, .. }) - | ast::Expr::Generator(ast::ExprGenerator { generators, .. }) => { + ast::Expr::Generator(ast::ExprGenerator { generators, .. }) => { if let Some(first) = generators.first() { self.visit_expr(&first.iter); } self.consume_scope(); } - ast::Expr::DictComp(ast::ExprDictComp { generators, .. }) => { + ast::Expr::ListComp(ast::ExprListComp { + elt, generators, .. + }) + | ast::Expr::SetComp(ast::ExprSetComp { + elt, generators, .. + }) => { if let Some(first) = generators.first() { self.visit_expr(&first.iter); } - self.consume_scope(); + if self.consume_inlined_comprehension_scope() { + self.visit_comprehension_tail(elt, None, generators); + } else { + self.consume_scope(); + } + } + ast::Expr::DictComp(ast::ExprDictComp { + key, + value, + generators, + .. + }) => { + if let Some(first) = generators.first() { + self.visit_expr(&first.iter); + } + if self.consume_inlined_comprehension_scope() { + self.visit_comprehension_tail(key, Some(value), generators); + } else { + self.consume_scope(); + } } _ => ast::visitor::walk_expr(self, expr), } @@ -9180,23 +10078,313 @@ impl Compiler { } } - fn peek_next_sub_table_after_skipped_nested_scopes_in_expr( + fn consume_skipped_nested_scopes_in_parameter_defaults( + &mut self, + parameters: &ast::Parameters, + ) -> CompileResult<()> { + for default in parameters + .posonlyargs + .iter() + .chain(¶meters.args) + .chain(¶meters.kwonlyargs) + .filter_map(|arg| arg.default.as_deref()) + { + self.consume_skipped_nested_scopes_in_expr(default)?; + } + Ok(()) + } + + fn consume_skipped_nested_scopes_in_statements( + &mut self, + statements: &[ast::Stmt], + ) -> CompileResult<()> { + use ast::visitor::Visitor; + + struct SkippedStatementScopeVisitor<'a, 'warnings> { + compiler: &'a mut Compiler<'warnings>, + error: Option, + } + + impl SkippedStatementScopeVisitor<'_, '_> { + fn consume_scope(&mut self) { + if self.error.is_none() { + self.error = self.compiler.consume_next_sub_table().err(); + } + } + + fn consume_function_annotation_scope_if_used(&mut self) { + if self.error.is_none() { + self.error = self + .compiler + .consume_function_annotation_symbol_table_if_used() + .err(); + } + } + + fn visit_parameter_defaults(&mut self, parameters: &ast::Parameters) { + for default in parameters + .posonlyargs + .iter() + .chain(¶meters.args) + .chain(¶meters.kwonlyargs) + .filter_map(|arg| arg.default.as_deref()) + { + self.visit_expr(default); + } + } + + fn visit_decorators(&mut self, decorators: &[ast::Decorator]) { + for decorator in decorators { + self.visit_expr(&decorator.expression); + } + } + + fn visit_arguments(&mut self, arguments: &ast::Arguments) { + for arg in &arguments.args { + self.visit_expr(arg); + } + for keyword in &arguments.keywords { + self.visit_expr(&keyword.value); + } + } + } + + impl ast::visitor::Visitor<'_> for SkippedStatementScopeVisitor<'_, '_> { + fn visit_stmt(&mut self, stmt: &ast::Stmt) { + if self.error.is_some() { + return; + } + + match stmt { + ast::Stmt::FunctionDef(ast::StmtFunctionDef { + parameters, + decorator_list, + type_params, + .. + }) => { + self.visit_parameter_defaults(parameters); + self.visit_decorators(decorator_list); + if type_params.is_some() { + self.consume_scope(); + } else { + self.consume_function_annotation_scope_if_used(); + self.consume_scope(); + } + } + ast::Stmt::ClassDef(ast::StmtClassDef { + arguments, + decorator_list, + type_params, + .. + }) => { + self.visit_decorators(decorator_list); + if type_params.is_some() { + self.consume_scope(); + } + if let Some(arguments) = arguments { + self.visit_arguments(arguments); + } + self.consume_scope(); + } + ast::Stmt::TypeAlias(ast::StmtTypeAlias { type_params, .. }) => { + if type_params.is_some() { + self.consume_scope(); + } + self.consume_scope(); + } + ast::Stmt::AnnAssign(ast::StmtAnnAssign { target, value, .. }) => { + self.visit_expr(target); + if let Some(value) = value { + self.visit_expr(value); + } + } + ast::Stmt::If(ast::StmtIf { + test, + body, + elif_else_clauses, + .. + }) => { + self.visit_expr(test); + for stmt in body { + self.visit_stmt(stmt); + } + for clause in elif_else_clauses { + if let Some(test) = &clause.test { + self.visit_expr(test); + } + for stmt in &clause.body { + self.visit_stmt(stmt); + } + } + } + ast::Stmt::Try(ast::StmtTry { + body, + handlers, + orelse, + finalbody, + .. + }) => { + for stmt in body { + self.visit_stmt(stmt); + } + for handler in handlers { + self.visit_except_handler(handler); + } + for stmt in orelse { + self.visit_stmt(stmt); + } + for stmt in finalbody { + self.visit_stmt(stmt); + } + } + _ => ast::visitor::walk_stmt(self, stmt), + } + } + + fn visit_expr(&mut self, expr: &ast::Expr) { + if self.error.is_some() { + return; + } + self.error = self + .compiler + .consume_skipped_nested_scopes_in_expr(expr) + .err(); + } + + fn visit_except_handler(&mut self, handler: &ast::ExceptHandler) { + if self.error.is_some() { + return; + } + let ast::ExceptHandler::ExceptHandler(ast::ExceptHandlerExceptHandler { + type_, + body, + .. + }) = handler; + if let Some(type_) = type_ { + self.visit_expr(type_); + } + for stmt in body { + self.visit_stmt(stmt); + } + } + } + + let mut visitor = SkippedStatementScopeVisitor { + compiler: self, + error: None, + }; + for statement in statements { + visitor.visit_stmt(statement); + } + if let Some(err) = visitor.error { + Err(err) + } else { + Ok(()) + } + } + + fn consume_skipped_nested_scopes_in_except_handlers( + &mut self, + handlers: &[ast::ExceptHandler], + ) -> CompileResult<()> { + use ast::visitor::Visitor; + + struct SkippedHandlerScopeVisitor<'a, 'warnings> { + compiler: &'a mut Compiler<'warnings>, + error: Option, + } + + impl ast::visitor::Visitor<'_> for SkippedHandlerScopeVisitor<'_, '_> { + fn visit_expr(&mut self, expr: &ast::Expr) { + if self.error.is_some() { + return; + } + self.error = self + .compiler + .consume_skipped_nested_scopes_in_expr(expr) + .err(); + } + + fn visit_stmt(&mut self, stmt: &ast::Stmt) { + if self.error.is_some() { + return; + } + self.error = self + .compiler + .consume_skipped_nested_scopes_in_statements(slice::from_ref(stmt)) + .err(); + } + } + + let mut visitor = SkippedHandlerScopeVisitor { + compiler: self, + error: None, + }; + for handler in handlers { + visitor.visit_except_handler(handler); + if visitor.error.is_some() { + break; + } + } + if let Some(err) = visitor.error { + Err(err) + } else { + Ok(()) + } + } + + fn current_symbol_table_cursors(&self) -> SymbolTableCursors { + let table = self + .symbol_table_stack + .last() + .expect("no current symbol table"); + SymbolTableCursors { + sub_table: table.next_sub_table, + hidden_annotation_block: table.next_hidden_annotation_block, + inlined_comprehension_block: table.next_inlined_comprehension_block, + } + } + + fn set_symbol_table_cursors(&mut self, cursors: SymbolTableCursors) { + let table = self + .symbol_table_stack + .last_mut() + .expect("no current symbol table"); + table.next_sub_table = cursors.sub_table; + table.next_hidden_annotation_block = cursors.hidden_annotation_block; + table.next_inlined_comprehension_block = cursors.inlined_comprehension_block; + } + + fn lookup_comprehension_symbol_table_after_skipped_nested_scopes_in_expr( &mut self, expression: &ast::Expr, - ) -> CompileResult { + comprehension_type: ComprehensionType, + ) -> CompileResult<(SymbolTable, ComprehensionSymbolSource)> { let saved_cursor = self .symbol_table_stack .last() .expect("no current symbol table") .next_sub_table; + let saved_inlined_cursor = self + .symbol_table_stack + .last() + .expect("no current symbol table") + .next_inlined_comprehension_block; let result = (|| { self.consume_skipped_nested_scopes_in_expr(expression)?; let current_table = self .symbol_table_stack .last() .expect("no current symbol table"); + if comprehension_type != ComprehensionType::Generator + && let Some(table) = current_table + .inlined_comprehension_blocks + .get(current_table.next_inlined_comprehension_block) + { + return Ok((table.clone(), ComprehensionSymbolSource::Inlined)); + } if let Some(table) = current_table.sub_tables.get(current_table.next_sub_table) { - Ok(table.clone()) + Ok((table.clone(), ComprehensionSymbolSource::Child)) } else { let name = current_table.name.clone(); let typ = current_table.typ; @@ -9209,6 +10397,10 @@ impl Compiler { .last_mut() .expect("no current symbol table") .next_sub_table = saved_cursor; + self.symbol_table_stack + .last_mut() + .expect("no current symbol table") + .next_inlined_comprehension_block = saved_inlined_cursor; result } @@ -9231,7 +10423,15 @@ impl Compiler { if let Some(info) = self.code_stack.last_mut() { info.flags = flags | (info.flags - & (CodeFlags::NESTED | CodeFlags::METHOD | CodeFlags::FUTURE_ANNOTATIONS)); + & (bytecode::CodeFlags::NESTED + | bytecode::CodeFlags::METHOD + | bytecode::CodeFlags::FUTURE_DIVISION + | bytecode::CodeFlags::FUTURE_ABSOLUTE_IMPORT + | bytecode::CodeFlags::FUTURE_WITH_STATEMENT + | bytecode::CodeFlags::FUTURE_PRINT_FUNCTION + | bytecode::CodeFlags::FUTURE_UNICODE_LITERALS + | bytecode::CodeFlags::FUTURE_GENERATOR_STOP + | bytecode::CodeFlags::FUTURE_ANNOTATIONS)); info.metadata.argcount = arg_count; info.metadata.posonlyargcount = posonlyarg_count; info.metadata.kwonlyargcount = kwonlyarg_count; @@ -9254,12 +10454,16 @@ impl Compiler { ) -> CompileResult<()> { let prev_ctx = self.ctx; let has_an_async_gen = generators.iter().any(|g| g.is_async); + let is_top_level_await_context = self.opts.allow_top_level_await + && prev_ctx.func == FunctionContext::NoFunction + && !prev_ctx.in_class; // Check for async comprehension outside async function (list/set/dict only, not generator expressions) // Use in_async_scope to allow nested async comprehensions inside an async function if comprehension_type != ComprehensionType::Generator && (has_an_async_gen || element_contains_await) && !prev_ctx.in_async_scope + && !is_top_level_await_context { return Err(self.error(CodegenErrorType::InvalidAsyncComprehension)); } @@ -9270,7 +10474,7 @@ impl Compiler { let is_async_list_set_dict_comprehension = comprehension_type != ComprehensionType::Generator && (has_an_async_gen || element_contains_await) - && prev_ctx.in_async_scope; + && (prev_ctx.in_async_scope || is_top_level_await_context); let is_async_generator_comprehension = comprehension_type == ComprehensionType::Generator && (has_an_async_gen || element_contains_await); @@ -9282,8 +10486,11 @@ impl Compiler { // We must have at least one generator: assert!(!generators.is_empty()); let outermost = &generators[0]; - let comp_table = - self.peek_next_sub_table_after_skipped_nested_scopes_in_expr(&outermost.iter)?; + let (comp_table, comp_source) = self + .lookup_comprehension_symbol_table_after_skipped_nested_scopes_in_expr( + &outermost.iter, + comprehension_type, + )?; let is_inlined = self.is_inlined_comprehension_context(comprehension_type, &comp_table); @@ -9299,6 +10506,7 @@ impl Compiler { generators, compile_element, (comprehension_range, element_range, outer_backedge_range), + comp_source, ); } @@ -9315,9 +10523,9 @@ impl Compiler { in_async_scope: prev_ctx.in_async_scope || is_async, }; - let flags = CodeFlags::NEWLOCALS | CodeFlags::OPTIMIZED; + let flags = bytecode::CodeFlags::NEWLOCALS | bytecode::CodeFlags::OPTIMIZED; let flags = if is_async { - flags | CodeFlags::COROUTINE + flags | bytecode::CodeFlags::COROUTINE } else { flags }; @@ -9459,9 +10667,6 @@ impl Compiler { is_async, end_async_for_target, } => { - self.set_source_range(backedge_range); - emit!(self, PseudoInstruction::Jump { delta: loop_block }); - self.use_cpython_label_block(if_cleanup_block); self.set_source_range(backedge_range); emit!(self, PseudoInstruction::Jump { delta: loop_block }); @@ -9490,6 +10695,7 @@ impl Compiler { if return_none { self.emit_return_const_no_location(ConstantData::None); } else { + self.set_source_range(comprehension_range); self.emit_return_value(); } @@ -9506,6 +10712,7 @@ impl Compiler { emit!(self, Instruction::Reraise { depth: 1u32 }); self.set_no_location(); } + self.emit_return_const_no_location(ConstantData::None); let code = self.exit_scope(); @@ -9513,7 +10720,7 @@ impl Compiler { // Create comprehension function with closure self.set_source_range(comprehension_range); - self.make_closure(code, MakeFunctionFlags::new())?; + self.make_closure(code, bytecode::MakeFunctionFlags::new())?; // Evaluate iterated item and get its iterator. self.compile_comprehension_iter(outermost)?; @@ -9543,37 +10750,32 @@ impl Compiler { generators: &[ast::Comprehension], compile_element: &dyn Fn(&mut Self, usize) -> CompileResult<()>, ranges: (TextRange, TextRange, TextRange), + comp_source: ComprehensionSymbolSource, ) -> CompileResult<()> { let (comprehension_range, element_range, outer_backedge_range) = ranges; - fn collect_bound_names(target: &ast::Expr, out: &mut Vec) { - match target { - ast::Expr::Name(ast::ExprName { id, .. }) => out.push(id.to_string()), - ast::Expr::Tuple(ast::ExprTuple { elts, .. }) - | ast::Expr::List(ast::ExprList { elts, .. }) => { - for elt in elts { - collect_bound_names(elt, out); - } - } - ast::Expr::Starred(ast::ExprStarred { value, .. }) => { - collect_bound_names(value, out); - } - _ => {} - } - } - // Compile the outermost iterator first. Its expression may reference // nested scopes (e.g. lambdas) whose sub_tables sit at the current // position in the parent's list. Those must be consumed before we // splice in the comprehension's own children. self.compile_comprehension_iter(&generators[0])?; - self.symbol_table_stack - .last_mut() - .expect("no current symbol table") - .next_sub_table += 1; + match comp_source { + ComprehensionSymbolSource::Child => { + self.symbol_table_stack + .last_mut() + .expect("no current symbol table") + .next_sub_table += 1; + } + ComprehensionSymbolSource::Inlined => { + self.symbol_table_stack + .last_mut() + .expect("no current symbol table") + .next_inlined_comprehension_block += 1; + } + } let was_in_inlined_comp = self.current_code_info().in_inlined_comp; let saved_source_range = self.current_source_range; - let in_class_block = { + let tweak_in_class_block = { let ct = self.current_symbol_table(); ct.typ == CompilerScope::Class && !was_in_inlined_comp }; @@ -9583,9 +10785,13 @@ impl Compiler { let mut changed_fast_hidden = Vec::new(); let result = (|| { - // Splice the comprehension's children (e.g. nested inlined - // comprehensions) into the parent so the compiler can find them. - if !comp_table.sub_tables.is_empty() { + // If the symbol table still carries the inlined comprehension as + // a child, splice its children here. The symtable normally + // performs this splice before codegen, and the Inlined source path + // has already done so. + if matches!(comp_source, ComprehensionSymbolSource::Child) + && !comp_table.sub_tables.is_empty() + { let current_table = self .symbol_table_stack .last_mut() @@ -9595,30 +10801,21 @@ impl Compiler { current_table.sub_tables.insert(insert_pos + i, st.clone()); } } - let mut source_order_bound_names = Vec::new(); - for generator in generators { - collect_bound_names(&generator.target, &mut source_order_bound_names); - } - let mut pushed_locals: Vec = Vec::new(); - for name in source_order_bound_names - .into_iter() - .chain(comp_table.symbols.keys().cloned()) - { - if pushed_locals.iter().any(|existing| existing == &name) { - continue; + let mut fast_hidden_locals: Vec = Vec::new(); + for (name, sym) in &comp_table.symbols { + if sym.flags.contains(SymbolFlags::PARAMETER) { + continue; // skip .0 } - if let Some(sym) = comp_table.symbols.get(&name) { - if sym.flags.contains(SymbolFlags::PARAMETER) { - continue; // skip .0 - } - let is_local = sym - .flags - .intersects(SymbolFlags::ASSIGNED | SymbolFlags::ITER) - && !sym.flags.contains(SymbolFlags::NONLOCAL); - if is_local { - pushed_locals.push(name); - } + let is_local = sym + .flags + .intersects(SymbolFlags::ASSIGNED | SymbolFlags::ITER) + && !sym.flags.contains(SymbolFlags::NONLOCAL); + if is_local { + pushed_locals.push(name.clone()); + } + if is_local || tweak_in_class_block { + fast_hidden_locals.push(name.clone()); } } @@ -9638,7 +10835,7 @@ impl Compiler { if (comp_scope != outer_scope && comp_scope != SymbolScope::Free && !(comp_scope == SymbolScope::Cell && outer_scope == SymbolScope::Free)) - || in_class_block + || tweak_in_class_block { temp_symbols.insert(name.clone(), outer_sym.clone()); let current_table = @@ -9648,7 +10845,7 @@ impl Compiler { } } if !self.ctx.in_func() { - for name in &pushed_locals { + for name in &fast_hidden_locals { if self .current_code_info() .metadata @@ -9953,12 +11150,28 @@ impl Compiler { // Python 3 features; we've already implemented them by default "nested_scopes" | "generators" | "division" | "absolute_import" | "with_statement" | "print_function" | "unicode_literals" | "generator_stop" => {} - "annotations" => self.future_annotations = true, - other => { + // Accept the future feature name, but do not implement + // Barry-as-BDFL parser mode. + "barry_as_FLUFL" => {} + "annotations" => { + self.future_annotations = true; + self.future_features + .insert(bytecode::CodeFlags::FUTURE_ANNOTATIONS); + self.current_code_info() + .flags + .insert(bytecode::CodeFlags::FUTURE_ANNOTATIONS); + } + "braces" => { return Err( - self.error(CodegenErrorType::InvalidFutureFeature(other.to_owned())) + self.error_ranged(CodegenErrorType::InvalidFutureBraces, feature.range) ); } + other => { + return Err(self.error_ranged( + CodegenErrorType::InvalidFutureFeature(other.to_owned()), + feature.range, + )); + } } } Ok(()) @@ -10012,22 +11225,18 @@ impl Compiler { } let instr = instr.into(); let opcode = AnyOpcode::from(instr); - debug_assert!( !instr.is_assembler(), "CPython codegen_addop_* must not emit assembler-only opcodes" ); - debug_assert!( opcode.has_arg() || instr.has_target() || u32::from(arg) == 0, "CPython _PyInstructionSequence_Addop requires either OPCODE_HAS_ARG, HAS_TARGET, or oparg == 0" ); - debug_assert!( target == BlockIdx::NULL || instr.has_target(), "CPython codegen_addop_j only accepts HAS_TARGET opcodes" ); - let range = self.current_source_range; let source = self.source_file.to_source_code(); let location = source.source_location(range.start(), PositionEncoding::Utf8); @@ -10111,7 +11320,6 @@ impl Compiler { .blocks .first_mut() .expect("code unit must have an entry block"); - debug_assert!( entry .used_instructions() @@ -10125,11 +11333,14 @@ impl Compiler { }), "scope entry must start with a function-start RESUME" ); - debug_assert!( !entry.used_instructions().iter().any(|info| matches!( - info.instr.real_opcode(), - Some(Opcode::ReturnGenerator | Opcode::MakeCell | Opcode::CopyFreeVars) + info.instr.real(), + Some( + Instruction::ReturnGenerator + | Instruction::MakeCell { .. } + | Instruction::CopyFreeVars { .. } + ) )), "CPython inserts StopIteration cleanup before CFG prefix instructions" ); @@ -10281,31 +11492,6 @@ impl Compiler { && lhs.exceptiontable == rhs.exceptiontable } - /// Try to fold a collection of constant expressions into a single ConstantData::Tuple. - /// Returns None if any element cannot be folded. - fn try_fold_constant_collection( - &mut self, - elts: &[ast::Expr], - collection_type: CollectionType, - ) -> CompileResult> { - let mut constants = Vec::with_capacity(elts.len()); - for elt in elts { - let Some(constant) = self.try_fold_constant_expr(elt)? else { - return Ok(None); - }; - constants.push(constant); - } - let constant = match collection_type { - CollectionType::Tuple | CollectionType::List => ConstantData::Tuple { - elements: constants, - }, - CollectionType::Set => ConstantData::Frozenset { - elements: constants, - }, - }; - Ok(Some(constant)) - } - fn constant_as_fold_int(constant: &ConstantData) -> Option<(BigInt, bool)> { match constant { ConstantData::Boolean { value } => Some((BigInt::from(u8::from(*value)), true)), @@ -10341,6 +11527,9 @@ impl Compiler { } fn try_fold_constant_expr(&mut self, expr: &ast::Expr) -> CompileResult> { + if let Some(constant) = self.ast_constant_value(expr) { + return Ok(Some(constant)); + } Ok(Some(match expr { ast::Expr::NumberLiteral(num) => match &num.value { ast::Number::Int(int) => ConstantData::Integer { @@ -10477,7 +11666,7 @@ impl Compiler { } (ast::UnaryOp::Not, ConstantData::Tuple { .. }) => return Ok(None), (ast::UnaryOp::Not, value) => ConstantData::Boolean { - value: !value.truthiness(), + value: !Self::constant_truthiness(&value), }, _ => return Ok(None), } @@ -10497,9 +11686,9 @@ impl Compiler { let mut selected = first; match op { ast::BoolOp::Or => { - if !selected.truthiness() { + if !Self::constant_truthiness(&selected) { for constant in iter { - let is_truthy = constant.truthiness(); + let is_truthy = Self::constant_truthiness(&constant); selected = constant; if is_truthy { break; @@ -10508,9 +11697,9 @@ impl Compiler { } } ast::BoolOp::And => { - if selected.truthiness() { + if Self::constant_truthiness(&selected) { for constant in iter { - let is_truthy = constant.truthiness(); + let is_truthy = Self::constant_truthiness(&constant); selected = constant; if !is_truthy { break; @@ -10529,6 +11718,9 @@ impl Compiler { &mut self, expr: &ast::Expr, ) -> CompileResult> { + if let Some(constant) = self.ast_constant_value(expr) { + return Ok(Some(constant)); + } Ok(Some(match expr { ast::Expr::NumberLiteral(num) => match &num.value { ast::Number::Int(int) => ConstantData::Integer { @@ -10552,24 +11744,76 @@ impl Compiler { })) } + fn try_compile_match_mapping_key_direct_constant( + &mut self, + expr: &ast::Expr, + ) -> CompileResult> { + if let Some(constant) = self.ast_constant_value(expr) { + return Ok(match constant { + ConstantData::Integer { .. } + | ConstantData::Float { .. } + | ConstantData::Bytes { .. } + | ConstantData::Complex { .. } + | ConstantData::Str { .. } + | ConstantData::Boolean { .. } + | ConstantData::None => Some(constant), + _ => None, + }); + } + if matches!( + expr, + ast::Expr::BooleanLiteral(_) | ast::Expr::NoneLiteral(_) + ) { + return self.try_compile_ast_constant(expr); + } + self.try_compile_match_pattern_direct_literal(expr) + } + + fn is_unexpected_match_literal_constant(expr: &ast::Expr) -> bool { + if let Some(constant) = expr + .as_constant_expr() + .map(|expr| ast_constant_value_to_constant_data(expr.value.clone())) + { + return matches!( + constant, + ConstantData::Boolean { .. } | ConstantData::None | ConstantData::Ellipsis + ); + } + matches!( + expr, + ast::Expr::BooleanLiteral(_) + | ast::Expr::NoneLiteral(_) + | ast::Expr::EllipsisLiteral(_) + ) + } + + fn try_compile_match_pattern_direct_literal( + &mut self, + expr: &ast::Expr, + ) -> CompileResult> { + if let Some(constant) = self.ast_constant_value(expr) { + return Ok(match constant { + ConstantData::Integer { .. } + | ConstantData::Float { .. } + | ConstantData::Bytes { .. } + | ConstantData::Complex { .. } + | ConstantData::Str { .. } => Some(constant), + _ => None, + }); + } + match expr { + ast::Expr::NumberLiteral(_) + | ast::Expr::StringLiteral(_) + | ast::Expr::BytesLiteral(_) => self.try_compile_ast_constant(expr), + _ => Ok(None), + } + } + fn try_negate_match_pattern_constant(constant: ConstantData) -> Option { match constant { ConstantData::Integer { value } => Some(ConstantData::Integer { value: -value }), ConstantData::Float { value } => Some(ConstantData::Float { value: -value }), ConstantData::Complex { value } => Some(ConstantData::Complex { value: -value }), - ConstantData::Boolean { value } => Some(ConstantData::Integer { - value: -BigInt::from(u8::from(value)), - }), - _ => None, - } - } - - fn constant_as_match_pattern_complex(constant: &ConstantData) -> Option> { - match constant { - ConstantData::Integer { value } => Some(Complex::new(value.to_f64()?, 0.0)), - ConstantData::Float { value } => Some(Complex::new(*value, 0.0)), - ConstantData::Complex { value } => Some(*value), - ConstantData::Boolean { value } => Some(Complex::new(f64::from(u8::from(*value)), 0.0)), _ => None, } } @@ -10579,51 +11823,20 @@ impl Compiler { left: &ConstantData, right: &ConstantData, ) -> Option { - if let (ConstantData::Integer { value: left }, ConstantData::Integer { value: right }) = - (left, right) - { - return match op { - ast::Operator::Add => Some(ConstantData::Integer { - value: left + right, - }), - ast::Operator::Sub => Some(ConstantData::Integer { - value: left - right, - }), - _ => None, - }; - } - - let left_is_complex = matches!(left, ConstantData::Complex { .. }); - let right_is_complex = matches!(right, ConstantData::Complex { .. }); - if left_is_complex || right_is_complex { - let left = Self::constant_as_match_pattern_complex(left)?; - let right = Self::constant_as_match_pattern_complex(right)?; - let value = match op { - ast::Operator::Add => Complex::new(left.re + right.re, left.im + right.im), - ast::Operator::Sub => { - let imag = if !left_is_complex && right_is_complex { - -right.im - } else { - left.im - right.im - }; - Complex::new(left.re - right.re, imag) - } - _ => return None, - }; - return Some(ConstantData::Complex { value }); - } - - let left = Self::constant_as_match_pattern_complex(left)?; - let right = Self::constant_as_match_pattern_complex(right)?; - match op { - ast::Operator::Add => Some(ConstantData::Float { - value: left.re + right.re, - }), - ast::Operator::Sub => Some(ConstantData::Float { - value: left.re - right.re, - }), - _ => None, - } + let left = match left { + ConstantData::Integer { value } => value.to_f64()?, + ConstantData::Float { value } => *value, + _ => return None, + }; + let ConstantData::Complex { value: right } = right else { + return None; + }; + let value = match op { + ast::Operator::Add => Complex::new(left + right.re, right.im), + ast::Operator::Sub => Complex::new(left - right.re, -right.im), + _ => return None, + }; + Some(ConstantData::Complex { value }) } fn try_fold_match_pattern_const_expr( @@ -10639,7 +11852,8 @@ impl Compiler { operand, .. }) => { - let Some(constant) = self.try_compile_ast_constant(operand)? else { + let Some(constant) = self.try_compile_match_pattern_number_constant(operand)? + else { return Ok(None); }; Self::try_negate_match_pattern_constant(constant) @@ -10647,13 +11861,10 @@ impl Compiler { ast::Expr::BinOp(ast::ExprBinOp { left, op, right, .. }) if matches!(op, ast::Operator::Add | ast::Operator::Sub) => { - let Some(left) = (match self.try_fold_match_pattern_const_expr(left)? { - Some(constant) => Some(constant), - None => self.try_compile_ast_constant(left)?, - }) else { + let Some(left) = self.try_compile_match_pattern_signed_real_constant(left)? else { return Ok(None); }; - let Some(right) = self.try_compile_ast_constant(right)? else { + let Some(right) = self.try_compile_match_pattern_imaginary_constant(right)? else { return Ok(None); }; Self::try_fold_match_pattern_binop(*op, &left, &right) @@ -10662,8 +11873,74 @@ impl Compiler { }) } + fn try_compile_match_pattern_signed_real_constant( + &mut self, + expr: &ast::Expr, + ) -> CompileResult> { + if let Some(constant) = self.try_compile_match_pattern_real_constant(expr)? { + return Ok(Some(constant)); + } + let ast::Expr::UnaryOp(ast::ExprUnaryOp { + op: ast::UnaryOp::USub, + operand, + .. + }) = expr + else { + return Ok(None); + }; + let Some(constant) = self.try_compile_match_pattern_real_constant(operand)? else { + return Ok(None); + }; + Ok(Self::try_negate_match_pattern_constant(constant)) + } + + fn try_compile_match_pattern_real_constant( + &mut self, + expr: &ast::Expr, + ) -> CompileResult> { + let Some(constant) = self.try_compile_match_pattern_number_constant(expr)? else { + return Ok(None); + }; + Ok(match constant { + ConstantData::Integer { .. } | ConstantData::Float { .. } => Some(constant), + _ => None, + }) + } + + fn try_compile_match_pattern_imaginary_constant( + &mut self, + expr: &ast::Expr, + ) -> CompileResult> { + let Some(constant) = self.try_compile_match_pattern_number_constant(expr)? else { + return Ok(None); + }; + Ok(match constant { + ConstantData::Complex { .. } => Some(constant), + _ => None, + }) + } + + fn try_compile_match_pattern_number_constant( + &mut self, + expr: &ast::Expr, + ) -> CompileResult> { + if let Some(constant) = self.ast_constant_value(expr) { + return Ok(match constant { + ConstantData::Integer { .. } + | ConstantData::Float { .. } + | ConstantData::Complex { .. } => Some(constant), + _ => None, + }); + } + match expr { + ast::Expr::NumberLiteral(_) => self.try_compile_ast_constant(expr), + _ => Ok(None), + } + } + fn compile_match_pattern_expr(&mut self, expr: &ast::Expr) -> CompileResult<()> { if let Some(constant) = self.try_fold_match_pattern_const_expr(expr)? { + self.set_source_range(expr.range()); self.emit_load_const(constant); } else { self.compile_expression(expr)?; @@ -10685,7 +11962,7 @@ impl Compiler { if [lower, upper, step] .into_iter() .flatten() - .any(|expr| !expr.is_constant()) + .any(|expr| !self.is_constant_expr(expr)) { return Ok(None); } @@ -10792,6 +12069,12 @@ impl Compiler { emit!(self, Instruction::ReturnValue) } + fn allows_top_level_await_in_current_context(&self) -> bool { + self.opts.allow_top_level_await + && self.ctx.func == FunctionContext::NoFunction + && !self.ctx.in_class + } + fn current_code_info(&mut self) -> &mut ir::CodeInfo { self.code_stack.last_mut().expect("no code on stack") } @@ -10836,17 +12119,12 @@ impl Compiler { _ => {} } } - if !found_loop { - let err_type = if is_break { - CodegenErrorType::InvalidBreak - } else { - CodegenErrorType::InvalidContinue - }; - - return Err(self.error_ranged(err_type, range)); + if is_break { + return Err(self.error_ranged(CodegenErrorType::InvalidBreak, range)); + } + return Err(self.error_ranged(CodegenErrorType::InvalidContinue, range)); } - return Ok(()); } @@ -10878,13 +12156,19 @@ impl Compiler { debug_assert!(loop_fblock.fb_block.is_jump_target_label()); loop_fblock.fb_block }; - if let Some(loc) = unwind_loc { + let jump_is_artificial = if let Some(loc) = unwind_loc { self.set_source_range(loc); + false } else { - self.set_source_range(range); + true }; - self.emit_jump_label(PseudoOpcode::Jump, target_label); - if unwind_loc.is_none() { + self.emit_jump_label( + PseudoInstruction::Jump { + delta: OpArgMarker::marker(), + }, + target_label, + ); + if jump_is_artificial { self.set_no_location(); } self.set_source_range(prev_source_range); @@ -10900,7 +12184,7 @@ impl Compiler { let code = self.current_code_info(); let cur = code.current_block; if !code.blocks[cur.idx()] - .instructions + .used_instructions() .last() .is_some_and(|instr| instr.instr.is_terminator()) { @@ -11081,7 +12365,7 @@ impl Compiler { if source.line_index(loc_range.start()) == source.line_index(attr_range.end()) { return loc_range; } - let Ok(attr_len) = u32::try_from(attr.len()) else { + let Ok(attr_len) = u32::try_from(attr.chars().count()) else { return TextRange::new(loc_range.start(), loc_range.end()); }; let attr_len = TextSize::new(attr_len); @@ -11113,10 +12397,10 @@ impl Compiler { let is_async = self.ctx.func == FunctionContext::AsyncFunction; let flags = &mut self.current_code_info().flags; if is_async { - flags.remove(CodeFlags::COROUTINE); - flags.insert(CodeFlags::ASYNC_GENERATOR); + flags.remove(bytecode::CodeFlags::COROUTINE); + flags.insert(bytecode::CodeFlags::ASYNC_GENERATOR); } else { - flags.insert(CodeFlags::GENERATOR); + flags.insert(bytecode::CodeFlags::GENERATOR); } } @@ -11184,7 +12468,7 @@ impl Compiler { let fstring_range = fstring.range; let fstring = fstring.value.as_slice(); if self.count_fstring_parts(fstring) > STACK_USE_GUIDELINE { - return self.compile_fstring_parts_joined(fstring); + return self.compile_fstring_parts_joined(fstring, fstring_range); } let mut element_count = 0; @@ -11198,7 +12482,7 @@ impl Compiler { &mut pending_literal_range, &mut pending_literal_no_location, &mut element_count, - false, + None, )?; } self.finish_fstring( @@ -11211,7 +12495,54 @@ impl Compiler { Ok(()) } - fn compile_fstring_parts_joined(&mut self, fstring: &[ast::FStringPart]) -> CompileResult<()> { + fn compile_runtime_joined_str( + &mut self, + fstring: &ast::ExprFString, + values: &[ast::Expr], + ) -> CompileResult<()> { + let range = fstring.range; + let value_count: u32 = values + .len() + .try_into() + .expect("JoinedStr value count overflowed"); + if value_count > STACK_USE_GUIDELINE { + self.set_source_range(range); + self.emit_load_const(ConstantData::Str { + value: Wtf8Buf::new(), + }); + let join_idx = self.get_global_name_index("join"); + self.emit_load_attr_method(join_idx); + emit!(self, Instruction::BuildList { count: 0 }); + for value in values { + self.compile_expression(value)?; + self.set_source_range(range); + emit!(self, Instruction::ListAppend { i: 1 }); + } + self.set_source_range(range); + emit!(self, Instruction::Call { argc: 1 }); + } else { + for value in values { + self.compile_expression(value)?; + } + if value_count > 1 { + self.set_source_range(range); + emit!(self, Instruction::BuildString { count: value_count }); + } else if value_count == 0 { + self.set_source_range(range); + self.emit_load_const(ConstantData::Str { + value: Wtf8Buf::new(), + }); + } + } + Ok(()) + } + + fn compile_fstring_parts_joined( + &mut self, + fstring: &[ast::FStringPart], + fstring_range: TextRange, + ) -> CompileResult<()> { + self.set_source_range(fstring_range); self.emit_load_const(ConstantData::Str { value: Wtf8Buf::new(), }); @@ -11230,7 +12561,7 @@ impl Compiler { &mut pending_literal_range, &mut pending_literal_no_location, &mut element_count, - true, + Some(fstring_range), )?; } self.finish_fstring_join( @@ -11238,6 +12569,7 @@ impl Compiler { pending_literal_range, pending_literal_no_location, element_count, + fstring_range, ); Ok(()) } @@ -11249,7 +12581,7 @@ impl Compiler { pending_literal_range: &mut Option, pending_literal_no_location: &mut bool, element_count: &mut u32, - append_to_join_list: bool, + join_append_range: Option, ) -> CompileResult<()> { match part { ast::FStringPart::Literal(string) => { @@ -11271,7 +12603,7 @@ impl Compiler { pending_literal, (pending_literal_range, pending_literal_no_location), element_count, - append_to_join_list, + join_append_range, ), } } @@ -11291,7 +12623,7 @@ impl Compiler { &mut pending_literal_no_location, &mut element_count, keep_empty, - false, + None, ); if element_count == 0 { @@ -11320,6 +12652,7 @@ impl Compiler { mut pending_literal_range: Option, mut pending_literal_no_location: bool, mut element_count: u32, + fstring_range: TextRange, ) { let keep_empty = element_count == 0; self.emit_pending_fstring_literal( @@ -11328,8 +12661,9 @@ impl Compiler { &mut pending_literal_no_location, &mut element_count, keep_empty, - true, + Some(fstring_range), ); + self.set_source_range(fstring_range); emit!(self, Instruction::Call { argc: 1 }); } @@ -11340,7 +12674,7 @@ impl Compiler { pending_literal_no_location: &mut bool, element_count: &mut u32, keep_empty: bool, - append_to_join_list: bool, + join_append_range: Option, ) { let Some(value) = pending_literal.take() else { return; @@ -11364,7 +12698,8 @@ impl Compiler { self.set_no_location(); } *element_count += 1; - if append_to_join_list { + if let Some(join_append_range) = join_append_range { + self.set_source_range(join_append_range); emit!(self, Instruction::ListAppend { i: 1 }); } } @@ -11439,7 +12774,8 @@ impl Compiler { fstring_range: Option, ) -> CompileResult<()> { if self.count_fstring_elements(flags, fstring_elements) > STACK_USE_GUIDELINE { - return self.compile_fstring_elements_joined(flags, fstring_elements); + let fstring_range = fstring_range.unwrap_or(self.current_source_range); + return self.compile_fstring_elements_joined(flags, fstring_elements, fstring_range); } let mut element_count = 0; @@ -11452,7 +12788,7 @@ impl Compiler { &mut pending_literal, (&mut pending_literal_range, &mut pending_literal_no_location), &mut element_count, - false, + None, )?; self.finish_fstring( pending_literal, @@ -11468,7 +12804,9 @@ impl Compiler { &mut self, flags: ast::FStringFlags, fstring_elements: &ast::InterpolatedStringElements, + fstring_range: TextRange, ) -> CompileResult<()> { + self.set_source_range(fstring_range); self.emit_load_const(ConstantData::Str { value: Wtf8Buf::new(), }); @@ -11486,13 +12824,14 @@ impl Compiler { &mut pending_literal, (&mut pending_literal_range, &mut pending_literal_no_location), &mut element_count, - true, + Some(fstring_range), )?; self.finish_fstring_join( pending_literal, pending_literal_range, pending_literal_no_location, element_count, + fstring_range, ); Ok(()) } @@ -11517,7 +12856,7 @@ impl Compiler { pending_literal: &mut Option, pending_literal_meta: (&mut Option, &mut bool), element_count: &mut u32, - append_to_join_list: bool, + join_append_range: Option, ) -> CompileResult<()> { let (pending_literal_range, pending_literal_no_location) = pending_literal_meta; for element in fstring_elements { @@ -11542,7 +12881,18 @@ impl Compiler { ast::ConversionFlag::Ascii => ConvertValueOparg::Ascii, }; - if let Some(ast::DebugText { leading, trailing }) = &fstring_expr.debug_text { + if let Some(debug_text) = &fstring_expr.debug_text { + let leading = debug_text.leading.as_str(); + let trailing = debug_text.trailing.as_str(); + self.emit_pending_fstring_literal( + pending_literal, + pending_literal_range, + pending_literal_no_location, + element_count, + false, + join_append_range, + ); + let range = fstring_expr.expression.range(); let leading = strip_fstring_debug_comments(leading); let trailing = strip_fstring_debug_comments(trailing); @@ -11562,16 +12912,9 @@ impl Compiler { ); let text: Wtf8Buf = text.into(); - if pending_literal.is_none() { - *pending_literal_range = Some(debug_text_range); - *pending_literal_no_location = false; - *pending_literal = Some(Wtf8Buf::new()); - } else { - Self::extend_pending_literal_range( - pending_literal_range, - debug_text_range, - ); - } + *pending_literal_range = Some(debug_text_range); + *pending_literal_no_location = false; + *pending_literal = Some(Wtf8Buf::new()); pending_literal.as_mut().unwrap().push_wtf8(text.as_ref()); // If debug text is present, apply repr conversion when no `format_spec` specified. @@ -11590,7 +12933,7 @@ impl Compiler { pending_literal_no_location, element_count, false, - append_to_join_list, + join_append_range, ); self.compile_expression(&fstring_expr.expression)?; @@ -11606,27 +12949,37 @@ impl Compiler { } } - match &fstring_expr.format_spec { - Some(format_spec) => { - let format_spec_range = - self.cpython_format_spec_range(format_spec.range); - self.compile_fstring_elements( - flags, - &format_spec.elements, - Some(format_spec_range), - )?; + if let Some(format_spec) = + fstring_expr.runtime_formatted_value_format_spec.as_deref() + { + self.compile_expression(format_spec)?; - self.set_source_range(formatted_value_range); - emit!(self, Instruction::FormatWithSpec); - } - None => { - self.set_source_range(formatted_value_range); - emit!(self, Instruction::FormatSimple); + self.set_source_range(formatted_value_range); + emit!(self, Instruction::FormatWithSpec); + } else { + match &fstring_expr.format_spec { + Some(format_spec) => { + let format_spec_range = + self.cpython_format_spec_range(format_spec.range); + self.compile_fstring_elements( + flags, + &format_spec.elements, + Some(format_spec_range), + )?; + + self.set_source_range(formatted_value_range); + emit!(self, Instruction::FormatWithSpec); + } + None => { + self.set_source_range(formatted_value_range); + emit!(self, Instruction::FormatSimple); + } } } *element_count += 1; - if append_to_join_list { + if let Some(join_append_range) = join_append_range { + self.set_source_range(join_append_range); emit!(self, Instruction::ListAppend { i: 1 }); } } @@ -11672,7 +13025,10 @@ impl Compiler { } } ast::InterpolatedStringElement::Interpolation(fstring_expr) => { - if let Some(ast::DebugText { leading, trailing }) = &fstring_expr.debug_text { + if let Some(debug_text) = &fstring_expr.debug_text { + let leading = debug_text.leading.as_str(); + let trailing = debug_text.trailing.as_str(); + Self::count_pending_fstring_literal(pending_literal, element_count, false); let range = fstring_expr.expression.range(); let source = self.source_file.slice(range); let text = [ @@ -11683,9 +13039,9 @@ impl Compiler { .concat(); let text: Wtf8Buf = text.into(); - pending_literal - .get_or_insert_with(Wtf8Buf::new) - .push_wtf8(text.as_ref()); + let mut debug_text = Wtf8Buf::new(); + debug_text.push_wtf8(text.as_ref()); + *pending_literal = Some(debug_text); } Self::count_pending_fstring_literal(pending_literal, element_count, false); @@ -11701,8 +13057,9 @@ impl Compiler { // strings tuple first, then evaluating interpolations left-to-right. let tstring_value = &expr_tstring.value; - let mut all_strings: Vec = Vec::new(); + let mut all_strings: Vec<(Wtf8Buf, TextRange)> = Vec::new(); let mut current_string = Wtf8Buf::new(); + let mut current_string_range = None; let mut interp_count: u32 = 0; for tstring in tstring_value { @@ -11710,19 +13067,26 @@ impl Compiler { tstring, &mut all_strings, &mut current_string, + &mut current_string_range, &mut interp_count, + expr_tstring.range, ); } - all_strings.push(core::mem::take(&mut current_string)); + all_strings.push(( + core::mem::take(&mut current_string), + current_string_range.unwrap_or(expr_tstring.range), + )); let string_count: u32 = all_strings .len() .try_into() .expect("t-string string count overflowed"); - for s in &all_strings { + for (s, range) in &all_strings { + self.set_source_range(*range); self.emit_load_const(ConstantData::Str { value: s.clone() }); } + self.set_source_range(expr_tstring.range); emit!( self, Instruction::BuildTuple { @@ -11734,32 +13098,168 @@ impl Compiler { self.compile_tstring_interpolations(tstring)?; } + self.set_source_range(expr_tstring.range); emit!( self, Instruction::BuildTuple { count: interp_count } ); + self.set_source_range(expr_tstring.range); + emit!(self, Instruction::BuildTemplate); + + Ok(()) + } + + fn compile_runtime_template_str( + &mut self, + expr_tstring: &ast::ExprTString, + values: &[ast::Expr], + ) -> CompileResult<()> { + let mut last_was_interpolation = true; + let mut strings_len = 0; + for value in values { + if self.runtime_template_value_interpolation(value).is_some() { + if last_was_interpolation { + self.set_source_range(expr_tstring.range); + self.emit_load_const(ConstantData::Str { + value: Wtf8Buf::new(), + }); + strings_len += 1; + } + last_was_interpolation = true; + } else { + self.compile_expression(value)?; + strings_len += 1; + last_was_interpolation = false; + } + } + if last_was_interpolation { + self.set_source_range(expr_tstring.range); + self.emit_load_const(ConstantData::Str { + value: Wtf8Buf::new(), + }); + strings_len += 1; + } + self.set_source_range(expr_tstring.range); + emit!(self, Instruction::BuildTuple { count: strings_len }); + + let mut interpolations_len = 0; + for value in values { + if let Some((tstring, interpolation)) = self.runtime_template_value_interpolation(value) + { + self.compile_runtime_interpolation(tstring, interpolation)?; + interpolations_len += 1; + } + } + self.set_source_range(expr_tstring.range); + emit!( + self, + Instruction::BuildTuple { + count: interpolations_len + } + ); + self.set_source_range(expr_tstring.range); emit!(self, Instruction::BuildTemplate); + Ok(()) + } + + fn runtime_template_value_interpolation<'a>( + &self, + value: &'a ast::Expr, + ) -> Option<( + &'a ast::ExprTString, + (&'a ast::ConstantValue, Option<&'a ast::Expr>), + )> { + let ast::Expr::TString(tstring) = value else { + return None; + }; + let interpolation = Self::single_runtime_interpolation(tstring)?; + Self::single_tstring_interpolation(tstring)?; + Some((tstring, interpolation)) + } + + fn compile_runtime_interpolation( + &mut self, + expr_tstring: &ast::ExprTString, + interpolation: (&ast::ConstantValue, Option<&ast::Expr>), + ) -> CompileResult { + let Some(interp) = Self::single_tstring_interpolation(expr_tstring) else { + return Ok(false); + }; + self.compile_interpolation(interp, interpolation)?; + Ok(true) + } + + fn compile_interpolation( + &mut self, + interp: &ast::InterpolatedElement, + interpolation: (&ast::ConstantValue, Option<&ast::Expr>), + ) -> CompileResult<()> { + let (str, format_spec) = interpolation; + self.compile_expression(&interp.expression)?; + self.set_source_range(interp.range); + self.emit_load_const(ast_constant_value_to_constant_data(str.clone())); + + let conversion = match interp.conversion { + ast::ConversionFlag::None => 0, + ast::ConversionFlag::Str => 1, + ast::ConversionFlag::Repr => 2, + ast::ConversionFlag::Ascii => 3, + }; + + let has_format_spec = format_spec.is_some(); + if let Some(format_spec) = format_spec { + self.compile_expression(format_spec)?; + } + + let format = 2 | (conversion << 2) | u32::from(has_format_spec); + self.set_source_range(interp.range); + emit!(self, Instruction::BuildInterpolation { format }); Ok(()) } + fn single_tstring_interpolation( + expr_tstring: &ast::ExprTString, + ) -> Option<&ast::InterpolatedElement> { + let [tstring] = expr_tstring.value.as_slice() else { + return None; + }; + let mut elements = tstring.elements.iter(); + let ast::InterpolatedStringElement::Interpolation(interp) = elements.next()? else { + return None; + }; + if elements.next().is_some() { + return None; + } + Some(interp) + } + fn collect_tstring_strings( &self, tstring: &ast::TString, - strings: &mut Vec, + strings: &mut Vec<(Wtf8Buf, TextRange)>, current_string: &mut Wtf8Buf, + current_string_range: &mut Option, interp_count: &mut u32, + template_range: TextRange, ) { for element in &tstring.elements { match element { ast::InterpolatedStringElement::Literal(lit) => { + if current_string_range.is_none() { + *current_string_range = Some(lit.range); + } else { + Self::extend_pending_literal_range(current_string_range, lit.range); + } current_string .push_wtf8(&self.compile_tstring_literal_value(lit, tstring.flags)); } ast::InterpolatedStringElement::Interpolation(interp) => { - if let Some(ast::DebugText { leading, trailing }) = &interp.debug_text { + if let Some(debug_text) = &interp.debug_text { + let leading = debug_text.leading.as_str(); + let trailing = debug_text.trailing.as_str(); let range = interp.expression.range(); let source = self.source_file.slice(range); let text = [ @@ -11768,20 +13268,57 @@ impl Compiler { strip_fstring_debug_comments(trailing).as_str(), ] .concat(); - current_string.push_str(&text); - } - strings.push(core::mem::take(current_string)); - *interp_count += 1; - } - } - } - } - - fn compile_tstring_interpolations(&mut self, tstring: &ast::TString) -> CompileResult<()> { - for element in &tstring.elements { - let ast::InterpolatedStringElement::Interpolation(interp) = element else { - continue; - }; + let debug_text_range = TextRange::new( + range.start() + - TextSize::new( + u32::try_from(leading.len()) + .expect("debug t-string leading text too long"), + ), + range.end() + + TextSize::new( + u32::try_from(trailing.len()) + .expect("debug t-string trailing text too long"), + ), + ); + if current_string_range.is_none() { + *current_string_range = Some(debug_text_range); + } else { + Self::extend_pending_literal_range( + current_string_range, + debug_text_range, + ); + } + current_string.push_str(&text); + strings.push(( + core::mem::take(current_string), + current_string_range.take().unwrap_or(template_range), + )); + } else { + strings.push(( + core::mem::take(current_string), + current_string_range.take().unwrap_or(template_range), + )); + } + *interp_count += 1; + } + } + } + } + + fn compile_tstring_interpolations(&mut self, tstring: &ast::TString) -> CompileResult<()> { + for element in &tstring.elements { + let ast::InterpolatedStringElement::Interpolation(interp) = element else { + continue; + }; + + if let Some(runtime_str) = interp.runtime_str.as_ref() { + let interpolation = ( + runtime_str, + interp.runtime_interpolation_format_spec.as_deref(), + ); + self.compile_interpolation(interp, interpolation)?; + continue; + } self.compile_expression(&interp.expression)?; @@ -11794,9 +13331,11 @@ impl Compiler { .slice(TextRange::new(after_brace, expr_range.end())) } else { self.source_file.slice(expr_range) - }; + } + .to_string(); + self.set_source_range(interp.range); self.emit_load_const(ConstantData::Str { - value: expr_source.to_string().into(), + value: expr_source.into(), }); let mut conversion: u32 = match interp.conversion { @@ -11812,16 +13351,18 @@ impl Compiler { let has_format_spec = interp.format_spec.is_some(); if let Some(format_spec) = &interp.format_spec { + let format_spec_range = self.cpython_format_spec_range(format_spec.range); self.compile_fstring_elements( ast::FStringFlags::empty(), &format_spec.elements, - Some(format_spec.range), + Some(format_spec_range), )?; } // CPython keeps bit 1 set in BUILD_INTERPOLATION's oparg and uses // bit 0 for the optional format spec. let format = 2 | (conversion << 2) | u32::from(has_format_spec); + self.set_source_range(interp.range); emit!(self, Instruction::BuildInterpolation { format }); } @@ -11922,20 +13463,24 @@ fn expandtabs(input: &str, tab_size: usize) -> String { expanded_str } -fn split_doc_with_range( - body: &[ast::Stmt], - opts: CompileOpts, -) -> (Option<(String, TextRange)>, &[ast::Stmt]) { +fn split_doc_with_range<'a>( + body: &'a [ast::Stmt], + opts: &CompileOpts, +) -> (Option<(String, TextRange)>, &'a [ast::Stmt]) { if let Some((ast::Stmt::Expr(expr), body_rest)) = body.split_first() { - let doc_comment = match &*expr.value { - ast::Expr::StringLiteral(value) => Some((&value.value, expr.value.range())), + let doc_comment: Option<(&str, TextRange)> = match &*expr.value { + ast::Expr::StringLiteral(value) => Some((value.value.to_str(), expr.value.range())), + ast::Expr::Constant(ast::ExprConstant { + value: ast::ConstantValue::Str(value), + .. + }) => Some((value.as_ref(), expr.value.range())), // f-strings are not allowed in Python doc comments. ast::Expr::FString(_) => None, _ => None, }; if let Some((doc, range)) = doc_comment { return if opts.optimize < 2 { - (Some((clean_doc(doc.to_str()), range)), body_rest) + (Some((clean_doc(doc), range)), body_rest) } else { (None, body_rest) }; @@ -11945,11 +13490,22 @@ fn split_doc_with_range( } #[cfg(test)] -fn split_doc(body: &[ast::Stmt], opts: CompileOpts) -> (Option, &[ast::Stmt]) { +fn split_doc<'a>(body: &'a [ast::Stmt], opts: &CompileOpts) -> (Option, &'a [ast::Stmt]) { let (doc, body) = split_doc_with_range(body, opts); (doc.map(|(doc, _)| doc), body) } +fn is_docstring_expr(expr: &ast::Expr) -> bool { + matches!( + expr, + ast::Expr::StringLiteral(_) + | ast::Expr::Constant(ast::ExprConstant { + value: ast::ConstantValue::Str(_), + .. + }) + ) +} + pub fn ruff_int_to_bigint(int: &ast::Int) -> Result { if let Some(small) = int.as_u64() { Ok(BigInt::from(small)) @@ -12053,11 +13609,16 @@ mod ruff_tests { debug_text: None, conversion: ast::ConversionFlag::None, format_spec: None, + runtime_str: None, + runtime_interpolation_format_spec: None, + runtime_formatted_value_format_spec: None, }, )] .into(), flags, }), + runtime_joined_str: None, + runtime_values: None, }); assert!(!Compiler::contains_await(not_present)); @@ -12086,11 +13647,16 @@ mod ruff_tests { debug_text: None, conversion: ast::ConversionFlag::None, format_spec: None, + runtime_str: None, + runtime_interpolation_format_spec: None, + runtime_formatted_value_format_spec: None, }, )] .into(), flags, }), + runtime_joined_str: None, + runtime_values: None, }); assert!(Compiler::contains_await(present)); @@ -12135,15 +13701,23 @@ mod ruff_tests { debug_text: None, conversion: ast::ConversionFlag::None, format_spec: None, + runtime_str: None, + runtime_interpolation_format_spec: None, + runtime_formatted_value_format_spec: None, }, )] .into(), })), + runtime_str: None, + runtime_interpolation_format_spec: None, + runtime_formatted_value_format_spec: None, }, )] .into(), flags, }), + runtime_joined_str: None, + runtime_values: None, }); assert!(Compiler::contains_await(present)); } @@ -12154,14 +13728,16 @@ mod tests { use super::*; use rustpython_compiler_core::{ SourceFileBuilder, - bytecode::{CodeUnit, OpArg}, + bytecode::{CO_FAST_ARG_KW, CO_FAST_ARG_POS, CodeUnit, OpArg}, }; fn assert_scope_exit_locations(code: &CodeObject) { for (instr, (location, _)) in code.instructions.iter().zip(code.locations.iter()) { if matches!( - instr.op.into(), - Opcode::ReturnValue | Opcode::RaiseVarargs | Opcode::Reraise + instr.op, + Instruction::ReturnValue + | Instruction::RaiseVarargs { .. } + | Instruction::Reraise { .. } ) { assert!( location.line.get() > 0, @@ -12201,7 +13777,7 @@ mod tests { compile_exec_with_options(source, opts) } - fn compile_exec_with_options(source: &str, opts: CompileOpts) -> CodeObject { + fn compile_exec_with_options(source: &str, mut opts: CompileOpts) -> CodeObject { let source_file = SourceFileBuilder::new("source_path", source).finish(); let parsed = ruff_python_parser::parse( source_file.source_text(), @@ -12209,175 +13785,1007 @@ mod tests { ) .unwrap(); let mut ast = parsed.into_syntax(); - preprocess::preprocess_mod(&mut ast); + opts.future_features |= checked_future_features(&ast, &source_file).unwrap(); + let future_annotations = opts + .future_features + .contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS); + preprocess::preprocess_mod(&mut ast, opts.optimize, future_annotations, false); let ast = match ast { ruff_python_ast::Mod::Module(stmts) => stmts, _ => unreachable!(), }; - let symbol_table = SymbolTable::scan_program(&ast, source_file.clone()) - .map_err(|e| e.into_codegen_error(source_file.name().to_owned())) - .unwrap(); - let mut compiler = Compiler::new(opts, source_file, ""); + let symbol_table = SymbolTable::scan_program_with_options( + &ast, + source_file.clone(), + opts.allow_top_level_await, + opts.future_features + .contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS), + opts.recursion_limit, + ) + .map_err(|e| e.into_codegen_error(source_file.name().to_owned())) + .unwrap(); + let mut compiler = + Compiler::new_with_syntax_warning_handler(opts, source_file, "", None); compiler.compile_program(&ast, symbol_table).unwrap(); compiler.exit_scope() } - #[test] - fn empty_module_implicit_return_inherits_resume_location_like_cpython() { - let code = compile_exec(""); - // CPython 3.14 codegen emits the implicit LOAD_CONST/RETURN_VALUE with - // NO_LOCATION, then flowgraph.c::propagate_line_numbers() propagates - // the module RESUME location, whose line is 0. - assert_eq!(code.linetable.as_ref(), &[0xf2, 0x03, 0x01, 0x01, 0x01]); - } - - #[test] - fn redundant_nop_location_copies_full_location_like_cpython() { - let code = compile_exec( - "\ -def f(x, y, z): - while x: - if y: - pass - elif z: - if y < 0: - return y - if z: - y = y + 1 - elif y: - return 1 - return -1 -", - ); - let f = find_code(&code, "f").expect("missing function code"); - assert_eq!( - f.linetable.as_ref(), - &[ - 0x80, 0x00, 0xdf, 0x0a, 0x0b, 0xdf, 0x0b, 0x0c, 0xd9, 0x0c, 0x10, 0xdf, 0x0d, 0x0e, - 0xd8, 0x0f, 0x10, 0x90, 0x31, 0x8c, 0x75, 0xd8, 0x17, 0x18, 0x90, 0x08, 0xdf, 0x0f, - 0x10, 0xd8, 0x14, 0x15, 0x98, 0x01, 0x95, 0x45, 0x92, 0x01, 0xf1, 0x03, 0x00, 0x10, - 0x11, 0xe7, 0x0d, 0x0e, 0x89, 0x51, 0xd9, 0x13, 0x14, 0xd8, 0x0b, 0x0d, 0x80, 0x49, - ], - "CPython basicblock_remove_redundant_nops() copies the full NOP location into a following no-location jump" - ); - } - - fn scan_program_symbol_table(source: &str) -> SymbolTable { + fn compile_module_instruction_infos(source: &str, mode: Mode) -> Vec { + let mut opts = CompileOpts::default(); let source_file = SourceFileBuilder::new("source_path", source).finish(); let parsed = ruff_python_parser::parse( source_file.source_text(), ruff_python_parser::Mode::Module.into(), ) .unwrap(); - let ast = parsed.into_syntax(); + let mut ast = parsed.into_syntax(); + opts.future_features |= checked_future_features(&ast, &source_file).unwrap(); + let future_annotations = opts + .future_features + .contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS); + if matches!(mode, Mode::Single) + && let ruff_python_ast::Mod::Module(module) = &mut ast + { + preprocess::preprocess_statements( + &mut module.body, + opts.optimize, + future_annotations, + false, + ); + } else { + preprocess::preprocess_mod(&mut ast, opts.optimize, future_annotations, false); + } let ast = match ast { ruff_python_ast::Mod::Module(stmts) => stmts, _ => unreachable!(), }; - SymbolTable::scan_program(&ast, source_file) - .map_err(|e| e.into_codegen_error("source_path".to_owned())) - .unwrap() - } - - fn find_symbol_table<'a>(table: &'a SymbolTable, name: &str) -> Option<&'a SymbolTable> { - if table.name == name { - return Some(table); + let symbol_table = SymbolTable::scan_program_with_options( + &ast, + source_file.clone(), + opts.allow_top_level_await, + opts.future_features + .contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS), + opts.recursion_limit, + ) + .map_err(|e| e.into_codegen_error(source_file.name().to_owned())) + .unwrap(); + let mut compiler = + Compiler::new_with_syntax_warning_handler(opts, source_file, "", None); + match mode { + Mode::Single => compiler.compile_program_single(&ast.body, symbol_table), + _ => compiler.compile_program(&ast, symbol_table), } - table - .sub_tables + .unwrap(); + + compiler + .current_code_info() + .blocks .iter() - .find_map(|sub_table| find_symbol_table(sub_table, name)) + .flat_map(|block| block.used_instructions().iter().copied()) + .collect() } - fn compile_exec_late_cfg_trace(source: &str) -> Vec<(String, String)> { + fn compile_eval_ast_with_options(expr: ast::Expr, opts: CompileOpts) -> CodeObject { + let source_file = SourceFileBuilder::new("source_path", "").finish(); + let parsed = ruff_python_ast::Mod::Expression(ast::ModExpression { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + body: Box::new(expr), + }); + compile_top(parsed, source_file, Mode::Eval, opts).unwrap() + } + + fn set_ast_constant(expr: &mut ast::Expr, constant: ConstantData) { + let constant = crate::constant_data_to_ast_constant_value(constant); + let range = expr.range(); + *expr = ast::Expr::Constant(ast::ExprConstant { + node_index: Default::default(), + range, + value: constant, + kind: None, + invalid_type: None, + }); + } + + fn compile_ast_constant_expr(mut expr: ast::Expr, constant: ConstantData) -> CodeObject { + set_ast_constant(&mut expr, constant); + compile_eval_ast_with_options(expr, CompileOpts::default()) + } + + fn first_ast_constant_warning(expr: ast::Expr) -> String { + let opts = CompileOpts::default(); + let source_file = SourceFileBuilder::new("source_path", "").finish(); + let parsed = ruff_python_ast::Mod::Expression(ast::ModExpression { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + body: Box::new(expr), + }); + let mut warning = None; + let mut handler = |location, message: String| { + warning = Some(message.clone()); + Err(CodegenError { + location: Some(location), + error: CodegenErrorType::SyntaxError(message), + source_path: "source_path".to_owned(), + }) + }; + compile_top_with_syntax_warning_handler( + parsed, + source_file, + Mode::Eval, + opts, + Some(&mut handler), + ) + .expect_err("expected SyntaxWarning handler to stop compilation"); + warning.expect("expected warning message") + } + + fn first_exec_warning(source: &str) -> String { let opts = CompileOpts::default(); let source_file = SourceFileBuilder::new("source_path", source).finish(); let parsed = ruff_python_parser::parse( source_file.source_text(), ruff_python_parser::Mode::Module.into(), ) - .unwrap(); - let ast = parsed.into_syntax(); + .unwrap() + .into_syntax(); + let mut warning = None; + let mut handler = |location, message: String| { + warning = Some(message.clone()); + Err(CodegenError { + location: Some(location), + error: CodegenErrorType::SyntaxError(message), + source_path: "source_path".to_owned(), + }) + }; + compile_top_with_syntax_warning_handler( + parsed, + source_file, + Mode::Exec, + opts, + Some(&mut handler), + ) + .expect_err("expected SyntaxWarning handler to stop compilation"); + warning.expect("expected warning message") + } + + fn frozenset_call_expr() -> ast::Expr { + ast::Expr::Call(ast::ExprCall { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + func: Box::new(ast::Expr::Name(ast::ExprName { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + id: ast::name::Name::new_static("frozenset"), + ctx: ast::ExprContext::Load, + })), + arguments: ast::Arguments { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + args: Box::default(), + keywords: Default::default(), + runtime_args: None, + runtime_bases: None, + }, + }) + } + + fn compile_exec_parsed_error( + source: &str, + parsed: ruff_python_parser::Parsed, + ) -> CodegenError { + let mut opts = CompileOpts::default(); + let source_file = SourceFileBuilder::new("source_path", source).finish(); + let mut ast = parsed.into_syntax(); + opts.future_features |= match checked_future_features(&ast, &source_file) { + Ok(features) => features, + Err(err) => return err, + }; + let future_annotations = opts + .future_features + .contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS); + preprocess::preprocess_mod(&mut ast, opts.optimize, future_annotations, false); let ast = match ast { ruff_python_ast::Mod::Module(stmts) => stmts, _ => unreachable!(), }; - let symbol_table = SymbolTable::scan_program(&ast, source_file.clone()) - .map_err(|e| e.into_codegen_error(source_file.name().to_owned())) - .unwrap(); - let mut compiler = Compiler::new(opts, source_file, ""); - compiler.compile_program(&ast, symbol_table).unwrap(); - let _table = compiler.pop_symbol_table(); - let stack_top = compiler.code_stack.pop().unwrap(); - stack_top.debug_late_cfg_trace().unwrap() + let symbol_table = match SymbolTable::scan_program(&ast, source_file.clone()) { + Ok(symbol_table) => symbol_table, + Err(err) => return err.into_codegen_error(source_file.name().to_owned()), + }; + let mut compiler = + Compiler::new_with_syntax_warning_handler(opts, source_file, "", None); + compiler.compile_program(&ast, symbol_table).unwrap_err() } - fn compile_single_function_late_cfg_trace( - source: &str, - function_name: &str, - ) -> Vec<(String, String)> { - let opts = CompileOpts::default(); + fn compile_exec_error(source: &str) -> CodegenError { let source_file = SourceFileBuilder::new("source_path", source).finish(); let parsed = ruff_python_parser::parse( source_file.source_text(), ruff_python_parser::Mode::Module.into(), ) .unwrap(); - let ast = parsed.into_syntax(); - let ast = match ast { - ruff_python_ast::Mod::Module(stmts) => stmts, - _ => unreachable!(), - }; - let mut symbol_table = SymbolTable::scan_program(&ast, source_file.clone()) - .map_err(|e| e.into_codegen_error(source_file.name().to_owned())) - .unwrap(); - let function = ast - .body - .iter() - .find_map(|stmt| match stmt { - ast::Stmt::FunctionDef(f) if f.name.as_str() == function_name => Some(f), - _ => None, - }) - .unwrap_or_else(|| panic!("missing function {function_name}")); - symbol_table.next_sub_table = symbol_table - .sub_tables - .iter() - .position(|table| table.name == function_name) - .unwrap_or_else(|| panic!("missing symbol table for {function_name}")); + compile_exec_parsed_error(source, parsed) + } - let name = &function.name; - let parameters = &function.parameters; - let body = &function.body; - let is_async = function.is_async; - let range = function.range(); + fn compile_exec_error_message(source: &str) -> String { + compile_exec_error(source).error.to_string() + } - let mut compiler = Compiler::new(opts, source_file, ""); - compiler.future_annotations = symbol_table.future_annotations; - compiler.symbol_table_stack.push(symbol_table); - compiler.set_source_range(range); - compiler.enter_function(name.as_str(), parameters).unwrap(); - compiler - .current_code_info() - .flags - .set(CodeFlags::COROUTINE, is_async); + fn compile_exec_unchecked_error_message(source: &str) -> String { + let source_file = SourceFileBuilder::new("source_path", source).finish(); + let parsed = ruff_python_parser::parse_unchecked( + source_file.source_text(), + ruff_python_parser::Mode::Module.into(), + ); + compile_exec_parsed_error(source, parsed).error.to_string() + } - let prev_ctx = compiler.ctx; - compiler.ctx = CompileContext { - in_class: prev_ctx.in_class, - func: if is_async { - FunctionContext::AsyncFunction - } else { - FunctionContext::Function + #[test] + fn ast_constant_frozenset_compiles_as_load_const() { + let code = compile_ast_constant_expr( + frozenset_call_expr(), + ConstantData::Frozenset { + elements: vec![ConstantData::Integer { + value: BigInt::from(1u8), + }], }, - in_async_scope: is_async, + ); + let ops: Vec<_> = code + .instructions + .iter() + .map(|unit| unit.op) + .filter(|op| !matches!(op, Instruction::Cache)) + .collect(); + + assert!( + ops.iter() + .any(|op| matches!(op, Instruction::LoadConst { .. })), + "public ast.Constant(frozenset(...)) must use CPython Constant_kind LOAD_CONST path, got {ops:?}" + ); + assert!( + !ops.iter().any(|op| matches!( + op, + Instruction::LoadName { .. } + | Instruction::Call { .. } + | Instruction::CallKw { .. } + )), + "public ast.Constant(frozenset(...)) must not compile as a frozenset() call, got {ops:?}" + ); + assert!( + code.constants.iter().any(|constant| matches!( + constant, + ConstantData::Frozenset { elements } + if matches!( + elements.as_slice(), + [ConstantData::Integer { value }] if *value == BigInt::from(1u8) + ) + )), + "missing frozenset constant in code constants" + ); + } + + #[test] + fn ast_constant_is_not_scanned_as_lowered_expression() { + let mut expr = frozenset_call_expr(); + set_ast_constant( + &mut expr, + ConstantData::Frozenset { + elements: Vec::new(), + }, + ); + let module = ast::ModExpression { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + body: Box::new(expr), }; - compiler.set_qualname(); - let (_doc_str, body) = split_doc(body, compiler.opts); - let start_label = compiler.use_cpython_function_start_label(); - let is_gen = is_async || compiler.current_symbol_table().is_generator; - let stop_iteration_block = if is_gen { - let handler_block = compiler.new_block(); - compiler.insert_cpython_stopiteration_setup_cleanup(handler_block); + let table = SymbolTable::scan_expr_with_options( + &module, + SourceFileBuilder::new("source_path", "").finish(), + false, + false, + CompileOpts::default().recursion_limit, + ) + .unwrap(); + + assert!( + table.lookup("frozenset").is_none(), + "CPython symtable Constant_kind does not visit the lowered frozenset() expression" + ); + } + + #[test] + fn ast_constant_frozenset_call_warns_like_cpython_constant() { + let mut func = frozenset_call_expr(); + set_ast_constant( + &mut func, + ConstantData::Frozenset { + elements: Vec::new(), + }, + ); + let message = first_ast_constant_warning(ast::Expr::Call(ast::ExprCall { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + func: Box::new(func), + arguments: ast::Arguments { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + args: Box::default(), + keywords: Default::default(), + runtime_args: None, + runtime_bases: None, + }, + })); + assert!( + message.contains("'frozenset' object is not callable"), + "expected public ast.Constant(frozenset()) callable warning, got {message:?}" + ); + } + + #[test] + fn ast_constant_frozenset_subscript_warns_like_cpython_constant() { + let mut value = frozenset_call_expr(); + set_ast_constant( + &mut value, + ConstantData::Frozenset { + elements: Vec::new(), + }, + ); + let message = first_ast_constant_warning(ast::Expr::Subscript(ast::ExprSubscript { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + value: Box::new(value), + slice: Box::new(ast::Expr::NumberLiteral(ast::ExprNumberLiteral { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + value: ast::Number::Int(ast::Int::ZERO), + })), + ctx: ast::ExprContext::Load, + })); + assert!( + message.contains("'frozenset' object is not subscriptable"), + "expected public ast.Constant(frozenset()) subscript warning, got {message:?}" + ); + } + + #[test] + fn ast_constant_str_bad_index_warns_like_cpython_constant() { + let mut value = frozenset_call_expr(); + set_ast_constant( + &mut value, + ConstantData::Str { + value: "abc".into(), + }, + ); + let message = first_ast_constant_warning(ast::Expr::Subscript(ast::ExprSubscript { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + value: Box::new(value), + slice: Box::new(ast::Expr::NumberLiteral(ast::ExprNumberLiteral { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + value: ast::Number::Float(1.0), + })), + ctx: ast::ExprContext::Load, + })); + assert!( + message.contains("str indices must be integers or slices, not float"), + "expected public ast.Constant(str) bad-index warning, got {message:?}" + ); + } + + #[test] + fn ast_constant_frozenset_is_warns_like_cpython_constant() { + let mut left = frozenset_call_expr(); + set_ast_constant( + &mut left, + ConstantData::Frozenset { + elements: Vec::new(), + }, + ); + let message = first_ast_constant_warning(ast::Expr::Compare(ast::ExprCompare { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + left: Box::new(left), + ops: Box::new([ast::CmpOp::Is]), + comparators: Box::new([ast::Expr::NoneLiteral(ast::ExprNoneLiteral { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + })]), + runtime_comparators: None, + })); + assert!( + message.contains("\"is\" with 'frozenset' literal"), + "expected public ast.Constant(frozenset()) identity warning, got {message:?}" + ); + } + + #[test] + fn ast_constant_tuple_compiles_as_load_const() { + let expr = ast::Expr::Tuple(ast::ExprTuple { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + elts: Vec::new(), + ctx: ast::ExprContext::Load, + parenthesized: true, + runtime_elts: None, + }); + let code = compile_ast_constant_expr( + expr, + ConstantData::Tuple { + elements: vec![ConstantData::Integer { + value: BigInt::from(1u8), + }], + }, + ); + let ops: Vec<_> = code + .instructions + .iter() + .map(|unit| unit.op) + .filter(|op| !matches!(op, Instruction::Cache)) + .collect(); + + assert!( + ops.iter() + .any(|op| matches!(op, Instruction::LoadConst { .. })), + "public ast.Constant(tuple(...)) must use CPython Constant_kind LOAD_CONST path, got {ops:?}" + ); + assert!( + !ops.iter() + .any(|op| matches!(op, Instruction::BuildTuple { .. })), + "public ast.Constant(tuple(...)) must not compile as a tuple display, got {ops:?}" + ); + assert!( + code.constants.iter().any(|constant| matches!( + constant, + ConstantData::Tuple { elements } + if matches!( + elements.as_slice(), + [ConstantData::Integer { value }] if *value == BigInt::from(1u8) + ) + )), + "missing tuple constant in code constants" + ); + } + + #[test] + fn ast_constant_slice_bound_uses_cpython_constant_slice_path() { + let mut lower = frozenset_call_expr(); + set_ast_constant( + &mut lower, + ConstantData::Integer { + value: BigInt::from(1u8), + }, + ); + let expr = ast::Expr::Subscript(ast::ExprSubscript { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + value: Box::new(ast::Expr::Name(ast::ExprName { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + id: ast::name::Name::new_static("obj"), + ctx: ast::ExprContext::Load, + })), + slice: Box::new(ast::Expr::Slice(ast::ExprSlice { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + lower: Some(Box::new(lower)), + upper: Some(Box::new(ast::Expr::NumberLiteral(ast::ExprNumberLiteral { + node_index: ast::AtomicNodeIndex::NONE, + range: TextRange::default(), + value: ast::Number::Int(ast::Int::ZERO), + }))), + step: None, + })), + ctx: ast::ExprContext::Load, + }); + let code = compile_eval_ast_with_options(expr, CompileOpts::default()); + let ops: Vec<_> = code + .instructions + .iter() + .map(|unit| unit.op) + .filter(|op| !matches!(op, Instruction::Cache)) + .collect(); + assert!( + !ops.iter().any(|op| matches!( + op, + Instruction::BinarySlice | Instruction::BuildSlice { .. } + )), + "public ast.Constant slice bound must follow CPython Constant_kind folded slice path, got {ops:?}" + ); + assert!( + code.constants.iter().any(|constant| matches!( + constant, + ConstantData::Slice { elements } + if matches!( + elements.as_ref(), + [ + ConstantData::Integer { value }, + ConstantData::Integer { .. }, + ConstantData::None, + ] if *value == BigInt::from(1u8) + ) + )), + "missing folded slice constant for public ast.Constant bound" + ); + } + + #[test] + fn match_pattern_errors_use_cpython_sequence_messages() { + let many_names = (0..256) + .map(|i| format!("a{i}")) + .collect::>() + .join(", "); + let too_many = format!( + "\ +match x: + case [{many_names}, *rest]: + pass +" + ); + assert_eq!( + compile_exec_error_message(&too_many), + "too many expressions in star-unpacking sequence pattern" + ); + + assert_eq!( + compile_exec_error_message( + "\ +match x: + case [*a, *b]: + pass +" + ), + "multiple starred names in sequence pattern" + ); + + assert_eq!( + compile_exec_unchecked_error_message( + "\ +match x: + case {**_}: + pass +" + ), + "invalid syntax" + ); + } + + #[test] + fn match_mapping_duplicate_literal_keys_use_cpython_equality() { + for (source, expected) in [ + ( + "\ +match x: + case {1: a, True: b}: + pass +", + "mapping pattern checks duplicate key (True)", + ), + ( + "\ +match x: + case {1: a, 1.0: b}: + pass +", + "mapping pattern checks duplicate key (1.0)", + ), + ( + "\ +match x: + case {0.0: a, -0.0: b}: + pass +", + "mapping pattern checks duplicate key (-0.0)", + ), + ( + "\ +match x: + case {9007199254740992: a, 9007199254740992.0: b}: + pass +", + "mapping pattern checks duplicate key (9007199254740992.0)", + ), + ( + "\ +match x: + case {-9007199254740992: a, -9007199254740992.0: b}: + pass +", + "mapping pattern checks duplicate key (-9007199254740992.0)", + ), + ( + "\ +match x: + case {1 + 0j: a, 1: b}: + pass +", + "mapping pattern checks duplicate key (1)", + ), + ( + "\ +match x: + case {1: a, 1 + 0j: b}: + pass +", + "mapping pattern checks duplicate key ((1+0j))", + ), + ( + "\ +match x: + case {0j: a, -0.0: b}: + pass +", + "mapping pattern checks duplicate key (-0.0)", + ), + ] { + assert_eq!(compile_exec_error_message(source), expected); + } + } + + #[test] + fn match_mapping_accepts_folded_literal_keys_like_cpython() { + compile_exec( + "\ +def f(x): + match x: + case {-1: a, 1 + 0j: b}: + return a, b + case {9007199254740993: a, 9007199254740992.0: b}: + return a, b + case {-9007199254740993: a, -9007199254740992.0: b}: + return a, b + case {1 + 1j: a, 1: b}: + return a, b + case _: + return None +", + ); + } + + #[test] + fn match_mapping_accepts_public_ast_constant_keys_like_cpython() { + let source = "\ +match x: + case {'a': _, b'b': _, 2: _, 1.5: _, 1j: _, True: _, None: _}: + pass +"; + let source_file = SourceFileBuilder::new("source_path", source).finish(); + let parsed = ruff_python_parser::parse( + source_file.source_text(), + ruff_python_parser::Mode::Module.into(), + ) + .unwrap(); + let mut ast = parsed.into_syntax(); + let ast::Mod::Module(module) = &mut ast else { + unreachable!(); + }; + let ast::Stmt::Match(match_stmt) = &mut module.body[0] else { + unreachable!(); + }; + let ast::Pattern::MatchMapping(mapping) = &mut match_stmt.cases[0].pattern else { + unreachable!(); + }; + + for (key, value) in mapping.keys.iter_mut().zip([ + ast::ConstantValue::Str("a".into()), + ast::ConstantValue::Bytes(vec![b'b'].into_boxed_slice()), + ast::ConstantValue::Integer("2".into()), + ast::ConstantValue::Float(1.5), + ast::ConstantValue::Complex { + real: 0.0, + imag: 1.0, + }, + ast::ConstantValue::Boolean(true), + ast::ConstantValue::None, + ]) { + let range = key.range(); + *key = ast::Expr::Constant(ast::ExprConstant { + node_index: ast::AtomicNodeIndex::NONE, + range, + value, + kind: None, + invalid_type: None, + }); + } + + compile_top(ast, source_file, Mode::Exec, CompileOpts::default()).unwrap(); + } + + #[test] + fn match_literal_binop_folding_uses_cpython_complex_shape() { + assert!( + Compiler::try_fold_match_pattern_binop( + ast::Operator::Add, + &ConstantData::Integer { + value: BigInt::from(1) + }, + &ConstantData::Integer { + value: BigInt::from(2) + }, + ) + .is_none() + ); + assert!( + Compiler::try_fold_match_pattern_binop( + ast::Operator::Add, + &ConstantData::Float { value: 1.0 }, + &ConstantData::Float { value: 2.0 }, + ) + .is_none() + ); + assert!(matches!( + Compiler::try_fold_match_pattern_binop( + ast::Operator::Add, + &ConstantData::Integer { + value: BigInt::from(1) + }, + &ConstantData::Complex { + value: Complex::new(0.0, 2.0) + }, + ), + Some(ConstantData::Complex { value }) if value == Complex::new(1.0, 2.0) + )); + assert!(matches!( + Compiler::try_fold_match_pattern_binop( + ast::Operator::Sub, + &ConstantData::Float { value: 1.5 }, + &ConstantData::Complex { + value: Complex::new(0.0, 2.0) + }, + ), + Some(ConstantData::Complex { value }) if value == Complex::new(1.5, -2.0) + )); + } + + #[test] + fn match_literal_patterns_reject_unexpected_constants_like_cpython() { + assert!(Compiler::is_unexpected_match_literal_constant( + &ast::ExprEllipsisLiteral { + range: TextRange::default(), + node_index: ast::AtomicNodeIndex::NONE, + } + .into() + )); + } + + #[test] + fn unpack_ex_allows_large_after_count_like_cpython() { + let suffix = (0..256) + .map(|i| format!("a{i}")) + .collect::>() + .join(", "); + let code = compile_exec(&format!( + "\ +def assignment(values): + *rest, {suffix} = values + return a255 + +def pattern(values): + match values: + case [*rest, {suffix}]: + return a255 + case _: + return None +" + )); + + let assignment = find_code(&code, "assignment").expect("missing assignment code"); + assert_eq!( + full_opargs_for(assignment, |op| matches!(op, Instruction::UnpackEx { .. })), + vec![256 << 8] + ); + + let pattern = find_code(&code, "pattern").expect("missing pattern code"); + assert_eq!( + full_opargs_for(pattern, |op| matches!(op, Instruction::UnpackEx { .. })), + vec![256 << 8] + ); + } + + #[test] + fn match_irrefutable_pattern_errors_use_cpython_messages() { + assert_eq!( + compile_exec_error_message( + "\ +match x: + case y | 1: + pass +" + ), + "name capture 'y' makes remaining patterns unreachable" + ); + + assert_eq!( + compile_exec_error_message( + "\ +match x: + case _ | 1: + pass +" + ), + "wildcard makes remaining patterns unreachable" + ); + } + + #[test] + fn empty_module_implicit_return_inherits_resume_location_like_cpython() { + let code = compile_exec(""); + // codegen emits the implicit LOAD_CONST/RETURN_VALUE with + // NO_LOCATION, then flowgraph.c::propagate_line_numbers() propagates + // the module RESUME location, whose line is 0. + assert_eq!(code.linetable.as_ref(), &[0xf2, 0x03, 0x01, 0x01, 0x01]); + } + + #[test] + fn module_docstring_load_uses_doc_location_like_cpython() { + let code = compile_exec( + "\ +\"doc\" +x = 1 +", + ); + + // codegen_body() emits the docstring LOAD_CONST at the + // string expression location, then emits STORE_NAME __doc__ with + // NO_LOCATION. + assert_eq!( + code.linetable.as_ref(), + &[ + 0xf0, 0x03, 0x01, 0x01, 0x01, 0xd9, 0x00, 0x05, 0xd8, 0x04, 0x05, 0x82, 0x01, + ], + ); + } + + #[test] + fn redundant_nop_location_copies_full_location_like_cpython() { + let code = compile_exec( + "\ +def f(x, y, z): + while x: + if y: + pass + elif z: + if y < 0: + return y + if z: + y = y + 1 + elif y: + return 1 + return -1 +", + ); + let f = find_code(&code, "f").expect("missing function code"); + assert_eq!( + f.linetable.as_ref(), + &[ + 0x80, 0x00, 0xdf, 0x0a, 0x0b, 0xdf, 0x0b, 0x0c, 0xd9, 0x0c, 0x10, 0xdf, 0x0d, 0x0e, + 0xd8, 0x0f, 0x10, 0x90, 0x31, 0x8c, 0x75, 0xd8, 0x17, 0x18, 0x90, 0x08, 0xdf, 0x0f, + 0x10, 0xd8, 0x14, 0x15, 0x98, 0x01, 0x95, 0x45, 0x92, 0x01, 0xf1, 0x03, 0x00, 0x10, + 0x11, 0xe7, 0x0d, 0x0e, 0x89, 0x51, 0xd9, 0x13, 0x14, 0xd8, 0x0b, 0x0d, 0x80, 0x49, + ], + "CPython basicblock_remove_redundant_nops() copies the full NOP location into a following no-location jump" + ); + } + + fn scan_program_symbol_table(source: &str) -> SymbolTable { + let source_file = SourceFileBuilder::new("source_path", source).finish(); + let parsed = ruff_python_parser::parse( + source_file.source_text(), + ruff_python_parser::Mode::Module.into(), + ) + .unwrap(); + let ast = parsed.into_syntax(); + let ast = match ast { + ruff_python_ast::Mod::Module(stmts) => stmts, + _ => unreachable!(), + }; + SymbolTable::scan_program(&ast, source_file) + .map_err(|e| e.into_codegen_error("source_path".to_owned())) + .unwrap() + } + + fn find_symbol_table<'a>(table: &'a SymbolTable, name: &str) -> Option<&'a SymbolTable> { + if table.name == name { + return Some(table); + } + table + .sub_tables + .iter() + .find_map(|sub_table| find_symbol_table(sub_table, name)) + } + + fn compile_exec_late_cfg_trace(source: &str) -> Vec<(String, String)> { + let opts = CompileOpts::default(); + let source_file = SourceFileBuilder::new("source_path", source).finish(); + let parsed = ruff_python_parser::parse( + source_file.source_text(), + ruff_python_parser::Mode::Module.into(), + ) + .unwrap(); + let ast = parsed.into_syntax(); + let ast = match ast { + ruff_python_ast::Mod::Module(stmts) => stmts, + _ => unreachable!(), + }; + let symbol_table = SymbolTable::scan_program(&ast, source_file.clone()) + .map_err(|e| e.into_codegen_error(source_file.name().to_owned())) + .unwrap(); + let mut compiler = + Compiler::new_with_syntax_warning_handler(opts, source_file, "", None); + compiler.compile_program(&ast, symbol_table).unwrap(); + compiler.pop_symbol_table(); + let stack_top = compiler.code_stack.pop().unwrap(); + stack_top.debug_late_cfg_trace().unwrap() + } + + fn compile_single_function_late_cfg_trace( + source: &str, + function_name: &str, + ) -> Vec<(String, String)> { + let opts = CompileOpts::default(); + let source_file = SourceFileBuilder::new("source_path", source).finish(); + let parsed = ruff_python_parser::parse( + source_file.source_text(), + ruff_python_parser::Mode::Module.into(), + ) + .unwrap(); + let ast = parsed.into_syntax(); + let ast = match ast { + ruff_python_ast::Mod::Module(stmts) => stmts, + _ => unreachable!(), + }; + let mut symbol_table = SymbolTable::scan_program(&ast, source_file.clone()) + .map_err(|e| e.into_codegen_error(source_file.name().to_owned())) + .unwrap(); + let function = ast + .body + .iter() + .find_map(|stmt| match stmt { + ast::Stmt::FunctionDef(f) if f.name.as_str() == function_name => Some(f), + _ => None, + }) + .unwrap_or_else(|| panic!("missing function {function_name}")); + symbol_table.next_sub_table = symbol_table + .sub_tables + .iter() + .position(|table| table.name == function_name) + .unwrap_or_else(|| panic!("missing symbol table for {function_name}")); + + let name = &function.name; + let parameters = &function.parameters; + let body = &function.body; + let is_async = function.is_async; + let range = function.range(); + + let mut compiler = + Compiler::new_with_syntax_warning_handler(opts, source_file, "", None); + compiler.future_annotations = symbol_table.future_annotations; + compiler.symbol_table_stack.push(symbol_table); + compiler.set_source_range(range); + compiler.enter_function(name.as_str(), parameters).unwrap(); + compiler + .current_code_info() + .flags + .set(bytecode::CodeFlags::COROUTINE, is_async); + + let prev_ctx = compiler.ctx; + compiler.ctx = CompileContext { + in_class: prev_ctx.in_class, + func: if is_async { + FunctionContext::AsyncFunction + } else { + FunctionContext::Function + }, + in_async_scope: is_async, + }; + compiler.set_qualname(); + let (_, body) = split_doc(body, &compiler.opts); + let start_label = compiler.use_cpython_function_start_label(); + let is_gen = is_async || compiler.current_symbol_table().is_generator; + let stop_iteration_block = if is_gen { + let handler_block = compiler.new_block(); + compiler.insert_cpython_stopiteration_setup_cleanup(handler_block); compiler .push_fblock_labels( FBlockType::StopIteration, @@ -12412,7 +14820,7 @@ def f(x, y, z): compiler.set_no_location(); } - let _table = compiler.pop_symbol_table(); + compiler.pop_symbol_table(); let stack_top = compiler.code_stack.pop().unwrap(); stack_top.debug_late_cfg_trace().unwrap() } @@ -12454,118 +14862,6 @@ def f(arch): ); } - #[test] - fn debug_trace_make_dataclass_borrow_tail() { - let trace = compile_single_function_late_cfg_trace( - r#" -def f(module, cls, decorator, init, repr, eq, order, unsafe_hash, frozen, match_args, kw_only, slots, weakref_slot): - if module is None: - try: - module = sys._getframemodulename(1) or '__main__' - except AttributeError: - try: - module = sys._getframe(1).f_globals.get('__name__', '__main__') - except (AttributeError, ValueError): - pass - if module is not None: - cls.__module__ = module - cls = decorator(cls, init=init, repr=repr, eq=eq, order=order, - unsafe_hash=unsafe_hash, frozen=frozen, - match_args=match_args, kw_only=kw_only, slots=slots, - weakref_slot=weakref_slot) - return cls -"#, - "f", - ); - for (label, dump) in trace { - if label.starts_with("after_") { - eprintln!("=== {label} ===\n{dump}"); - } - } - } - - #[test] - fn debug_trace_protected_attr_subscript_tail() { - let trace = compile_single_function_late_cfg_trace( - r#" -def f(f, oldcls, newcls): - try: - idx = f.__code__.co_freevars.index("__class__") - except ValueError: - return False - closure = f.__closure__[idx] - if closure.cell_contents is oldcls: - closure.cell_contents = newcls - return True - return False -"#, - "f", - ); - for (label, dump) in trace { - if label.starts_with("after_") { - eprintln!("=== {label} ===\n{dump}"); - } - } - } - - #[test] - fn debug_trace_dtrace_tail() { - let trace = compile_single_function_late_cfg_trace( - r#" -def f(proc, unittest): - try: - with proc: - version, stderr = proc.communicate() - if proc.returncode: - raise Exception(version, stderr) - except OSError: - raise unittest.SkipTest("x") - match = re.search("pat", version) - if match is None: - raise unittest.SkipTest(f"Unable to parse readelf version: {version}") - return int(match.group(1)), int(match.group(2)) -"#, - "f", - ); - for (label, dump) in trace { - if label == "after_optimize_load_fast" - || label.contains("deoptimize_borrow_in_protected_conditional_tail") - { - eprintln!("=== {label} ===\n{dump}"); - } - } - } - - #[test] - fn debug_trace_colorize_tail() { - let trace = compile_single_function_late_cfg_trace( - r#" -def f(sys, os, file): - if sys.platform == "win32": - try: - import nt - if not nt._supports_virtual_terminal(): - return False - except (ImportError, AttributeError): - return False - - try: - return os.isatty(file.fileno()) - except OSError: - return hasattr(file, "isatty") and file.isatty() -"#, - "f", - ); - for (label, dump) in trace { - if label == "after_optimize_load_fast" - || label == "after_deoptimize_borrow_after_protected_import" - || label == "after_borrow_deopts" - { - eprintln!("=== {label} ===\n{dump}"); - } - } - } - #[test] fn for_try_except_break_keeps_cpython_if_layout() { let code = compile_exec( @@ -14289,73 +16585,188 @@ def g(): // for the parent MAKE_FUNCTION annotate sequence. assert_eq!(g.linetable.as_ref(), &[0x80, 0x00, 0xdf, 0x04, 0x26]); assert_eq!( - annotate.linetable.as_ref(), - &[ - 0x80, 0x00, 0xd7, 0x04, 0x26, 0xd1, 0x04, 0x26, 0x94, 0x23, 0x9c, 0x13, 0xd0, 0x0d, - 0x1d, 0xd1, 0x04, 0x26, - ], + annotate.linetable.as_ref(), + &[ + 0x80, 0x00, 0xd7, 0x04, 0x26, 0xd1, 0x04, 0x26, 0x94, 0x23, 0x9c, 0x13, 0xd0, 0x0d, + 0x1d, 0xd1, 0x04, 0x26, + ], + ); + } + + #[test] + fn starred_arg_annotation_unpack_uses_function_location_like_cpython() { + let code = compile_exec("def f(*args: *Ts): pass\n"); + let annotate = find_code(&code, "__annotate__").expect("missing annotation code"); + + // codegen_argannotation() visits `Ts` at the annotation + // expression location, then emits UNPACK_SEQUENCE at LOC(function). + assert_eq!( + annotate.linetable.as_ref(), + &[ + 0x80, 0x00, 0xd7, 0x00, 0x17, 0xd1, 0x00, 0x17, 0x8c, 0x62, 0xd3, 0x00, 0x17 + ], + ); + } + + #[test] + fn module_deferred_annotations_use_start_location_like_cpython() { + let code = compile_exec( + "\ +import os +X: int +Y: str +", + ); + let annotate = find_code(&code, "__annotate__").expect("missing __annotate__ code"); + + // compile.c::start_location() passes the first module + // statement location into _PyCodegen_Module(), and + // codegen_process_deferred_annotations() uses that loc for annotation + // scope setup, BUILD_MAP, STORE_SUBSCR, and RETURN_VALUE. + assert_eq!( + annotate.linetable.as_ref(), + &[ + 0x80, 0x00, 0x87, 0x09, 0x81, 0x09, 0xdf, 0x00, 0x06, 0x82, 0x06, 0x84, 0x33, 0x81, + 0x06, 0xf1, 0x03, 0x00, 0x01, 0x0a, 0xe7, 0x00, 0x06, 0x82, 0x06, 0x84, 0x33, 0x81, + 0x06, 0xf2, 0x05, 0x00, 0x01, 0x0a, + ] + ); + } + + #[test] + fn super_method_call_kw_names_use_attribute_location_like_cpython() { + let code = compile_exec( + "\ +class C: + def f(self, x, y): + super().__init__( + x=x, + y=y) +", + ); + let f = find_code(&code, "f").expect("missing f code"); + let call_kw_index = f + .instructions + .iter() + .position(|unit| matches!(unit.op, Instruction::CallKw { .. })) + .expect("missing CALL_KW"); + let (kw_names, (location, end_location)) = f + .instructions + .iter() + .zip(&f.locations) + .take(call_kw_index) + .rev() + .find(|(unit, _)| matches!(unit.op, Instruction::LoadConst { .. })) + .expect("missing CALL_KW names tuple"); + + assert!( + matches!(kw_names.op, Instruction::LoadConst { .. }), + "expected keyword names tuple before CALL_KW" + ); + assert_eq!( + (location.line.get(), end_location.line.get()), + (3, 3), + "CPython maybe_optimize_method_call() passes the updated method-attribute loc into codegen_call_simple_kw_helper()" + ); + } + + #[test] + fn multiline_super_method_load_uses_expression_start_location_like_cpython() { + let code = compile_exec( + "\ +class C: + def f(self): + return super( + ).m() +", + ); + let f = find_code(&code, "f").expect("missing f code"); + let load_super_index = f + .instructions + .iter() + .position(|unit| match unit.op { + Instruction::LoadSuperAttr { namei } => namei + .get(OpArg::new(u32::from(u8::from(unit.arg)))) + .is_load_method(), + _ => false, + }) + .expect("missing LOAD_SUPER_METHOD"); + let (load_location, _) = f.locations[load_super_index]; + + assert_eq!( + load_location.line.get(), + 3, + "CPython maybe_optimize_method_call() emits LOAD_SUPER_METHOD at LOC(meth), before updating to the attribute start" + ); + } + + #[test] + fn multiline_non_ascii_attribute_uses_cpython_unicode_length() { + let code = compile_exec( + "\ +def f(obj): + return (obj + .é) +", + ); + let f = find_code(&code, "f").expect("missing f code"); + let load_attr_position = f + .instructions + .iter() + .zip(&f.locations) + .find_map(|(unit, (location, end_location))| { + matches!(unit.op, Instruction::LoadAttr { .. }).then_some(( + location.line.get(), + location.character_offset.get(), + end_location.line.get(), + end_location.character_offset.get(), + )) + }) + .expect("missing LOAD_ATTR"); + + assert_eq!( + load_attr_position, + (3, 11, 3, 12), + "CPython update_start_location_to_match_attr() subtracts PyUnicode_GET_LENGTH(attr), not the UTF-8 byte length; Rust SourceLocation exposes the resulting columns as one-based" ); } #[test] - fn module_deferred_annotations_use_start_location_like_cpython() { + fn two_arg_super_attr_in_class_body_is_optimized_like_cpython() { let code = compile_exec( "\ -import os -X: int -Y: str +class C: + x = super(C, self).attr ", ); - let annotate = find_code(&code, "__annotate__").expect("missing __annotate__ code"); + let class_code = find_code(&code, "C").expect("missing class code"); - // CPython 3.14 compile.c::start_location() passes the first module - // statement location into _PyCodegen_Module(), and - // codegen_process_deferred_annotations() uses that loc for annotation - // scope setup, BUILD_MAP, STORE_SUBSCR, and RETURN_VALUE. - assert_eq!( - annotate.linetable.as_ref(), - &[ - 0x80, 0x00, 0x87, 0x09, 0x81, 0x09, 0xdf, 0x00, 0x06, 0x82, 0x06, 0x84, 0x33, 0x81, - 0x06, 0xf1, 0x03, 0x00, 0x01, 0x0a, 0xe7, 0x00, 0x06, 0x82, 0x06, 0x84, 0x33, 0x81, - 0x06, 0xf2, 0x05, 0x00, 0x01, 0x0a, - ] + assert!( + class_code + .instructions + .iter() + .any(|unit| matches!(unit.op, Instruction::LoadSuperAttr { .. })), + "CPython can_optimize_super_call() does not require function scope for two-argument super()" ); } #[test] - fn super_method_call_kw_names_use_attribute_location_like_cpython() { + fn module_super_symbol_blocks_zero_arg_super_optimization_like_cpython() { let code = compile_exec( "\ +super class C: - def f(self, x, y): - super().__init__( - x=x, - y=y) + def f(self): + return super().attr ", ); let f = find_code(&code, "f").expect("missing f code"); - let call_kw_index = f - .instructions - .iter() - .position(|unit| matches!(unit.op, Instruction::CallKw { .. })) - .expect("missing CALL_KW"); - let (kw_names, (location, end_location)) = f - .instructions - .iter() - .zip(&f.locations) - .take(call_kw_index) - .rev() - .find(|(unit, _)| matches!(unit.op, Instruction::LoadConst { .. })) - .expect("missing CALL_KW names tuple"); assert!( - matches!(kw_names.op, Instruction::LoadConst { .. }), - "expected keyword names tuple before CALL_KW" - ); - assert_eq!( - (location.line.get(), end_location.line.get()), - (3, 3), - "CPython maybe_optimize_method_call() passes the updated method-attribute loc into codegen_call_simple_kw_helper()" + !f.instructions + .iter() + .any(|unit| matches!(unit.op, Instruction::LoadSuperAttr { .. })), + "CPython can_optimize_super_call() rejects any top-level symbol-table entry for super" ); } @@ -14389,6 +16800,89 @@ def outer(): ); } + #[test] + fn explicit_return_value_locations_match_cpython_codegen_return() { + let code = compile_exec( + "\ +def dynamic(x): + return x + +def constant(): + return 1 + +def bare(): + return +", + ); + + let cases = [ + ("dynamic", vec![(2, 5, 2, 13)]), + ("constant", vec![(5, 12, 5, 13)]), + ("bare", vec![(8, 5, 8, 11)]), + ]; + for (name, expected) in cases { + let function = find_code(&code, name).expect("missing function code"); + let return_positions: Vec<_> = function + .instructions + .iter() + .zip(&function.locations) + .filter_map(|(unit, (location, end_location))| { + matches!(unit.op, Instruction::ReturnValue).then_some(( + location.line.get(), + location.character_offset.get(), + end_location.line.get(), + end_location.character_offset.get(), + )) + }) + .collect(); + + assert_eq!( + return_positions, expected, + "CPython codegen_return() emits explicit return at loc for {name}" + ); + } + } + + #[test] + fn continue_jump_keeps_statement_location_like_cpython() { + let code = compile_exec( + "\ +def continues(xs): + for x in xs: + if x: + continue + use(x) +", + ); + + { + let (name, expected_position) = ("continues", (4, 13, 4, 21)); + let function = find_code(&code, name).expect("missing function code"); + let jump_positions: Vec<_> = function + .instructions + .iter() + .zip(&function.locations) + .filter_map(|(unit, (location, end_location))| { + matches!( + unit.op, + Instruction::JumpForward { .. } | Instruction::JumpBackward { .. } + ) + .then_some(( + location.line.get(), + location.character_offset.get(), + end_location.line.get(), + end_location.character_offset.get(), + )) + }) + .collect(); + + assert!( + jump_positions.contains(&expected_position), + "CPython codegen_continue() emits final jump at statement loc for {name}, got {jump_positions:?}" + ); + } + } + #[test] fn not_compare_uses_unary_location_like_cpython() { let code = compile_exec( @@ -14466,6 +16960,73 @@ def f(c): ); } + #[test] + fn typealias_value_scope_has_single_return_like_cpython() { + let code = compile_exec("type Alias = int\n"); + let alias = find_direct_child_code(&code, "Alias").expect("missing alias code"); + let return_count = alias + .instructions + .iter() + .filter(|unit| matches!(unit.op, Instruction::ReturnValue)) + .count(); + assert_eq!( + return_count, 1, + "CPython codegen_typealias_body() emits one RETURN_VALUE and assembles with addNone=0, got instructions={:?}", + alias.instructions + ); + } + + #[test] + fn generic_typealias_wrapper_return_uses_alias_location_like_cpython() { + let code = compile_exec("type A[T] = T\n"); + let type_params = + find_code(&code, "").expect("missing type params code"); + + // codegen_typealias() assembles the generic-parameters + // wrapper with addNone=0 after codegen_typealias_body() leaves the type + // alias object on the stack. The final RETURN_VALUE keeps LOC(type alias). + assert_eq!( + type_params.linetable.as_ref(), + &[ + 0xf8, 0x80, 0x00, 0x80, 0x0d, 0x84, 0x71, 0x87, 0x0d, 0x81, 0x0d + ], + ); + } + + #[test] + fn type_param_bound_scope_has_single_return_like_cpython() { + let code = compile_exec("type Alias[T: int] = T\n"); + let type_params = + find_code(&code, "").expect("missing type params code"); + let bound = find_direct_child_code(type_params, "T").expect("missing T bound code"); + let return_count = bound + .instructions + .iter() + .filter(|unit| matches!(unit.op, Instruction::ReturnValue)) + .count(); + assert_eq!( + return_count, 1, + "CPython codegen_type_param_bound_or_default() emits one explicit RETURN_VALUE before OptimizeAndAssemble(addNone=1), got instructions={:?}", + bound.instructions + ); + } + + #[test] + fn class_body_scope_has_single_return_like_cpython() { + let code = compile_exec("class C:\n pass\n"); + let class_code = find_code(&code, "C").expect("missing class code"); + let return_count = class_code + .instructions + .iter() + .filter(|unit| matches!(unit.op, Instruction::ReturnValue)) + .count(); + assert_eq!( + return_count, 1, + "CPython codegen_class_body() emits one explicit RETURN_VALUE before OptimizeAndAssemble(addNone=1), got instructions={:?}", + class_code.instructions + ); + } + #[test] fn generic_function_annotation_scope_uses_function_location_like_cpython() { let code = compile_exec("def f[T](x: int): ...\n"); @@ -14485,6 +17046,23 @@ def f(c): ); } + #[test] + fn decorated_generic_function_type_params_use_decorator_firstlineno_like_cpython() { + let code = compile_exec( + "\ +def deco(obj): return obj +@deco +def f[T](): pass +", + ); + let type_params = + find_code(&code, "").expect("missing type params code"); + + // codegen_function() passes firstlineno, not LOC(s).lineno, to + // the generic-parameters scope. + assert_eq!(type_params.first_line_number.unwrap().get(), 2); + } + #[test] fn generic_class_type_params_store_uses_class_location_like_cpython() { let code = compile_exec( @@ -14576,6 +17154,160 @@ def f(): ); } + #[test] + fn try_except_else_finally_child_scopes_follow_cpython_symbol_order() { + let code = compile_exec( + "\ +def f(x): + try: + pass + except Exception: + y = 1 + def h(): + return y + else: + def e(): + return x + finally: + def z(): + return x +", + ); + let f = find_code(&code, "f").expect("missing function code"); + let h = find_code(f, "h").expect("missing handler function code"); + let e = find_code(f, "e").expect("missing else function code"); + let z = find_code(f, "z").expect("missing finally function code"); + + assert_eq!( + h.freevars + .iter() + .map(|name| name.as_str()) + .collect::>(), + vec!["y"], + "handler child scope should consume the handler symbol table" + ); + assert_eq!( + e.freevars + .iter() + .map(|name| name.as_str()) + .collect::>(), + vec!["x"], + "else child scope should be consumed before handler scopes, matching CPython codegen_try_except()" + ); + assert_eq!( + z.freevars + .iter() + .map(|name| name.as_str()) + .collect::>(), + vec!["x"], + "finally child scope should remain after body/else/handler scopes" + ); + } + + #[test] + fn try_star_child_scopes_follow_codegen_order_like_cpython() { + let code = compile_exec( + "\ +def f(x): + try: + pass + except* Exception: + y = 1 + def h(): + return y + else: + def e(): + return x +", + ); + let f = find_code(&code, "f").expect("missing function code"); + let h = find_code(f, "h").expect("missing except* handler function code"); + let e = find_code(f, "e").expect("missing else function code"); + + assert_eq!( + h.freevars + .iter() + .map(|name| name.as_str()) + .collect::>(), + vec!["y"], + "except* handler child scope should consume handler symbol table before else" + ); + assert_eq!( + e.freevars + .iter() + .map(|name| name.as_str()) + .collect::>(), + vec!["x"], + "except* else child scope should be consumed after handler scopes, matching CPython codegen_try_star_except()" + ); + } + + #[test] + fn function_default_and_decorator_child_scopes_follow_cpython_symbol_order() { + fn direct_child_codes<'a>(code: &'a CodeObject, name: &str) -> Vec<&'a CodeObject> { + code.constants + .iter() + .filter_map(|constant| { + if let ConstantData::Code { code } = constant + && code.obj_name == name + { + Some(code.as_ref()) + } else { + None + } + }) + .collect() + } + + let code = compile_exec( + "\ +def outer(x, deco): + @(lambda f: deco(f)) + def inner(a=(lambda: x)()): + return a +", + ); + let outer = find_code(&code, "outer").expect("missing outer function code"); + let lambdas = direct_child_codes(outer, ""); + + assert_eq!(lambdas.len(), 2); + assert_eq!( + lambdas[0] + .freevars + .iter() + .map(|name| name.as_str()) + .collect::>(), + vec!["deco"], + "decorator lambda is emitted first by codegen_function()" + ); + assert_eq!( + lambdas[1] + .freevars + .iter() + .map(|name| name.as_str()) + .collect::>(), + vec!["x"], + "default lambda should still consume the default symbol table" + ); + } + + #[test] + fn decorated_generic_class_type_params_use_decorator_firstlineno_like_cpython() { + let code = compile_exec( + "\ +def deco(obj): return obj +@deco +class C[T]: pass +", + ); + let type_params = + find_code(&code, "").expect("missing type params code"); + + // codegen_class() also enters the generic-parameters scope with + // firstlineno, which is the first decorator line when decorators exist. + assert_eq!(type_params.first_line_number.unwrap().get(), 2); + } + #[test] fn class_deferred_annotations_use_class_body_location_like_cpython() { let code = compile_exec( @@ -14649,6 +17381,45 @@ g = lambda i: {**i} ); } + #[test] + fn dict_unpacking_large_regular_run_uses_subdict_chunks_like_cpython() { + let pairs = (0..17) + .map(|i| format!("{i}: {i}")) + .collect::>() + .join(", "); + let source = format!("def f(x):\n return {{{pairs}, **x}}\n"); + let code = compile_exec(&source); + let f = find_code(&code, "f").expect("missing f code"); + let first_dict_update = f + .instructions + .iter() + .position(|unit| matches!(unit.op, Instruction::DictUpdate { .. })) + .expect("missing DICT_UPDATE"); + let prefix = &f.instructions[..first_dict_update]; + let build_map_args: Vec<_> = prefix + .iter() + .filter_map(|unit| { + matches!(unit.op, Instruction::BuildMap { .. }).then_some(u8::from(unit.arg)) + }) + .collect(); + let map_adds = prefix + .iter() + .filter(|unit| matches!(unit.op, Instruction::MapAdd { .. })) + .count(); + + assert_eq!( + build_map_args, + vec![0], + "CPython codegen_dict() routes a 17-pair run before ** through codegen_subdict(), got instructions={:?}", + f.instructions + ); + assert_eq!( + map_adds, 17, + "CPython codegen_subdict() uses MAP_ADD for all 17 pairs before **, got instructions={:?}", + f.instructions + ); + } + #[test] fn class_function_like_scopes_set_method_flag_like_cpython() { let code = compile_exec_with_options( @@ -14677,13 +17448,13 @@ def f(): for code in [method, async_method, lambda, genexpr] { assert!( - code.flags.contains(CodeFlags::METHOD), + code.flags.contains(bytecode::CodeFlags::METHOD), "class-scope function-like code should carry CO_METHOD like CPython 3.14, got {:?}", code.flags ); } assert!( - !module_function.flags.contains(CodeFlags::METHOD), + !module_function.flags.contains(bytecode::CodeFlags::METHOD), "module-scope function must not carry CO_METHOD" ); } @@ -14702,15 +17473,47 @@ class C: let class_code = find_code(&code, "C").expect("missing class code"); let lambda = find_code(class_code, "").expect("missing lambda code"); assert!( - lambda.flags.contains(CodeFlags::NESTED), + lambda.flags.contains(bytecode::CodeFlags::NESTED), "lambda under inlined class comprehension should stay nested" ); assert!( - !lambda.flags.contains(CodeFlags::METHOD), + !lambda.flags.contains(bytecode::CodeFlags::METHOD), "CPython creates this lambda while the current symtable block is the comprehension, not the class" ); } + #[test] + fn class_inlined_comprehension_pushes_only_bound_locals_like_cpython() { + let code = compile_exec( + "\ +class C: + x = 1 + items = [x for i in range(3)] +", + ); + let class_code = find_code(&code, "C").expect("missing class code"); + let cleared_names = class_code + .instructions + .iter() + .filter_map(|unit| match unit.op { + Instruction::LoadFastAndClear { var_num } => { + let idx = var_num.get(OpArg::new(u32::from(u8::from(unit.arg)))); + Some(class_code.varnames[usize::from(idx)].as_str()) + } + _ => None, + }) + .collect::>(); + + assert!( + cleared_names.contains(&"i"), + "the comprehension iteration variable should be isolated, got {cleared_names:?}" + ); + assert!( + !cleared_names.contains(&"x"), + "CPython applies the class-block special case while tweaking scopes, but codegen_push_inlined_comprehension_locals() runs after u_in_inlined_comp is set and only clears DEF_LOCAL names; got {cleared_names:?}" + ); + } + #[test] fn genexpr_implicit_iterator_is_not_posonly_like_cpython() { let code = compile_exec("x = (i for i in ())"); @@ -14723,6 +17526,29 @@ class C: ); } + #[test] + fn posonly_function_argcount_metadata_matches_cpython_assemble_split() { + let code = compile_exec("def f(a, /, b):\n pass\n"); + let func = find_code(&code, "f").expect("missing function code"); + + assert_eq!( + func.arg_count, 2, + "CPython assemble.c exposes co_argcount as u_posonlyargcount + u_argcount" + ); + assert_eq!(func.posonlyarg_count, 1); + assert_eq!(func.varnames.as_ref(), &["a".to_owned(), "b".to_owned()]); + assert_eq!( + func.localspluskinds[0] & (CO_FAST_ARG_POS | CO_FAST_ARG_KW), + CO_FAST_ARG_POS, + "CPython compute_localsplus_info marks only u_posonlyargcount slots as positional-only" + ); + assert_eq!( + func.localspluskinds[1] & (CO_FAST_ARG_POS | CO_FAST_ARG_KW), + CO_FAST_ARG_POS | CO_FAST_ARG_KW, + "CPython compute_localsplus_info marks u_argcount slots after posonly as positional-or-keyword" + ); + } + #[test] fn async_generator_uses_cpython_async_generator_flag() { let code = compile_exec_with_options( @@ -14742,17 +17568,37 @@ async def ag(): let coroutine = find_code(&code, "c").expect("missing coroutine code"); let async_generator = find_code(&code, "ag").expect("missing async generator code"); - assert!(generator.flags.contains(CodeFlags::GENERATOR)); - assert!(!generator.flags.contains(CodeFlags::COROUTINE)); - assert!(!generator.flags.contains(CodeFlags::ASYNC_GENERATOR)); + assert!(generator.flags.contains(bytecode::CodeFlags::GENERATOR)); + assert!(!generator.flags.contains(bytecode::CodeFlags::COROUTINE)); + assert!( + !generator + .flags + .contains(bytecode::CodeFlags::ASYNC_GENERATOR) + ); - assert!(coroutine.flags.contains(CodeFlags::COROUTINE)); - assert!(!coroutine.flags.contains(CodeFlags::GENERATOR)); - assert!(!coroutine.flags.contains(CodeFlags::ASYNC_GENERATOR)); + assert!(coroutine.flags.contains(bytecode::CodeFlags::COROUTINE)); + assert!(!coroutine.flags.contains(bytecode::CodeFlags::GENERATOR)); + assert!( + !coroutine + .flags + .contains(bytecode::CodeFlags::ASYNC_GENERATOR) + ); - assert!(async_generator.flags.contains(CodeFlags::ASYNC_GENERATOR)); - assert!(!async_generator.flags.contains(CodeFlags::GENERATOR)); - assert!(!async_generator.flags.contains(CodeFlags::COROUTINE)); + assert!( + async_generator + .flags + .contains(bytecode::CodeFlags::ASYNC_GENERATOR) + ); + assert!( + !async_generator + .flags + .contains(bytecode::CodeFlags::GENERATOR) + ); + assert!( + !async_generator + .flags + .contains(bytecode::CodeFlags::COROUTINE) + ); } #[test] @@ -15089,6 +17935,27 @@ def f(a, b, c): .filter(|unit| !matches!(unit.op, Instruction::Cache)) } + fn full_opargs_for( + code: &CodeObject, + mut predicate: impl FnMut(Instruction) -> bool, + ) -> Vec { + let mut extended = 0u32; + let mut args = Vec::new(); + for unit in non_cache_instructions(code) { + let byte = u32::from(u8::from(unit.arg)); + if matches!(unit.op, Instruction::ExtendedArg) { + extended = (extended << 8) | byte; + continue; + } + let oparg = (extended << 8) | byte; + extended = 0; + if predicate(unit.op) { + args.push(oparg); + } + } + args + } + fn varname_index(code: &CodeObject, name: &str) -> usize { code.varnames .iter() @@ -15288,6 +18155,48 @@ def f(): ); } + #[test] + fn match_or_conflicting_bind_error_uses_or_pattern_location_like_cpython() { + let error = compile_exec_error( + "\ +def f(x): + match x: + case ( + a + | b + ): + pass +", + ); + let location = error.location.expect("missing error location"); + assert_eq!( + location.line.get(), + 4, + "CPython codegen_pattern_or() reports alternative binding mismatches at LOC(p), not LOC(alt)" + ); + } + + #[test] + fn match_or_duplicate_store_error_uses_or_pattern_location_like_cpython() { + let error = compile_exec_error( + "\ +def f(value): + match value: + case [ + x, + (x | x), + ]: + pass +", + ); + let location = error.location.expect("missing error location"); + assert_eq!( + location.line.get(), + 5, + "CPython codegen_pattern_or() reports merge-time duplicate stores at LOC(p)" + ); + } + #[test] fn match_success_jump_uses_no_location_like_cpython() { let code = compile_exec( @@ -15313,6 +18222,55 @@ def f(self): ); } + #[test] + fn match_default_simple_guard_jump_uses_guard_location_like_cpython() { + let code = compile_exec( + "\ +def f(x, y): + match x: + case 0: + return 1 + case _ if y: + return 2 + return 3 +", + ); + let f = find_code(&code, "f").expect("missing f code"); + let guard_jump_location = f + .instructions + .iter() + .enumerate() + .find_map(|(idx, unit)| { + let (Instruction::LoadFast { var_num } | Instruction::LoadFastBorrow { var_num }) = + unit.op + else { + return None; + }; + let arg = OpArg::new(u32::from(u8::from(unit.arg))); + if f.varnames[usize::from(var_num.get(arg))] != "y" { + return None; + } + f.instructions + .iter() + .zip(&f.locations) + .skip(idx + 1) + .take(8) + .find_map(|(unit, (location, _))| { + matches!(unit.op, Instruction::PopJumpIfFalse { .. }).then_some(*location) + }) + }) + .expect("missing default guard jump"); + + assert_eq!( + ( + guard_jump_location.line.get(), + guard_jump_location.character_offset.get() + ), + (5, 19), + "CPython codegen_jump_if() receives LOC(pattern), but the simple guard fallback emits TO_BOOL/jump at LOC(guard)" + ); + } + #[test] fn match_mapping_keys_scaffolding_uses_mapping_location_like_cpython() { let code = compile_exec( @@ -15339,6 +18297,57 @@ def f(self): ); } + #[test] + fn match_mapping_rest_cleanup_uses_mapping_location_like_cpython() { + let code = compile_exec( + "\ +def f(x): + match x: + case { + 0: _, + **rest, + }: + return rest +", + ); + let f = find_code(&code, "f").expect("missing f code"); + let rest_cleanup_start = f + .instructions + .iter() + .position(|unit| matches!(unit.op, Instruction::BuildMap { .. })) + .expect("missing BUILD_MAP"); + for expected in [ + "BUILD_MAP", + "DICT_UPDATE", + "DELETE_SUBSCR", + "rest cleanup COPY", + "rest cleanup SWAP", + ] { + let location = f + .instructions + .iter() + .zip(&f.locations) + .skip(rest_cleanup_start) + .find_map(|(unit, (location, _))| { + let found = matches!( + (expected, unit.op), + ("BUILD_MAP", Instruction::BuildMap { .. }) + | ("DICT_UPDATE", Instruction::DictUpdate { .. }) + | ("DELETE_SUBSCR", Instruction::DeleteSubscr) + | ("rest cleanup COPY", Instruction::Copy { .. }) + | ("rest cleanup SWAP", Instruction::Swap { .. }) + ); + found.then_some(*location) + }) + .unwrap_or_else(|| panic!("missing {expected}")); + assert_eq!( + location.line.get(), + 3, + "CPython codegen_pattern_mapping() emits {expected} with LOC(p)" + ); + } + } + #[test] fn match_class_scaffolding_uses_class_pattern_location_like_cpython() { let code = compile_exec( @@ -15362,6 +18371,40 @@ def f(x): ); } + #[test] + fn match_class_wildcard_pop_uses_class_pattern_location_like_cpython() { + let code = compile_exec( + "\ +def f(x): + match x: + case bool( + _ + ): + return True +", + ); + let f = find_code(&code, "f").expect("missing f code"); + let unpack_index = f + .instructions + .iter() + .position(|unit| matches!(unit.op, Instruction::UnpackSequence { .. })) + .expect("missing class pattern UNPACK_SEQUENCE"); + let wildcard_pop_location = f + .instructions + .iter() + .zip(&f.locations) + .skip(unpack_index + 1) + .find_map(|(unit, (location, _))| { + matches!(unit.op, Instruction::PopTop).then_some(*location) + }) + .expect("missing wildcard POP_TOP"); + assert_eq!( + wildcard_pop_location.line.get(), + 3, + "CPython codegen_pattern_class() emits wildcard POP_TOP with LOC(p)" + ); + } + #[test] fn while_try_body_layout_keeps_false_jump_to_anchor() { let code = compile_exec( @@ -16112,52 +19155,178 @@ def boolop(fields): let boolop = find_code(&code, "boolop").expect("missing boolop code"); let boolop_gen = find_code(boolop, "").expect("missing boolop genexpr code"); - // CPython 3.14 codegen_sync_comprehension_generator() emits the - // comprehension guard jump to if_cleanup, then emits the if_cleanup - // backedge with elt_loc. flowgraph.c::jump_thread() copies that target - // jump location to the threaded POP_JUMP/NOT_TAKEN cleanup path. + // codegen_sync_comprehension_generator() emits the + // comprehension guard jump to if_cleanup, then emits the if_cleanup + // backedge with elt_loc. flowgraph.c::jump_thread() copies that target + // jump location to the threaded POP_JUMP/NOT_TAKEN cleanup path. + assert_eq!( + simple_gen.linetable.as_ref(), + &[ + 0xe9, 0x00, 0x80, 0x00, 0xd0, 0x0b, 0x31, 0x91, 0x75, 0x90, 0x21, 0xa4, 0x49, 0xa8, + 0x61, 0xa7, 0x4c, 0x8f, 0x41, 0x8a, 0x41, 0x93, 0x75, 0xf9, + ] + ); + assert_eq!( + boolop_gen.linetable.as_ref(), + &[ + 0xe9, 0x00, 0x80, 0x00, 0xd0, 0x0b, 0x3a, 0x91, 0x76, 0x90, 0x21, 0xa7, 0x16, 0xa5, + 0x16, 0x8c, 0x41, 0xb0, 0x01, 0xb7, 0x09, 0xb5, 0x09, 0x8f, 0x41, 0x8a, 0x41, 0x93, + 0x76, 0xf9, + ] + ); + } + + #[test] + fn try_finally_exception_scaffolding_uses_no_location_like_cpython() { + let code = compile_exec( + "\ +def f(self, node): + self.flag = True + try: + self.body(node) + finally: + self.flag = False +", + ); + let f = find_code(&code, "f").expect("missing f code"); + + // codegen_try_finally() emits the exception path + // SETUP_CLEANUP/PUSH_EXC_INFO and POP_EXCEPT_AND_RERAISE with + // NO_LOCATION; flowgraph line propagation then gives only the + // finalbody's direct RERAISE the finalbody location. + assert_eq!( + f.linetable.as_ref(), + &[ + 0x80, 0x00, 0xd8, 0x10, 0x14, 0x80, 0x44, 0x84, 0x49, 0xf0, 0x02, 0x03, 0x05, 0x1a, + 0xd8, 0x08, 0x0c, 0x8f, 0x09, 0x89, 0x09, 0x90, 0x24, 0x8c, 0x0f, 0xe0, 0x14, 0x19, + 0x88, 0x04, 0x8e, 0x09, 0xf8, 0x90, 0x45, 0x88, 0x04, 0x8d, 0x09, 0xfa, + ] + ); + } + + #[test] + fn return_debug_in_finally_uses_cpython_preprocessed_constant_order() { + let code = compile_exec( + "\ +def f(close): + try: + return __debug__ + finally: + close() +", + ); + let f = find_code(&code, "f").expect("missing f code"); + let call_pos = f + .instructions + .iter() + .position(|unit| matches!(unit.op, Instruction::Call { .. })) + .expect("missing finally-body call"); + let debug_load_pos = f + .instructions + .iter() + .position(|unit| { + let Instruction::LoadConst { consti } = unit.op else { + return false; + }; + let constant = &f.constants[consti.get(OpArg::new(u32::from(u8::from(unit.arg))))]; + matches!(constant, ConstantData::Boolean { value: true }) + }) + .expect("missing __debug__ constant load"); + + assert!( + call_pos < debug_load_pos, + "CPython ast_preprocess.c folds __debug__ to Constant before codegen_return(), so the return constant is loaded after finally cleanup; ops={:?}", + f.instructions + .iter() + .map(|unit| unit.op) + .collect::>() + ); + } + + #[test] + fn debug_statement_is_preprocessed_constant_like_cpython() { + for code in [ + compile_exec("__debug__\n"), + compile_exec_optimized("__debug__\n"), + ] { + let ops = non_cache_instructions(&code) + .map(|unit| unit.op) + .collect::>(); + assert!( + !ops.iter().any(|op| matches!(op, Instruction::PopTop)), + "CPython ast_preprocess.c folds __debug__ to Constant before codegen_stmt_expr(), so it must not compile as LOAD_CONST/POP_TOP; ops={ops:?}" + ); + } + } + + #[test] + fn statement_expr_pop_top_uses_no_location_like_cpython() { + let infos = compile_module_instruction_infos("x + 1\n", Mode::Exec); + let pop = infos + .iter() + .find(|info| matches!(info.instr.real(), Some(Instruction::PopTop))) + .expect("missing expression-statement POP_TOP"); + assert_eq!( - simple_gen.linetable.as_ref(), - &[ - 0xe9, 0x00, 0x80, 0x00, 0xd0, 0x0b, 0x31, 0x91, 0x75, 0x90, 0x21, 0xa4, 0x49, 0xa8, - 0x61, 0xa7, 0x4c, 0x8f, 0x41, 0x8a, 0x41, 0x93, 0x75, 0xf9, - ] + pop.lineno_override, + Some(ir::NO_LOCATION_OVERRIDE), + "CPython codegen_stmt_expr() emits artificial expression-statement POP_TOP at NO_LOCATION" + ); + } + + #[test] + fn interactive_statement_expr_pop_top_uses_no_location_like_cpython() { + let infos = compile_module_instruction_infos("x + 1\n", Mode::Single); + let print = infos + .iter() + .position(|info| { + matches!( + info.instr.real(), + Some(Instruction::CallIntrinsic1 { func }) + if func.get(info.arg) == bytecode::IntrinsicFunction1::Print + ) + }) + .expect("missing interactive PRINT intrinsic"); + let pop = infos + .get(print + 1) + .expect("missing POP_TOP after interactive PRINT"); + + assert!( + matches!(pop.instr.real(), Some(Instruction::PopTop)), + "CPython codegen_stmt_expr() emits POP_TOP immediately after INTRINSIC_PRINT; got {pop:?}" ); assert_eq!( - boolop_gen.linetable.as_ref(), - &[ - 0xe9, 0x00, 0x80, 0x00, 0xd0, 0x0b, 0x3a, 0x91, 0x76, 0x90, 0x21, 0xa7, 0x16, 0xa5, - 0x16, 0x8c, 0x41, 0xb0, 0x01, 0xb7, 0x09, 0xb5, 0x09, 0x8f, 0x41, 0x8a, 0x41, 0x93, - 0x76, 0xf9, - ] + pop.lineno_override, + Some(ir::NO_LOCATION_OVERRIDE), + "CPython codegen_stmt_expr() emits interactive PRINT cleanup POP_TOP at NO_LOCATION" ); } #[test] - fn try_finally_exception_scaffolding_uses_no_location_like_cpython() { - let code = compile_exec( - "\ -def f(self, node): - self.flag = True - try: - self.body(node) - finally: - self.flag = False -", - ); - let f = find_code(&code, "f").expect("missing f code"); + fn import_star_pop_top_uses_no_location_like_cpython() { + let infos = compile_module_instruction_infos("from m import *\n", Mode::Exec); + let import_star = infos + .iter() + .position(|info| { + matches!( + info.instr.real(), + Some(Instruction::CallIntrinsic1 { func }) + if func.get(info.arg) == bytecode::IntrinsicFunction1::ImportStar + ) + }) + .expect("missing IMPORT_STAR intrinsic"); + let pop = infos + .get(import_star + 1) + .expect("missing POP_TOP after IMPORT_STAR"); - // CPython 3.14 codegen_try_finally() emits the exception path - // SETUP_CLEANUP/PUSH_EXC_INFO and POP_EXCEPT_AND_RERAISE with - // NO_LOCATION; flowgraph line propagation then gives only the - // finalbody's direct RERAISE the finalbody location. + assert!( + matches!(pop.instr.real(), Some(Instruction::PopTop)), + "CPython codegen_from_import() emits POP_TOP immediately after INTRINSIC_IMPORT_STAR; got {pop:?}" + ); assert_eq!( - f.linetable.as_ref(), - &[ - 0x80, 0x00, 0xd8, 0x10, 0x14, 0x80, 0x44, 0x84, 0x49, 0xf0, 0x02, 0x03, 0x05, 0x1a, - 0xd8, 0x08, 0x0c, 0x8f, 0x09, 0x89, 0x09, 0x90, 0x24, 0x8c, 0x0f, 0xe0, 0x14, 0x19, - 0x88, 0x04, 0x8e, 0x09, 0xf8, 0x90, 0x45, 0x88, 0x04, 0x8d, 0x09, 0xfa, - ] + pop.lineno_override, + Some(ir::NO_LOCATION_OVERRIDE), + "CPython codegen_from_import() emits import-star cleanup POP_TOP at NO_LOCATION" ); } @@ -16272,9 +19441,9 @@ def prefixed(x): "CPython represents f'{{x=}}' debug text as a literal at the expression/debug-text location" ); assert_eq!( - string_load_position(prefixed, "a x="), - (5, 14, 5, 19), - "CPython extends a pending f-string literal through the debug text range" + string_load_position(prefixed, "x="), + (5, 17, 5, 19), + "CPython keeps debug text as a separate JoinedStr Constant instead of merging it with the preceding literal" ); } @@ -17015,6 +20184,98 @@ def f(cls, args, kwargs): } } + #[test] + fn method_call_at_stack_guideline_uses_plain_load_attr_like_cpython() { + let params = (0..STACK_USE_GUIDELINE) + .map(|i| format!("a{i}")) + .collect::>() + .join(", "); + let code = compile_exec(&format!( + "def f(obj, {params}):\n return obj.m({params})\n" + )); + let f = find_code(&code, "f").expect("missing function code"); + let ops: Vec<_> = f + .instructions + .iter() + .map(|unit| unit.op) + .filter(|op| !matches!(op, Instruction::Cache)) + .collect(); + + let plain_load_attr = f.instructions.iter().any(|unit| { + if let Instruction::LoadAttr { namei } = unit.op { + !namei + .get(OpArg::new(u32::from(u8::from(unit.arg)))) + .is_method() + } else { + false + } + }); + let direct_call_30 = f.instructions.iter().any(|unit| match unit.op { + Instruction::Call { argc } => { + argc.get(OpArg::new(u32::from(u8::from(unit.arg)))) == STACK_USE_GUIDELINE + } + _ => false, + }); + + assert!( + plain_load_attr && direct_call_30, + "CPython maybe_optimize_method_call rejects arg count at the guideline, got ops={ops:?}" + ); + assert!( + !ops.iter() + .any(|op| matches!(op, Instruction::CallFunctionEx)), + "exactly guideline-sized method call should stay direct after LOAD_ATTR fallback, got ops={ops:?}" + ); + } + + #[test] + fn method_call_many_keywords_stays_load_method_call_kw_like_cpython() { + let params = (0..16) + .map(|i| format!("a{i}")) + .collect::>() + .join(", "); + let keywords = (0..16) + .map(|i| format!("k{i}=a{i}")) + .collect::>() + .join(", "); + let code = compile_exec(&format!( + "def f(obj, {params}):\n return obj.m({keywords})\n" + )); + let f = find_code(&code, "f").expect("missing function code"); + let ops: Vec<_> = f + .instructions + .iter() + .map(|unit| unit.op) + .filter(|op| !matches!(op, Instruction::Cache)) + .collect(); + + let method_load_attr = f.instructions.iter().any(|unit| { + if let Instruction::LoadAttr { namei } = unit.op { + namei + .get(OpArg::new(u32::from(u8::from(unit.arg)))) + .is_method() + } else { + false + } + }); + let call_kw_16 = f.instructions.iter().any(|unit| match unit.op { + Instruction::CallKw { argc } => { + argc.get(OpArg::new(u32::from(u8::from(unit.arg)))) == 16 + } + _ => false, + }); + + assert!( + method_load_attr && call_kw_16, + "CPython maybe_optimize_method_call emits LOAD_METHOD/CALL_KW under its own stack threshold, got ops={ops:?}" + ); + assert!( + !ops.iter() + .any(|op| matches!(op, Instruction::CallFunctionEx)), + "method-call keyword path should not reuse codegen_call_helper_impl's lower kw threshold, got ops={ops:?}" + ); + } + #[test] fn large_plain_call_uses_direct_call_until_stack_guideline() { let code = compile_exec( @@ -17288,7 +20549,7 @@ def set_f(xs): ); assert!( !has_common_constant(list_f, bytecode::CommonConstant::BuiltinList), - "CPython 3.14.2 does not optimize list(genexpr)" + "CPython 3.14.5 does not optimize list(genexpr)" ); let set_f = find_code(&code, "set_f").expect("missing set_f code"); @@ -17301,7 +20562,7 @@ def set_f(xs): ); assert!( !has_common_constant(set_f, bytecode::CommonConstant::BuiltinSet), - "CPython 3.14.2 does not optimize set(genexpr)" + "CPython 3.14.5 does not optimize set(genexpr)" ); } @@ -17716,6 +20977,40 @@ def aug_const(x, y): ); } + #[test] + fn augassign_attribute_copy_uses_target_location_like_cpython() { + let code = compile_exec( + "\ +def f(obj, value): + obj.attr += value +", + ); + let f = find_code(&code, "f").expect("missing f code"); + let copy_position = f + .instructions + .iter() + .zip(&f.locations) + .find_map(|(unit, (location, end_location))| { + let Instruction::Copy { i } = unit.op else { + return None; + }; + let arg = OpArg::new(u32::from(u8::from(unit.arg))); + (i.get(arg) == 1).then_some(( + location.line.get(), + location.character_offset.get(), + end_location.line.get(), + end_location.character_offset.get(), + )) + }) + .expect("missing augmented attribute COPY"); + + assert_eq!( + copy_position, + (2, 5, 2, 13), + "CPython codegen_augassign() emits COPY 1 at LOC(target) before updating to attr location" + ); + } + #[test] fn loop_return_reorders_backedge_before_exit_cleanup() { let code = compile_exec( @@ -18610,6 +21905,82 @@ t = t\"Value: {value=}\" ); } + #[test] + fn tstring_ops_restore_template_and_interpolation_locations_like_cpython() { + let code = compile_exec( + "\ +def f(x): + return t\"{x}\" +", + ); + let f = find_code(&code, "f").expect("missing f code"); + let mut build_interpolation = None; + let mut build_tuple = None; + let mut build_template = None; + for (unit, (location, end_location)) in f.instructions.iter().zip(&f.locations) { + let range = ( + location.line.get(), + location.character_offset.get(), + end_location.line.get(), + end_location.character_offset.get(), + ); + match unit.op { + Instruction::BuildInterpolation { .. } => build_interpolation = Some(range), + Instruction::BuildTuple { .. } => build_tuple = Some(range), + Instruction::BuildTemplate => build_template = Some(range), + _ => {} + } + } + + assert_eq!( + build_interpolation, + Some((2, 14, 2, 17)), + "CPython codegen_interpolation() restores LOC(Interpolation) after visiting the value; this direct codegen path uses the parser's Interpolation range" + ); + assert_eq!( + build_tuple, + Some((2, 12, 2, 18)), + "CPython codegen_template_str() emits the interpolations tuple at LOC(TemplateStr); this direct codegen path uses the parser's TemplateStr range" + ); + assert_eq!( + build_template, + Some((2, 12, 2, 18)), + "CPython codegen_template_str() emits BUILD_TEMPLATE at LOC(TemplateStr); this direct codegen path uses the parser's TemplateStr range" + ); + } + + #[test] + fn regular_call_push_null_uses_callee_location_like_cpython() { + let code = compile_exec( + "\ +def f(g, x): + return ( + g + )(x) +", + ); + let f = find_code(&code, "f").expect("missing f code"); + let push_null = f + .instructions + .iter() + .zip(&f.locations) + .find_map(|(unit, (location, end_location))| { + matches!(unit.op, Instruction::PushNull).then_some(( + location.line.get(), + location.character_offset.get(), + end_location.line.get(), + end_location.character_offset.get(), + )) + }) + .expect("missing PUSH_NULL"); + + assert_eq!( + push_null, + (3, 9, 3, 10), + "CPython codegen_call() resets loc to LOC(func) before emitting PUSH_NULL; this direct codegen path uses the parser's callee range" + ); + } + #[test] fn tstring_literal_preserves_surrogate_wtf8() { let code = compile_exec("t = t\"\\ud800\""); @@ -19668,87 +23039,30 @@ def f(self): matches!(assert_raises_receiver, Instruction::LoadFastBorrow { .. }), "mapping attribute key handling must not disable borrow optimization for the whole block; got ops={:?}", f.instructions - .iter() - .map(|unit| unit.op) - .collect::>() - ); - let key_load_idx = f - .instructions - .iter() - .position(|unit| match unit.op { - Instruction::LoadAttr { namei } => { - let load_attr = namei.get(OpArg::new(u32::from(u8::from(unit.arg)))); - f.names[usize::try_from(load_attr.name_idx()).unwrap()].as_str() == "KEY" - } - _ => false, - }) - .expect("missing Keys.KEY attribute load"); - let prev = f.instructions[key_load_idx - 1].op; - assert!( - matches!(prev, Instruction::LoadFast { .. }), - "CPython optimize_load_fast() records MATCH_KEYS' no-input pseudo-ref with the produced-value loop index, so this consumed Keys load stays strong; got ops={:?}", - f.instructions - .iter() - .map(|unit| unit.op) - .collect::>() - ); - } - - #[test] - fn debug_trace_match_sequence_star_wildcard_layout() { - let trace = compile_single_function_late_cfg_trace( - "\ -def f(w): - match w: - case [x, *_, y]: - z = 0 - return x, y, z -", - "f", - ); - for (stage, dump) in trace { - eprintln!("=== {stage} ===\n{dump}"); - } - } - - #[test] - fn debug_trace_loop_break_bool_chain_layout() { - let trace = compile_single_function_late_cfg_trace( - "\ -def f(filters, text, category, module, lineno, defaultaction): - for item in filters: - action, msg, cat, mod, ln = item - if ((msg is None or msg.match(text)) and - issubclass(category, cat) and - (mod is None or mod.match(module)) and - (ln == 0 or lineno == ln)): - break - else: - action = defaultaction - return action -", - "f", - ); - for (stage, dump) in trace { - eprintln!("=== {stage} ===\n{dump}"); - } - } - - #[test] - fn debug_trace_loop_conditional_body_layout() { - let trace = compile_single_function_late_cfg_trace( - "\ -def f(new, old): - for replace in ['__module__', '__name__', '__qualname__', '__doc__']: - if hasattr(old, replace): - setattr(new, replace, getattr(old, replace)) - return new -", - "f", + .iter() + .map(|unit| unit.op) + .collect::>() + ); + let key_load_idx = f + .instructions + .iter() + .position(|unit| match unit.op { + Instruction::LoadAttr { namei } => { + let load_attr = namei.get(OpArg::new(u32::from(u8::from(unit.arg)))); + f.names[usize::try_from(load_attr.name_idx()).unwrap()].as_str() == "KEY" + } + _ => false, + }) + .expect("missing Keys.KEY attribute load"); + let prev = f.instructions[key_load_idx - 1].op; + assert!( + matches!(prev, Instruction::LoadFast { .. }), + "CPython optimize_load_fast() records MATCH_KEYS' no-input pseudo-ref with the produced-value loop index, so this consumed Keys load stays strong; got ops={:?}", + f.instructions + .iter() + .map(|unit| unit.op) + .collect::>() ); - for (stage, dump) in trace { - eprintln!("=== {stage} ===\n{dump}"); - } } #[test] @@ -19884,58 +23198,6 @@ def f(self): ); } - #[test] - fn debug_trace_utf7_min_encode_layout() { - let trace = compile_single_function_late_cfg_trace( - "\ -def f(s, size, encodeSetO, encodeWhiteSpace): - inShift = True - base64bits = 0 - out = [] - for i, ch in enumerate(s): - if base64bits == 0: - if i + 1 < size: - ch2 = s[i + 1] - if E(ch2, encodeSetO, encodeWhiteSpace): - if B(ch2) or ch2 == '-': - out.append(b'-') - inShift = False - else: - out.append(b'-') - inShift = False - return out -", - "f", - ); - for (stage, dump) in trace { - eprintln!("=== {stage} ===\n{dump}"); - } - } - - #[test] - fn debug_trace_with_loop_break_bool_chain_layout() { - let trace = compile_single_function_late_cfg_trace( - "\ -def f(filters, text, category, module, lineno, defaultaction, _wm): - with _wm._lock: - for item in filters: - action, msg, cat, mod, ln = item - if ((msg is None or msg.match(text)) and - issubclass(category, cat) and - (mod is None or mod.match(module)) and - (ln == 0 or lineno == ln)): - break - else: - action = defaultaction - return action -", - "f", - ); - for (stage, dump) in trace { - eprintln!("=== {stage} ===\n{dump}"); - } - } - #[test] fn try_except_else_with_finally_keeps_with_handler_before_outer_except() { let code = compile_exec( @@ -20898,6 +24160,63 @@ def f(lines, close): ); } + #[test] + fn try_finally_return_inside_with_pops_unwound_fblocks_for_finalbody() { + let code = compile_exec( + "\ +def f(cm): + try: + with cm: + return 1 + finally: + return 2 +", + ); + let f = find_code(&code, "f").expect("missing f code"); + assert!( + f.instructions + .iter() + .any(|unit| matches!(unit.op, Instruction::ReturnValue)), + "return inside with/try-finally should compile without leaving an invalid CFG" + ); + } + + #[test] + fn except_star_return_after_with_unwind_uses_no_location_like_cpython() { + let err = compile_exec_error( + "\ +def f(cm): + try: + pass + except* Exception: + with cm: + return 1 +", + ); + assert!(matches!( + err.error, + CodegenErrorType::BreakContinueReturnInExceptStar + )); + assert!( + err.location.is_none(), + "CPython codegen_unwind_fblock(WITH) sets *ploc = NO_LOCATION before the except* error" + ); + } + + #[test] + fn async_generator_return_value_error_message_matches_cpython() { + assert_eq!( + compile_exec_error_message( + "\ +async def f(): + yield 1 + return 2 +" + ), + "'return' with value in async generator" + ); + } + #[test] fn try_except_finally_handler_normal_exit_keeps_nointerrupt_jump() { let code = compile_exec( @@ -23981,6 +27300,66 @@ class C: ); } + #[test] + fn optimize_two_strips_docstrings_during_preprocess() { + let code = compile_exec_with_options( + "\ +\"module doc\" + +def f(): + \"function doc\" + return 1 + +class C: + \"class doc\" + x = 1 +", + CompileOpts { + optimize: 2, + ..CompileOpts::default() + }, + ); + + assert!( + !code.instructions.iter().any(|unit| { + matches!( + unit.op, + Instruction::StoreName { namei } + if code.names + [namei.get(OpArg::new(u32::from(u8::from(unit.arg)))) as usize] + .as_str() + == "__doc__" + ) + }), + "module docstring should be stripped before codegen, got instructions={:?}", + code.instructions + ); + + let function_code = find_code(&code, "f").expect("missing function code"); + assert!( + !function_code + .flags + .contains(bytecode::CodeFlags::HAS_DOCSTRING), + "function docstring should not set HAS_DOCSTRING when optimize=2" + ); + + let class_code = find_code(&code, "C").expect("missing class code"); + assert!( + !class_code.instructions.iter().any(|unit| { + matches!( + unit.op, + Instruction::StoreName { namei } + if class_code.names + [namei.get(OpArg::new(u32::from(u8::from(unit.arg)))) as usize] + .as_str() + == "__doc__" + ) + }), + "class docstring should be stripped before codegen, got instructions={:?}", + class_code.instructions + ); + } + #[test] fn future_annotations_flag_is_inherited_like_cpython() { let code = compile_exec( @@ -23993,11 +27372,117 @@ def f(): return C ", ); - assert!(code.flags.contains(CodeFlags::FUTURE_ANNOTATIONS)); + assert!(code.flags.contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS)); let f = find_code(&code, "f").expect("missing f code"); - assert!(f.flags.contains(CodeFlags::FUTURE_ANNOTATIONS)); + assert!(f.flags.contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS)); let class_code = find_code(f, "C").expect("missing C code"); - assert!(class_code.flags.contains(CodeFlags::FUTURE_ANNOTATIONS)); + assert!( + class_code + .flags + .contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS) + ); + } + + #[test] + fn future_flags_from_compile_options_are_merged_like_cpython() { + let opts = CompileOpts { + future_features: bytecode::CodeFlags::FUTURE_ANNOTATIONS + | bytecode::CodeFlags::FUTURE_DIVISION, + ..CompileOpts::default() + }; + let code = compile_exec_with_options( + "\ +x: int +def f(): + pass +", + opts, + ); + assert!(code.flags.contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS)); + assert!(code.flags.contains(bytecode::CodeFlags::FUTURE_DIVISION)); + assert!( + code.instructions + .iter() + .any(|unit| matches!(unit.op, Instruction::SetupAnnotations)) + ); + let f = find_code(&code, "f").expect("missing f code"); + assert!(f.flags.contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS)); + assert!(f.flags.contains(bytecode::CodeFlags::FUTURE_DIVISION)); + } + + #[test] + fn future_barry_as_flufl_is_accepted_but_ignored() { + let code = compile_exec( + "\ +from __future__ import barry_as_FLUFL + +def f(): + pass +", + ); + let future_flags = bytecode::CodeFlags::FUTURE_DIVISION + | bytecode::CodeFlags::FUTURE_ABSOLUTE_IMPORT + | bytecode::CodeFlags::FUTURE_WITH_STATEMENT + | bytecode::CodeFlags::FUTURE_PRINT_FUNCTION + | bytecode::CodeFlags::FUTURE_UNICODE_LITERALS + | bytecode::CodeFlags::FUTURE_GENERATOR_STOP + | bytecode::CodeFlags::FUTURE_ANNOTATIONS; + assert!((code.flags & future_flags).is_empty()); + let f = find_code(&code, "f").expect("missing f code"); + assert!((f.flags & future_flags).is_empty()); + } + + #[test] + fn relative_future_import_does_not_enable_annotations_like_cpython() { + let code = compile_exec( + "\ +from .__future__ import annotations +x: int +", + ); + assert!(!code.flags.contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS)); + } + + #[test] + fn future_braces_uses_cpython_special_error() { + assert_eq!( + compile_exec_error_message("from __future__ import braces\n"), + "not a chance" + ); + } + + #[test] + fn invalid_future_feature_is_checked_before_ast_preprocess_like_cpython() { + assert_eq!( + compile_exec_error_message("from __future__ import spam, annotations\nx: (y := int)\n"), + "future feature spam is not defined" + ); + } + + #[test] + fn allow_top_level_await_marks_module_coroutine_like_cpython() { + let opts = CompileOpts { + allow_top_level_await: true, + ..CompileOpts::default() + }; + let code = compile_exec_with_options("await f()\n", opts); + assert!(code.flags.contains(bytecode::CodeFlags::COROUTINE)); + } + + #[test] + fn allow_top_level_await_accepts_module_async_for_like_cpython() { + let opts = CompileOpts { + allow_top_level_await: true, + ..CompileOpts::default() + }; + let code = compile_exec_with_options( + "\ +async for x in y: + pass +", + opts, + ); + assert!(code.flags.contains(bytecode::CodeFlags::COROUTINE)); } #[test] @@ -24016,7 +27501,7 @@ def outer(): let class_annotate = find_code(class_code, "__annotate__").expect("missing class annotation code"); assert!( - !class_annotate.flags.contains(CodeFlags::NESTED), + !class_annotate.flags.contains(bytecode::CodeFlags::NESTED), "module-level class annotation scope should not be nested" ); @@ -24025,7 +27510,7 @@ def outer(): let nested_annotate = find_code(nested_class, "__annotate__").expect("missing nested annotation code"); assert!( - nested_annotate.flags.contains(CodeFlags::NESTED), + nested_annotate.flags.contains(bytecode::CodeFlags::NESTED), "annotation scope under a nested class should be nested" ); } @@ -24040,25 +27525,25 @@ type A[T] = T ); let outer_lambda = find_code(&code, "").expect("missing outer lambda code"); assert!( - !outer_lambda.flags.contains(CodeFlags::NESTED), + !outer_lambda.flags.contains(bytecode::CodeFlags::NESTED), "module-level lambda should not be nested" ); let inner_lambda = find_direct_child_code(outer_lambda, "").expect("missing inner lambda code"); assert!( - inner_lambda.flags.contains(CodeFlags::NESTED), + inner_lambda.flags.contains(bytecode::CodeFlags::NESTED), "lambda inside lambda should be nested" ); let type_params = find_code(&code, "").expect("missing type params code"); assert!( - !type_params.flags.contains(CodeFlags::NESTED), + !type_params.flags.contains(bytecode::CodeFlags::NESTED), "module-level type-parameter scope should not be nested" ); let type_alias = find_direct_child_code(type_params, "A").expect("missing type alias code"); assert!( - type_alias.flags.contains(CodeFlags::NESTED), + type_alias.flags.contains(bytecode::CodeFlags::NESTED), "type alias body inside type-parameter scope should be nested" ); } @@ -26699,21 +30184,90 @@ def f(cm, func, args, kwds): let return_positions: Vec<_> = f .instructions .iter() - .zip(&f.locations) - .filter_map(|(unit, (location, end_location))| { - matches!(unit.op, Instruction::ReturnValue).then_some(( - location.line.get(), - location.character_offset.get(), - end_location.line.get(), - end_location.character_offset.get(), + .zip(&f.locations) + .filter_map(|(unit, (location, end_location))| { + matches!(unit.op, Instruction::ReturnValue).then_some(( + location.line.get(), + location.character_offset.get(), + end_location.line.get(), + end_location.character_offset.get(), + )) + }) + .collect(); + + assert_eq!( + return_positions, + vec![(2, 10, 2, 12), (2, 10, 2, 12)], + "CPython codegen_unwind_fblock(WITH) leaves RETURN_VALUE inheriting the context expression location" + ); + } + + #[test] + fn with_normal_cleanup_jump_uses_context_expr_location_like_cpython() { + let source = "\ +with cm: + pass +x = 1 +"; + let mut opts = CompileOpts::default(); + let source_file = SourceFileBuilder::new("source_path", source).finish(); + let parsed = ruff_python_parser::parse( + source_file.source_text(), + ruff_python_parser::Mode::Module.into(), + ) + .unwrap(); + let mut ast = parsed.into_syntax(); + opts.future_features |= preprocess::future_features(&ast); + let future_annotations = opts + .future_features + .contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS); + preprocess::preprocess_mod(&mut ast, opts.optimize, future_annotations, false); + let ast = match ast { + ruff_python_ast::Mod::Module(stmts) => stmts, + _ => unreachable!(), + }; + let symbol_table = SymbolTable::scan_program_with_options( + &ast, + source_file.clone(), + opts.allow_top_level_await, + opts.future_features + .contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS), + opts.recursion_limit, + ) + .map_err(|e| e.into_codegen_error(source_file.name().to_owned())) + .unwrap(); + let mut compiler = + Compiler::new_with_syntax_warning_handler(opts, source_file, "", None); + compiler.compile_program(&ast, symbol_table).unwrap(); + + let jump_positions = compiler + .current_code_info() + .blocks + .iter() + .flat_map(|block| block.used_instructions()) + .filter_map(|info| { + matches!( + info.instr, + AnyInstruction::Pseudo(PseudoInstruction::Jump { .. }) + ) + .then_some(( + ( + info.location.line.get(), + info.location.character_offset.get(), + info.end_location.line.get(), + info.end_location.character_offset.get(), + ), + info.lineno_override, )) }) - .collect(); + .collect::>(); - assert_eq!( - return_positions, - vec![(2, 10, 2, 12), (2, 10, 2, 12)], - "CPython codegen_unwind_fblock(WITH) leaves RETURN_VALUE inheriting the context expression location" + assert!( + jump_positions + .iter() + .any(|(position, lineno_override)| *position == (1, 6, 1, 8) + && *lineno_override != Some(ir::NO_LOCATION_OVERRIDE)), + "CPython codegen_with_inner() emits the normal-exit JUMP at LOC(context_expr), not NO_LOCATION; got {jump_positions:?}" ); } @@ -27029,6 +30583,44 @@ def f(x): assert_eq!(join_attr_count, 1); } + #[test] + fn large_fstring_join_scaffolding_uses_joinedstr_location_like_cpython() { + let mut source = String::from("def f(x):\n return f\""); + for _ in 0..=STACK_USE_GUIDELINE { + source.push_str("{x}"); + } + source.push_str("\"\n"); + + let code = compile_exec(&source); + let f = find_code(&code, "f").expect("missing function code"); + let fstring_end = " return ".len() + + 3 + + 3 * usize::try_from(STACK_USE_GUIDELINE + 1).expect("guideline overflowed") + + 1; + let expected = (2, 12, 2, fstring_end); + + for (unit, (location, end_location)) in f.instructions.iter().zip(&f.locations) { + if matches!( + unit.op, + Instruction::BuildList { .. } + | Instruction::ListAppend { .. } + | Instruction::Call { .. } + ) { + assert_eq!( + ( + location.line.get(), + location.character_offset.get(), + end_location.line.get(), + end_location.character_offset.get(), + ), + expected, + "CPython codegen_joined_str() emits join scaffolding at LOC(JoinedStr); this direct codegen path uses the parser's FString range, op={:?}", + unit.op + ); + } + } + } + #[test] fn large_power_is_not_constant_folded() { let code = compile_exec("x = 2**100\n"); @@ -27529,6 +31121,65 @@ class C: assert_eq!(varnames, vec!["format"]); } + #[test] + fn future_function_signature_annotation_uses_hidden_block_like_cpython() { + let code = compile_exec( + "\ +from __future__ import annotations +def f(x: T): pass +", + ); + let annotate = find_code(&code, "__annotate__").expect("missing __annotate__ code"); + let varnames = annotate + .varnames + .iter() + .map(|name| name.as_str()) + .collect::>(); + assert_eq!(varnames, vec!["format"]); + assert!( + find_code(&code, "f").is_some(), + "function body symbol-table cursor must skip the hidden AnnotationBlock" + ); + } + + #[test] + fn deferred_annotation_format_name_does_not_capture_helper_parameter() { + let code = compile_exec( + "\ +format = object() +x: format +", + ); + let annotate = find_code(&code, "__annotate__").expect("missing __annotate__ code"); + let varnames = annotate + .varnames + .iter() + .map(|name| name.as_str()) + .collect::>(); + assert_eq!(varnames, vec!["format"]); + assert!( + annotate.names.iter().any(|name| name.as_str() == "format"), + "CPython keeps the helper parameter as internal .format during symbol analysis, so annotation expression `format` must remain a separate name; got names={:?}", + annotate.names + ); + + let helper_param_loads = annotate + .instructions + .iter() + .filter(|unit| match unit.op { + Instruction::LoadFast { var_num } | Instruction::LoadFastBorrow { var_num } => { + let arg = OpArg::new(u32::from(u8::from(unit.arg))); + annotate.varnames[usize::from(var_num.get(arg))].as_str() == "format" + } + _ => false, + }) + .count(); + assert_eq!( + helper_param_loads, 1, + "only the CPython format-validation prologue should load the helper parameter; annotation expression `format` must not compile as LOAD_FAST" + ); + } + #[test] fn non_simple_class_annotation_is_not_deferred_like_cpython() { let code = compile_exec( @@ -27576,6 +31227,106 @@ class C: ); } + #[test] + fn class_deferred_annotations_guard_only_conditional_entries_like_cpython() { + let code = compile_exec( + "\ +class C: + x: int + if flag: + y: str + z: float +", + ); + let class_code = find_code(&code, "C").expect("missing class code"); + let class_ops: Vec<_> = class_code + .instructions + .iter() + .map(|unit| unit.op) + .filter(|op| !matches!(op, Instruction::Cache)) + .collect(); + let class_set_adds = class_ops + .iter() + .filter(|op| matches!(op, Instruction::SetAdd { .. })) + .count(); + assert_eq!( + class_set_adds, 1, + "CPython _PyCompile_AddDeferredAnnotation() adds class annotations to __conditional_annotations__ only inside conditional blocks, got ops={class_ops:?}" + ); + assert!( + class_code.instructions.iter().any(|unit| match unit.op { + Instruction::LoadDeref { i } => { + let idx = i.get(OpArg::new(u32::from(u8::from(unit.arg)))).as_usize(); + localsplus_name(class_code, idx) == Some("__conditional_annotations__") + } + _ => false, + }), + "CPython codegen_annassign() emits LOAD_DEREF for class __conditional_annotations__, got ops={class_ops:?}" + ); + assert!( + !class_code + .instructions + .iter() + .any(|unit| matches!(unit.op, Instruction::LoadFromDictOrDeref { .. })), + "CPython codegen_annassign() bypasses codegen_nameop for class __conditional_annotations__, got ops={class_ops:?}" + ); + + let annotate = find_code(class_code, "__annotate__").expect("missing __annotate__ code"); + let annotate_ops: Vec<_> = annotate + .instructions + .iter() + .map(|unit| unit.op) + .filter(|op| !matches!(op, Instruction::Cache)) + .collect(); + let annotation_body = annotate_ops + .iter() + .position(|op| matches!(op, Instruction::BuildMap { .. })) + .map(|idx| &annotate_ops[idx..]) + .expect("missing annotation map build"); + let guarded_entries = annotation_body + .iter() + .filter(|op| matches!(op, Instruction::PopJumpIfFalse { .. })) + .count(); + assert_eq!( + guarded_entries, 1, + "CPython codegen_deferred_annotations_body() guards only conditional class annotations, got ops={annotate_ops:?}" + ); + } + + #[test] + fn future_annotations_non_simple_target_checks_target_but_not_annotation_like_cpython() { + let code = compile_exec( + "\ +from __future__ import annotations +class C: + target[item]: missing +", + ); + let class_code = find_code(&code, "C").expect("missing class code"); + let loaded_names: Vec<_> = class_code + .instructions + .iter() + .filter_map(|unit| match unit.op { + Instruction::LoadName { namei } => { + let idx = namei.get(OpArg::new(u32::from(u8::from(unit.arg)))); + Some(class_code.names[usize::try_from(idx).unwrap()].as_str()) + } + _ => None, + }) + .collect(); + + assert!( + ["target", "item"] + .iter() + .all(|name| loaded_names.contains(name)), + "CPython codegen_annassign() still checks bare complex annotation targets under future annotations, got loaded_names={loaded_names:?}" + ); + assert!( + !loaded_names.contains(&"missing"), + "CPython codegen_check_annotation() skips the annotation expression under future annotations, got loaded_names={loaded_names:?}" + ); + } + #[test] fn type_param_evaluator_uses_dot_format_varname() { let code = compile_exec( @@ -27680,7 +31431,7 @@ def func[T](a: T = 'a', *, b: T = 'b'): } #[test] - fn generic_function_type_params_varnames_include_defaults_like_cpython() { + fn generic_function_type_params_omit_defaults_without_defaults_like_cpython() { let code = compile_exec( "\ def func[T](): @@ -27696,8 +31447,29 @@ def func[T](): .iter() .map(String::as_str) .collect::>(), - vec![".defaults", "T"] + vec!["T"] + ); + } + + #[test] + fn generic_function_type_params_split_defaults_like_cpython() { + let code = compile_exec( + "\ +def with_pos[T](a: T = 1): + pass +def with_kw[U](*, a: U = 1): + pass +", ); + let with_pos = + find_code(&code, "").expect("missing type params code"); + let with_kw = + find_code(&code, "").expect("missing type params code"); + + assert!(with_pos.varnames.iter().any(|name| name == ".defaults")); + assert!(!with_pos.varnames.iter().any(|name| name == ".kwdefaults")); + assert!(!with_kw.varnames.iter().any(|name| name == ".defaults")); + assert!(with_kw.varnames.iter().any(|name| name == ".kwdefaults")); } #[test] @@ -27925,6 +31697,39 @@ class C[T]: } } + #[test] + fn non_inlined_listcomp_return_uses_comprehension_location_like_cpython() { + let code = compile_exec( + "\ +class C[T]: + class Inner[U]( + make_base([T for _ in (1,)]) + ): + pass +", + ); + let listcomp = find_code(&code, "").expect("missing listcomp code"); + let return_positions: Vec<_> = listcomp + .instructions + .iter() + .zip(&listcomp.locations) + .filter_map(|(unit, (location, end_location))| { + matches!(unit.op, Instruction::ReturnValue).then_some(( + location.line.get(), + location.character_offset.get(), + end_location.line.get(), + end_location.character_offset.get(), + )) + }) + .collect(); + + assert_eq!( + return_positions, + vec![(3, 19, 3, 36)], + "CPython codegen_comprehension() emits non-gen RETURN_VALUE at LOC(e)" + ); + } + #[test] fn class_annotation_global_resolution_matches_cpython() { let class_global = compile_exec( @@ -28280,6 +32085,21 @@ def tuple_or_tuple(): ); } + #[test] + fn chained_compare_jump_if_runs_cpython_check_compare_warning() { + let message = first_exec_warning( + "\ +def f(x): + if 1 is 1 < x: + return x +", + ); + assert!( + message.contains("\"is\" with 'int' literal"), + "CPython codegen_jump_if() checks chained comparisons before conditional lowering, got {message:?}" + ); + } + #[test] fn lambda_without_body_constants_keeps_none_like_cpython() { let code = compile_exec("f = lambda x: x"); @@ -28293,6 +32113,17 @@ def tuple_or_tuple(): ); } + #[test] + fn generator_lambda_without_body_constants_omits_none_like_cpython() { + let code = compile_exec("f = lambda x: (yield x)"); + let lambda = find_code(&code, "").expect("missing lambda code"); + + assert!( + lambda.constants.is_empty(), + "CPython codegen_lambda() assembles generator lambdas with addNone=0" + ); + } + #[test] fn call_function_ex_empty_args_tuple_is_folded_late_like_cpython() { let code = compile_exec( @@ -28425,6 +32256,39 @@ f = lambda x: x in {0} ))); } + #[test] + fn frozenset_membership_consts_deduplicate_like_cpython_constant_key() { + let code = compile_exec( + "\ +def f(x): + return x in {1, 2}, x in {2, 1}, x in {1, 1} +", + ); + let f = find_code(&code, "f").expect("missing function code"); + let frozensets: Vec<_> = f + .constants + .iter() + .filter_map(|constant| match constant { + ConstantData::Frozenset { elements } => Some(elements.as_slice()), + _ => None, + }) + .collect(); + + assert_eq!( + frozensets.len(), + 2, + "CPython folds equal frozensets to the same const key and removes duplicate set items" + ); + assert!( + frozensets.iter().any(|elements| elements.len() == 2), + "missing shared frozenset constant for {{1, 2}} and {{2, 1}}" + ); + assert!( + frozensets.iter().any(|elements| elements.len() == 1), + "missing duplicate-collapsed frozenset constant for {{1, 1}}" + ); + } + #[test] fn nonconstant_list_membership_uses_tuple() { let code = compile_exec( @@ -29210,7 +33074,7 @@ deoptmap = { } let comp = symbol_table - .sub_tables + .inlined_comprehension_blocks .first() .expect("missing comprehension symbol table"); assert!(comp.comp_inlined, "expected comprehension to be inlined"); @@ -29609,6 +33473,34 @@ values = ( ); } + #[test] + fn single_mode_returns_none_after_print_like_cpython() { + let code = compile_single("1\n"); + let ops = code + .instructions + .iter() + .filter(|unit| !matches!(unit.op, Instruction::Resume { .. })) + .collect::>(); + + assert!( + !ops.iter() + .any(|unit| matches!(unit.op, Instruction::Copy { .. })), + "CPython codegen_stmt_expr() prints and pops interactive expressions; it does not preserve the final expression as the code object's return value, got ops={ops:?}" + ); + let Some(load_none) = ops.iter().rev().nth(1) else { + panic!("missing final LOAD_CONST None before RETURN_VALUE, got ops={ops:?}"); + }; + let Instruction::LoadConst { consti } = load_none.op else { + panic!("missing final LOAD_CONST None before RETURN_VALUE, got ops={ops:?}"); + }; + let constant = &code.constants[consti.get(OpArg::new(u32::from(u8::from(load_none.arg))))]; + assert!(matches!(constant, ConstantData::None)); + assert!(matches!( + ops.last().map(|unit| unit.op), + Some(Instruction::ReturnValue) + )); + } + #[test] fn folded_multiline_bytes_binop_does_not_leave_operand_nops() { let code = compile_exec( diff --git a/crates/codegen/src/error.rs b/crates/codegen/src/error.rs index fb848354e86..9f11eba946f 100644 --- a/crates/codegen/src/error.rs +++ b/crates/codegen/src/error.rs @@ -3,21 +3,6 @@ use core::fmt::Display; use rustpython_compiler_core::SourceLocation; use thiserror::Error; -#[derive(Clone, Copy, Debug)] -pub enum PatternUnreachableReason { - NameCapture, - Wildcard, -} - -impl Display for PatternUnreachableReason { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::NameCapture => write!(f, "name capture"), - Self::Wildcard => write!(f, "wildcard"), - } - } -} - // pub type CodegenError = rustpython_parser_core::source_code::LocatedError; #[derive(Error, Debug)] @@ -70,6 +55,8 @@ pub enum CodegenErrorType { SyntaxError(String), /// Multiple `*` detected MultipleStarArgs, + MultipleStarredExpressionsInSequencePattern, + MultipleStarredNamesInSequencePattern, /// Misplaced `*` expression InvalidStarExpr, /// Break statement outside of loop. @@ -87,14 +74,17 @@ pub enum CodegenErrorType { AsyncReturnValue, InvalidFuturePlacement, InvalidFutureFeature(String), - FunctionImportStar, + InvalidFutureBraces, + RecursionError, TooManyStarUnpack, + TooManyExpressionsInStarUnpackingSequencePattern, EmptyWithItems, EmptyWithBody, ForbiddenName, DuplicateStore(String), - UnreachablePattern(PatternUnreachableReason), - RepeatedAttributePattern, + UnreachableWildcardPattern, + UnreachableNameCapturePattern(String), + RepeatedAttributePattern(String), ConflictingNameBindPattern, /// break/continue/return inside except* block BreakContinueReturnInExceptStar, @@ -112,6 +102,12 @@ impl fmt::Display for CodegenErrorType { Self::MultipleStarArgs => { write!(f, "multiple starred expressions in assignment") } + Self::MultipleStarredExpressionsInSequencePattern => { + write!(f, "multiple starred expressions in sequence pattern") + } + Self::MultipleStarredNamesInSequencePattern => { + write!(f, "multiple starred names in sequence pattern") + } Self::InvalidStarExpr => write!(f, "can't use starred expression here"), Self::InvalidBreak => write!(f, "'break' outside loop"), Self::InvalidContinue => write!(f, "'continue' not properly in loop"), @@ -128,9 +124,7 @@ impl fmt::Display for CodegenErrorType { ) } Self::AsyncYieldFrom => write!(f, "'yield from' inside async function"), - Self::AsyncReturnValue => { - write!(f, "'return' with value inside async generator") - } + Self::AsyncReturnValue => write!(f, "'return' with value in async generator"), Self::InvalidFuturePlacement => write!( f, "from __future__ imports must occur at the beginning of the file" @@ -138,12 +132,16 @@ impl fmt::Display for CodegenErrorType { Self::InvalidFutureFeature(feat) => { write!(f, "future feature {feat} is not defined") } - Self::FunctionImportStar => { - write!(f, "import * only allowed at module level") + Self::InvalidFutureBraces => write!(f, "not a chance"), + Self::RecursionError => { + write!(f, "maximum recursion depth exceeded during compilation") } Self::TooManyStarUnpack => { write!(f, "too many expressions in star-unpacking assignment") } + Self::TooManyExpressionsInStarUnpackingSequencePattern => { + write!(f, "too many expressions in star-unpacking sequence pattern") + } Self::EmptyWithItems => { write!(f, "empty items on With") } @@ -153,14 +151,18 @@ impl fmt::Display for CodegenErrorType { Self::ForbiddenName => { write!(f, "forbidden attribute name") } - Self::DuplicateStore(s) => { - write!(f, "duplicate store {s}") + Self::DuplicateStore(s) => write!(f, "multiple assignments to name '{s}' in pattern"), + Self::UnreachableWildcardPattern => { + write!(f, "wildcard makes remaining patterns unreachable") } - Self::UnreachablePattern(reason) => { - write!(f, "{reason} makes remaining patterns unreachable") + Self::UnreachableNameCapturePattern(name) => { + write!( + f, + "name capture '{name}' makes remaining patterns unreachable" + ) } - Self::RepeatedAttributePattern => { - write!(f, "attribute name repeated in class pattern") + Self::RepeatedAttributePattern(name) => { + write!(f, "attribute name repeated in class pattern: {name}") } Self::ConflictingNameBindPattern => { write!(f, "alternative patterns bind different names") diff --git a/crates/codegen/src/ir.rs b/crates/codegen/src/ir.rs index 548b57d85a4..b369ec37df6 100644 --- a/crates/codegen/src/ir.rs +++ b/crates/codegen/src/ir.rs @@ -78,15 +78,108 @@ impl ConstantPool { } } - pub fn insert_full(&mut self, constant: ConstantData) -> (usize, bool) { - // CPython's _PyCode_ConstantKey() keeps NaN-bearing constants distinct - // because Python-level NaN keys do not compare equal. - if !Self::constant_contains_nan(&constant) - && let Some(idx) = self - .constants + fn frozenset_key_contains(elements: &[ConstantData], needle: &ConstantData) -> bool { + if Self::constant_contains_nan(needle) { + return false; + } + elements.iter().any(|element| { + !Self::constant_contains_nan(element) && Self::constant_key_eq(element, needle) + }) + } + + fn frozenset_key_eq(left: &[ConstantData], right: &[ConstantData]) -> bool { + left.iter() + .all(|element| Self::frozenset_key_contains(right, element)) + && right .iter() - .position(|existing| existing == &constant) - { + .all(|element| Self::frozenset_key_contains(left, element)) + } + + fn constant_key_eq(left: &ConstantData, right: &ConstantData) -> bool { + match (left, right) { + (ConstantData::Tuple { elements: left }, ConstantData::Tuple { elements: right }) => { + left.len() == right.len() + && left + .iter() + .zip(right.iter()) + .all(|(left, right)| Self::constant_key_eq(left, right)) + } + ( + ConstantData::Frozenset { elements: left }, + ConstantData::Frozenset { elements: right }, + ) => Self::frozenset_key_eq(left, right), + (ConstantData::Slice { elements: left }, ConstantData::Slice { elements: right }) => { + left.iter() + .zip(right.iter()) + .all(|(left, right)| Self::constant_key_eq(left, right)) + } + _ => left == right, + } + } + + fn canonicalize_constant_key(constant: ConstantData) -> crate::InternalResult { + match constant { + ConstantData::Tuple { elements } => { + let mut canonical = Vec::new(); + canonical + .try_reserve_exact(elements.len()) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + for element in elements { + canonical.push(Self::canonicalize_constant_key(element)?); + } + Ok(ConstantData::Tuple { + elements: canonical, + }) + } + ConstantData::Slice { elements } => { + let [start, stop, step] = *elements; + Ok(ConstantData::Slice { + elements: Box::new([ + Self::canonicalize_constant_key(start)?, + Self::canonicalize_constant_key(stop)?, + Self::canonicalize_constant_key(step)?, + ]), + }) + } + ConstantData::Frozenset { elements } => { + let mut canonical = Vec::new(); + canonical + .try_reserve_exact(elements.len()) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + for element in elements { + let element = Self::canonicalize_constant_key(element)?; + if !Self::frozenset_key_contains(&canonical, &element) { + canonical.push(element); + } + } + Ok(ConstantData::Frozenset { + elements: canonical, + }) + } + other => Ok(other), + } + } + + fn canonicalize_constant_key_infallible(constant: ConstantData) -> ConstantData { + Self::canonicalize_constant_key(constant) + .expect("constant key canonicalization only fails on allocation error") + } + + /// Index of an already-stored constant equal to `constant`, if any. + /// _PyCode_ConstantKey() keeps NaN-bearing constants distinct because + /// Python-level NaN keys do not compare equal. + fn find_existing(&self, constant: &ConstantData) -> Option { + if Self::constant_contains_nan(constant) { + return None; + } + self.constants + .iter() + .position(|existing| Self::constant_key_eq(existing, constant)) + } + + pub fn insert_full(&mut self, constant: ConstantData) -> (usize, bool) { + let constant = Self::canonicalize_constant_key_infallible(constant); + if let Some(idx) = self.find_existing(&constant) { return (idx, false); } let idx = self.constants.len(); @@ -95,14 +188,8 @@ impl ConstantPool { } fn try_insert_full(&mut self, constant: ConstantData) -> crate::InternalResult<(usize, bool)> { - // CPython's _PyCode_ConstantKey() keeps NaN-bearing constants distinct - // because Python-level NaN keys do not compare equal. - if !Self::constant_contains_nan(&constant) - && let Some(idx) = self - .constants - .iter() - .position(|existing| existing == &constant) - { + let constant = Self::canonicalize_constant_key(constant)?; + if let Some(idx) = self.find_existing(&constant) { return Ok((idx, false)); } self.constants @@ -1485,6 +1572,7 @@ impl Blocks { | PseudoInstruction::JumpIfTrue { .. }), ) => { let opcode = pseudo.into(); + let opcode_is_false = matches!(pseudo, PseudoInstruction::JumpIfFalse { .. }); match target.instr.pseudo().map(Into::into) { Some(PseudoOpcode::Jump) if self.jump_thread(block_idx, i, &target, opcode)? => @@ -1492,22 +1580,25 @@ impl Blocks { continue; } Some(PseudoOpcode::JumpIfFalse) - if matches!( - opcode, - AnyInstruction::Pseudo(PseudoInstruction::JumpIfFalse { .. }) - ) && self.jump_thread(block_idx, i, &target, opcode)? => + if opcode_is_false + && self.jump_thread(block_idx, i, &target, opcode)? => { continue; } Some(PseudoOpcode::JumpIfTrue) - if matches!( - opcode, - AnyInstruction::Pseudo(PseudoInstruction::JumpIfTrue { .. }) - ) && self.jump_thread(block_idx, i, &target, opcode)? => + if !opcode_is_false + && self.jump_thread(block_idx, i, &target, opcode)? => { continue; } - Some(PseudoOpcode::JumpIfFalse | PseudoOpcode::JumpIfTrue) => { + Some(PseudoOpcode::JumpIfTrue) if opcode_is_false => { + let next = self[inst.target].next; + debug_assert!(next != BlockIdx::NULL); + debug_assert!(next != inst.target); + self[block_idx].instructions[i].target = next; + continue; + } + Some(PseudoOpcode::JumpIfFalse) if !opcode_is_false => { let next = self[inst.target].next; debug_assert!(next != BlockIdx::NULL); debug_assert!(next != inst.target); @@ -2335,10 +2426,10 @@ impl Blocks { Ok(()) } - /// flowgraph.c mark_cold (two-pass to match CPython). + /// flowgraph.c mark_cold (two-pass). /// /// Phase 1 (mark_warm): propagate "warm" from entry via fall-through and - /// jump targets. CPython asserts while visiting warm blocks that they are not + /// jump targets. The pass asserts while visiting warm blocks that they are not /// exception handlers. /// /// Phase 2 (mark_cold): propagate "cold" from except_handler blocks via @@ -2349,7 +2440,7 @@ impl Blocks { /// empty unreachable placeholders left by remove_unreachable; they stay in /// their original chain position (e.g. between entry and the post-try /// continuation for a nested try/except whose inner_end was emptied by - /// optimize_cfg). This matches CPython's behavior and is necessary for + /// optimize_cfg). This is necessary for /// optimize_load_fast to terminate fall-through at those placeholders. /// flowgraph.c mark_warm fn mark_warm(&mut self) -> crate::InternalResult<()> { @@ -2981,42 +3072,12 @@ impl Blocks { } } -impl From> for Blocks { - fn from(value: Vec) -> Self { - Self(value) - } -} - -impl From> for Blocks { - fn from(value: Box<[Block]>) -> Self { - Self(value.into()) - } -} - -impl From<&[Block]> for Blocks { - fn from(value: &[Block]) -> Self { - Self(value.to_vec()) - } -} - -impl From<&mut [Block]> for Blocks { - fn from(value: &mut [Block]) -> Self { - Self(value.to_vec()) - } -} - impl From<[Block; N]> for Blocks { fn from(value: [Block; N]) -> Self { Self(value.into()) } } -impl From<&[Block; N]> for Blocks { - fn from(value: &[Block; N]) -> Self { - Self(value.to_vec()) - } -} - impl Deref for Blocks { type Target = [Block]; @@ -3060,7 +3121,7 @@ impl IndexMut for Blocks { } pub(crate) const START_DEPTH_UNSET: i32 = i32::MIN; -const CO_MAXBLOCKS: usize = 20; +const CO_MAXBLOCKS: usize = 21; /// flowgraph.c struct _PyCfgExceptStack #[derive(Clone, Debug)] @@ -3099,7 +3160,7 @@ impl CfgTraversalStack { #[derive(Clone, Debug)] pub(crate) struct InstructionSequenceLabelMap { block_labels: Vec, - /// Codegen-side shadow of CPython's instruction-sequence label map. + /// Codegen-side shadow of the instruction-sequence label map. /// /// `_PyInstructionSequence_UseLabel()` can map multiple labels to the same /// instruction offset before `_PyCfg_FromInstructionSequence()` materializes @@ -3305,7 +3366,7 @@ pub struct CodeInfo { // Reference to the symbol table for this scope pub symbol_table_index: usize, - // CPython compile.c uses PyList_GET_SIZE(u->u_ste->ste_varnames) + // compile.c uses PyList_GET_SIZE(u->u_ste->ste_varnames) // when calling flowgraph.c _PyCfg_OptimizeCodeUnit(). pub nparams: usize, @@ -3488,7 +3549,7 @@ impl CodeInfo { } fn prepare_cfg_from_codegen(&mut self) -> crate::InternalResult { - // CPython compile.c optimize_and_assemble_code_unit passes + // compile.c optimize_and_assemble_code_unit passes // u_instr_sequence directly into flowgraph.c _PyCfg_FromInstructionSequence(). self.take_recorded_instr_sequence() } @@ -3509,12 +3570,12 @@ fn optimize_code_unit( optimize_cfg(metadata, blocks, metadata.firstlineno)?; blocks.remove_unused_consts(&mut metadata.consts)?; add_checks_for_loads_of_uninitialized_variables(blocks, nlocals, nparams)?; - // CPython inserts superinstructions in _PyCfg_OptimizeCodeUnit, before + // Superinstructions are inserted in _PyCfg_OptimizeCodeUnit, before // later jump normalization / block reordering can create adjacencies // that never exist at this stage in flowgraph.c. blocks.insert_superinstructions()?; blocks.push_cold_blocks_to_end()?; - // CPython resolves line numbers again after cold-block extraction. + // Line numbers are resolved again after cold-block extraction. blocks.resolve_line_numbers(metadata.firstlineno)?; Ok(()) } @@ -3525,20 +3586,20 @@ fn optimize_cfg( firstlineno: OneIndexed, ) -> crate::InternalResult<()> { // flowgraph.c optimize_cfg - // CPython optimize_cfg() starts with check_cfg() and raises + // optimize_cfg() starts with check_cfg() and raises // SystemError if a jump or scope exit is not the last instruction in // its block. blocks.check_cfg()?; blocks.inline_small_or_no_lineno_blocks()?; - // CPython does not re-run instruction-sequence label-map/CFG conversion + // The instruction-sequence label-map/CFG conversion is not re-run // after this point. Unreferenced label blocks left by jump inlining // remain block boundaries and can preserve line-marker NOPs. blocks.remove_unreachable()?; - // CPython optimize_cfg resolves line numbers before local checks and + // optimize_cfg resolves line numbers before local checks and // superinstruction insertion, so fusion decisions see propagated // source locations. blocks.resolve_line_numbers(firstlineno)?; - // CPython optimize_cfg() runs optimize_load_const() and then + // optimize_cfg() runs optimize_load_const() and then // optimize_basic_block() after line numbers are resolved. optimize_load_const(metadata, blocks)?; let mut block_idx = BlockIdx(0); @@ -3548,7 +3609,7 @@ fn optimize_cfg( block_idx = next_block; } blocks.remove_redundant_nops_and_pairs()?; - // CPython optimize_cfg() removes newly-unreachable blocks and + // optimize_cfg() removes newly-unreachable blocks and // redundant NOP/jump chains before _PyCfg_OptimizeCodeUnit() prunes // unused constants. blocks.remove_unreachable()?; @@ -3568,7 +3629,7 @@ fn optimized_cfg_to_instruction_sequence( let max_stackdepth = blocks.calculate_stackdepth()?; debug_assert!(!is_generator(flags) || max_stackdepth != 0); let nlocalsplus = prepare_localsplus(metadata, blocks, flags)?; - // Match CPython order: pseudo ops are lowered after stackdepth and + // Pseudo ops are lowered after stackdepth and // localsplus preparation, before normalize_jumps. convert_pseudo_ops(blocks)?; blocks.normalize_jumps()?; @@ -3636,6 +3697,9 @@ impl CodeInfo { kwonlyargcount: kwonlyarg_count, firstlineno: first_line_number, } = metadata; + let code_arg_count = posonlyarg_count + .checked_add(arg_count) + .ok_or(InternalError::MalformedControlFlowGraph)?; resolve_unconditional_jumps(&mut instr_sequence)?; resolve_jump_offsets(&mut instr_sequence)?; @@ -3653,7 +3717,7 @@ impl CodeInfo { Ok(CodeObject { flags, posonlyarg_count, - arg_count, + arg_count: code_arg_count, kwonlyarg_count, source_path, first_line_number: Some(first_line_number), @@ -4109,8 +4173,13 @@ fn const_folding_safe_multiply(left: &ConstantData, right: &ConstantData) -> Opt const_folding_safe_multiply(right, left) } (ConstantData::Tuple { elements }, ConstantData::Integer { value: n }) => { + if elements.is_empty() { + return Some(ConstantData::Tuple { + elements: Vec::new(), + }); + } let n = n.to_usize()?; - if n != 0 && !elements.is_empty() { + if n != 0 { if n > MAX_COLLECTION_SIZE / elements.len() { return None; } @@ -4265,7 +4334,7 @@ fn eval_const_complex_binop( BinOp::Add => left + right, BinOp::Subtract => { let re = left.re - right.re; - // Preserve CPython's signed-zero behavior for real-zero + // Preserve signed-zero behavior for real-zero // minus zero-complex expressions such as `0 - 0j`. let im = if left.re == 0.0 && left.im == 0.0 @@ -4829,7 +4898,7 @@ fn fold_constant_intrinsic_list_to_tuple( Ok(false) } -/// Port of CPython's flowgraph.c optimize_lists_and_sets(). +/// Port of flowgraph.c optimize_lists_and_sets(). fn optimize_lists_and_sets( metadata: &mut CodeUnitMetadata, block: &mut Block, @@ -4889,8 +4958,7 @@ fn optimize_lists_and_sets( if !contains_or_iter { debug_assert!(i >= 2); - let folded_loc = block.instructions[i].location; - let end_loc = block.instructions[i].end_location; + let folded_loc = instr_location(&block.instructions[i]); nop_out(block, &operand_indices); @@ -4901,9 +4969,7 @@ fn optimize_lists_and_sets( } .into(); instr_set_op1(&mut block.instructions[i - 2], build_instr, OpArg::new(0)); - block.instructions[i - 2].location = folded_loc; - block.instructions[i - 2].end_location = end_loc; - block.instructions[i - 2].lineno_override = None; + instr_set_location(&mut block.instructions[i - 2], folded_loc); instr_set_op1( &mut block.instructions[i - 1], @@ -5152,7 +5218,7 @@ fn basicblock_optimize_load_const( block: &mut Block, ) -> crate::InternalResult<()> { let mut i = 0; - let mut effective_opcode = None; + let mut effective_opcode = Instruction::Nop.into(); let mut effective_oparg = OpArg::new(0); while i < block.instruction_used { if matches!( @@ -5166,21 +5232,19 @@ fn basicblock_optimize_load_const( let curr = block.instructions[i]; let curr_arg = curr.arg; - // Only combine if the source is a real instruction. - let Some(curr_instr) = curr.instr.real() else { - i += 1; - continue; - }; - let is_copy_of_load_const = matches!( - (effective_opcode, curr_instr), - (Some(Instruction::LoadConst { .. }), Instruction::Copy { i }) if i.get(curr_arg) == 1 + (effective_opcode, curr.instr.real()), + (AnyInstruction::Real(Instruction::LoadConst { .. }), Some(Instruction::Copy { i })) + if i.get(curr_arg) == 1 ); if !is_copy_of_load_const { - effective_opcode = Some(curr_instr); + effective_opcode = curr.instr; effective_oparg = curr_arg; } - let Some(const_instr) = effective_opcode else { + debug_assert!(!effective_opcode.is_assembler()); + let Some(const_instr @ (Instruction::LoadConst { .. } | Instruction::LoadSmallInt { .. })) = + effective_opcode.real() + else { i += 1; continue; }; @@ -5273,7 +5337,7 @@ fn basicblock_optimize_load_const( Opcode::PopJumpIfNone } .into(); - i = jump_idx; + i += 1; continue; } } @@ -6557,21 +6621,19 @@ fn get_max_label(blocks: &Blocks) -> i32 { } /// flowgraph.c make_except_stack -#[allow(clippy::unnecessary_wraps)] -fn make_except_stack() -> crate::InternalResult { +fn make_except_stack() -> CfgExceptStack { let handlers = [BlockIdx::NULL; CO_MAXBLOCKS + 2]; debug_assert_eq!(handlers[0], BlockIdx::NULL); - Ok(CfgExceptStack { handlers, depth: 0 }) + CfgExceptStack { handlers, depth: 0 } } /// flowgraph.c copy_except_stack -#[allow(clippy::unnecessary_wraps)] -fn copy_except_stack(stack: &CfgExceptStack) -> crate::InternalResult { +fn copy_except_stack(stack: &CfgExceptStack) -> CfgExceptStack { debug_assert!(stack.depth <= CO_MAXBLOCKS + 1); - Ok(CfgExceptStack { + CfgExceptStack { handlers: stack.handlers, depth: stack.depth, - }) + } } /// flowgraph.c except_stack_top @@ -6623,7 +6685,7 @@ pub(crate) fn label_exception_targets(blocks: &mut Blocks) -> crate::InternalRes todo.push(BlockIdx(0)); blocks[0].visited = true; - blocks[0].except_stack = Some(make_except_stack()?); + blocks[0].except_stack = Some(make_except_stack()); while let Some(block_idx) = todo.pop() { let bi = block_idx.idx(); @@ -6650,7 +6712,7 @@ pub(crate) fn label_exception_targets(blocks: &mut Blocks) -> crate::InternalRes if !blocks[target].visited { blocks[target].except_stack = Some(copy_except_stack( stack.as_ref().expect("active exception stack"), - )?); + )); todo.push(target); blocks[target].visited = true; } @@ -6674,7 +6736,7 @@ pub(crate) fn label_exception_targets(blocks: &mut Blocks) -> crate::InternalRes if bb_has_fallthrough(&blocks[bi]) { blocks[target].except_stack = Some(copy_except_stack( stack.as_ref().expect("active exception stack"), - )?); + )); } else { blocks[target].except_stack = stack.take(); stack_transferred = true; @@ -6852,6 +6914,58 @@ pub(crate) fn fix_cell_offsets( #[cfg(test)] mod tests { use super::*; + use rustpython_compiler_core::bytecode::Arg; + + fn int_const(value: i32) -> ConstantData { + ConstantData::Integer { + value: BigInt::from(value), + } + } + + fn nan_const() -> ConstantData { + ConstantData::Float { value: f64::NAN } + } + + #[test] + fn constant_pool_frozenset_key_ignores_order_and_duplicates_like_cpython() { + let mut pool = ConstantPool::default(); + let (first, inserted) = pool.insert_full(ConstantData::Frozenset { + elements: vec![int_const(1), int_const(2)], + }); + assert_eq!(first, 0); + assert!(inserted); + + let (second, inserted) = pool.insert_full(ConstantData::Frozenset { + elements: vec![int_const(2), int_const(1), int_const(1)], + }); + assert_eq!( + second, first, + "CPython _PyCode_ConstantKey uses frozenset item keys, not insertion order" + ); + assert!(!inserted); + assert!(matches!( + &pool.constants[first], + ConstantData::Frozenset { elements } if elements.len() == 2 + )); + } + + #[test] + fn constant_pool_frozenset_key_preserves_nan_duplicates_like_cpython() { + let mut pool = ConstantPool::default(); + let (idx, inserted) = pool.insert_full(ConstantData::Frozenset { + elements: vec![nan_const(), nan_const()], + }); + + assert_eq!(idx, 0); + assert!(inserted); + assert!(matches!( + &pool.constants[idx], + ConstantData::Frozenset { elements } + if elements.iter().filter(|constant| { + matches!(constant, ConstantData::Float { value } if value.is_nan()) + }).count() == 2 + )); + } fn test_location(line: u32) -> SourceLocation { SourceLocation { @@ -6886,6 +7000,13 @@ mod tests { instr } + fn test_true_cond_jump(target: BlockIdx, line: u32) -> InstructionInfo { + let mut instr = test_instr(Instruction::Nop, line); + instr.instr = PseudoOpcode::JumpIfTrue.into(); + instr.target = target; + instr + } + fn test_block_push(block: &mut Block, info: InstructionInfo) { let off = basicblock_next_instr(block).expect("test block instruction slot"); block.instructions[off] = info; @@ -6970,7 +7091,7 @@ mod tests { #[test] fn except_stack_tracks_cpython_depth_and_handler_slots() { - let mut stack = make_except_stack().unwrap(); + let mut stack = make_except_stack(); assert_eq!(stack.depth, 0); assert_eq!(stack.handlers.len(), CO_MAXBLOCKS + 2); assert_eq!(stack.handlers[0], BlockIdx::NULL); @@ -6994,7 +7115,7 @@ mod tests { assert!(handler.preserve_lasti); assert!(blocks[1].preserve_lasti); - let copy = copy_except_stack(&stack).unwrap(); + let copy = copy_except_stack(&stack); assert_eq!(copy.depth, stack.depth); assert_eq!(copy.handlers, stack.handlers); @@ -7399,6 +7520,52 @@ mod tests { ); } + #[test] + fn optimize_load_const_pseudo_opcode_breaks_effective_load_const() { + let mut block = Block::default(); + test_block_push( + &mut block, + test_instr( + Instruction::LoadConst { + consti: Arg::marker(), + }, + 90, + ), + ); + test_block_push(&mut block, test_true_cond_jump(BlockIdx::new(0), 90)); + let mut copy = test_instr(Instruction::Copy { i: Arg::marker() }, 90); + copy.arg = OpArg::new(1); + test_block_push(&mut block, copy); + test_block_push(&mut block, test_instr(Instruction::ToBool, 90)); + + let mut code = test_code_info(block); + let (const_idx, _) = code.metadata.consts.insert_full(ConstantData::Tuple { + elements: vec![ConstantData::Integer { + value: BigInt::from(1), + }], + }); + code.blocks[0].instructions[0].arg = OpArg::new(const_idx as u32); + + optimize_load_const(&mut code.metadata, &mut code.blocks) + .expect("optimize_load_const succeeds"); + + // `basicblock_optimize_load_const()` assigns the current + // pseudo opcode to its effective opcode slot, so the following COPY 1 + // is not treated as a copy of the earlier LOAD_CONST. + assert!(matches!( + code.blocks[0].instructions[1].instr.pseudo(), + Some(PseudoInstruction::Jump { .. }) + )); + assert!(matches!( + code.blocks[0].instructions[2].instr.real(), + Some(Instruction::Copy { .. }) + )); + assert!(matches!( + code.blocks[0].instructions[3].instr.real(), + Some(Instruction::ToBool) + )); + } + #[test] fn optimize_load_fast_records_no_input_opcode_ref_at_cpython_produced_index() { let mut block = Block::default(); @@ -7488,6 +7655,24 @@ mod tests { )); } + #[test] + fn empty_tuple_repeat_folds_negative_count_like_cpython() { + let folded = const_folding_safe_multiply( + &ConstantData::Tuple { + elements: Vec::new(), + }, + &ConstantData::Integer { + value: BigInt::from(-1), + }, + ) + .expect("CPython skips repeat-count checks for empty tuples"); + + assert!(matches!( + folded, + ConstantData::Tuple { elements } if elements.is_empty() + )); + } + #[test] fn resolve_line_numbers_duplicates_exit_blocks_like_cpython() { let exit = BlockIdx::new(2); @@ -7621,4 +7806,51 @@ mod tests { assert_eq!(threaded.target, BlockIdx::new(3)); assert_eq!(u32::from(threaded.arg), 3); } + + #[test] + fn same_direction_pseudo_conditional_jump_thread_false_keeps_target() { + let mut blocks = Blocks::from([Block::default(), Block::default(), Block::default()]); + for (i, block) in blocks.iter_mut().enumerate() { + block.cpython_label = InstructionSequenceLabel::from_index(i as i32); + } + blocks[0].next = BlockIdx::new(1); + blocks[1].next = BlockIdx::new(2); + test_block_push(&mut blocks[0], test_cond_jump(BlockIdx::new(1), 10)); + test_block_push(&mut blocks[1], test_cond_jump(BlockIdx::new(1), 20)); + test_block_push(&mut blocks[2], test_instr(Instruction::ReturnValue, 30)); + + let mut metadata = test_code_info(Block::default()).metadata; + blocks + .optimize_basic_block(&mut metadata, BlockIdx::new(0)) + .expect("valid conditional jump chain"); + + // Only rewrite JUMP_IF_FALSE -> JUMP_IF_TRUE through + // target->b_next. For same-direction jumps, a failed jump_thread() + // leaves the original target unchanged. + assert_eq!(blocks[0].instructions[0].target, BlockIdx::new(1)); + assert!(matches!( + blocks[0].instructions[0].instr.pseudo(), + Some(PseudoInstruction::JumpIfFalse { .. }) + )); + } + + #[test] + fn opposite_direction_pseudo_conditional_uses_target_fallthrough() { + let mut blocks = Blocks::from([Block::default(), Block::default(), Block::default()]); + for (i, block) in blocks.iter_mut().enumerate() { + block.cpython_label = InstructionSequenceLabel::from_index(i as i32); + } + blocks[0].next = BlockIdx::new(1); + blocks[1].next = BlockIdx::new(2); + test_block_push(&mut blocks[0], test_cond_jump(BlockIdx::new(1), 10)); + test_block_push(&mut blocks[1], test_true_cond_jump(BlockIdx::new(2), 20)); + test_block_push(&mut blocks[2], test_instr(Instruction::ReturnValue, 30)); + + let mut metadata = test_code_info(Block::default()).metadata; + blocks + .optimize_basic_block(&mut metadata, BlockIdx::new(0)) + .expect("valid conditional jump chain"); + + assert_eq!(blocks[0].instructions[0].target, BlockIdx::new(2)); + } } diff --git a/crates/codegen/src/lib.rs b/crates/codegen/src/lib.rs index b598ab7e933..a7349a5762f 100644 --- a/crates/codegen/src/lib.rs +++ b/crates/codegen/src/lib.rs @@ -8,13 +8,15 @@ extern crate log; extern crate alloc; +use rustpython_compiler_core::bytecode::ConstantData; + type IndexMap = indexmap::IndexMap; type IndexSet = indexmap::IndexSet; pub mod compile; pub mod error; pub mod ir; -mod preprocess; +pub mod preprocess; mod string_parser; pub mod symboltable; mod unparse; @@ -24,6 +26,73 @@ use ruff_python_ast as ast; pub(crate) use compile::InternalResult; +#[cfg(test)] +pub(crate) fn constant_data_to_ast_constant_value(value: ConstantData) -> ast::ConstantValue { + match value { + ConstantData::None => ast::ConstantValue::None, + ConstantData::Boolean { value } => ast::ConstantValue::Boolean(value), + ConstantData::Str { value } => ast::ConstantValue::Str(value.to_string().into_boxed_str()), + ConstantData::Bytes { value } => ast::ConstantValue::Bytes(value.into_boxed_slice()), + ConstantData::Integer { value } => ast::ConstantValue::Integer(value.to_string().into()), + ConstantData::Tuple { elements } => ast::ConstantValue::Tuple( + elements + .into_iter() + .map(constant_data_to_ast_constant_value) + .collect(), + ), + ConstantData::Frozenset { elements } => ast::ConstantValue::Frozenset( + elements + .into_iter() + .map(constant_data_to_ast_constant_value) + .collect(), + ), + ConstantData::Float { value } => ast::ConstantValue::Float(value), + ConstantData::Complex { value } => ast::ConstantValue::Complex { + real: value.re, + imag: value.im, + }, + ConstantData::Ellipsis => ast::ConstantValue::Ellipsis, + ConstantData::Code { .. } | ConstantData::Slice { .. } => { + unreachable!("ast.Constant values cannot contain code objects or slices") + } + } +} + +pub(crate) fn ast_constant_value_to_constant_data(value: ast::ConstantValue) -> ConstantData { + match value { + ast::ConstantValue::None => ConstantData::None, + ast::ConstantValue::Boolean(value) => ConstantData::Boolean { value }, + ast::ConstantValue::Str(value) => ConstantData::Str { + value: value.to_string().into(), + }, + ast::ConstantValue::Bytes(value) => ConstantData::Bytes { + value: value.into_vec(), + }, + ast::ConstantValue::Integer(value) => ConstantData::Integer { + value: value + .parse() + .expect("RustPython ast.Constant integer values are decimal integers"), + }, + ast::ConstantValue::Tuple(elements) => ConstantData::Tuple { + elements: elements + .into_iter() + .map(ast_constant_value_to_constant_data) + .collect(), + }, + ast::ConstantValue::Frozenset(elements) => ConstantData::Frozenset { + elements: elements + .into_iter() + .map(ast_constant_value_to_constant_data) + .collect(), + }, + ast::ConstantValue::Float(value) => ConstantData::Float { value }, + ast::ConstantValue::Complex { real, imag } => ConstantData::Complex { + value: num_complex::Complex::new(real, imag), + }, + ast::ConstantValue::Ellipsis => ConstantData::Ellipsis, + } +} + pub trait ToPythonName { /// Returns a short name for the node suitable for use in error messages. fn python_name(&self) -> &'static str; @@ -48,6 +117,19 @@ impl ToPythonName for ast::Expr { } Self::EllipsisLiteral(_) => "ellipsis", Self::NoneLiteral(_) => "None", + Self::Constant(expr) => match &expr.value { + ast::ConstantValue::None => "None", + ast::ConstantValue::Boolean(true) => "True", + ast::ConstantValue::Boolean(false) => "False", + ast::ConstantValue::Ellipsis => "ellipsis", + ast::ConstantValue::Tuple(_) => "tuple", + ast::ConstantValue::Frozenset(_) => "literal", + ast::ConstantValue::Str(_) + | ast::ConstantValue::Bytes(_) + | ast::ConstantValue::Integer(_) + | ast::ConstantValue::Float(_) + | ast::ConstantValue::Complex { .. } => "literal", + }, Self::NumberLiteral(_) | Self::BytesLiteral(_) | Self::StringLiteral(_) => "literal", Self::Tuple(_) => "tuple", Self::List { .. } => "list", @@ -65,7 +147,7 @@ impl ToPythonName for ast::Expr { Self::Lambda { .. } => "lambda", Self::If { .. } => "conditional expression", Self::Named { .. } => "named expression", - Self::IpyEscapeCommand(_) => todo!(), + Self::IpyEscapeCommand(_) => "expression", } } } diff --git a/crates/codegen/src/preprocess.rs b/crates/codegen/src/preprocess.rs index ae2e65bf3fe..f6ca18b67ba 100644 --- a/crates/codegen/src/preprocess.rs +++ b/crates/codegen/src/preprocess.rs @@ -8,29 +8,401 @@ use ruff_python_ast::{ visitor::transformer::{self, Transformer}, }; use ruff_text_size::{Ranged, TextRange}; +use rustpython_compiler_core::bytecode; const MAXDIGITS: usize = 3; const F_LJUST: u8 = 1; -pub(crate) fn preprocess_mod(module: &mut ast::Mod) { - let preprocessor = AstPreprocessor; +/// ast_preprocess.c ControlFlowInFinallyContext +#[derive(Clone, Copy)] +struct ControlFlowInFinallyContext { + in_finally: bool, + in_funcdef: bool, + in_loop: bool, +} + +/// ast_preprocess.c before_return +fn before_return( + contexts: &[ControlFlowInFinallyContext], + range: TextRange, + warn: &mut impl FnMut(TextRange, String) -> Result<(), E>, +) -> Result<(), E> { + if let Some(ctx) = contexts.last() + && ctx.in_finally + && !ctx.in_funcdef + { + warn(range, "'return' in a 'finally' block".to_owned())?; + } + Ok(()) +} + +/// ast_preprocess.c before_loop_exit +fn before_loop_exit( + contexts: &[ControlFlowInFinallyContext], + range: TextRange, + kw: &str, + warn: &mut impl FnMut(TextRange, String) -> Result<(), E>, +) -> Result<(), E> { + if let Some(ctx) = contexts.last() + && ctx.in_finally + && !ctx.in_loop + { + warn(range, format!("'{kw}' in a 'finally' block"))?; + } + Ok(()) +} + +fn visit_body_with_control_flow_context( + body: &[ast::Stmt], + contexts: &mut Vec, + warn: &mut impl FnMut(TextRange, String) -> Result<(), E>, + in_finally: bool, + in_funcdef: bool, + in_loop: bool, +) -> Result<(), E> { + contexts.push(ControlFlowInFinallyContext { + in_finally, + in_funcdef, + in_loop, + }); + visit_body_for_control_flow_in_finally(body, contexts, warn)?; + contexts.pop(); + Ok(()) +} + +fn visit_body_for_control_flow_in_finally( + body: &[ast::Stmt], + contexts: &mut Vec, + warn: &mut impl FnMut(TextRange, String) -> Result<(), E>, +) -> Result<(), E> { + for stmt in body { + visit_stmt_for_control_flow_in_finally(stmt, contexts, warn)?; + } + Ok(()) +} + +/// ast_preprocess.c astfold_stmt control-flow warning traversal. +fn visit_stmt_for_control_flow_in_finally( + stmt: &ast::Stmt, + contexts: &mut Vec, + warn: &mut impl FnMut(TextRange, String) -> Result<(), E>, +) -> Result<(), E> { + match stmt { + ast::Stmt::FunctionDef(function) => { + visit_body_with_control_flow_context( + &function.body, + contexts, + warn, + false, + true, + false, + )?; + } + ast::Stmt::ClassDef(class) => { + visit_body_for_control_flow_in_finally(&class.body, contexts, warn)?; + } + ast::Stmt::Return(return_stmt) => { + before_return(contexts, return_stmt.range, warn)?; + } + ast::Stmt::For(for_stmt) => { + visit_body_with_control_flow_context( + &for_stmt.body, + contexts, + warn, + false, + false, + true, + )?; + visit_body_for_control_flow_in_finally(&for_stmt.orelse, contexts, warn)?; + } + ast::Stmt::While(while_stmt) => { + visit_body_with_control_flow_context( + &while_stmt.body, + contexts, + warn, + false, + false, + true, + )?; + visit_body_for_control_flow_in_finally(&while_stmt.orelse, contexts, warn)?; + } + ast::Stmt::If(if_stmt) => { + visit_body_for_control_flow_in_finally(&if_stmt.body, contexts, warn)?; + for clause in &if_stmt.elif_else_clauses { + visit_body_for_control_flow_in_finally(&clause.body, contexts, warn)?; + } + } + ast::Stmt::Try(try_stmt) => { + visit_body_for_control_flow_in_finally(&try_stmt.body, contexts, warn)?; + for handler in &try_stmt.handlers { + match handler { + ast::ExceptHandler::ExceptHandler(handler) => { + visit_body_for_control_flow_in_finally(&handler.body, contexts, warn)?; + } + } + } + visit_body_for_control_flow_in_finally(&try_stmt.orelse, contexts, warn)?; + visit_body_with_control_flow_context( + &try_stmt.finalbody, + contexts, + warn, + true, + false, + false, + )?; + } + ast::Stmt::With(with_stmt) => { + visit_body_for_control_flow_in_finally(&with_stmt.body, contexts, warn)?; + } + ast::Stmt::Match(match_stmt) => { + for case in &match_stmt.cases { + visit_body_for_control_flow_in_finally(&case.body, contexts, warn)?; + } + } + ast::Stmt::Break(break_stmt) => { + before_loop_exit(contexts, break_stmt.range, "break", warn)?; + } + ast::Stmt::Continue(continue_stmt) => { + before_loop_exit(contexts, continue_stmt.range, "continue", warn)?; + } + _ => {} + } + Ok(()) +} + +/// ast_preprocess.c control_flow_in_finally_warning +pub fn warn_control_flow_in_finally( + module: &ast::Mod, + mut warn: impl FnMut(TextRange, String) -> Result<(), E>, +) -> Result<(), E> { + let mut contexts = Vec::new(); + match module { + ast::Mod::Module(module) => { + visit_body_for_control_flow_in_finally(&module.body, &mut contexts, &mut warn)?; + } + ast::Mod::Expression(_) => {} + } + Ok(()) +} + +pub fn has_future_annotations(module: &ast::Mod) -> bool { + future_features(module).contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS) +} + +pub fn future_features(module: &ast::Mod) -> bytecode::CodeFlags { + checked_future_features(module).unwrap_or_else(|err| err.features) +} + +pub struct FutureFeatureError { + pub features: bytecode::CodeFlags, + pub range: TextRange, + pub kind: FutureFeatureErrorKind, +} + +pub enum FutureFeatureErrorKind { + InvalidFeature(String), + InvalidBraces, +} + +pub fn checked_future_features( + module: &ast::Mod, +) -> Result { + let ast::Mod::Module(module) = module else { + return Ok(bytecode::CodeFlags::empty()); + }; + checked_future_features_in_body(&module.body) +} + +pub fn checked_future_features_in_body( + body: &[ast::Stmt], +) -> Result { + let mut future_features = bytecode::CodeFlags::empty(); + let mut statements = body.iter(); + if let Some(ast::Stmt::Expr(ast::StmtExpr { value, .. })) = statements.clone().next() + && string_literal_expr_value(value).is_some() + { + statements.next(); + } + for statement in statements { + match statement { + ast::Stmt::ImportFrom(ast::StmtImportFrom { + module, + names, + level, + .. + }) if *level == 0 && module.as_ref().map(|id| id.as_str()) == Some("__future__") => { + for alias in names { + match alias.name.as_str() { + "nested_scopes" | "generators" | "division" | "absolute_import" + | "with_statement" | "print_function" | "unicode_literals" + | "generator_stop" => {} + "annotations" => { + future_features.insert(bytecode::CodeFlags::FUTURE_ANNOTATIONS); + } + // Accept the future feature name, but leave it + // as a RustPython no-op. + "barry_as_FLUFL" => {} + "braces" => { + return Err(FutureFeatureError { + features: future_features, + range: alias.range, + kind: FutureFeatureErrorKind::InvalidBraces, + }); + } + other => { + return Err(FutureFeatureError { + features: future_features, + range: alias.range, + kind: FutureFeatureErrorKind::InvalidFeature(other.to_owned()), + }); + } + } + } + } + _ => return Ok(future_features), + } + } + Ok(future_features) +} + +pub fn preprocess_statements( + body: &mut [ast::Stmt], + optimize: u8, + future_annotations: bool, + syntax_check_only: bool, +) { + let preprocessor = AstPreprocessor { + optimize, + future_annotations, + constant_folding: !syntax_check_only, + }; + for stmt in body { + preprocessor.visit_stmt(stmt); + } +} + +pub fn preprocess_mod( + module: &mut ast::Mod, + optimize: u8, + future_annotations: bool, + syntax_check_only: bool, +) { + let preprocessor = AstPreprocessor { + optimize, + future_annotations, + constant_folding: !syntax_check_only, + }; match module { - ast::Mod::Module(module) => preprocessor.visit_body(&mut module.body), + ast::Mod::Module(module) => preprocessor.visit_astfold_body(&mut module.body), ast::Mod::Expression(expr) => preprocessor.visit_expr(&mut expr.body), } } -struct AstPreprocessor; +struct AstPreprocessor { + optimize: u8, + future_annotations: bool, + constant_folding: bool, +} + +impl AstPreprocessor { + fn visit_astfold_body(&self, body: &mut ast::Suite) { + let mut docstring = body_starts_with_docstring(body); + if docstring && self.optimize >= 2 { + remove_docstring_from_body(body); + docstring = false; + } + + for stmt in body.iter_mut() { + self.visit_stmt(stmt); + } + + if !docstring && body_starts_with_docstring(body) { + wrap_first_docstring_as_fstring(body); + } + } +} impl Transformer for AstPreprocessor { + fn visit_stmt(&self, stmt: &mut ast::Stmt) { + match stmt { + ast::Stmt::FunctionDef(function) => { + if let Some(type_params) = &mut function.type_params { + self.visit_type_params(type_params); + } + self.visit_parameters(&mut function.parameters); + self.visit_astfold_body(&mut function.body); + for decorator in &mut function.decorator_list { + self.visit_decorator(decorator); + } + if let Some(returns) = &mut function.returns { + self.visit_annotation(returns); + } + } + ast::Stmt::ClassDef(class) => { + if let Some(type_params) = &mut class.type_params { + self.visit_type_params(type_params); + } + if let Some(arguments) = &mut class.arguments { + self.visit_arguments(arguments); + } + self.visit_astfold_body(&mut class.body); + for decorator in &mut class.decorator_list { + self.visit_decorator(decorator); + } + } + _ => transformer::walk_stmt(self, stmt), + } + } + + fn visit_annotation(&self, expr: &mut Expr) { + if !self.future_annotations { + transformer::walk_annotation(self, expr); + } + } + + fn visit_pattern(&self, pattern: &mut ast::Pattern) { + transformer::walk_pattern(self, pattern); + if !self.constant_folding { + return; + } + match pattern { + ast::Pattern::MatchValue(value) => fold_match_value_constant_expr(&mut value.value), + ast::Pattern::MatchMapping(mapping) => { + for key in &mut mapping.keys { + fold_match_value_constant_expr(key); + } + } + _ => {} + } + } + fn visit_expr(&self, expr: &mut Expr) { transformer::walk_expr(self, expr); - if let Some(optimized) = optimize_format(expr) { - *expr = optimized; + if self.constant_folding { + if let Some(optimized) = optimize_format(expr) { + *expr = optimized; + } else if let Some(optimized) = fold_debug_constant(expr, self.optimize) { + *expr = optimized; + } } } } +fn fold_debug_constant(expr: &Expr, optimize: u8) -> Option { + let Expr::Name(name) = expr else { + return None; + }; + if !matches!(name.ctx, ast::ExprContext::Load) || name.id.as_str() != "__debug__" { + return None; + } + + Some(Expr::BooleanLiteral(ast::ExprBooleanLiteral { + node_index: name.node_index.clone(), + range: name.range, + value: optimize == 0, + })) +} + fn optimize_format(expr: &Expr) -> Option { let Expr::BinOp(binop) = expr else { return None; @@ -38,9 +410,7 @@ fn optimize_format(expr: &Expr) -> Option { if !matches!(binop.op, Operator::Mod) { return None; } - let Expr::StringLiteral(format) = binop.left.as_ref() else { - return None; - }; + let (format, _) = string_literal_expr_value(&binop.left)?; let Expr::Tuple(tuple) = binop.right.as_ref() else { return None; }; @@ -52,7 +422,7 @@ fn optimize_format(expr: &Expr) -> Option { return None; } - let elements = parse_format(format.value.to_str(), &tuple.elts)?; + let elements = parse_format(format, &tuple.elts)?; Some(Expr::FString(ExprFString { node_index: binop.node_index.clone(), range: binop.range, @@ -62,6 +432,8 @@ fn optimize_format(expr: &Expr) -> Option { elements: InterpolatedStringElements::from(elements), flags: FStringFlags::empty(), }), + runtime_joined_str: None, + runtime_values: None, })) } @@ -163,6 +535,9 @@ fn parse_format_arg(chars: &[char], pos: &mut usize, arg: Expr) -> Option InterpolatedStringLiteralElement { value: value.into_boxed_str(), } } + +fn remove_docstring_from_body(body: &mut ast::Suite) { + if let Some(range) = take_docstring(body) { + if !body.is_empty() { + return; + } + let start = range.start(); + let pass_range = TextRange::new(start, start + ruff_text_size::TextSize::from(4)); + body.push(ast::Stmt::Pass(ast::StmtPass { + node_index: Default::default(), + range: pass_range, + })); + } +} + +fn take_docstring(body: &mut ast::Suite) -> Option { + let ast::Stmt::Expr(expr_stmt) = body.first()? else { + return None; + }; + if let Some((_, range)) = string_literal_expr_value(&expr_stmt.value) { + body.remove(0); + return Some(range); + } + None +} + +fn body_starts_with_docstring(body: &[ast::Stmt]) -> bool { + let Some(ast::Stmt::Expr(expr_stmt)) = body.first() else { + return false; + }; + string_literal_expr_value(&expr_stmt.value).is_some() +} + +fn wrap_first_docstring_as_fstring(body: &mut [ast::Stmt]) { + let Some(ast::Stmt::Expr(expr_stmt)) = body.first_mut() else { + return; + }; + let Some((value, range)) = string_literal_expr_value(&expr_stmt.value) else { + return; + }; + let value = value.to_string(); + *expr_stmt.value = ast::Expr::FString(ast::ExprFString { + node_index: AtomicNodeIndex::NONE, + range, + value: FStringValue::single(FString { + range, + node_index: AtomicNodeIndex::NONE, + elements: InterpolatedStringElements::from(vec![InterpolatedStringElement::Literal( + InterpolatedStringLiteralElement { + range, + node_index: AtomicNodeIndex::NONE, + value: value.into_boxed_str(), + }, + )]), + flags: FStringFlags::empty(), + }), + runtime_joined_str: None, + runtime_values: None, + }); +} + +fn string_literal_expr_value(expr: &Expr) -> Option<(&str, TextRange)> { + match expr { + Expr::StringLiteral(string) => Some((string.value.to_str(), expr.range())), + Expr::Constant(ast::ExprConstant { + value: ast::ConstantValue::Str(value), + .. + }) => Some((value.as_ref(), expr.range())), + _ => None, + } +} + +fn fold_match_value_constant_expr(expr: &mut ast::Expr) { + match expr { + ast::Expr::UnaryOp(unary) + if matches!(unary.op, ast::UnaryOp::USub) + && matches!(unary.operand.as_ref(), ast::Expr::NumberLiteral(_)) => + { + if let Some(number) = negate_match_number(&unary.operand) { + *expr = ast::Expr::NumberLiteral(ast::ExprNumberLiteral { + node_index: unary.node_index.clone(), + range: unary.range, + value: number, + }); + } + } + ast::Expr::BinOp(binop) if matches!(binop.op, ast::Operator::Add | ast::Operator::Sub) => { + fold_match_value_constant_expr(&mut binop.left); + if let Some(number) = fold_match_number_binop(&binop.left, binop.op, &binop.right) { + *expr = ast::Expr::NumberLiteral(ast::ExprNumberLiteral { + node_index: binop.node_index.clone(), + range: binop.range, + value: number, + }); + } + } + _ => {} + } +} + +fn negate_match_number(expr: &ast::Expr) -> Option { + let ast::Expr::NumberLiteral(number) = expr else { + return None; + }; + Some(match &number.value { + ast::Number::Int(value) => { + if *value == ast::Int::ZERO { + ast::Number::Int(ast::Int::ZERO) + } else { + return None; + } + } + ast::Number::Float(value) => ast::Number::Float(-value), + ast::Number::Complex { real, imag } => ast::Number::Complex { + real: -real, + imag: -imag, + }, + }) +} + +fn fold_match_number_binop( + left: &ast::Expr, + op: ast::Operator, + right: &ast::Expr, +) -> Option { + let ast::Expr::NumberLiteral(left) = left else { + return None; + }; + let ast::Expr::NumberLiteral(right) = right else { + return None; + }; + let right = match right.value { + ast::Number::Complex { real, imag } => (real, imag), + _ => return None, + }; + enum MatchNumberLeft { + Real(f64), + Complex { real: f64, imag: f64 }, + } + let left = match &left.value { + ast::Number::Int(value) => MatchNumberLeft::Real(value.as_i64()? as f64), + ast::Number::Float(value) => MatchNumberLeft::Real(*value), + ast::Number::Complex { real, imag } => MatchNumberLeft::Complex { + real: *real, + imag: *imag, + }, + }; + let (real, imag) = match (left, op) { + (MatchNumberLeft::Real(left), ast::Operator::Add) => (left + right.0, right.1), + (MatchNumberLeft::Real(left), ast::Operator::Sub) => (left - right.0, -right.1), + (MatchNumberLeft::Complex { real, imag }, ast::Operator::Add) => { + (real + right.0, imag + right.1) + } + (MatchNumberLeft::Complex { real, imag }, ast::Operator::Sub) => { + (real - right.0, imag - right.1) + } + _ => return None, + }; + Some(ast::Number::Complex { real, imag }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn first_match_value(source: &str) -> ast::Expr { + let parsed = ruff_python_parser::parse(source, ruff_python_parser::Mode::Module.into()) + .unwrap() + .into_syntax(); + let mut module = parsed; + let future_annotations = has_future_annotations(&module); + preprocess_mod(&mut module, 0, future_annotations, false); + let ast::Mod::Module(module) = module else { + panic!("expected module"); + }; + let [ast::Stmt::Match(match_stmt)] = &module.body[..] else { + panic!("expected a single match statement"); + }; + let ast::Pattern::MatchValue(value) = &match_stmt.cases[0].pattern else { + panic!("expected a value pattern"); + }; + *value.value.clone() + } + + fn preprocess_source(source: &str) -> ast::Mod { + let mut module = ruff_python_parser::parse(source, ruff_python_parser::Mode::Module.into()) + .unwrap() + .into_syntax(); + let future_annotations = has_future_annotations(&module); + preprocess_mod(&mut module, 0, future_annotations, false); + module + } + + fn preprocess_source_with_optimize(source: &str, optimize: u8) -> ast::Mod { + let mut module = ruff_python_parser::parse(source, ruff_python_parser::Mode::Module.into()) + .unwrap() + .into_syntax(); + let future_annotations = has_future_annotations(&module); + preprocess_mod(&mut module, optimize, future_annotations, false); + module + } + + fn preprocess_source_syntax_check_only(source: &str, optimize: u8) -> ast::Mod { + let mut module = ruff_python_parser::parse(source, ruff_python_parser::Mode::Module.into()) + .unwrap() + .into_syntax(); + let future_annotations = has_future_annotations(&module); + preprocess_mod(&mut module, optimize, future_annotations, true); + module + } + + #[test] + fn folds_match_value_negative_float_in_preprocess() { + let value = first_match_value( + "\ +match value: + case -1.5: + pass +", + ); + let ast::Expr::NumberLiteral(number) = value else { + panic!("expected folded number literal, got {value:?}"); + }; + assert!(matches!(number.value, ast::Number::Float(value) if value == -1.5)); + } + + #[test] + fn folds_match_value_complex_binop_in_preprocess() { + let value = first_match_value( + "\ +match value: + case 1 + 2j: + pass +", + ); + let ast::Expr::NumberLiteral(number) = value else { + panic!("expected folded number literal, got {value:?}"); + }; + assert!( + matches!(number.value, ast::Number::Complex { real, imag } if real == 1.0 && imag == 2.0) + ); + } + + #[test] + fn folds_match_value_complex_complex_binop_in_preprocess() { + let left = ast::Expr::NumberLiteral(ast::ExprNumberLiteral { + node_index: AtomicNodeIndex::NONE, + range: TextRange::default(), + value: ast::Number::Complex { + real: 0.0, + imag: 1.0, + }, + }); + let right = ast::Expr::NumberLiteral(ast::ExprNumberLiteral { + node_index: AtomicNodeIndex::NONE, + range: TextRange::default(), + value: ast::Number::Complex { + real: 0.0, + imag: 2.0, + }, + }); + let number = fold_match_number_binop(&left, ast::Operator::Add, &right) + .expect("CPython fold_const_match_patterns() uses PyNumber_Add"); + assert!( + matches!(number, ast::Number::Complex { real, imag } if real == 0.0 && imag == 3.0) + ); + } + + #[test] + fn folds_match_value_real_minus_zero_complex_preserves_negative_zero_in_preprocess() { + let value = first_match_value( + "\ +match value: + case 0 - 0j: + pass +", + ); + let ast::Expr::NumberLiteral(number) = value else { + panic!("expected folded number literal, got {value:?}"); + }; + assert!(matches!(number.value, ast::Number::Complex { real, imag } + if real == 0.0 && imag == 0.0 && imag.is_sign_negative())); + } + + #[test] + fn future_annotations_skip_annotation_preprocess_like_cpython() { + let module = preprocess_source( + "\ +from __future__ import annotations +def f(x: __debug__) -> __debug__: + pass +y: __debug__ +z = __debug__ +", + ); + let ast::Mod::Module(module) = module else { + panic!("expected module"); + }; + let ast::Stmt::FunctionDef(function) = &module.body[1] else { + panic!("expected function"); + }; + let annotation = function.parameters.args[0] + .parameter + .annotation + .as_deref() + .expect("missing parameter annotation"); + assert!( + matches!(annotation, ast::Expr::Name(name) if name.id.as_str() == "__debug__"), + "future annotations should skip parameter annotation folding, got {annotation:?}" + ); + let returns = function + .returns + .as_deref() + .expect("missing return annotation"); + assert!( + matches!(returns, ast::Expr::Name(name) if name.id.as_str() == "__debug__"), + "future annotations should skip return annotation folding, got {returns:?}" + ); + let ast::Stmt::AnnAssign(ann_assign) = &module.body[2] else { + panic!("expected annotated assignment"); + }; + assert!( + matches!(ann_assign.annotation.as_ref(), ast::Expr::Name(name) if name.id.as_str() == "__debug__"), + "future annotations should skip annotated assignment annotation folding, got {:?}", + ann_assign.annotation + ); + let ast::Stmt::Assign(assign) = &module.body[3] else { + panic!("expected assignment"); + }; + assert!( + matches!(assign.value.as_ref(), ast::Expr::BooleanLiteral(boolean) if boolean.value), + "non-annotation expression should still fold __debug__, got {:?}", + assign.value + ); + } + + #[test] + fn late_future_annotations_do_not_affect_preprocess_like_cpython() { + let module = preprocess_source( + "\ +x = 1 +from __future__ import annotations +y: __debug__ +", + ); + let ast::Mod::Module(module) = module else { + panic!("expected module"); + }; + let ast::Stmt::AnnAssign(ann_assign) = &module.body[2] else { + panic!("expected annotated assignment"); + }; + assert!( + matches!(ann_assign.annotation.as_ref(), ast::Expr::BooleanLiteral(boolean) if boolean.value), + "late future import should not disable annotation folding, got {:?}", + ann_assign.annotation + ); + } + + #[test] + fn optimize_two_wraps_new_docstring_after_removing_original() { + let module = preprocess_source_with_optimize("\"first\"\n\"second\"\n", 2); + let ast::Mod::Module(module) = module else { + panic!("expected module"); + }; + let [ast::Stmt::Expr(expr)] = &module.body[..] else { + panic!("expected only the second statement to remain"); + }; + assert!( + matches!(expr.value.as_ref(), ast::Expr::FString(_)), + "CPython wraps the new leading string as JoinedStr so it is not a docstring" + ); + } + + #[test] + fn syntax_check_only_disables_constant_folding_but_keeps_docstring_strip() { + let module = preprocess_source_syntax_check_only("\"doc\"\nvalue = __debug__\n", 2); + let ast::Mod::Module(module) = module else { + panic!("expected module"); + }; + assert!( + matches!(module.body[0], ast::Stmt::Assign(_)), + "optimize=2 should still strip docstrings in syntax_check_only mode" + ); + let ast::Stmt::Assign(assign) = &module.body[0] else { + panic!("expected assignment"); + }; + assert!( + matches!(assign.value.as_ref(), ast::Expr::Name(name) if name.id.as_str() == "__debug__"), + "syntax_check_only should skip __debug__ folding, got {:?}", + assign.value + ); + } +} diff --git a/crates/codegen/src/symboltable.rs b/crates/codegen/src/symboltable.rs index 27ba10ebadb..b021a494c93 100644 --- a/crates/codegen/src/symboltable.rs +++ b/crates/codegen/src/symboltable.rs @@ -17,6 +17,9 @@ use ruff_python_ast as ast; use ruff_text_size::{Ranged, TextRange}; use rustpython_compiler_core::{PositionEncoding, SourceFile, SourceLocation}; +const DEFAULT_RECURSION_LIMIT: usize = 1000; +const RECURSION_ERROR: &str = "maximum recursion depth exceeded during compilation"; + /// Captures all symbols in the current scope, and has a list of sub-scopes in this scope. #[derive(Clone)] pub struct SymbolTable { @@ -42,6 +45,20 @@ pub struct SymbolTable { /// AST nodes. pub sub_tables: Vec, + /// Annotation scopes registered in st_blocks but not added + /// to ste_children, e.g. future-annotation function signatures. + pub hidden_annotation_blocks: Vec, + + /// Cursor pointing to the next hidden annotation block to consume. + pub next_hidden_annotation_block: usize, + + /// Inlined comprehension scopes removed from ste_children but + /// can still find through st_blocks keyed by the comprehension expression. + pub inlined_comprehension_blocks: Vec, + + /// Cursor pointing to the next inlined comprehension block to consume. + pub next_inlined_comprehension_block: usize, + /// Cursor pointing to the next sub-table to consume during compilation. pub next_sub_table: usize, @@ -63,6 +80,19 @@ pub struct SymbolTable { /// Whether this scope contains await or async comprehension machinery. pub is_coroutine: bool, + /// Whether this scope contains a return statement with a value. + pub returns_value: bool, + + /// Whether this block visited at least one annotation expression. + pub annotations_used: bool, + + /// Optional description of the current type-variable evaluator context. + pub scope_info: Option<&'static str>, + + /// Whether this annotation block is currently visiting an unevaluated + /// function-local annotation. + pub in_unevaluated_annotation: bool, + /// Whether this comprehension scope should be inlined (PEP 709) /// True for list/set/dict comprehensions in non-generator expressions pub comp_inlined: bool, @@ -73,7 +103,7 @@ pub struct SymbolTable { /// True only for deferred function/class/module annotation scopes that /// should resolve outer names as if they were siblings of the owning - /// function body, matching CPython's PEP 649 lookup rules. + /// function body, matching PEP 649 lookup rules. pub skip_enclosing_function_scope: bool, /// PEP 649: Whether this scope has conditional annotations @@ -99,6 +129,10 @@ impl SymbolTable { is_method: false, symbols: IndexMap::default(), sub_tables: vec![], + hidden_annotation_blocks: vec![], + next_hidden_annotation_block: 0, + inlined_comprehension_blocks: vec![], + next_inlined_comprehension_block: 0, next_sub_table: 0, varnames: Vec::new(), needs_class_closure: false, @@ -106,6 +140,10 @@ impl SymbolTable { can_see_class_scope: false, is_generator: false, is_coroutine: false, + returns_value: false, + annotations_used: false, + scope_info: None, + in_unevaluated_annotation: false, comp_inlined: false, annotation_block: None, skip_enclosing_function_scope: false, @@ -115,11 +153,39 @@ impl SymbolTable { } } + fn add_format_parameter(&mut self) { + let name = ".format"; + let symbol = self + .symbols + .entry(name.to_owned()) + .or_insert_with(|| Symbol::new(name)); + symbol + .flags + .insert(SymbolFlags::PARAMETER | SymbolFlags::REFERENCED); + if !self.varnames.iter().any(|varname| varname == name) { + self.varnames.push(name.to_owned()); + } + } + pub fn scan_program( program: &ast::ModModule, source_file: SourceFile, + ) -> SymbolTableResult { + Self::scan_program_with_options(program, source_file, false, false, DEFAULT_RECURSION_LIMIT) + } + + pub fn scan_program_with_options( + program: &ast::ModModule, + source_file: SourceFile, + allow_top_level_await: bool, + future_annotations: bool, + recursion_limit: usize, ) -> SymbolTableResult { let mut builder = SymbolTableBuilder::new(source_file); + builder.allow_top_level_await = allow_top_level_await; + builder.recursion_limit = recursion_limit; + builder.future_annotations = future_annotations + || SymbolTableBuilder::future_annotations_from_module_body(program.body.as_ref()); builder.scan_statements(program.body.as_ref())?; builder.finish() } @@ -127,8 +193,21 @@ impl SymbolTable { pub fn scan_expr( expr: &ast::ModExpression, source_file: SourceFile, + ) -> SymbolTableResult { + Self::scan_expr_with_options(expr, source_file, false, false, DEFAULT_RECURSION_LIMIT) + } + + pub fn scan_expr_with_options( + expr: &ast::ModExpression, + source_file: SourceFile, + allow_top_level_await: bool, + future_annotations: bool, + recursion_limit: usize, ) -> SymbolTableResult { let mut builder = SymbolTableBuilder::new(source_file); + builder.allow_top_level_await = allow_top_level_await; + builder.recursion_limit = recursion_limit; + builder.future_annotations = future_annotations; builder.scan_expression(expr.body.as_ref(), ExpressionContext::Load)?; builder.finish() } @@ -150,6 +229,8 @@ pub enum CompilerScope { TypeParams, /// PEP 649: Annotation scope for deferred evaluation Annotation, + TypeAlias, + TypeVariable, } impl fmt::Display for CompilerScope { @@ -163,11 +244,8 @@ impl fmt::Display for CompilerScope { Self::Comprehension => write!(f, "comprehension"), Self::TypeParams => write!(f, "type parameter"), Self::Annotation => write!(f, "annotation"), - // TODO missing types from the C implementation - // if self._table.type == _symtable.TYPE_TYPE_VAR_BOUND: - // return "TypeVar bound" - // if self._table.type == _symtable.TYPE_TYPE_ALIAS: - // return "type alias" + Self::TypeAlias => write!(f, "type alias"), + Self::TypeVariable => write!(f, "TypeVar bound"), } } } @@ -287,9 +365,14 @@ pub struct SymbolTableError { impl SymbolTableError { #[must_use] pub fn into_codegen_error(self, source_path: String) -> CodegenError { + let error = if self.error == RECURSION_ERROR { + CodegenErrorType::RecursionError + } else { + CodegenErrorType::SyntaxError(self.error) + }; CodegenError { location: self.location, - error: CodegenErrorType::SyntaxError(self.error), + error, source_path, } } @@ -320,7 +403,7 @@ fn analyze_symbol_table(symbol_table: &mut SymbolTable) -> SymbolTableResult { } /* Drop __class__ and __classdict__ from free variables in class scope - and set the appropriate flags. Equivalent to CPython's drop_class_free(). + and set the appropriate flags. Equivalent to drop_class_free(). See: https://github.com/python/cpython/blob/main/Python/symtable.c#L884 This function removes __class__ and __classdict__ from the @@ -339,20 +422,6 @@ fn drop_class_free(symbol_table: &mut SymbolTable, newfree: &mut IndexSet, parent_type: CompilerScope, ) -> IndexSet { - let mut removed_class_implicit = IndexSet::default(); + let mut removed_class_implicits = IndexSet::default(); for (name, sub_symbol) in &comp.symbols { // Skip the .0 parameter if sub_symbol.flags.contains(SymbolFlags::PARAMETER) { @@ -383,15 +452,24 @@ fn inline_comprehension( inlined_cells.insert(name.clone()); } - // Handle __class__ in ClassBlock + // __class__, __classdict__ and __conditional_annotations__ are never + // allowed to be free through a class scope. let scope = if sub_symbol.scope == SymbolScope::Free && parent_type == CompilerScope::Class && matches!( name.as_str(), "__class__" | "__classdict__" | "__conditional_annotations__" ) { - comp_free.swap_remove(name); - removed_class_implicit.insert(name.clone()); + let is_free_in_child = comp.sub_tables.iter().any(|child| { + child + .symbols + .get(name) + .is_some_and(|s| s.scope == SymbolScope::Free) + }); + if !is_free_in_child { + comp_free.swap_remove(name); + } + removed_class_implicits.insert(name.clone()); SymbolScope::GlobalImplicit } else { sub_symbol.scope @@ -413,14 +491,14 @@ fn inline_comprehension( } } else { // Name doesn't exist in parent, copy the comprehension binding. - // This matches CPython's inline_comprehension(): newly introduced + // Matches inline_comprehension(): newly introduced // comprehension locals stay locals in the parent scope. let mut symbol = sub_symbol.clone(); symbol.scope = scope; parent_symbols.insert(name.clone(), symbol); } } - removed_class_implicit + removed_class_implicits } type SymbolMap = IndexMap; @@ -471,12 +549,6 @@ mod stack { pub(super) fn iter_mut(&mut self) -> impl DoubleEndedIterator + '_ { self.as_mut().iter_mut().map(|x| &mut **x) } - // pub fn top(&self) -> Option<&T> { - // self.as_ref().last().copied() - // } - // pub fn top_mut(&mut self) -> Option<&mut T> { - // self.as_mut().last_mut().map(|x| &mut **x) - // } pub(super) fn len(&self) -> usize { self.v.len() } @@ -548,32 +620,27 @@ impl SymbolTableAnalyzer { symbol_table.typ, symbol_table.skip_enclosing_function_scope, ); + let class_scope_entry = if is_class { + class_symbols_clone.as_ref() + } else { + class_entry + }; self.tables.with_append(&mut info, |list| { let inner_scope = unsafe { &mut *(list as *mut _ as *mut Self) }; for sub_table in sub_tables.iter_mut() { - let child_class_entry = if sub_table.can_see_class_scope { - if is_class { - class_symbols_clone.as_ref() - } else { - class_entry - } - } else { - None - }; + let child_class_entry = sub_table + .can_see_class_scope + .then_some(class_scope_entry) + .flatten(); let child_free = inner_scope.analyze_symbol_table(sub_table, child_class_entry)?; child_frees.push((child_free, sub_table.comp_inlined)); } // PEP 649: Analyze annotation block if present if let Some(annotation_table) = annotation_block { - let ann_class_entry = if annotation_table.can_see_class_scope { - if is_class { - class_symbols_clone.as_ref() - } else { - class_entry - } - } else { - None - }; + let ann_class_entry = annotation_table + .can_see_class_scope + .then_some(class_scope_entry) + .flatten(); let child_free = inner_scope.analyze_symbol_table(annotation_table, ann_class_entry)?; annotation_free = Some(child_free); @@ -614,6 +681,26 @@ impl SymbolTableAnalyzer { newfree.extend(ann_free); } + let mut inlined_blocks = Vec::new(); + let mut idx = 0; + while idx < symbol_table.sub_tables.len() { + if symbol_table.sub_tables[idx].comp_inlined { + let comp = symbol_table.sub_tables.remove(idx); + let nested_inlined_blocks = comp.inlined_comprehension_blocks.clone(); + let children = comp.sub_tables.clone(); + let inserted = children.len(); + inlined_blocks.push(comp); + inlined_blocks.extend(nested_inlined_blocks); + symbol_table.sub_tables.splice(idx..idx, children); + idx += inserted; + } else { + idx += 1; + } + } + symbol_table + .inlined_comprehension_blocks + .extend(inlined_blocks); + let sub_tables = &*symbol_table.sub_tables; for symbol in symbol_table.symbols.values_mut() { @@ -633,7 +720,7 @@ impl SymbolTableAnalyzer { class_entry, )?; - // CPython analyze_cells(): once a function-like scope owns a + // analyze_cells(): once a function-like scope owns a // child-requested name as a cell, that name is no longer free in // the enclosing scope. if function_like_scope && symbol.scope == SymbolScope::Cell { @@ -646,7 +733,7 @@ impl SymbolTableAnalyzer { } } - // PEP 709 / CPython symtable.c: + // PEP 709 / symtable.c: // - only promote LOCAL -> CELL in function-like scopes, where // analyze_cells() runs. Module and class scopes keep their normal // scope and rely on DEF_COMP_CELL for comprehension-only cells. @@ -664,27 +751,19 @@ impl SymbolTableAnalyzer { drop_class_free(symbol_table, &mut newfree); } - // CPython update_symbols(..., classflag): after class implicit frees + // update_symbols(..., classflag): after class implicit frees // are dropped, a class block, or an annotation/type-params block that // can see a class scope, records existing child-free names with // DEF_FREE_CLASS. This preserves the current scope's own lookup kind // (for example GLOBAL_IMPLICIT via __classdict__) while still making // the name available as a closure cell for nested children such as // generator expressions. - if symbol_table.typ == CompilerScope::Class { + if symbol_table.typ == CompilerScope::Class || symbol_table.can_see_class_scope { for name in &newfree { if let Some(symbol) = symbol_table.symbols.get_mut(name) { symbol.flags.insert(SymbolFlags::FREE_CLASS); } } - } else if symbol_table.can_see_class_scope { - for name in &newfree { - if let Some(symbol) = symbol_table.symbols.get_mut(name) - && !symbol.is_local() - { - symbol.flags.insert(SymbolFlags::FREE_CLASS); - } - } } Ok(newfree) @@ -706,7 +785,6 @@ impl SymbolTableAnalyzer { // propagate symbol to next higher level that can hold it, // i.e., function or module. Comprehension is skipped and // Class is not allowed and detected as error. - //symbol.scope = SymbolScope::Nonlocal; self.analyze_symbol_comprehension(symbol, 0)? } else { match symbol.scope { @@ -1039,12 +1117,8 @@ impl SymbolTableAnalyzer { location: None, }); } - CompilerScope::Annotation => { - // Named expression is not allowed in annotation scope - return Err(SymbolTableError { - error: "named expression cannot be used within an annotation".to_string(), - location: None, - }); + CompilerScope::Annotation | CompilerScope::TypeAlias | CompilerScope::TypeVariable => { + self.analyze_symbol_comprehension(symbol, parent_offset + 1)?; } } Ok(()) @@ -1071,6 +1145,7 @@ struct SymbolTableBuilder { // Scope stack. tables: Vec, future_annotations: bool, + allow_top_level_await: bool, source_file: SourceFile, // Current scope's varnames being collected (temporary storage) current_varnames: Vec, @@ -1078,19 +1153,16 @@ struct SymbolTableBuilder { varnames_stack: Vec>, // Track if we're inside an iterable definition expression (for nested comprehensions) in_iter_def_exp: bool, - // Track if we're inside an annotation (yield/await/named expr not allowed) - in_annotation: bool, - // CPython's ste_in_unevaluated_annotation: function-local AnnAssign - // annotations are not executed and do not contribute name bindings. - in_unevaluated_annotation: bool, - // Track if we're inside a type alias (yield/await/named expr not allowed) - in_type_alias: bool, // Track if we're scanning an inner loop iteration target (not the first generator) in_comp_inner_loop_target: bool, - // Scope info for error messages (e.g., "a TypeVar bound") - scope_info: Option<&'static str>, + // yield/yield from inside comprehension scopes is rejected with a + // message that names the comprehension kind. + comprehension_yield_context: Option<&'static str>, // PEP 649: Track if we're inside a conditional block (if/for/while/etc.) in_conditional_block: bool, + // Mirrors symtable ENTER_RECURSIVE guards during compilation. + recursion_depth: usize, + recursion_limit: usize, } /// Enum to indicate in what mode an expression @@ -1112,16 +1184,16 @@ impl SymbolTableBuilder { class_name: None, tables: vec![], future_annotations: false, + allow_top_level_await: false, source_file, current_varnames: Vec::new(), varnames_stack: Vec::new(), in_iter_def_exp: false, - in_annotation: false, - in_unevaluated_annotation: false, - in_type_alias: false, in_comp_inner_loop_target: false, - scope_info: None, + comprehension_yield_context: None, in_conditional_block: false, + recursion_depth: 0, + recursion_limit: DEFAULT_RECURSION_LIMIT, }; this.enter_scope("top", CompilerScope::Module, 0); this @@ -1135,10 +1207,42 @@ impl SymbolTableBuilder { | CompilerScope::Lambda | CompilerScope::Comprehension | CompilerScope::Annotation + | CompilerScope::TypeAlias + | CompilerScope::TypeVariable | CompilerScope::TypeParams ) } + fn future_annotations_from_module_body(body: &[ast::Stmt]) -> bool { + let mut statements = body.iter(); + if let Some(ast::Stmt::Expr(ast::StmtExpr { value, .. })) = statements.clone().next() + && is_docstring_expr(value) + { + statements.next(); + } + for statement in statements { + match statement { + ast::Stmt::ImportFrom(ast::StmtImportFrom { + module, + names, + level, + .. + }) if *level == 0 + && module.as_ref().map(|id| id.as_str()) == Some("__future__") => + { + if names + .iter() + .any(|future| future.name.as_str() == "annotations") + { + return true; + } + } + _ => return false, + } + } + false + } + fn finish(mut self) -> Result { assert_eq!(self.tables.len(), 1); let mut symbol_table = self.tables.pop().unwrap(); @@ -1183,7 +1287,7 @@ impl SymbolTableBuilder { fn enter_type_param_block( &mut self, name: &str, - line_number: u32, + range: TextRange, for_class: bool, has_defaults: bool, has_kwdefaults: bool, @@ -1194,7 +1298,11 @@ impl SymbolTableBuilder { .last() .is_some_and(|t| t.typ == CompilerScope::Class); - self.enter_scope(name, CompilerScope::TypeParams, line_number); + self.enter_scope( + name, + CompilerScope::TypeParams, + self.line_index_start(range), + ); // Set properties on the newly created type param scope if let Some(table) = self.tables.last_mut() { @@ -1208,19 +1316,22 @@ impl SymbolTableBuilder { // Add __classdict__ as a USE symbol in type param scope if in class if in_class { - self.register_name("__classdict__", SymbolUsage::Used, TextRange::default())?; + self.register_name("__classdict__", SymbolUsage::Used, range)?; } - // Register .type_params as a SET symbol (it will be converted to cell variable later) - self.register_name(".type_params", SymbolUsage::Assigned, TextRange::default())?; if for_class { - self.register_name(".generic_base", SymbolUsage::Assigned, TextRange::default())?; + // It gets set when we create the type params tuple and used when + // we build up the bases. + self.register_name(".type_params", SymbolUsage::Assigned, range)?; + self.register_name(".type_params", SymbolUsage::Used, range)?; + self.register_name(".generic_base", SymbolUsage::Assigned, range)?; + self.register_name(".generic_base", SymbolUsage::Used, range)?; } if has_defaults { - self.register_name(".defaults", SymbolUsage::Parameter, TextRange::default())?; + self.register_name(".defaults", SymbolUsage::Parameter, range)?; } if has_kwdefaults { - self.register_name(".kwdefaults", SymbolUsage::Parameter, TextRange::default())?; + self.register_name(".kwdefaults", SymbolUsage::Parameter, range)?; } Ok(()) @@ -1236,9 +1347,22 @@ impl SymbolTableBuilder { self.current_varnames = self.varnames_stack.pop().unwrap_or_default(); } + /// Pop symbol table without adding it to the parent children list. + fn discard_scope(&mut self) -> SymbolTable { + let mut table = self.tables.pop().unwrap(); + table.varnames = core::mem::take(&mut self.current_varnames); + self.current_varnames = self.varnames_stack.pop().unwrap_or_default(); + table + } + /// Enter annotation scope (PEP 649) /// Creates or reuses the annotation block for the current scope - fn enter_annotation_scope(&mut self, line_number: u32) { + fn enter_annotation_scope( + &mut self, + line_number: u32, + include_classdict_with_future: bool, + include_conditional_annotations: bool, + ) { let current = self.tables.last_mut().unwrap(); let can_see_class_scope = current.typ == CompilerScope::Class || current.can_see_class_scope; @@ -1256,8 +1380,7 @@ impl SymbolTableBuilder { // Annotation scope in class can see class scope annotation_table.can_see_class_scope = can_see_class_scope; annotation_table.skip_enclosing_function_scope = true; - // Add 'format' parameter - annotation_table.varnames.push("format".to_owned()); + annotation_table.add_format_parameter(); current.annotation_block = Some(Box::new(annotation_table)); } @@ -1269,10 +1392,10 @@ impl SymbolTableBuilder { .push(core::mem::take(&mut self.current_varnames)); self.current_varnames = self.tables.last().unwrap().varnames.clone(); - if can_see_class_scope && !self.future_annotations { + if can_see_class_scope && (include_classdict_with_future || !self.future_annotations) { self.add_classdict_freevar(); // Also add __conditional_annotations__ as free var if parent has conditional annotations - if has_conditional { + if include_conditional_annotations && has_conditional { self.add_conditional_annotations_freevar(); } } @@ -1321,11 +1444,6 @@ impl SymbolTableBuilder { /// Annotation and TypeParams scopes act as async barriers (always non-async). /// Comprehension scopes are transparent (inherit parent's async context). fn is_in_async_context(&self) -> bool { - // Annotations are evaluated in a non-async scope even when - // the enclosing function is async. - if self.in_annotation { - return false; - } for table in self.tables.iter().rev() { match table.typ { CompilerScope::AsyncFunction => return true, @@ -1334,6 +1452,8 @@ impl SymbolTableBuilder { | CompilerScope::Class | CompilerScope::Module | CompilerScope::Annotation + | CompilerScope::TypeAlias + | CompilerScope::TypeVariable | CompilerScope::TypeParams => return false, // Comprehension inherits parent's async context CompilerScope::Comprehension => continue, @@ -1342,6 +1462,14 @@ impl SymbolTableBuilder { false } + fn allows_top_level_await(&self) -> bool { + self.allow_top_level_await + && self + .tables + .last() + .is_some_and(|table| table.typ == CompilerScope::Module) + } + fn line_index_start(&self, range: TextRange) -> u32 { self.source_file .to_source_code() @@ -1364,12 +1492,6 @@ impl SymbolTableBuilder { } fn scan_parameter(&mut self, parameter: &ast::Parameter) -> SymbolTableResult { - self.check_name( - parameter.name.as_str(), - ExpressionContext::Store, - parameter.name.range, - )?; - let usage = if parameter.annotation.is_some() { SymbolUsage::AnnotationParameter } else { @@ -1395,15 +1517,85 @@ impl SymbolTableBuilder { self.register_ident(¶meter.name, usage) } - fn scan_annotation(&mut self, annotation: &ast::Expr) -> SymbolTableResult { - self.scan_annotation_inner(annotation, false) - } - /// Scan an annotation from an AnnAssign statement (can be conditional) fn scan_ann_assign_annotation(&mut self, annotation: &ast::Expr) -> SymbolTableResult { self.scan_annotation_inner(annotation, true) } + fn scan_function_annotations( + &mut self, + parameters: &ast::Parameters, + returns: Option<&ast::Expr>, + line_number: u32, + ) -> SymbolTableResult { + let current = self.tables.last().unwrap(); + let can_see_class_scope = + current.typ == CompilerScope::Class || current.can_see_class_scope; + self.enter_scope("__annotate__", CompilerScope::Annotation, line_number); + self.tables.last_mut().unwrap().can_see_class_scope = can_see_class_scope; + self.tables.last_mut().unwrap().add_format_parameter(); + if can_see_class_scope { + self.register_name("__classdict__", SymbolUsage::Used, TextRange::default())?; + } + + let was_in_unevaluated_annotation = self.tables.last().unwrap().in_unevaluated_annotation; + self.tables.last_mut().unwrap().in_unevaluated_annotation = false; + + let result = (|| { + for annotation in parameters + .posonlyargs + .iter() + .chain(parameters.args.iter()) + .filter_map(|arg| arg.parameter.annotation.as_ref()) + { + self.tables.last_mut().unwrap().annotations_used = true; + self.scan_expression(annotation, ExpressionContext::Load)?; + } + if let Some(annotation) = parameters + .vararg + .as_ref() + .and_then(|arg| arg.annotation.as_ref()) + { + self.tables.last_mut().unwrap().annotations_used = true; + self.scan_expression(annotation, ExpressionContext::Load)?; + } + if let Some(annotation) = parameters + .kwarg + .as_ref() + .and_then(|arg| arg.annotation.as_ref()) + { + self.tables.last_mut().unwrap().annotations_used = true; + self.scan_expression(annotation, ExpressionContext::Load)?; + } + for annotation in parameters + .kwonlyargs + .iter() + .filter_map(|arg| arg.parameter.annotation.as_ref()) + { + self.tables.last_mut().unwrap().annotations_used = true; + self.scan_expression(annotation, ExpressionContext::Load)?; + } + if let Some(annotation) = returns { + self.tables.last_mut().unwrap().annotations_used = true; + self.scan_expression(annotation, ExpressionContext::Load)?; + } + Ok(()) + })(); + + self.tables.last_mut().unwrap().in_unevaluated_annotation = was_in_unevaluated_annotation; + if self.future_annotations { + let annotation_block = self.discard_scope(); + self.tables + .last_mut() + .unwrap() + .hidden_annotation_blocks + .push(annotation_block); + } else { + self.leave_scope(); + } + result + } + fn scan_annotation_inner( &mut self, annotation: &ast::Expr, @@ -1431,11 +1623,6 @@ impl SymbolTableBuilder { } if should_register_conditional_annotations { - self.register_name( - "__conditional_annotations__", - SymbolUsage::Assigned, - annotation.range(), - )?; self.register_name( "__conditional_annotations__", SymbolUsage::Used, @@ -1445,25 +1632,14 @@ impl SymbolTableBuilder { // Create annotation scope for deferred evaluation let line_number = self.line_index_start(annotation.range()); - self.enter_annotation_scope(line_number); - - if self.future_annotations { - // PEP 563: annotations are stringified at compile time - // Don't scan expression - symbols would fail to resolve - // Just create the annotation_block structure - self.leave_annotation_scope(); - return Ok(()); - } + self.enter_annotation_scope(line_number, false, true); // PEP 649: scan expression for symbol references // Class annotations are evaluated in class locals (not module globals) - let was_in_annotation = self.in_annotation; - let was_in_unevaluated_annotation = self.in_unevaluated_annotation; - self.in_annotation = true; - self.in_unevaluated_annotation = is_unevaluated; + let was_in_unevaluated_annotation = self.tables.last().unwrap().in_unevaluated_annotation; + self.tables.last_mut().unwrap().in_unevaluated_annotation = is_unevaluated; let result = self.scan_expression(annotation, ExpressionContext::Load); - self.in_annotation = was_in_annotation; - self.in_unevaluated_annotation = was_in_unevaluated_annotation; + self.tables.last_mut().unwrap().in_unevaluated_annotation = was_in_unevaluated_annotation; self.leave_annotation_scope(); @@ -1471,468 +1647,517 @@ impl SymbolTableBuilder { } fn scan_statement(&mut self, statement: &ast::Stmt) -> SymbolTableResult { - use ast::*; - if let Stmt::ImportFrom(StmtImportFrom { module, names, .. }) = &statement - && module.as_ref().map(|id| id.as_str()) == Some("__future__") - { - self.future_annotations = - self.future_annotations || names.iter().any(|future| &future.name == "annotations"); + if self.recursion_depth >= self.recursion_limit { + return Err(SymbolTableError { + error: RECURSION_ERROR.to_owned(), + location: None, + }); } - - match &statement { - Stmt::Global(StmtGlobal { names, .. }) => { - for name in names { - self.register_ident(name, SymbolUsage::Global)?; + self.recursion_depth += 1; + let result = (|| { + use ast::*; + match &statement { + Stmt::Global(StmtGlobal { names, .. }) => { + for name in names { + self.register_name(name.as_str(), SymbolUsage::Global, statement.range())?; + } } - } - Stmt::Nonlocal(StmtNonlocal { names, .. }) => { - for name in names { - self.register_ident(name, SymbolUsage::Nonlocal)?; + Stmt::Nonlocal(StmtNonlocal { names, .. }) => { + for name in names { + self.register_name( + name.as_str(), + SymbolUsage::Nonlocal, + statement.range(), + )?; + } } - } - Stmt::FunctionDef(StmtFunctionDef { - name, - body, - parameters, - decorator_list, - type_params, - returns, - range, - is_async, - .. - }) => { - self.scan_decorators(decorator_list, ExpressionContext::Load)?; - self.register_ident(name, SymbolUsage::Assigned)?; - - // Save the parent's annotation_block before scanning function annotations, - // so function annotations don't interfere with parent scope annotations. - // This applies to both class scope (methods) and module scope (top-level functions). - let parent_scope_typ = self.tables.last().map(|t| t.typ); - let should_save_annotation_block = matches!( - parent_scope_typ, - Some( - CompilerScope::Class - | CompilerScope::Module - | CompilerScope::Function - | CompilerScope::AsyncFunction - ) - ); - let saved_annotation_block = if should_save_annotation_block { - self.tables.last_mut().unwrap().annotation_block.take() - } else { - None - }; + Stmt::FunctionDef(StmtFunctionDef { + name, + body, + parameters, + decorator_list, + type_params, + returns, + range, + is_async, + .. + }) => { + self.register_name(name.as_str(), SymbolUsage::Assigned, *range)?; - // For generic functions, scan defaults before entering type_param_block - // (defaults are evaluated in the enclosing scope, not the type param scope) - let has_type_params = type_params.is_some(); - if has_type_params { self.scan_parameter_defaults(parameters)?; - } + self.scan_decorators(decorator_list, ExpressionContext::Load)?; - // For generic functions, enter type_param block FIRST so that - // annotation scopes are nested inside and can see type parameters. - if let Some(type_params) = type_params { - self.enter_type_param_block( + // For generic functions, enter type_param block FIRST so that + // annotation scopes are nested inside and can see type parameters. + if let Some(type_params) = type_params { + self.enter_type_param_block( + name.as_str(), + *range, + false, + Self::has_positional_defaults(parameters), + Self::has_kwonlydefaults(parameters), + )?; + self.scan_type_params(type_params)?; + } + self.enter_scope_with_parameters( name.as_str(), - self.line_index_start(type_params.range), + parameters, + self.line_index_start(*range), + returns.as_deref(), + if *is_async { + CompilerScope::AsyncFunction + } else { + CompilerScope::Function + }, + true, // skip_defaults: already scanned above false, - true, - Self::has_kwonlydefaults(parameters), )?; - self.scan_type_params(type_params)?; - } - let has_return_annotation = if let Some(expression) = returns { - self.scan_annotation(expression)?; - true - } else { - false - }; - self.enter_scope_with_parameters( - name.as_str(), - parameters, - self.line_index_start(*range), - has_return_annotation, if *is_async { - CompilerScope::AsyncFunction - } else { - CompilerScope::Function - }, - has_type_params, // skip_defaults: already scanned above - )?; - if *is_async { - self.tables.last_mut().unwrap().is_coroutine = true; - } - self.scan_statements(body)?; - self.leave_scope(); - if type_params.is_some() { + self.tables.last_mut().unwrap().is_coroutine = true; + } + self.scan_statements(body)?; self.leave_scope(); + if type_params.is_some() { + self.leave_scope(); + } } + Stmt::ClassDef(StmtClassDef { + name, + body, + arguments, + decorator_list, + type_params, + range, + node_index: _, + .. + }) => { + let prev_class = self.class_name.clone(); + self.register_name(name.as_str(), SymbolUsage::Assigned, *range)?; + self.scan_decorators(decorator_list, ExpressionContext::Load)?; - // Restore parent's annotation_block after processing the function - if let Some(block) = saved_annotation_block { - self.tables.last_mut().unwrap().annotation_block = Some(block); - } - } - Stmt::ClassDef(StmtClassDef { - name, - body, - arguments, - decorator_list, - type_params, - range, - node_index: _, - }) => { - // Save class_name for the entire ClassDef processing - let prev_class = self.class_name.take(); - if let Some(type_params) = type_params { - self.enter_type_param_block( - name.as_str(), - self.line_index_start(type_params.range), - true, // for_class: enable selective mangling - false, - false, - )?; - // Set class_name for mangling in type param scope - self.class_name = Some(name.to_string()); - self.scan_type_params(type_params)?; - } - self.enter_scope( - name.as_str(), - CompilerScope::Class, - self.line_index_start(*range), - ); - // Reset in_conditional_block for new class scope - let saved_in_conditional = self.in_conditional_block; - self.in_conditional_block = false; - self.class_name = Some(name.to_string()); - self.register_name("__module__", SymbolUsage::Assigned, *range)?; - self.register_name("__qualname__", SymbolUsage::Assigned, *range)?; - self.register_name("__doc__", SymbolUsage::Assigned, *range)?; - self.register_name("__class__", SymbolUsage::Assigned, *range)?; - if type_params.is_some() { - self.register_name(".type_params", SymbolUsage::Used, *range)?; - self.register_name("__type_params__", SymbolUsage::Assigned, *range)?; - } - self.scan_statements(body)?; - self.leave_scope(); - self.in_conditional_block = saved_in_conditional; - // For non-generic classes, restore class_name before base scanning. - // Bases are evaluated in the enclosing scope, not the class scope. - // For generic classes, bases are scanned within the type_param scope - // where class_name is already correctly set. - if type_params.is_none() { - self.class_name = prev_class.clone(); - } - if let Some(arguments) = arguments { - self.scan_expressions(&arguments.args, ExpressionContext::Load)?; - for keyword in &arguments.keywords { - self.scan_expression(&keyword.value, ExpressionContext::Load)?; + if let Some(type_params) = type_params { + self.enter_type_param_block( + name.as_str(), + *range, + true, // for_class: enable selective mangling + false, + false, + )?; + // Set class_name for mangling in type param scope + self.class_name = Some(name.to_string()); + self.scan_type_params(type_params)?; } - } - if type_params.is_some() { - self.leave_scope(); - } - // Restore class_name after all ClassDef processing - self.class_name = prev_class; - self.scan_decorators(decorator_list, ExpressionContext::Load)?; - self.register_ident(name, SymbolUsage::Assigned)?; - } - Stmt::Expr(StmtExpr { value, .. }) => { - self.scan_expression(value, ExpressionContext::Load)? - } - Stmt::If(StmtIf { - test, - body, - elif_else_clauses, - .. - }) => { - self.scan_expression(test, ExpressionContext::Load)?; - // PEP 649: Track conditional block for annotations - let saved_in_conditional_block = self.in_conditional_block; - self.in_conditional_block = true; - self.scan_statements(body)?; - for elif in elif_else_clauses { - if let Some(test) = &elif.test { - self.scan_expression(test, ExpressionContext::Load)?; - } - self.scan_statements(&elif.body)?; - } - self.in_conditional_block = saved_in_conditional_block; - } - Stmt::For(StmtFor { - target, - iter, - body, - orelse, - .. - }) => { - self.scan_expression(target, ExpressionContext::Store)?; - self.scan_expression(iter, ExpressionContext::Load)?; - // PEP 649: Track conditional block for annotations - let saved_in_conditional_block = self.in_conditional_block; - self.in_conditional_block = true; - self.scan_statements(body)?; - self.scan_statements(orelse)?; - self.in_conditional_block = saved_in_conditional_block; - } - Stmt::While(StmtWhile { - test, body, orelse, .. - }) => { - self.scan_expression(test, ExpressionContext::Load)?; - // PEP 649: Track conditional block for annotations - let saved_in_conditional_block = self.in_conditional_block; - self.in_conditional_block = true; - self.scan_statements(body)?; - self.scan_statements(orelse)?; - self.in_conditional_block = saved_in_conditional_block; - } - Stmt::Break(_) | Stmt::Continue(_) | Stmt::Pass(_) => { - // No symbols here. - } - Stmt::Import(StmtImport { names, .. }) - | Stmt::ImportFrom(StmtImportFrom { names, .. }) => { - for name in names { - if let Some(alias) = &name.asname { - // `import my_module as my_alias` - self.check_name(alias.as_str(), ExpressionContext::Store, alias.range)?; - self.register_ident(alias, SymbolUsage::Imported)?; - } else if name.name.as_str() == "*" { - // Star imports are only allowed at module level - if self.tables.last().unwrap().typ != CompilerScope::Module { - return Err(SymbolTableError { - error: "'import *' only allowed at module level".to_string(), - location: Some(self.source_file.to_source_code().source_location( - name.name.range.start(), - PositionEncoding::Utf8, - )), - }); - } - // Don't register star imports as symbols - } else { - // `import module` or `from x import name` - let imported_name = name.name.split('.').next().unwrap(); - self.check_name(imported_name, ExpressionContext::Store, name.name.range)?; - self.register_name(imported_name, SymbolUsage::Imported, name.name.range)?; - } - } - } - Stmt::Return(StmtReturn { value, .. }) => { - if let Some(expression) = value { - self.scan_expression(expression, ExpressionContext::Load)?; - } - } - Stmt::Assert(StmtAssert { test, msg, .. }) => { - self.scan_expression(test, ExpressionContext::Load)?; - if let Some(expression) = msg { - self.scan_expression(expression, ExpressionContext::Load)?; - } - } - Stmt::Delete(StmtDelete { targets, .. }) => { - self.scan_expressions(targets, ExpressionContext::Delete)?; - } - Stmt::Assign(StmtAssign { targets, value, .. }) => { - self.scan_expressions(targets, ExpressionContext::Store)?; - self.scan_expression(value, ExpressionContext::Load)?; - } - Stmt::AugAssign(StmtAugAssign { target, value, .. }) => { - self.scan_expression(target, ExpressionContext::Store)?; - self.scan_expression(value, ExpressionContext::Load)?; - } - Stmt::AnnAssign(StmtAnnAssign { - target, - annotation, - value, - simple, - range, - node_index: _, - }) => { - // https://github.com/python/cpython/blob/main/Python/symtable.c#L1233 - match &**target { - Expr::Name(ast::ExprName { id, .. }) => { - let id_str = id.as_str(); - - if *simple { - self.check_name(id_str, ExpressionContext::Store, *range)?; - - self.register_name(id_str, SymbolUsage::AnnotationAssigned, *range)?; - // PEP 649: Register annotate function in module/class scope - let current_scope = self.tables.last().map(|t| t.typ); - match current_scope { - Some(CompilerScope::Module) => { - self.register_name( - "__annotate__", - SymbolUsage::Assigned, - *range, - )?; - } - Some(CompilerScope::Class) => { - self.register_name( - "__annotate_func__", - SymbolUsage::Assigned, - *range, - )?; - } - _ => {} + + if type_params.is_none() { + self.class_name = prev_class.clone(); + } + if let Some(arguments) = arguments { + self.scan_expressions(&arguments.args, ExpressionContext::Load)?; + for keyword in &arguments.keywords { + if let Some(arg) = &keyword.arg { + self.check_name( + arg.as_str(), + ExpressionContext::Store, + keyword.range, + )?; } - } else if value.is_some() { - self.check_name(id_str, ExpressionContext::Store, *range)?; - self.register_name(id_str, SymbolUsage::Assigned, *range)?; + } + for keyword in &arguments.keywords { + self.scan_expression(&keyword.value, ExpressionContext::Load)?; } } - _ => { - self.scan_expression(target, ExpressionContext::Store)?; + + self.enter_scope( + name.as_str(), + CompilerScope::Class, + self.line_index_start(*range), + ); + // Reset in_conditional_block for new class scope + let saved_in_conditional = self.in_conditional_block; + self.in_conditional_block = false; + self.class_name = Some(name.to_string()); + if type_params.is_some() { + self.register_name(".type_params", SymbolUsage::Used, *range)?; + self.register_name("__type_params__", SymbolUsage::Assigned, *range)?; } + self.scan_statements(body)?; + self.leave_scope(); + self.in_conditional_block = saved_in_conditional; + if type_params.is_some() { + self.leave_scope(); + } + // Restore class_name after all ClassDef processing + self.class_name = prev_class; } - self.scan_ann_assign_annotation(annotation)?; - if let Some(value) = value { - self.scan_expression(value, ExpressionContext::Load)?; + Stmt::Expr(StmtExpr { value, .. }) => { + self.scan_expression(value, ExpressionContext::Load)? } - } - Stmt::With(StmtWith { items, body, .. }) => { - for item in items { - self.scan_expression(&item.context_expr, ExpressionContext::Load)?; - if let Some(expression) = &item.optional_vars { - self.scan_expression(expression, ExpressionContext::Store)?; + Stmt::If(StmtIf { + test, + body, + elif_else_clauses, + .. + }) => { + self.scan_expression(test, ExpressionContext::Load)?; + // PEP 649: Track conditional block for annotations + let saved_in_conditional_block = self.in_conditional_block; + self.in_conditional_block = true; + self.scan_statements(body)?; + for elif in elif_else_clauses { + if let Some(test) = &elif.test { + self.scan_expression(test, ExpressionContext::Load)?; + } + self.scan_statements(&elif.body)?; } - } - // PEP 649: Track conditional block for annotations - let saved_in_conditional_block = self.in_conditional_block; - self.in_conditional_block = true; - self.scan_statements(body)?; - self.in_conditional_block = saved_in_conditional_block; - } - Stmt::Try(StmtTry { - body, - handlers, - orelse, - finalbody, - .. - }) => { - // PEP 649: Track conditional block for annotations - let saved_in_conditional_block = self.in_conditional_block; - self.in_conditional_block = true; - self.scan_statements(body)?; - // Preserve source-order symbol analysis so `global`/`nonlocal` - // semantics match CPython, but reorder child scope storage to - // match the codegen order for plain try/except/else. - let body_subtables_len = self.tables.last().unwrap().sub_tables.len(); - for handler in handlers { - let ExceptHandler::ExceptHandler(ast::ExceptHandlerExceptHandler { - type_, - name, - body, - .. - }) = &handler; - if let Some(expression) = type_ { - self.scan_expression(expression, ExpressionContext::Load)?; + self.in_conditional_block = saved_in_conditional_block; + } + Stmt::For(StmtFor { + target, + iter, + body, + orelse, + is_async, + .. + }) => { + if *is_async && self.allows_top_level_await() { + self.tables.last_mut().unwrap().is_coroutine = true; } - if let Some(name) = name { - self.register_ident(name, SymbolUsage::Assigned)?; + if *is_async && !self.tables.last().unwrap().is_coroutine { + return Err(SymbolTableError { + error: "'async for' outside async function".to_owned(), + location: Some(self.source_file.to_source_code().source_location( + statement.range().start(), + PositionEncoding::Utf8, + )), + }); } + self.scan_expression(target, ExpressionContext::Store)?; + self.scan_expression(iter, ExpressionContext::Load)?; + // PEP 649: Track conditional block for annotations + let saved_in_conditional_block = self.in_conditional_block; + self.in_conditional_block = true; self.scan_statements(body)?; - } - if finalbody.is_empty() { - let handler_subtables = self - .tables - .last_mut() - .unwrap() - .sub_tables - .split_off(body_subtables_len); self.scan_statements(orelse)?; - self.tables - .last_mut() - .unwrap() - .sub_tables - .extend(handler_subtables); - } else { + self.in_conditional_block = saved_in_conditional_block; + } + Stmt::While(StmtWhile { + test, body, orelse, .. + }) => { + self.scan_expression(test, ExpressionContext::Load)?; + // PEP 649: Track conditional block for annotations + let saved_in_conditional_block = self.in_conditional_block; + self.in_conditional_block = true; + self.scan_statements(body)?; self.scan_statements(orelse)?; + self.in_conditional_block = saved_in_conditional_block; + } + Stmt::Break(_) | Stmt::Continue(_) | Stmt::Pass(_) => { + // No symbols here. + } + Stmt::Import(StmtImport { names, .. }) + | Stmt::ImportFrom(StmtImportFrom { names, .. }) => { + for name in names { + if let Some(alias) = &name.asname { + // `import my_module as my_alias` + self.register_name( + alias.as_str(), + SymbolUsage::Imported, + name.name.range, + )?; + } else if name.name.as_str() == "*" { + // Star imports are only allowed at module level + if self.tables.last().unwrap().typ != CompilerScope::Module { + return Err(SymbolTableError { + error: "import * only allowed at module level".to_string(), + location: Some( + self.source_file.to_source_code().source_location( + name.name.range.start(), + PositionEncoding::Utf8, + ), + ), + }); + } + // Don't register star imports as symbols + } else { + // `import module` or `from x import name` + let imported_name = name.name.split('.').next().unwrap(); + self.check_name( + imported_name, + ExpressionContext::Store, + name.name.range, + )?; + self.register_name( + imported_name, + SymbolUsage::Imported, + name.name.range, + )?; + } + } } - self.scan_statements(finalbody)?; - self.in_conditional_block = saved_in_conditional_block; - } - Stmt::Match(StmtMatch { subject, cases, .. }) => { - self.scan_expression(subject, ExpressionContext::Load)?; - // PEP 649: Track conditional block for annotations - let saved_in_conditional_block = self.in_conditional_block; - self.in_conditional_block = true; - for case in cases { - self.scan_pattern(&case.pattern)?; - if let Some(guard) = &case.guard { - self.scan_expression(guard, ExpressionContext::Load)?; + Stmt::Return(StmtReturn { value, .. }) => { + if let Some(expression) = value { + self.scan_expression(expression, ExpressionContext::Load)?; + self.tables.last_mut().unwrap().returns_value = true; } - self.scan_statements(&case.body)?; } - self.in_conditional_block = saved_in_conditional_block; - } - Stmt::Raise(StmtRaise { exc, cause, .. }) => { - if let Some(expression) = exc { - self.scan_expression(expression, ExpressionContext::Load)?; + Stmt::Assert(StmtAssert { test, msg, .. }) => { + self.scan_expression(test, ExpressionContext::Load)?; + if let Some(expression) = msg { + self.scan_expression(expression, ExpressionContext::Load)?; + } } - if let Some(expression) = cause { - self.scan_expression(expression, ExpressionContext::Load)?; + Stmt::Delete(StmtDelete { targets, .. }) => { + self.scan_expressions(targets, ExpressionContext::Delete)?; } - } - Stmt::TypeAlias(StmtTypeAlias { - name, - value, - type_params, - .. - }) => { - let Some(name_expr) = name.as_name_expr() else { + Stmt::Assign(StmtAssign { targets, value, .. }) => { + self.scan_expressions(targets, ExpressionContext::Store)?; + self.scan_expression(value, ExpressionContext::Load)?; + } + Stmt::AugAssign(StmtAugAssign { target, value, .. }) => { + self.scan_expression(target, ExpressionContext::Store)?; + self.scan_expression(value, ExpressionContext::Load)?; + } + Stmt::AnnAssign(StmtAnnAssign { + target, + annotation, + value, + simple, + range, + node_index: _, + .. + }) => { + self.tables.last_mut().unwrap().annotations_used = true; + // https://github.com/python/cpython/blob/main/Python/symtable.c#L1233 + match &**target { + Expr::Name(ast::ExprName { + id, + range: target_range, + .. + }) => { + let id_str = id.as_str(); + + if *simple { + let existing_flags = self.tables.last().and_then(|table| { + let name = maybe_mangle_name( + self.class_name.as_deref(), + table.mangled_names.as_ref(), + id_str, + ); + table.symbols.get(name.as_ref()).map(|symbol| symbol.flags) + }); + if self + .tables + .last() + .is_some_and(|table| table.typ != CompilerScope::Module) + && let Some(flags) = existing_flags + && flags.intersects(SymbolFlags::GLOBAL | SymbolFlags::NONLOCAL) + { + let usage = if flags.contains(SymbolFlags::GLOBAL) { + "global" + } else { + "nonlocal" + }; + return Err(SymbolTableError { + error: format!( + "annotated name '{id_str}' can't be {usage}" + ), + location: Some( + self.source_file.to_source_code().source_location( + range.start(), + PositionEncoding::Utf8, + ), + ), + }); + } + + self.register_name( + id_str, + SymbolUsage::AnnotationAssigned, + *target_range, + )?; + // PEP 649: Register annotate function in module/class scope + let current_scope = self.tables.last().map(|t| t.typ); + match current_scope { + Some(CompilerScope::Module) => { + self.register_name( + "__annotate__", + SymbolUsage::Assigned, + *range, + )?; + } + Some(CompilerScope::Class) => { + self.register_name( + "__annotate_func__", + SymbolUsage::Assigned, + *range, + )?; + } + _ => {} + } + } else if value.is_some() { + self.register_name(id_str, SymbolUsage::Assigned, *target_range)?; + } + } + _ => { + self.scan_expression(target, ExpressionContext::Store)?; + } + } + self.scan_ann_assign_annotation(annotation)?; + if let Some(value) = value { + self.scan_expression(value, ExpressionContext::Load)?; + } + } + Stmt::With(StmtWith { + items, + body, + is_async, + .. + }) => { + if *is_async && self.allows_top_level_await() { + self.tables.last_mut().unwrap().is_coroutine = true; + } + if *is_async && !self.tables.last().unwrap().is_coroutine { + return Err(SymbolTableError { + error: "'async with' outside async function".to_owned(), + location: Some(self.source_file.to_source_code().source_location( + statement.range().start(), + PositionEncoding::Utf8, + )), + }); + } + // PEP 649: Track conditional block for annotations + let saved_in_conditional_block = self.in_conditional_block; + self.in_conditional_block = true; + for item in items { + self.scan_expression(&item.context_expr, ExpressionContext::Load)?; + if let Some(expression) = &item.optional_vars { + self.scan_expression(expression, ExpressionContext::Store)?; + } + } + self.scan_statements(body)?; + self.in_conditional_block = saved_in_conditional_block; + } + Stmt::Try(StmtTry { + body, + handlers, + orelse, + finalbody, + .. + }) => { + // PEP 649: Track conditional block for annotations + let saved_in_conditional_block = self.in_conditional_block; + self.in_conditional_block = true; + self.scan_statements(body)?; + for handler in handlers { + let ExceptHandler::ExceptHandler(ast::ExceptHandlerExceptHandler { + type_, + name, + body, + .. + }) = &handler; + if let Some(expression) = type_ { + self.scan_expression(expression, ExpressionContext::Load)?; + } + if let Some(name) = name { + self.register_name( + name.as_str(), + SymbolUsage::Assigned, + handler.range(), + )?; + } + self.scan_statements(body)?; + } + self.scan_statements(orelse)?; + self.scan_statements(finalbody)?; + self.in_conditional_block = saved_in_conditional_block; + } + Stmt::Match(StmtMatch { subject, cases, .. }) => { + self.scan_expression(subject, ExpressionContext::Load)?; + // PEP 649: Track conditional block for annotations + let saved_in_conditional_block = self.in_conditional_block; + self.in_conditional_block = true; + for case in cases { + self.scan_pattern(&case.pattern)?; + if let Some(guard) = &case.guard { + self.scan_expression(guard, ExpressionContext::Load)?; + } + self.scan_statements(&case.body)?; + } + self.in_conditional_block = saved_in_conditional_block; + } + Stmt::Raise(StmtRaise { exc, cause, .. }) => { + if let Some(expression) = exc { + self.scan_expression(expression, ExpressionContext::Load)?; + if let Some(expression) = cause { + self.scan_expression(expression, ExpressionContext::Load)?; + } + } + } + Stmt::TypeAlias(StmtTypeAlias { + name, + value, + type_params, + range, + .. + }) => { + let Some(name_expr) = name.as_name_expr() else { + return Err(SymbolTableError { + error: "type alias expects name".to_owned(), + location: Some( + self.source_file + .to_source_code() + .source_location(name.range().start(), PositionEncoding::Utf8), + ), + }); + }; + let alias_name = name_expr.id.to_string(); + self.scan_expression(name, ExpressionContext::Store)?; + // Check before entering any sub-scopes + let in_class = self + .tables + .last() + .is_some_and(|t| t.typ == CompilerScope::Class); + let is_generic = type_params.is_some(); + if let Some(type_params) = type_params { + self.enter_type_param_block(&alias_name, *range, false, false, false)?; + self.scan_type_params(type_params)?; + } + // Value scope for lazy evaluation + self.enter_scope( + &alias_name, + CompilerScope::TypeAlias, + self.line_index_start(*range), + ); + // Evaluator takes a format parameter + self.register_name(".format", SymbolUsage::Parameter, *range)?; + self.register_name(".format", SymbolUsage::Used, *range)?; + if in_class { + if let Some(table) = self.tables.last_mut() { + table.can_see_class_scope = true; + } + self.register_name("__classdict__", SymbolUsage::Used, value.range())?; + } + self.scan_expression(value, ExpressionContext::Load)?; + self.leave_scope(); + if is_generic { + self.leave_scope(); + } + } + Stmt::IpyEscapeCommand(stmt) => { return Err(SymbolTableError { - error: "type alias expects name".to_owned(), + error: "invalid syntax".to_owned(), location: Some( self.source_file .to_source_code() - .source_location(name.range().start(), PositionEncoding::Utf8), + .source_location(stmt.range.start(), PositionEncoding::Utf8), ), }); - }; - let alias_name = name_expr.id.to_string(); - let was_in_type_alias = self.in_type_alias; - self.in_type_alias = true; - // Check before entering any sub-scopes - let in_class = self - .tables - .last() - .is_some_and(|t| t.typ == CompilerScope::Class); - let is_generic = type_params.is_some(); - if let Some(type_params) = type_params { - self.enter_type_param_block( - &alias_name, - self.line_index_start(type_params.range), - false, - false, - false, - )?; - self.scan_type_params(type_params)?; - } - // Value scope for lazy evaluation - self.enter_scope( - &alias_name, - CompilerScope::Annotation, - self.line_index_start(value.range()), - ); - // Evaluator takes a format parameter - self.register_name(".format", SymbolUsage::Parameter, TextRange::default())?; - if in_class { - if let Some(table) = self.tables.last_mut() { - table.can_see_class_scope = true; - } - self.register_name("__classdict__", SymbolUsage::Used, TextRange::default())?; - } - self.scan_expression(value, ExpressionContext::Load)?; - self.leave_scope(); - if is_generic { - self.leave_scope(); } - self.in_type_alias = was_in_type_alias; - self.scan_expression(name, ExpressionContext::Store)?; } - Stmt::IpyEscapeCommand(_) => todo!(), - } - Ok(()) + Ok(()) + })(); + self.recursion_depth -= 1; + result } fn scan_decorators( @@ -1962,389 +2187,536 @@ impl SymbolTableBuilder { expression: &ast::Expr, context: ExpressionContext, ) -> SymbolTableResult { - use ast::*; - - // Check for expressions not allowed in certain contexts - // (type parameters, annotations, type aliases, TypeVar bounds/defaults) - if let Some(keyword) = match expression { - Expr::Yield(_) | Expr::YieldFrom(_) => Some("yield"), - Expr::Await(_) => Some("await"), - Expr::Named(_) => Some("named"), - _ => None, - } { - // Determine the context name for the error message - // scope_info takes precedence (e.g., "a TypeVar bound") - let context_name = if let Some(scope_info) = self.scope_info { - Some(scope_info) - } else if let Some(table) = self.tables.last() - && table.typ == CompilerScope::TypeParams - { - Some("a type parameter") - } else if self.in_annotation { - Some("an annotation") - } else if self.in_type_alias { - Some("a type alias") - } else { - None - }; - - if let Some(context_name) = context_name { - return Err(SymbolTableError { - error: format!("{keyword} expression cannot be used within {context_name}"), - location: Some( - self.source_file - .to_source_code() - .source_location(expression.range().start(), PositionEncoding::Utf8), - ), - }); - } + if self.recursion_depth >= self.recursion_limit { + return Err(SymbolTableError { + error: RECURSION_ERROR.to_owned(), + location: None, + }); } + self.recursion_depth += 1; + let result = (|| { + use ast::*; + + if expression.is_constant_expr() { + return Ok(()); + } + + // Check for expressions not allowed in certain contexts + // (type parameters, annotations, type aliases, TypeVar bounds/defaults) + if let Some(keyword) = match expression { + Expr::Yield(_) | Expr::YieldFrom(_) => Some("yield"), + Expr::Await(_) => Some("await"), + Expr::Named(_) => Some("named"), + _ => None, + } { + // Determine the context name for the error message from the + // current symbol table entry, matching ste_type checks. + let current_is_comprehension = self + .tables + .last() + .is_some_and(|table| table.typ == CompilerScope::Comprehension); + let context_name = if keyword == "named" && current_is_comprehension { + None + } else if let Some(table) = self.tables.last() { + match table.typ { + CompilerScope::Annotation => Some("an annotation"), + CompilerScope::TypeVariable => table.scope_info, + CompilerScope::TypeAlias => Some("a type alias"), + CompilerScope::TypeParams => Some("the definition of a generic"), + _ => None, + } + } else { + None + }; - match expression { - Expr::BinOp(ExprBinOp { - left, - right, - range: _, - .. - }) => { - self.scan_expression(left, context)?; - self.scan_expression(right, context)?; - } - Expr::BoolOp(ExprBoolOp { - values, range: _, .. - }) => { - self.scan_expressions(values, context)?; - } - Expr::Compare(ExprCompare { - left, - comparators, - range: _, - .. - }) => { - self.scan_expression(left, context)?; - self.scan_expressions(comparators, context)?; - } - Expr::Subscript(ExprSubscript { - value, - slice, - range: _, - .. - }) => { - self.scan_expression(value, ExpressionContext::Load)?; - self.scan_expression(slice, ExpressionContext::Load)?; - } - Expr::Attribute(ExprAttribute { - value, attr, range, .. - }) => { - self.check_name(attr.as_str(), context, *range)?; - self.scan_expression(value, ExpressionContext::Load)?; - } - Expr::Dict(ExprDict { - items, - node_index: _, - range: _, - }) => { - for item in items { - if let Some(key) = &item.key { - self.scan_expression(key, context)?; - } - self.scan_expression(&item.value, context)?; - } - } - Expr::Await(ExprAwait { - value, - node_index: _, - range: _, - }) => { - self.scan_expression(value, context)?; - self.tables.last_mut().unwrap().is_coroutine = true; + if let Some(context_name) = context_name { + return Err(SymbolTableError { + error: format!("{keyword} expression cannot be used within {context_name}"), + location: Some( + self.source_file.to_source_code().source_location( + expression.range().start(), + PositionEncoding::Utf8, + ), + ), + }); + } } - Expr::Yield(ExprYield { - value, - node_index: _, - range: _, - }) => { - self.tables.last_mut().unwrap().is_generator = true; - if let Some(expression) = value { - self.scan_expression(expression, context)?; - } - } - Expr::YieldFrom(ExprYieldFrom { - value, - node_index: _, - range: _, - }) => { - self.tables.last_mut().unwrap().is_generator = true; - self.scan_expression(value, context)?; - } - Expr::UnaryOp(ExprUnaryOp { - operand, range: _, .. - }) => { - self.scan_expression(operand, context)?; - } - Expr::Starred(ExprStarred { - value, range: _, .. - }) => { - self.scan_expression(value, context)?; - } - Expr::Tuple(ExprTuple { elts, range: _, .. }) - | Expr::Set(ExprSet { elts, range: _, .. }) - | Expr::List(ExprList { elts, range: _, .. }) => { - self.scan_expressions(elts, context)?; - } - Expr::Slice(ExprSlice { - lower, - upper, - step, - node_index: _, - range: _, - }) => { - if let Some(lower) = lower { - self.scan_expression(lower, context)?; - } - if let Some(upper) = upper { - self.scan_expression(upper, context)?; - } - if let Some(step) = step { - self.scan_expression(step, context)?; - } - } - Expr::Generator(ExprGenerator { - elt, - generators, - range, - .. - }) => { - let was_in_iter_def_exp = self.in_iter_def_exp; - if context == ExpressionContext::IterDefinitionExp { - self.in_iter_def_exp = true; - } - // Generator expression - is_generator = true - self.scan_comprehension("", elt, None, generators, *range, true)?; - self.in_iter_def_exp = was_in_iter_def_exp; - } - Expr::ListComp(ExprListComp { - elt, - generators, - range, - node_index: _, - }) => { - let was_in_iter_def_exp = self.in_iter_def_exp; - if context == ExpressionContext::IterDefinitionExp { - self.in_iter_def_exp = true; - } - // List comprehension - is_generator = false (can be inlined) - self.scan_comprehension("", elt, None, generators, *range, false)?; - self.in_iter_def_exp = was_in_iter_def_exp; - } - Expr::SetComp(ExprSetComp { - elt, - generators, - range, - node_index: _, - }) => { - let was_in_iter_def_exp = self.in_iter_def_exp; - if context == ExpressionContext::IterDefinitionExp { - self.in_iter_def_exp = true; - } - // Set comprehension - is_generator = false (can be inlined) - self.scan_comprehension("", elt, None, generators, *range, false)?; - self.in_iter_def_exp = was_in_iter_def_exp; - } - Expr::DictComp(ExprDictComp { - key, - value, - generators, - range, - node_index: _, - }) => { - let was_in_iter_def_exp = self.in_iter_def_exp; - if context == ExpressionContext::IterDefinitionExp { - self.in_iter_def_exp = true; - } - // Dict comprehension - is_generator = false (can be inlined) - self.scan_comprehension("", key, Some(value), generators, *range, false)?; - self.in_iter_def_exp = was_in_iter_def_exp; - } - Expr::Call(ExprCall { - func, - arguments, - node_index: _, - range: _, - }) => { - match context { - ExpressionContext::IterDefinitionExp => { - self.scan_expression(func, ExpressionContext::IterDefinitionExp)?; + + match expression { + Expr::BinOp(ExprBinOp { + left, + right, + range: _, + .. + }) => { + self.scan_expression(left, context)?; + self.scan_expression(right, context)?; + } + Expr::BoolOp(ExprBoolOp { + values, range: _, .. + }) => { + self.scan_expressions(values, context)?; + } + Expr::Compare(ExprCompare { + left, + comparators, + range: _, + .. + }) => { + self.scan_expression(left, context)?; + self.scan_expressions(comparators, context)?; + } + Expr::Subscript(ExprSubscript { + value, + slice, + range: _, + .. + }) => { + self.scan_expression(value, ExpressionContext::Load)?; + self.scan_expression(slice, ExpressionContext::Load)?; + } + Expr::Attribute(ExprAttribute { + value, attr, range, .. + }) => { + self.check_name(attr.as_str(), context, *range)?; + self.scan_expression(value, ExpressionContext::Load)?; + } + Expr::Dict(ExprDict { + items, + node_index: _, + range: _, + .. + }) => { + for item in items { + if let Some(key) = &item.key { + self.scan_expression(key, context)?; + } } - _ => { - self.scan_expression(func, ExpressionContext::Load)?; + for item in items { + self.scan_expression(&item.value, context)?; } } - - self.scan_expressions(&arguments.args, ExpressionContext::Load)?; - for keyword in &arguments.keywords { - if let Some(arg) = &keyword.arg { - self.check_name(arg.as_str(), ExpressionContext::Store, keyword.range)?; + Expr::Await(ExprAwait { + value, + node_index: _, + range: _, + .. + }) => { + let current_scope = self.tables.last().unwrap().typ; + if !self.allows_top_level_await() + && !Self::is_function_like_scope(current_scope) + { + return Err(SymbolTableError { + error: "'await' outside function".to_owned(), + location: Some(self.source_file.to_source_code().source_location( + expression.range().start(), + PositionEncoding::Utf8, + )), + }); } - self.scan_expression(&keyword.value, ExpressionContext::Load)?; + if current_scope != CompilerScope::AsyncFunction + && current_scope != CompilerScope::Comprehension + && !self.allows_top_level_await() + { + return Err(SymbolTableError { + error: "'await' outside async function".to_owned(), + location: Some(self.source_file.to_source_code().source_location( + expression.range().start(), + PositionEncoding::Utf8, + )), + }); + } + self.scan_expression(value, context)?; + self.tables.last_mut().unwrap().is_coroutine = true; } - } - Expr::Name(ExprName { id, range, .. }) => { - let id = id.as_str(); - - self.check_name(id, context, *range)?; - - if !self.in_unevaluated_annotation { - // Determine the contextual usage of this symbol: + Expr::Yield(ExprYield { + value, + node_index: _, + range: _, + .. + }) => { + if let Some(expression) = value { + self.scan_expression(expression, context)?; + } + self.tables.last_mut().unwrap().is_generator = true; + if let Some(context_name) = self.comprehension_yield_context + && self + .tables + .last() + .is_some_and(|table| table.typ == CompilerScope::Comprehension) + { + return Err(SymbolTableError { + error: format!("'yield' inside {context_name}"), + location: Some(self.source_file.to_source_code().source_location( + expression.range().start(), + PositionEncoding::Utf8, + )), + }); + } + } + Expr::YieldFrom(ExprYieldFrom { + value, + node_index: _, + range: _, + .. + }) => { + self.scan_expression(value, context)?; + self.tables.last_mut().unwrap().is_generator = true; + if let Some(context_name) = self.comprehension_yield_context + && self + .tables + .last() + .is_some_and(|table| table.typ == CompilerScope::Comprehension) + { + return Err(SymbolTableError { + error: format!("'yield' inside {context_name}"), + location: Some(self.source_file.to_source_code().source_location( + expression.range().start(), + PositionEncoding::Utf8, + )), + }); + } + } + Expr::UnaryOp(ExprUnaryOp { + operand, range: _, .. + }) => { + self.scan_expression(operand, context)?; + } + Expr::Starred(ExprStarred { + value, range: _, .. + }) => { + self.scan_expression(value, context)?; + } + Expr::Tuple(ExprTuple { elts, range: _, .. }) + | Expr::Set(ExprSet { elts, range: _, .. }) + | Expr::List(ExprList { elts, range: _, .. }) => { + self.scan_expressions(elts, context)?; + } + Expr::Slice(ExprSlice { + lower, + upper, + step, + node_index: _, + range: _, + .. + }) => { + if let Some(lower) = lower { + self.scan_expression(lower, context)?; + } + if let Some(upper) = upper { + self.scan_expression(upper, context)?; + } + if let Some(step) = step { + self.scan_expression(step, context)?; + } + } + Expr::Generator(ExprGenerator { + elt, + generators, + range, + .. + }) => { + let was_in_iter_def_exp = self.in_iter_def_exp; + if context == ExpressionContext::IterDefinitionExp { + self.in_iter_def_exp = true; + } + // Generator expression - is_generator = true + self.scan_comprehension("", elt, None, generators, *range, true)?; + self.in_iter_def_exp = was_in_iter_def_exp; + } + Expr::ListComp(ExprListComp { + elt, + generators, + range, + node_index: _, + .. + }) => { + let was_in_iter_def_exp = self.in_iter_def_exp; + if context == ExpressionContext::IterDefinitionExp { + self.in_iter_def_exp = true; + } + // List comprehension - is_generator = false (can be inlined) + self.scan_comprehension("", elt, None, generators, *range, false)?; + self.in_iter_def_exp = was_in_iter_def_exp; + } + Expr::SetComp(ExprSetComp { + elt, + generators, + range, + node_index: _, + .. + }) => { + let was_in_iter_def_exp = self.in_iter_def_exp; + if context == ExpressionContext::IterDefinitionExp { + self.in_iter_def_exp = true; + } + // Set comprehension - is_generator = false (can be inlined) + self.scan_comprehension("", elt, None, generators, *range, false)?; + self.in_iter_def_exp = was_in_iter_def_exp; + } + Expr::DictComp(ExprDictComp { + key, + value, + generators, + range, + node_index: _, + .. + }) => { + let was_in_iter_def_exp = self.in_iter_def_exp; + if context == ExpressionContext::IterDefinitionExp { + self.in_iter_def_exp = true; + } + // Dict comprehension - is_generator = false (can be inlined) + let key = key.as_ref(); + self.scan_comprehension( + "", + key, + Some(value), + generators, + *range, + false, + )?; + self.in_iter_def_exp = was_in_iter_def_exp; + } + Expr::Call(ExprCall { + func, + arguments, + node_index: _, + range: _, + .. + }) => { match context { - ExpressionContext::Delete => { - self.register_name(id, SymbolUsage::Assigned, *range)?; - self.register_name(id, SymbolUsage::Used, *range)?; - } - ExpressionContext::Load | ExpressionContext::IterDefinitionExp => { - self.register_name(id, SymbolUsage::Used, *range)?; + ExpressionContext::IterDefinitionExp => { + self.scan_expression(func, ExpressionContext::IterDefinitionExp)?; } - ExpressionContext::Store => { - self.register_name(id, SymbolUsage::Assigned, *range)?; + _ => { + self.scan_expression(func, ExpressionContext::Load)?; } - ExpressionContext::Iter => { - self.register_name(id, SymbolUsage::Iter, *range)?; + } + + self.scan_expressions(&arguments.args, ExpressionContext::Load)?; + for keyword in &arguments.keywords { + if let Some(arg) = &keyword.arg { + self.check_name(arg.as_str(), ExpressionContext::Store, keyword.range)?; } } - // Interesting stuff about the __class__ variable: - // https://docs.python.org/3/reference/datamodel.html?highlight=__class__#creating-the-class-object - if context == ExpressionContext::Load - && matches!( - self.tables.last().unwrap().typ, - CompilerScope::Function | CompilerScope::AsyncFunction - ) - && id == "super" + for keyword in &arguments.keywords { + self.scan_expression(&keyword.value, ExpressionContext::Load)?; + } + } + Expr::Name(ExprName { id, range, .. }) => { + let id = id.as_str(); + + self.check_name(id, context, *range)?; + + if !self + .tables + .last() + .is_some_and(|table| table.in_unevaluated_annotation) { - self.register_name("__class__", SymbolUsage::Used, *range)?; + // Determine the contextual usage of this symbol: + match context { + ExpressionContext::Delete => { + self.register_name(id, SymbolUsage::Assigned, *range)?; + } + ExpressionContext::Load | ExpressionContext::IterDefinitionExp => { + self.register_name(id, SymbolUsage::Used, *range)?; + } + ExpressionContext::Store => { + self.register_name(id, SymbolUsage::Assigned, *range)?; + } + ExpressionContext::Iter => { + self.register_name(id, SymbolUsage::Iter, *range)?; + } + } + // Interesting stuff about the __class__ variable: + // https://docs.python.org/3/reference/datamodel.html?highlight=__class__#creating-the-class-object + if context == ExpressionContext::Load + && Self::is_function_like_scope(self.tables.last().unwrap().typ) + && id == "super" + { + self.register_name("__class__", SymbolUsage::Used, *range)?; + } } } - } - Expr::Lambda(ExprLambda { - body, - parameters, - node_index: _, - range: _, - }) => { - if let Some(parameters) = parameters { - self.enter_scope_with_parameters( - "lambda", - parameters, - self.line_index_start(expression.range()), - false, // lambdas have no return annotation - CompilerScope::Lambda, - false, // don't skip defaults - )?; - } else { - self.enter_scope( - "lambda", - CompilerScope::Lambda, - self.line_index_start(expression.range()), - ); + Expr::Lambda(ExprLambda { + body, + parameters, + node_index: _, + range: _, + .. + }) => { + let was_in_iter_def_exp = self.in_iter_def_exp; + if let Some(parameters) = parameters { + if was_in_iter_def_exp { + self.scan_parameter_defaults(parameters)?; + } + self.enter_scope_with_parameters( + "lambda", + parameters, + self.line_index_start(expression.range()), + None, // lambdas have no return annotation + CompilerScope::Lambda, + was_in_iter_def_exp, + false, + )?; + } else { + self.enter_scope( + "lambda", + CompilerScope::Lambda, + self.line_index_start(expression.range()), + ); + } + self.scan_expression(body, ExpressionContext::Load)?; + self.in_iter_def_exp = was_in_iter_def_exp; + self.leave_scope(); } - match context { - ExpressionContext::IterDefinitionExp => { - self.scan_expression(body, ExpressionContext::IterDefinitionExp)?; + Expr::FString(fstring) => { + if let Some(joined_str) = &fstring.runtime_joined_str { + for expr in joined_str { + self.scan_expression(expr, ExpressionContext::Load)?; + } + return Ok(()); } - _ => { - self.scan_expression(body, ExpressionContext::Load)?; + for expr in fstring + .value + .elements() + .filter_map(|x| x.as_interpolation()) + { + self.scan_expression(&expr.expression, ExpressionContext::Load)?; + if let Some(format_spec) = &expr.runtime_formatted_value_format_spec { + self.scan_expression(format_spec, ExpressionContext::Load)?; + } else if let Some(format_spec) = &expr.format_spec { + for element in format_spec.elements.interpolations() { + self.scan_expression(&element.expression, ExpressionContext::Load)? + } + } } } - self.leave_scope(); - } - Expr::FString(ExprFString { value, .. }) => { - for expr in value.elements().filter_map(|x| x.as_interpolation()) { - self.scan_expression(&expr.expression, ExpressionContext::Load)?; - if let Some(format_spec) = &expr.format_spec { - for element in format_spec.elements.interpolations() { - self.scan_expression(&element.expression, ExpressionContext::Load)? + Expr::TString(tstring) => { + if let Some(template_str) = &tstring.runtime_template_str { + for expr in template_str { + self.scan_expression(expr, ExpressionContext::Load)?; } + return Ok(()); } - } - } - Expr::TString(tstring) => { - // Scan t-string interpolation expressions (similar to f-strings) - for expr in tstring - .value - .elements() - .filter_map(|x| x.as_interpolation()) - { - self.scan_expression(&expr.expression, ExpressionContext::Load)?; - if let Some(format_spec) = &expr.format_spec { - for element in format_spec.elements.interpolations() { - self.scan_expression(&element.expression, ExpressionContext::Load)? + // Scan t-string interpolation expressions (similar to f-strings) + for expr in tstring + .value + .elements() + .filter_map(|x| x.as_interpolation()) + { + self.scan_expression(&expr.expression, ExpressionContext::Load)?; + if expr.runtime_str.is_some() { + if let Some(format_spec) = &expr.runtime_interpolation_format_spec { + self.scan_expression(format_spec, ExpressionContext::Load)?; + } + } else if let Some(format_spec) = &expr.format_spec { + for element in format_spec.elements.interpolations() { + self.scan_expression(&element.expression, ExpressionContext::Load)? + } } } } - } - // Constants - Expr::StringLiteral(_) - | Expr::BytesLiteral(_) - | Expr::NumberLiteral(_) - | Expr::BooleanLiteral(_) - | Expr::NoneLiteral(_) - | Expr::EllipsisLiteral(_) => {} - Expr::IpyEscapeCommand(_) => todo!(), - Expr::If(ExprIf { - test, - body, - orelse, - node_index: _, - range: _, - }) => { - self.scan_expression(test, ExpressionContext::Load)?; - self.scan_expression(body, ExpressionContext::Load)?; - self.scan_expression(orelse, ExpressionContext::Load)?; - } - - Expr::Named(ExprNamed { - target, - value, - range, - node_index: _, - }) => { - // named expressions are not allowed in the definition of - // comprehension iterator definitions (including nested comprehensions) - if context == ExpressionContext::IterDefinitionExp || self.in_iter_def_exp { + // Constants + Expr::StringLiteral(_) + | Expr::BytesLiteral(_) + | Expr::NumberLiteral(_) + | Expr::Constant(_) + | Expr::BooleanLiteral(_) + | Expr::NoneLiteral(_) + | Expr::EllipsisLiteral(_) => {} + Expr::IpyEscapeCommand(expr) => { return Err(SymbolTableError { - error: "assignment expression cannot be used in a comprehension iterable expression".to_string(), - location: Some(self.source_file.to_source_code().source_location(target.range().start(), PositionEncoding::Utf8)), - }); + error: "invalid syntax".to_owned(), + location: Some( + self.source_file + .to_source_code() + .source_location(expr.range.start(), PositionEncoding::Utf8), + ), + }); + } + Expr::If(ExprIf { + test, + body, + orelse, + node_index: _, + range: _, + .. + }) => { + self.scan_expression(test, ExpressionContext::Load)?; + self.scan_expression(body, ExpressionContext::Load)?; + self.scan_expression(orelse, ExpressionContext::Load)?; } - self.scan_expression(value, ExpressionContext::Load)?; + Expr::Named(ExprNamed { + target, + value, + range, + node_index: _, + .. + }) => { + // named expressions are not allowed in the definition of + // comprehension iterator definitions (including nested comprehensions) + if context == ExpressionContext::IterDefinitionExp || self.in_iter_def_exp { + return Err(SymbolTableError { + error: + "assignment expression cannot be used in a comprehension iterable expression" + .to_string(), + location: Some( + self.source_file + .to_source_code() + .source_location(range.start(), PositionEncoding::Utf8), + ), + }); + } - // special handling for assigned identifier in named expressions - // that are used in comprehensions. This required to correctly - // propagate the scope of the named assigned named and not to - // propagate inner names. - if let Expr::Name(ExprName { id, .. }) = &**target { - let id = id.as_str(); - self.check_name(id, ExpressionContext::Store, *range)?; - let table = self.tables.last().unwrap(); - if table.typ == CompilerScope::Comprehension { - self.extend_namedexpr_scope(id, *range)?; - self.register_name( - id, - SymbolUsage::AssignedNamedExprInComprehension, - *range, - )?; + let named_target = if let Expr::Name(ExprName { + id, + range: target_range, + .. + }) = &**target + { + let id = id.as_str(); + self.check_name(id, ExpressionContext::Store, *target_range)?; + let table = self.tables.last().unwrap(); + if table.typ == CompilerScope::Comprehension { + self.extend_namedexpr_scope(id, *target_range)?; + } + Some((id, *target_range)) } else { - // omit one recursion. When the handling of an store changes for - // Identifiers this needs adapted - more forward safe would be - // calling scan_expression directly. - self.register_name(id, SymbolUsage::Assigned, *range)?; + None + }; + + self.scan_expression(value, ExpressionContext::Load)?; + + // special handling for assigned identifier in named expressions + // that are used in comprehensions. This required to correctly + // propagate the scope of the named assigned named and not to + // propagate inner names. + if let Some((id, target_range)) = named_target { + let table = self.tables.last().unwrap(); + if table.typ == CompilerScope::Comprehension { + self.register_name( + id, + SymbolUsage::AssignedNamedExprInComprehension, + target_range, + )?; + } else { + // omit one recursion. When the handling of an store changes for + // Identifiers this needs adapted - more forward safe would be + // calling scan_expression directly. + self.register_name(id, SymbolUsage::Assigned, target_range)?; + } + } else { + self.scan_expression(target, ExpressionContext::Store)?; } - } else { - self.scan_expression(target, ExpressionContext::Store)?; } } - } - Ok(()) + Ok(()) + })(); + self.recursion_depth -= 1; + result } fn scan_comprehension( @@ -2356,26 +2728,15 @@ impl SymbolTableBuilder { range: TextRange, is_generator: bool, ) -> SymbolTableResult { - // Check for async comprehension outside async function - // (list/set/dict comprehensions only, not generator expressions) - let has_async_gen = generators.iter().any(|g| g.is_async); - if has_async_gen && !is_generator && !self.is_in_async_context() { - return Err(SymbolTableError { - error: "asynchronous comprehension outside of an asynchronous function".to_owned(), - location: Some( - self.source_file - .to_source_code() - .source_location(range.start(), PositionEncoding::Utf8), - ), - }); - } - assert!(!generators.is_empty()); let outermost = &generators[0]; // CPython evaluates the outermost iterator in the enclosing scope // before entering the comprehension scope. + let was_in_iter_def_exp = self.in_iter_def_exp; + self.in_iter_def_exp = true; self.scan_expression(&outermost.iter, ExpressionContext::IterDefinitionExp)?; + self.in_iter_def_exp = was_in_iter_def_exp; // Comprehensions are compiled as functions, so create a scope for them: self.enter_scope( @@ -2383,14 +2744,12 @@ impl SymbolTableBuilder { CompilerScope::Comprehension, self.line_index_start(range), ); - // Generator expressions need the is_generator flag - self.tables.last_mut().unwrap().is_generator = is_generator; - if generators.iter().any(|generator| generator.is_async) { + if outermost.is_async { self.tables.last_mut().unwrap().is_coroutine = true; } // PEP 709: Mark non-generator comprehensions for inlining. - // CPython's symtable marks all non-generator comprehensions for + // symtable marks all non-generator comprehensions for // inlining, except scopes nested under a parent that can see class // scope (for example annotation scopes inside classes). if !is_generator { @@ -2404,6 +2763,15 @@ impl SymbolTableBuilder { // Register the passed argument to the generator function as the name ".0" self.register_name(".0", SymbolUsage::Parameter, range)?; + let saved_comprehension_yield_context = self.comprehension_yield_context; + self.comprehension_yield_context = Some(match scope_name { + "" => "list comprehension", + "" => "set comprehension", + "" => "dict comprehension", + "" => "generator expression", + _ => "comprehension", + }); + self.scan_expression(&outermost.target, ExpressionContext::Iter)?; for if_expr in &outermost.ifs { self.scan_expression(if_expr, ExpressionContext::Load)?; @@ -2413,22 +2781,47 @@ impl SymbolTableBuilder { self.in_comp_inner_loop_target = true; self.scan_expression(&generator.target, ExpressionContext::Iter)?; self.in_comp_inner_loop_target = false; + let was_in_iter_def_exp = self.in_iter_def_exp; + self.in_iter_def_exp = true; self.scan_expression(&generator.iter, ExpressionContext::IterDefinitionExp)?; + self.in_iter_def_exp = was_in_iter_def_exp; for if_expr in &generator.ifs { self.scan_expression(if_expr, ExpressionContext::Load)?; } + if generator.is_async { + self.tables.last_mut().unwrap().is_coroutine = true; + } } if let Some(elt2) = elt2 { self.scan_expression(elt2, ExpressionContext::Load)?; } self.scan_expression(elt1, ExpressionContext::Load)?; + self.tables.last_mut().unwrap().is_generator = is_generator; + self.comprehension_yield_context = saved_comprehension_yield_context; - // CPython symtable_handle_comprehension(): non-generator async + // symtable_handle_comprehension(): non-generator async // comprehensions propagate ste_coroutine to the enclosing scope after // the comprehension block is exited. let propagate_coroutine = self.tables.last().unwrap().is_coroutine && !is_generator; self.leave_scope(); + if propagate_coroutine + && self + .tables + .last() + .is_none_or(|table| table.typ != CompilerScope::Comprehension) + && !self.is_in_async_context() + && !self.allows_top_level_await() + { + return Err(SymbolTableError { + error: "asynchronous comprehension outside of an asynchronous function".to_owned(), + location: Some( + self.source_file + .to_source_code() + .source_location(range.start(), PositionEncoding::Utf8), + ), + }); + } if propagate_coroutine { self.tables.last_mut().unwrap().is_coroutine = true; } @@ -2444,142 +2837,151 @@ impl SymbolTableBuilder { scope_name: &str, scope_info: &'static str, ) -> SymbolTableResult { - // Bounds/defaults are compiled as annotation scopes in CPython. + // Bounds/defaults are compiled as annotation scopes. let in_class = self.tables.last().is_some_and(|t| t.can_see_class_scope); let line_number = self.line_index_start(expr.range()); - self.enter_scope(scope_name, CompilerScope::Annotation, line_number); + self.enter_scope(scope_name, CompilerScope::TypeVariable, line_number); // Evaluator takes a format parameter - self.register_name(".format", SymbolUsage::Parameter, TextRange::default())?; + self.register_name(".format", SymbolUsage::Parameter, expr.range())?; + self.register_name(".format", SymbolUsage::Used, expr.range())?; if in_class { if let Some(table) = self.tables.last_mut() { table.can_see_class_scope = true; } - self.register_name("__classdict__", SymbolUsage::Used, TextRange::default())?; + self.register_name("__classdict__", SymbolUsage::Used, expr.range())?; } - // Set scope_info for better error messages - let old_scope_info = self.scope_info; - self.scope_info = Some(scope_info); + self.tables.last_mut().unwrap().scope_info = Some(scope_info); // Scan the expression in this new scope let result = self.scan_expression(expr, ExpressionContext::Load); - // Restore scope_info and exit the scope - self.scope_info = old_scope_info; self.leave_scope(); result } fn scan_type_params(&mut self, type_params: &ast::TypeParams) -> SymbolTableResult { - // Check for duplicate type parameter names - let mut seen_names: IndexSet<&str> = IndexSet::default(); - // Check for non-default type parameter after default type parameter - let mut default_seen = false; + // Each type parameter is visited as: register name, scan bound, scan default. for type_param in &type_params.type_params { - let (name, range, has_default) = match type_param { - ast::TypeParam::TypeVar(tv) => (tv.name.as_str(), tv.range, tv.default.is_some()), - ast::TypeParam::ParamSpec(ps) => (ps.name.as_str(), ps.range, ps.default.is_some()), - ast::TypeParam::TypeVarTuple(tvt) => { - (tvt.name.as_str(), tvt.range, tvt.default.is_some()) - } - }; - if !seen_names.insert(name) { + if self.recursion_depth >= self.recursion_limit { return Err(SymbolTableError { - error: format!("duplicate type parameter '{name}'"), - location: Some( - self.source_file - .to_source_code() - .source_location(range.start(), PositionEncoding::Utf8), - ), - }); - } - if has_default { - default_seen = true; - } else if default_seen { - return Err(SymbolTableError { - error: format!( - "non-default type parameter '{name}' follows default type parameter" - ), - location: Some( - self.source_file - .to_source_code() - .source_location(range.start(), PositionEncoding::Utf8), - ), + error: RECURSION_ERROR.to_owned(), + location: None, }); } - } - - // Register .type_params as a type parameter (automatically becomes cell variable) - self.register_name(".type_params", SymbolUsage::TypeParam, type_params.range)?; - - // First register all type parameters - for type_param in &type_params.type_params { - match type_param { - ast::TypeParam::TypeVar(ast::TypeParamTypeVar { - name, - bound, - range: type_var_range, - default, - node_index: _, - }) => { - self.register_name(name.as_str(), SymbolUsage::TypeParam, *type_var_range)?; + self.recursion_depth += 1; + let result = (|| { + match type_param { + ast::TypeParam::TypeVar(ast::TypeParamTypeVar { + name, + bound, + range: type_var_range, + default, + node_index: _, + .. + }) => { + self.register_name(name.as_str(), SymbolUsage::TypeParam, *type_var_range)?; + if name.as_str() == "__classdict__" { + return Err(SymbolTableError { + error: format!( + "reserved name '{}' cannot be used for type parameter", + name.as_str() + ), + location: Some(self.source_file.to_source_code().source_location( + type_var_range.start(), + PositionEncoding::Utf8, + )), + }); + } - // Process bound in a separate scope - if let Some(binding) = bound { - let scope_info = if binding.is_tuple_expr() { - "a TypeVar constraint" - } else { - "a TypeVar bound" - }; - self.scan_type_param_bound_or_default(binding, name.as_str(), scope_info)?; - } + // Process bound in a separate scope + if let Some(binding) = bound { + let scope_info = if binding.is_tuple_expr() { + "a TypeVar constraint" + } else { + "a TypeVar bound" + }; + self.scan_type_param_bound_or_default( + binding, + name.as_str(), + scope_info, + )?; + } - // Process default in a separate scope - if let Some(default_value) = default { - self.scan_type_param_bound_or_default( - default_value, - name.as_str(), - "a TypeVar default", - )?; + // Process default in a separate scope + if let Some(default_value) = default { + self.scan_type_param_bound_or_default( + default_value, + name.as_str(), + "a TypeVar default", + )?; + } } - } - ast::TypeParam::ParamSpec(ast::TypeParamParamSpec { - name, - range: param_spec_range, - default, - node_index: _, - }) => { - self.register_name(name, SymbolUsage::TypeParam, *param_spec_range)?; + ast::TypeParam::ParamSpec(ast::TypeParamParamSpec { + name, + range: param_spec_range, + default, + node_index: _, + .. + }) => { + self.register_name(name, SymbolUsage::TypeParam, *param_spec_range)?; + if name == "__classdict__" { + return Err(SymbolTableError { + error: format!( + "reserved name '{name}' cannot be used for type parameter" + ), + location: Some(self.source_file.to_source_code().source_location( + param_spec_range.start(), + PositionEncoding::Utf8, + )), + }); + } - // Process default in a separate scope - if let Some(default_value) = default { - self.scan_type_param_bound_or_default( - default_value, - name, - "a ParamSpec default", - )?; + // Process default in a separate scope + if let Some(default_value) = default { + self.scan_type_param_bound_or_default( + default_value, + name, + "a ParamSpec default", + )?; + } } - } - ast::TypeParam::TypeVarTuple(ast::TypeParamTypeVarTuple { - name, - range: type_var_tuple_range, - default, - node_index: _, - }) => { - self.register_name(name, SymbolUsage::TypeParam, *type_var_tuple_range)?; + ast::TypeParam::TypeVarTuple(ast::TypeParamTypeVarTuple { + name, + range: type_var_tuple_range, + default, + node_index: _, + .. + }) => { + self.register_name(name, SymbolUsage::TypeParam, *type_var_tuple_range)?; + if name == "__classdict__" { + return Err(SymbolTableError { + error: format!( + "reserved name '{name}' cannot be used for type parameter" + ), + location: Some(self.source_file.to_source_code().source_location( + type_var_tuple_range.start(), + PositionEncoding::Utf8, + )), + }); + } - // Process default in a separate scope - if let Some(default_value) = default { - self.scan_type_param_bound_or_default( - default_value, - name, - "a TypeVarTuple default", - )?; + // Process default in a separate scope + if let Some(default_value) = default { + self.scan_type_param_bound_or_default( + default_value, + name, + "a TypeVarTuple default", + )?; + } } } - } + Ok(()) + })(); + self.recursion_depth -= 1; + result?; } Ok(()) } @@ -2590,53 +2992,88 @@ impl SymbolTableBuilder { } Ok(()) } - - fn scan_pattern(&mut self, pattern: &ast::Pattern) -> SymbolTableResult { - match pattern { - ast::Pattern::MatchValue(ast::PatternMatchValue { value, .. }) => { - self.scan_expression(value, ExpressionContext::Load)? - } - ast::Pattern::MatchSingleton(_) => {} - ast::Pattern::MatchSequence(ast::PatternMatchSequence { patterns, .. }) => { - self.scan_patterns(patterns)? - } - ast::Pattern::MatchMapping(ast::PatternMatchMapping { - keys, - patterns, - rest, - .. - }) => { - self.scan_expressions(keys, ExpressionContext::Load)?; - self.scan_patterns(patterns)?; - if let Some(rest) = rest { - self.register_ident(rest, SymbolUsage::Assigned)?; - } - } - ast::Pattern::MatchClass(ast::PatternMatchClass { cls, arguments, .. }) => { - self.scan_expression(cls, ExpressionContext::Load)?; - self.scan_patterns(&arguments.patterns)?; - for kw in &arguments.keywords { - self.scan_pattern(&kw.pattern)?; + + fn scan_pattern(&mut self, pattern: &ast::Pattern) -> SymbolTableResult { + if self.recursion_depth >= self.recursion_limit { + return Err(SymbolTableError { + error: RECURSION_ERROR.to_owned(), + location: None, + }); + } + self.recursion_depth += 1; + let result = (|| { + use ast::Pattern::{ + MatchAs, MatchClass, MatchMapping, MatchOr, MatchSequence, MatchSingleton, + MatchStar, MatchValue, + }; + match pattern { + MatchValue(ast::PatternMatchValue { value, .. }) => { + self.scan_expression(value, ExpressionContext::Load)? + } + MatchSingleton(_) => {} + MatchSequence(ast::PatternMatchSequence { patterns, .. }) => { + self.scan_patterns(patterns)? + } + MatchMapping(ast::PatternMatchMapping { + keys, + patterns, + rest, + .. + }) => { + self.scan_expressions(keys, ExpressionContext::Load)?; + self.scan_patterns(patterns)?; + if let Some(rest) = rest { + if rest.as_str() == "_" { + return Err(SymbolTableError { + error: "invalid syntax".to_owned(), + location: Some( + self.source_file.to_source_code().source_location( + rest.range.start(), + PositionEncoding::Utf8, + ), + ), + }); + } + self.register_name(rest.as_str(), SymbolUsage::Assigned, pattern.range())?; + } } - } - ast::Pattern::MatchStar(ast::PatternMatchStar { name, .. }) => { - if let Some(name) = name { - self.register_ident(name, SymbolUsage::Assigned)?; + MatchClass(ast::PatternMatchClass { cls, arguments, .. }) => { + self.scan_expression(cls, ExpressionContext::Load)?; + self.scan_patterns(&arguments.patterns)?; + for kw in &arguments.keywords { + self.check_name( + kw.attr.as_str(), + ExpressionContext::Store, + kw.pattern.range(), + )?; + } + for kw in &arguments.keywords { + self.scan_pattern(&kw.pattern)?; + } } - } - ast::Pattern::MatchAs(ast::PatternMatchAs { pattern, name, .. }) => { - if let Some(pattern) = pattern { - self.scan_pattern(pattern)?; + MatchStar(ast::PatternMatchStar { name, .. }) => { + if let Some(name) = name { + self.register_name(name.as_str(), SymbolUsage::Assigned, pattern.range())?; + } } - if let Some(name) = name { - self.register_ident(name, SymbolUsage::Assigned)?; + MatchAs(ast::PatternMatchAs { + pattern: as_pattern, + name, + .. + }) => { + if let Some(as_pattern) = as_pattern { + self.scan_pattern(as_pattern)?; + } + if let Some(name) = name { + self.register_name(name.as_str(), SymbolUsage::Assigned, pattern.range())?; + } } + MatchOr(ast::PatternMatchOr { patterns, .. }) => self.scan_patterns(patterns)?, } - ast::Pattern::MatchOr(ast::PatternMatchOr { patterns, .. }) => { - self.scan_patterns(patterns)? - } - } - Ok(()) + Ok(()) + })(); + self.recursion_depth -= 1; + result } /// Scan default parameter values (evaluated in the enclosing scope) @@ -2660,79 +3097,43 @@ impl SymbolTableBuilder { .any(|arg| arg.default.is_some()) } + fn has_positional_defaults(parameters: &ast::Parameters) -> bool { + parameters + .posonlyargs + .iter() + .chain(parameters.args.iter()) + .any(|arg| arg.default.is_some()) + } + + #[expect( + clippy::too_many_arguments, + reason = "keeps parameter/default scanning options explicit at call sites" + )] fn enter_scope_with_parameters( &mut self, name: &str, parameters: &ast::Parameters, line_number: u32, - has_return_annotation: bool, + returns: Option<&ast::Expr>, scope_type: CompilerScope, skip_defaults: bool, + skip_annotations: bool, ) -> SymbolTableResult { // Evaluate eventual default parameters (unless already scanned before type_param_block): if !skip_defaults { self.scan_parameter_defaults(parameters)?; } - // Annotations are scanned in outer scope: - for annotation in parameters - .posonlyargs - .iter() - .chain(parameters.args.iter()) - .chain(parameters.kwonlyargs.iter()) - .filter_map(|arg| arg.parameter.annotation.as_ref()) - { - self.scan_annotation(annotation)?; - } - if let Some(annotation) = parameters - .vararg - .as_ref() - .and_then(|arg| arg.annotation.as_ref()) - { - self.scan_annotation(annotation)?; - } - if let Some(annotation) = parameters - .kwarg - .as_ref() - .and_then(|arg| arg.annotation.as_ref()) - { - self.scan_annotation(annotation)?; + let is_function_scope = matches!( + scope_type, + CompilerScope::Function | CompilerScope::AsyncFunction + ); + if is_function_scope && !skip_annotations { + self.scan_function_annotations(parameters, returns, line_number)?; } - // Check if this function has any annotations (parameter or return) - let has_param_annotations = parameters - .posonlyargs - .iter() - .chain(parameters.args.iter()) - .chain(parameters.kwonlyargs.iter()) - .any(|p| p.parameter.annotation.is_some()) - || parameters - .vararg - .as_ref() - .is_some_and(|p| p.annotation.is_some()) - || parameters - .kwarg - .as_ref() - .is_some_and(|p| p.annotation.is_some()); - - let has_any_annotations = has_param_annotations || has_return_annotation; - - // Take annotation_block if this function has any annotations. - // When in class scope, the class's annotation_block was saved before scanning - // function annotations, so the current annotation_block belongs to this function. - let annotation_block = if has_any_annotations { - self.tables.last_mut().unwrap().annotation_block.take() - } else { - None - }; - self.enter_scope(name, scope_type, line_number); - // Move annotation_block to function scope only if we have one - if let Some(block) = annotation_block { - self.tables.last_mut().unwrap().annotation_block = Some(block); - } - // Fill scope with parameter names: self.scan_parameters(¶meters.posonlyargs)?; self.scan_parameters(¶meters.args)?; @@ -2781,7 +3182,7 @@ impl SymbolTableBuilder { Ok(()) } - // Mirrors CPython symtable_extend_namedexpr_scope(): assignment expressions + // Mirrors symtable_extend_namedexpr_scope(): assignment expressions // inside comprehensions bind in the nearest function/module-like scope, not // in the synthetic comprehension scope itself. fn extend_namedexpr_scope(&mut self, name: &str, range: TextRange) -> SymbolTableResult { @@ -2871,12 +3272,23 @@ impl SymbolTableBuilder { location, }); } - CompilerScope::Annotation => { + CompilerScope::TypeAlias => { + return Err(SymbolTableError { + error: + "assignment expression within a comprehension cannot be used in a type alias" + .to_string(), + location, + }); + } + CompilerScope::TypeVariable => { return Err(SymbolTableError { - error: "named expression cannot be used within an annotation".to_string(), + error: + "assignment expression within a comprehension cannot be used in a TypeVar bound" + .to_string(), location, }); } + CompilerScope::Annotation => {} CompilerScope::Comprehension => unreachable!(), } } @@ -2896,10 +3308,27 @@ impl SymbolTableBuilder { .source_location(range.start(), PositionEncoding::Utf8); let location = Some(location); - // Note: __debug__ checks are handled by check_name function, so no check needed here. + // symtable_add_def_ctx() runs check_name() for definition + // roles covered by DEF_PARAM | DEF_LOCAL | DEF_IMPORT before adding + // the symbol. Several Rust callers reach register_name() directly + // instead of going through scan_expression(Name), so keep the guard here. + if matches!( + role, + SymbolUsage::Assigned + | SymbolUsage::Imported + | SymbolUsage::AnnotationAssigned + | SymbolUsage::Parameter + | SymbolUsage::AnnotationParameter + | SymbolUsage::AssignedNamedExprInComprehension + | SymbolUsage::Iter + | SymbolUsage::TypeParam + ) { + self.check_name(name, ExpressionContext::Store, range)?; + } let scope_depth = self.tables.len(); let table = self.tables.last_mut().unwrap(); + let current_scope = table.typ; // Add type param names to mangled_names set for selective mangling if matches!(role, SymbolUsage::TypeParam) @@ -2908,6 +3337,7 @@ impl SymbolTableBuilder { set.insert(name.to_owned()); } + let original_name = name; let name = maybe_mangle_name( self.class_name.as_deref(), table.mangled_names.as_ref(), @@ -2932,7 +3362,24 @@ impl SymbolTableBuilder { }); } + if matches!( + role, + SymbolUsage::Parameter | SymbolUsage::AnnotationParameter + ) && flags.contains(SymbolFlags::PARAMETER) + { + return Err(SymbolTableError { + error: format!("duplicate argument '{original_name}' in function definition"), + location, + }); + } + // Role already set.. + if matches!(role, SymbolUsage::TypeParam) && flags.contains(SymbolFlags::TYPE_PARAM) { + return Err(SymbolTableError { + error: format!("duplicate type parameter '{name}'"), + location, + }); + } match role { SymbolUsage::Global if !symbol.is_global() => { if flags.contains(SymbolFlags::PARAMETER) { @@ -2990,6 +3437,20 @@ impl SymbolTableBuilder { }); } } + SymbolUsage::AnnotationAssigned + if current_scope != CompilerScope::Module + && flags.intersects(SymbolFlags::GLOBAL | SymbolFlags::NONLOCAL) => + { + let usage = if flags.contains(SymbolFlags::GLOBAL) { + "global" + } else { + "nonlocal" + }; + return Err(SymbolTableError { + error: format!("annotated name '{name}' can't be {usage}"), + location, + }); + } _ => { // Ok? } @@ -3045,24 +3506,9 @@ impl SymbolTableBuilder { } SymbolUsage::Assigned => { flags.insert(SymbolFlags::ASSIGNED); - // Local variables (assigned) are added to varnames if they are local scope - // and not already in varnames - if symbol.scope == SymbolScope::Local { - let name_str = symbol.name.clone(); - if !self.current_varnames.contains(&name_str) { - self.current_varnames.push(name_str); - } - } } SymbolUsage::AssignedNamedExprInComprehension => { flags.insert(SymbolFlags::ASSIGNED | SymbolFlags::ASSIGNED_IN_COMPREHENSION); - // Named expressions in comprehensions might also be locals - if symbol.scope == SymbolScope::Local { - let name_str = symbol.name.clone(); - if !self.current_varnames.contains(&name_str) { - self.current_varnames.push(name_str); - } - } } SymbolUsage::Global => { symbol.scope = SymbolScope::GlobalExplicit; @@ -3072,7 +3518,7 @@ impl SymbolTableBuilder { flags.insert(SymbolFlags::REFERENCED); } SymbolUsage::Iter => { - flags.insert(SymbolFlags::ITER); + flags.insert(SymbolFlags::ITER | SymbolFlags::COMP_ITER); } SymbolUsage::TypeParam => { flags.insert(SymbolFlags::ASSIGNED | SymbolFlags::TYPE_PARAM); @@ -3096,6 +3542,17 @@ impl SymbolTableBuilder { } } +fn is_docstring_expr(expr: &ast::Expr) -> bool { + matches!( + expr, + ast::Expr::StringLiteral(_) + | ast::Expr::Constant(ast::ExprConstant { + value: ast::ConstantValue::Str(_), + .. + }) + ) +} + pub(crate) fn mangle_name<'a>(class_name: Option<&str>, name: &'a str) -> Cow<'a, str> { let class_name = match class_name { Some(n) => n, @@ -3134,7 +3591,27 @@ pub(crate) fn maybe_mangle_name<'a>( #[cfg(test)] mod tests { - use super::mangle_name; + use super::{CompilerScope, SymbolFlags, SymbolTable, mangle_name}; + use rustpython_compiler_core::SourceFileBuilder; + + fn scan_source(source: &str) -> SymbolTable { + scan_source_result(source).unwrap() + } + + fn scan_source_result(source: &str) -> Result { + let source_file = SourceFileBuilder::new("source_path", source).finish(); + let parsed = ruff_python_parser::parse( + source_file.source_text(), + ruff_python_parser::Mode::Module.into(), + ) + .unwrap() + .into_syntax(); + let module = match parsed { + ruff_python_ast::Mod::Module(module) => module, + _ => unreachable!(), + }; + SymbolTable::scan_program(&module, source_file) + } #[test] fn mangle_name_leaves_private_name_in_underscore_only_class() { @@ -3148,4 +3625,470 @@ mod tests { assert_eq!(mangle_name(Some("_a"), "__a"), "_a__a"); assert_eq!(mangle_name(Some("__a"), "__a"), "_a__a"); } + + #[test] + fn duplicate_parameter_check_uses_mangled_name_like_cpython() { + let err = scan_source_result("class C:\n def f(__x, _C__x):\n pass\n") + .expect_err("expected duplicate argument after class-private mangling"); + + assert_eq!( + err.error, + "duplicate argument '_C__x' in function definition" + ); + } + + #[test] + fn super_name_marks_class_use_in_lambda_scope_like_cpython() { + let table = scan_source("def f():\n return lambda: super()\n"); + let function = table + .sub_tables + .iter() + .find(|table| table.name == "f") + .expect("missing function scope"); + let lambda = function + .sub_tables + .iter() + .find(|table| table.typ == CompilerScope::Lambda) + .expect("missing lambda scope"); + + assert!( + lambda.lookup("__class__").is_some(), + "CPython symtable Name_kind treats super as a __class__ use in any function-like scope" + ); + } + + #[test] + fn comprehension_iteration_target_sets_comp_iter_flag_like_cpython() { + let table = scan_source("result = [i for i in xs]\n"); + let comprehension = table + .inlined_comprehension_blocks + .iter() + .find(|table| table.typ == CompilerScope::Comprehension) + .expect("missing comprehension scope"); + let symbol = comprehension + .lookup("i") + .expect("missing comprehension iteration target"); + + assert!( + symbol.flags.contains(SymbolFlags::COMP_ITER), + "CPython symtable_add_def_helper sets DEF_COMP_ITER on comprehension iteration targets" + ); + } + + #[test] + fn inlined_comprehension_children_are_spliced_like_cpython() { + let table = scan_source("result = [(lambda: i) for i in xs]\n"); + + assert!( + !table + .sub_tables + .iter() + .any(|table| table.typ == CompilerScope::Comprehension), + "CPython removes inlined comprehension entries from ste_children" + ); + assert!( + table + .sub_tables + .iter() + .any(|table| table.typ == CompilerScope::Lambda), + "CPython splices children of inlined comprehensions into the parent children list" + ); + + let comprehension = table + .inlined_comprehension_blocks + .iter() + .find(|table| table.typ == CompilerScope::Comprehension) + .expect("missing inlined comprehension block"); + assert!( + comprehension.comp_inlined, + "CPython keeps the comprehension entry addressable through st_blocks with ste_comp_inlined set" + ); + } + + #[test] + fn future_annotations_annassign_still_scans_annotation_symbols_like_cpython() { + let table = scan_source("from __future__ import annotations\nx: T\n"); + let annotation_block = table + .annotation_block + .as_ref() + .expect("CPython still creates an AnnotationBlock for future annotations"); + + assert!( + annotation_block.lookup("T").is_some(), + "CPython symtable_visit_annotation still visits the annotation expression with future annotations" + ); + } + + #[test] + fn annotation_like_format_parameter_is_marked_used_like_cpython() { + let table = scan_source("def f(x: T): pass\n"); + let annotation_block = table + .sub_tables + .iter() + .find(|table| table.typ == CompilerScope::Annotation) + .expect("missing function annotation block"); + let format = annotation_block + .lookup(".format") + .expect("missing annotation .format parameter"); + assert!( + format + .flags + .contains(SymbolFlags::PARAMETER | SymbolFlags::REFERENCED), + "CPython symtable_enter_block() adds both DEF_PARAM and USE for annotation-like .format" + ); + + let table = scan_source("type A = T\n"); + let alias = table + .sub_tables + .iter() + .find(|table| table.typ == CompilerScope::TypeAlias) + .expect("missing type alias scope"); + let format = alias + .lookup(".format") + .expect("missing type alias .format parameter"); + assert!( + format + .flags + .contains(SymbolFlags::PARAMETER | SymbolFlags::REFERENCED), + "CPython TypeAliasBlock .format has DEF_PARAM | USE" + ); + + let table = scan_source("def f[T: B](): pass\n"); + let type_params = table + .sub_tables + .iter() + .find(|table| table.typ == CompilerScope::TypeParams) + .expect("missing type params scope"); + let type_variable = type_params + .sub_tables + .iter() + .find(|table| table.typ == CompilerScope::TypeVariable) + .expect("missing type variable scope"); + let format = type_variable + .lookup(".format") + .expect("missing type variable .format parameter"); + assert!( + format + .flags + .contains(SymbolFlags::PARAMETER | SymbolFlags::REFERENCED), + "CPython TypeVariableBlock .format has DEF_PARAM | USE" + ); + } + + #[test] + fn function_signature_annotation_block_is_sibling_like_cpython() { + let table = scan_source("def f(x: T): pass\n"); + assert_eq!(table.sub_tables[0].typ, CompilerScope::Annotation); + assert!(table.sub_tables[0].annotations_used); + assert_eq!(table.sub_tables[1].typ, CompilerScope::Function); + assert!( + table.sub_tables[1].annotation_block.is_none(), + "CPython stores the function signature AnnotationBlock as a child keyed by arguments, not on the function block" + ); + + let table = scan_source("def f(x): pass\n"); + assert_eq!(table.sub_tables[0].typ, CompilerScope::Annotation); + assert!(!table.sub_tables[0].annotations_used); + assert_eq!(table.sub_tables[1].typ, CompilerScope::Function); + } + + #[test] + fn future_function_signature_annotation_block_is_hidden_like_cpython() { + let table = scan_source("from __future__ import annotations\ndef f(x: T): pass\n"); + assert_eq!(table.sub_tables[0].typ, CompilerScope::Function); + assert_eq!( + table.hidden_annotation_blocks[0].typ, + CompilerScope::Annotation + ); + assert!(table.hidden_annotation_blocks[0].annotations_used); + assert!( + table.sub_tables[0].annotation_block.is_none(), + "CPython future AnnotationBlock stays in st_blocks and is not attached to the FunctionBlock" + ); + + let table = scan_source("from __future__ import annotations\ndef f(x): pass\n"); + assert_eq!(table.sub_tables[0].typ, CompilerScope::Function); + assert_eq!( + table.hidden_annotation_blocks[0].typ, + CompilerScope::Annotation + ); + assert!(!table.hidden_annotation_blocks[0].annotations_used); + } + + #[test] + fn annassign_marks_current_scope_annotations_used_like_cpython() { + let table = scan_source("x: int\n"); + assert!( + table.annotations_used, + "CPython AnnAssign_kind sets ste_annotations_used on the current scope" + ); + + let table = scan_source("class C:\n x: int\n"); + let class = table + .sub_tables + .iter() + .find(|table| table.typ == CompilerScope::Class) + .expect("missing class scope"); + assert!( + class.annotations_used, + "CPython AnnAssign_kind sets ste_annotations_used on class scopes" + ); + + let table = scan_source("def f():\n x: int\n"); + let function = table + .sub_tables + .iter() + .find(|table| table.typ == CompilerScope::Function) + .expect("missing function scope"); + assert!( + function.annotations_used, + "CPython AnnAssign_kind also marks function-local annotations" + ); + } + + #[test] + fn class_base_child_scope_precedes_class_scope_like_cpython() { + let table = scan_source("class C((lambda: Base)()):\n pass\n"); + assert_eq!(table.sub_tables[0].typ, CompilerScope::Lambda); + assert_eq!(table.sub_tables[1].typ, CompilerScope::Class); + } + + #[test] + fn try_handler_child_scope_precedes_else_scope_like_cpython() { + let table = scan_source( + "\ +def f(x): + try: + pass + except Exception: + y = 1 + def h(): + return y + else: + def e(): + return x +", + ); + let function = table + .sub_tables + .iter() + .find(|table| table.name == "f") + .expect("missing function scope"); + + let function_child_names = function + .sub_tables + .iter() + .filter(|table| table.typ == CompilerScope::Function) + .map(|table| table.name.as_str()) + .collect::>(); + assert_eq!(function_child_names, vec!["h", "e"]); + } + + #[test] + fn function_default_child_scope_precedes_decorator_scope_like_cpython() { + let table = scan_source( + "\ +@(lambda decorator_arg: decorator_arg) +def f(x=(lambda: 1)()): + pass +", + ); + let lambdas = table + .sub_tables + .iter() + .filter(|table| table.typ == CompilerScope::Lambda) + .collect::>(); + + assert_eq!(lambdas.len(), 2); + assert!( + lambdas[0].varnames.is_empty(), + "CPython symtable visits function defaults before decorators" + ); + assert_eq!(lambdas[1].varnames, vec!["decorator_arg"]); + } + + #[test] + fn future_annotations_still_rejects_named_expr_in_annotation_like_cpython() { + let err = + scan_source_result("from __future__ import annotations\nx: (y := int)\n").unwrap_err(); + + assert_eq!( + err.error, + "named expression cannot be used within an annotation" + ); + } + + #[test] + fn import_star_outside_module_uses_cpython_symtable_message() { + let err = scan_source_result("def f():\n from m import *\n").unwrap_err(); + + assert_eq!(err.error, "import * only allowed at module level"); + } + + #[test] + fn import_as_error_location_uses_alias_location_like_cpython() { + let source = "import module as __debug__\n"; + let err = scan_source_result(source).unwrap_err(); + + assert_eq!(err.error, "cannot assign to __debug__"); + let location = err.location.unwrap(); + assert_eq!(location.line.get(), 1); + assert_eq!( + location.character_offset.get(), + 8, + "CPython reports LOCATION(a) for import aliases, at the imported name" + ); + } + + #[test] + fn function_def_error_location_uses_statement_location_like_cpython() { + let source = "def __debug__():\n pass\n"; + let err = scan_source_result(source).unwrap_err(); + + assert_eq!(err.error, "cannot assign to __debug__"); + let location = err.location.unwrap(); + assert_eq!(location.line.get(), 1); + assert_eq!( + location.character_offset.get(), + 1, + "CPython reports LOCATION(s) for FunctionDef, at 'def'" + ); + } + + #[test] + fn global_after_assign_error_location_uses_statement_location_like_cpython() { + let source = "def f():\n x = 1\n global x\n"; + let err = scan_source_result(source).unwrap_err(); + + assert_eq!( + err.error, + "name 'x' is assigned to before global declaration" + ); + let location = err.location.unwrap(); + assert_eq!(location.line.get(), 3); + assert_eq!( + location.character_offset.get(), + 5, + "CPython reports LOCATION(s) for global directives, at 'global'" + ); + } + + #[test] + fn type_param_debug_name_is_checked_like_cpython_add_def_ctx() { + let source = "class C[__debug__]:\n pass\n"; + let err = scan_source_result(source).unwrap_err(); + + assert_eq!(err.error, "cannot assign to __debug__"); + let location = err.location.unwrap(); + assert_eq!(location.line.get(), 1); + assert_eq!( + location.character_offset.get(), + 9, + "CPython symtable_add_def_ctx checks DEF_TYPE_PARAM | DEF_LOCAL at LOCATION(tp)" + ); + } + + #[test] + fn except_handler_name_error_location_uses_handler_location_like_cpython() { + let source = "try:\n pass\nexcept Exception as __debug__:\n pass\n"; + let err = scan_source_result(source).unwrap_err(); + + assert_eq!(err.error, "cannot assign to __debug__"); + let location = err.location.unwrap(); + assert_eq!(location.line.get(), 3); + assert_eq!( + location.character_offset.get(), + 1, + "CPython reports LOCATION(eh) for except-handler names, at 'except'" + ); + } + + #[test] + fn match_star_capture_error_location_uses_pattern_location_like_cpython() { + let source = "match subject:\n case [*__debug__]:\n pass\n"; + let err = scan_source_result(source).unwrap_err(); + + assert_eq!(err.error, "cannot assign to __debug__"); + let location = err.location.unwrap(); + assert_eq!(location.line.get(), 2); + assert_eq!( + location.character_offset.get(), + 11, + "CPython reports LOCATION(p) for MatchStar, at the '*'" + ); + } + + #[test] + fn named_expr_in_lambda_inside_comprehension_iter_is_rejected_like_cpython() { + let err = scan_source_result("[x for x in (lambda: (y := 1))()]\n").unwrap_err(); + + assert_eq!( + err.error, + "assignment expression cannot be used in a comprehension iterable expression" + ); + } + + #[test] + fn yield_in_lambda_inside_comprehension_body_is_not_comprehension_yield_like_cpython() { + scan_source_result("[(lambda: (yield x)) for x in xs]\n").expect( + "CPython checks ste_comprehension on the current lambda block, not the enclosing comprehension", + ); + } + + #[test] + fn yield_in_comprehension_scans_value_before_comprehension_error_like_cpython() { + let err = scan_source_result("[(yield (x := 1)) for x in xs]\n").unwrap_err(); + + assert_eq!( + err.error, + "assignment expression cannot rebind comprehension iteration variable 'x'" + ); + } + + #[test] + fn named_expr_in_function_annotation_comprehension_is_allowed_like_cpython() { + scan_source_result("def f(x: [(y := int) for _ in xs]): pass\n").expect( + "CPython skips AnnotationBlock while extending namedexpr scope from a comprehension", + ); + } + + #[test] + fn named_expr_in_class_annotation_comprehension_uses_cpython_message() { + let err = scan_source_result("class C:\n x: [(y := int) for _ in xs]\n").unwrap_err(); + + assert_eq!( + err.error, + "assignment expression within a comprehension cannot be used in a class body" + ); + } + + #[test] + fn named_expr_in_type_alias_comprehension_uses_cpython_message() { + let err = scan_source_result("type A = [(y := int) for _ in xs]\n").unwrap_err(); + + assert_eq!( + err.error, + "assignment expression within a comprehension cannot be used in a type alias" + ); + } + + #[test] + fn named_expr_in_type_parameters_block_uses_cpython_message() { + let err = scan_source_result("class C[T]((base := object)): pass\n").unwrap_err(); + + assert_eq!( + err.error, + "named expression cannot be used within the definition of a generic" + ); + } + + #[test] + fn named_expr_in_typevar_bound_comprehension_uses_cpython_message() { + let err = scan_source_result("def f[T: [(y := int) for _ in xs]](): pass\n").unwrap_err(); + + assert_eq!( + err.error, + "assignment expression within a comprehension cannot be used in a TypeVar bound" + ); + } } diff --git a/crates/codegen/src/unparse.rs b/crates/codegen/src/unparse.rs index d7f754e2f9d..679560642e5 100644 --- a/crates/codegen/src/unparse.rs +++ b/crates/codegen/src/unparse.rs @@ -58,6 +58,63 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { self.f.write_fmt(f) } + fn unparse_float(&mut self, value: f64) -> fmt::Result { + #[allow(clippy::correctness, clippy::assertions_on_constants)] + const { + assert!(f64::MAX_10_EXP == 308) + }; + + if value.is_infinite() { + self.p("1e309") + } else { + self.p(&rustpython_literal::float::to_string(value)) + } + } + + fn unparse_complex(&mut self, real: f64, imag: f64) -> fmt::Result { + self.p(&rustpython_literal::complex::to_string(real, imag).replace("inf", "1e309")) + } + + fn unparse_constant_value(&mut self, value: &ast::ConstantValue) -> fmt::Result { + match value { + ast::ConstantValue::None => self.p("None"), + ast::ConstantValue::Boolean(value) => self.p(if *value { "True" } else { "False" }), + ast::ConstantValue::Str(value) => UnicodeEscape::new_repr(value.as_ref().into()) + .str_repr() + .fmt(self.f), + ast::ConstantValue::Bytes(value) => AsciiEscape::new_repr(value.as_ref()) + .bytes_repr() + .fmt(self.f), + ast::ConstantValue::Integer(value) => self.p(value.as_ref()), + ast::ConstantValue::Tuple(elements) => { + self.p("(")?; + let mut first = true; + for element in elements { + self.p_delim(&mut first, ", ")?; + self.unparse_constant_value(element)?; + } + self.p_if(elements.len() == 1, ",")?; + self.p(")") + } + ast::ConstantValue::Frozenset(elements) => { + if elements.is_empty() { + self.p("frozenset()") + } else { + self.p("frozenset({")?; + let mut first = true; + for element in elements { + self.p_delim(&mut first, ", ")?; + self.unparse_constant_value(element)?; + } + self.p("})") + } + } + ast::ConstantValue::Float(value) => self.unparse_float(*value), + ast::ConstantValue::Complex { real, imag } => self.unparse_complex(*real, *imag), + ast::ConstantValue::Ellipsis => self.p("..."), + } + } + fn unparse_expr(&mut self, ast: &ast::Expr, level: u8) -> fmt::Result { macro_rules! op_prec { ($op_ty:ident, $x:expr, $enu:path, $($var:ident($op:literal, $prec:ident)),*$(,)?) => { @@ -87,6 +144,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { values, node_index: _, range: _range, + .. }) => { let (op, prec) = op_prec!(bin, op, ast::BoolOp, And("and", AND), Or("or", OR)); group_if!(prec, { @@ -102,6 +160,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { value, node_index: _, range: _range, + .. }) => { group_if!(precedence::TUPLE, { self.unparse_expr(target, precedence::ATOM)?; @@ -115,6 +174,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { right, node_index: _, range: _range, + .. }) => { let right_associative = matches!(op, ast::Operator::Pow); let (op, prec) = op_prec!( @@ -146,6 +206,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { operand, node_index: _, range: _range, + .. }) => { let (op, prec) = op_prec!( un, @@ -166,6 +227,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { body, node_index: _, range: _range, + .. }) => { group_if!(precedence::TEST, { if let Some(parameters) = parameters { @@ -183,6 +245,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { orelse, node_index: _, range: _range, + .. }) => { group_if!(precedence::TEST, { self.unparse_expr(body, precedence::TEST + 1)?; @@ -196,6 +259,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { items, node_index: _, range: _range, + .. }) => { self.p("{")?; let mut first = true; @@ -214,6 +278,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { elts, node_index: _, range: _range, + .. }) => { self.p("{")?; let mut first = true; @@ -228,6 +293,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { generators, node_index: _, range: _range, + .. }) => { self.p("[")?; self.unparse_expr(elt, precedence::TEST)?; @@ -239,6 +305,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { generators, node_index: _, range: _range, + .. }) => { self.p("{")?; self.unparse_expr(elt, precedence::TEST)?; @@ -251,6 +318,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { generators, node_index: _, range: _range, + .. }) => { self.p("{")?; self.unparse_expr(key, precedence::TEST)?; @@ -265,6 +333,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { generators, node_index: _, range: _range, + .. }) => { self.p("(")?; self.unparse_expr(elt, precedence::TEST)?; @@ -275,6 +344,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { value, node_index: _, range: _range, + .. }) => { group_if!(precedence::AWAIT, { self.p("await ")?; @@ -285,6 +355,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { value, node_index: _, range: _range, + .. }) => { if let Some(value) = value { write!(self, "(yield {})", UnparseExpr::new(value, self.source))?; @@ -296,6 +367,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { value, node_index: _, range: _range, + .. }) => { write!( self, @@ -309,6 +381,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { comparators, node_index: _, range: _range, + .. }) => { group_if!(precedence::CMP, { let new_lvl = precedence::CMP + 1; @@ -326,6 +399,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { arguments: ast::Arguments { args, keywords, .. }, node_index: _, range: _range, + .. }) => { self.unparse_expr(func, precedence::ATOM)?; self.p("(")?; @@ -379,26 +453,13 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { .bytes_repr() .fmt(self.f)? } - ast::Expr::NumberLiteral(ast::ExprNumberLiteral { value, .. }) => { - #[allow(clippy::correctness, clippy::assertions_on_constants)] - const { - assert!(f64::MAX_10_EXP == 308) - }; - - let inf_str = "1e309"; - match value { - ast::Number::Int(int) => int.fmt(self.f)?, - &ast::Number::Float(fp) => { - if fp.is_infinite() { - self.p(inf_str)? - } else { - self.p(&rustpython_literal::float::to_string(fp))? - } - } - &ast::Number::Complex { real, imag } => self - .p(&rustpython_literal::complex::to_string(real, imag) - .replace("inf", inf_str))?, - } + ast::Expr::NumberLiteral(ast::ExprNumberLiteral { value, .. }) => match value { + ast::Number::Int(int) => int.fmt(self.f)?, + &ast::Number::Float(fp) => self.unparse_float(fp)?, + &ast::Number::Complex { real, imag } => self.unparse_complex(real, imag)?, + }, + ast::Expr::Constant(ast::ExprConstant { value, .. }) => { + self.unparse_constant_value(value)? } ast::Expr::BooleanLiteral(ast::ExprBooleanLiteral { value, .. }) => { self.p(if *value { "True" } else { "False" })? @@ -460,6 +521,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { step, node_index: _, range: _range, + .. }) => { if let Some(lower) = lower { self.unparse_expr(lower, precedence::TEST)?; @@ -554,7 +616,9 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { let buffered = fmt::from_fn(|f| Unparser::new(f, self.source).unparse_expr(val, precedence::TEST + 1)) .to_string(); - if let Some(ast::DebugText { leading, trailing }) = debug_text { + if let Some(debug_text) = debug_text { + let leading = debug_text.leading.as_str(); + let trailing = debug_text.trailing.as_str(); self.p(leading)?; self.p(self.source.slice(val.range()))?; self.p(trailing)?; diff --git a/crates/compiler-core/src/bytecode.rs b/crates/compiler-core/src/bytecode.rs index ee6b6e5d96c..c01f2f05739 100644 --- a/crates/compiler-core/src/bytecode.rs +++ b/crates/compiler-core/src/bytecode.rs @@ -468,6 +468,13 @@ bitflags! { const COROUTINE = 0x0080; const ITERABLE_COROUTINE = 0x0100; const ASYNC_GENERATOR = 0x0200; + const FUTURE_DIVISION = 0x20000; + const FUTURE_ABSOLUTE_IMPORT = 0x40000; + const FUTURE_WITH_STATEMENT = 0x80000; + const FUTURE_PRINT_FUNCTION = 0x100000; + const FUTURE_UNICODE_LITERALS = 0x200000; + const FUTURE_BARRY_AS_BDFL = 0x400000; + const FUTURE_GENERATOR_STOP = 0x800000; const FUTURE_ANNOTATIONS = 0x1000000; /// If a code object represents a function and has a docstring, /// this bit is set and the first item in co_consts is the docstring. diff --git a/crates/compiler-core/src/bytecode/instruction.rs b/crates/compiler-core/src/bytecode/instruction.rs index 69714a0fe66..76871b2c97e 100644 --- a/crates/compiler-core/src/bytecode/instruction.rs +++ b/crates/compiler-core/src/bytecode/instruction.rs @@ -754,9 +754,8 @@ impl Opcode { /// Stack effect when the instruction takes its branch (jump=true). /// /// CPython equivalent: `stack_effect(opcode, oparg, jump=True)`. - /// For most instructions this equals the fallthrough effect. - /// Override for instructions where branch and fallthrough differ - /// (e.g. [`Self::ForIter`]: fallthrough = +1, branch = −1). + /// Current opcode metadata has the same real-opcode stack effect + /// for jump and fallthrough stack-depth calculation. #[must_use] pub fn stack_effect_jump(&self, oparg: u32) -> i32 { self.stack_effect(oparg) @@ -1415,6 +1414,26 @@ mod tests { assert!(!AnyInstruction::from(PseudoOpcode::Jump).has_const()); } + #[test] + fn stack_effects_match_cpython_opcode_metadata() { + assert_eq!(Opcode::ForIter.stack_effect_info(0).popped(), 1); + assert_eq!(Opcode::ForIter.stack_effect_info(0).pushed(), 2); + assert_eq!(Opcode::ForIter.stack_effect(0), 1); + assert_eq!(Opcode::ForIter.stack_effect_jump(0), 1); + + assert_eq!(Opcode::EndAsyncFor.stack_effect_info(0).popped(), 2); + assert_eq!(Opcode::EndAsyncFor.stack_effect_info(0).pushed(), 0); + assert_eq!(Opcode::PopJumpIfFalse.stack_effect(0), -1); + assert_eq!(Opcode::PopJumpIfFalse.stack_effect_jump(0), -1); + + assert_eq!(PseudoOpcode::SetupFinally.stack_effect_info(0).pushed(), 1); + assert_eq!(PseudoOpcode::SetupFinally.stack_effect(0), 0); + assert_eq!(PseudoOpcode::SetupFinally.stack_effect_jump(0), 1); + assert_eq!(PseudoOpcode::SetupCleanup.stack_effect_info(0).pushed(), 2); + assert_eq!(PseudoOpcode::SetupCleanup.stack_effect(0), 0); + assert_eq!(PseudoOpcode::SetupCleanup.stack_effect_jump(0), 2); + } + #[test] fn no_fallthrough_flags_match_cpython_basicblock_nofallthrough() { assert!(Opcode::JumpForward.is_no_fallthrough()); diff --git a/crates/compiler-core/src/bytecode/oparg.rs b/crates/compiler-core/src/bytecode/oparg.rs index 03628604a3f..8f706003091 100644 --- a/crates/compiler-core/src/bytecode/oparg.rs +++ b/crates/compiler-core/src/bytecode/oparg.rs @@ -777,19 +777,20 @@ oparg_enum!( #[derive(Copy, Clone)] pub struct UnpackExArgs { pub before: u8, - pub after: u8, + pub after: u32, } impl From for UnpackExArgs { fn from(value: u32) -> Self { - let [before, after, ..] = value.to_le_bytes(); + let before = (value & 0xFF) as u8; + let after = value >> 8; Self { before, after } } } impl From for u32 { fn from(value: UnpackExArgs) -> Self { - Self::from_le_bytes([value.before, value.after, 0, 0]) + Self::from(value.before) | (value.after << 8) } } diff --git a/crates/compiler/src/lib.rs b/crates/compiler/src/lib.rs index 9c0884c7520..2cebfcb8bb5 100644 --- a/crates/compiler/src/lib.rs +++ b/crates/compiler/src/lib.rs @@ -1,244 +1,5060 @@ pub use ruff_python_ast::token::TokenKind; -use ruff_python_parser::{LexicalErrorType, ParseErrorType}; +use ruff_python_parser::ParseErrorType; use ruff_source_file::{PositionEncoding, SourceFile, SourceFileBuilder, SourceLocation}; -use ruff_text_size::TextSlice; +use ruff_text_size::{Ranged, TextSize, TextSlice}; +use rustpython_codegen::{compile, symboltable}; use thiserror::Error; -use rustpython_codegen::{compile, symboltable}; +pub use rustpython_codegen::compile::CompileOpts; +pub use rustpython_compiler_core::{Mode, bytecode::CodeObject}; + +// these modules are out of repository. re-exporting them here for convenience. +pub use ruff_python_ast as ast; +pub use ruff_python_parser as parser; +pub use rustpython_codegen as codegen; +pub use rustpython_compiler_core as core; + +#[derive(Error, Debug)] +pub enum CompileErrorType { + #[error(transparent)] + Codegen(#[from] codegen::error::CodegenErrorType), + #[error(transparent)] + Parse(#[from] ParseErrorType), +} + +#[derive(Error, Debug)] +pub struct ParseError { + #[source] + pub error: ParseErrorType, + pub raw_location: ruff_text_size::TextRange, + pub location: SourceLocation, + pub end_location: SourceLocation, + pub source_path: String, + /// Set when the error is an unclosed bracket (converted from EOF). + pub is_unclosed_bracket: bool, +} + +impl ::core::fmt::Display for ParseError { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + self.error.fmt(f) + } +} + +#[derive(Error, Debug)] +pub enum CompileError { + #[error(transparent)] + Codegen(#[from] codegen::error::CodegenError), + #[error(transparent)] + Parse(#[from] ParseError), +} + +impl CompileError { + #[must_use] + pub fn from_ruff_parse_error(error: parser::ParseError, source_file: &SourceFile) -> Self { + let raw_location = error.location; + let diagnostic = match cpython_parse_diagnostic_override(&error, source_file) { + Some(diagnostic) => diagnostic, + None => default_parse_diagnostic(error, source_file), + }; + + Self::Parse(ParseError { + error: diagnostic.error, + raw_location, + location: diagnostic.location, + end_location: diagnostic.end_location, + source_path: source_file.name().to_owned(), + is_unclosed_bracket: diagnostic.is_unclosed_bracket, + }) + } + + fn from_source_error( + source_file: &SourceFile, + message: String, + start: usize, + end: usize, + ) -> Self { + let start = TextSize::new(start as u32); + let end = TextSize::new(end as u32); + let (location, end_location) = source_locations(source_file, start, end); + Self::Parse(ParseError { + error: parser::ParseErrorType::OtherError(message), + raw_location: ruff_text_size::TextRange::new(start, end), + location, + end_location, + source_path: source_file.name().to_owned(), + is_unclosed_bracket: false, + }) + } + + #[must_use] + pub const fn location(&self) -> Option { + match self { + Self::Codegen(codegen_error) => codegen_error.location, + Self::Parse(parse_error) => Some(parse_error.location), + } + } + + #[must_use] + pub const fn python_location(&self) -> (usize, usize) { + if let Some(location) = self.location() { + (location.line.get(), location.character_offset.get()) + } else { + (0, 0) + } + } + + #[must_use] + pub fn python_end_location(&self) -> Option<(usize, usize)> { + match self { + Self::Codegen(_) => None, + Self::Parse(parse_error) => Some(( + parse_error.end_location.line.get(), + parse_error.end_location.character_offset.get(), + )), + } + } + + #[must_use] + pub fn source_path(&self) -> &str { + match self { + Self::Codegen(codegen_error) => &codegen_error.source_path, + Self::Parse(parse_error) => &parse_error.source_path, + } + } +} + +fn source_location(source_file: &SourceFile, offset: TextSize) -> SourceLocation { + source_file + .to_source_code() + .source_location(offset, PositionEncoding::Utf8) +} + +fn source_locations( + source_file: &SourceFile, + start: TextSize, + end: TextSize, +) -> (SourceLocation, SourceLocation) { + let source_code = source_file.to_source_code(); + ( + source_code.source_location(start, PositionEncoding::Utf8), + source_code.source_location(end, PositionEncoding::Utf8), + ) +} + +struct NormalizedParseDiagnostic { + error: parser::ParseErrorType, + location: SourceLocation, + end_location: SourceLocation, + is_unclosed_bracket: bool, +} + +impl NormalizedParseDiagnostic { + const fn new( + error: parser::ParseErrorType, + location: SourceLocation, + end_location: SourceLocation, + ) -> Self { + Self { + error, + location, + end_location, + is_unclosed_bracket: false, + } + } + + fn other(source_file: &SourceFile, message: String, start: usize, end: usize) -> Self { + let (location, end_location) = source_locations( + source_file, + TextSize::new(start as u32), + TextSize::new(end as u32), + ); + Self::new( + parser::ParseErrorType::OtherError(message), + location, + end_location, + ) + } + + const fn with_unclosed_bracket(mut self, is_unclosed_bracket: bool) -> Self { + self.is_unclosed_bracket = is_unclosed_bracket; + self + } +} + +fn cpython_parse_diagnostic_override( + error: &parser::ParseError, + source_file: &SourceFile, +) -> Option { + let source_text = source_file.source_text(); + + macro_rules! source_error { + ($expr:expr) => { + if let Some((message, start, end)) = $expr { + return Some(NormalizedParseDiagnostic::other( + source_file, + message, + start, + end, + )); + } + }; + } + + if let Some((message, offset)) = invalid_number_literal_error(source_text) { + return Some(NormalizedParseDiagnostic::other( + source_file, + message, + offset, + offset, + )); + } + source_error!(invalid_legacy_statement_error(source_text)); + source_error!(non_printable_character_error(source_text)); + source_error!(invalid_interpolated_string_error(source_text)); + + if let Some((message, start, end, unclosed)) = bracket_syntax_error(source_text) { + return Some( + NormalizedParseDiagnostic::other(source_file, message, start, end) + .with_unclosed_bracket(unclosed), + ); + } + + if matches!( + &error.error, + parser::ParseErrorType::Lexical(parser::LexicalErrorType::LineContinuationError) + ) { + let loc = source_location(source_file, error.location.start() + TextSize::from(1)); + return Some(NormalizedParseDiagnostic::new( + error.error.clone(), + loc, + loc, + )); + } + + source_error!(unterminated_string_error(source_text)); + source_error!(expected_indented_block_error(error, source_text)); + + if matches!( + &error.error, + parser::ParseErrorType::Lexical(parser::LexicalErrorType::Eof) + ) { + return Some(eof_parse_diagnostic(error, source_file)); + } + + source_error!(invalid_type_param_error(source_text)); + source_error!(invalid_comprehension_error(source_text)); + source_error!(invalid_parameter_star_annotation_error(source_text)); + source_error!(invalid_parameter_list_error(source_text)); + source_error!(invalid_call_argument_error(source_text)); + + if is_missing_comma_between_literals(error) { + let (loc, end_loc) = adjusted_error_locations(source_file, error.location); + let msg = "invalid syntax. Perhaps you forgot a comma?".into(); + return Some(NormalizedParseDiagnostic::new( + parser::ParseErrorType::OtherError(msg), + loc, + end_loc, + )); + } + + source_error!(invalid_dict_error(source_text)); + source_error!(invalid_collection_assignment_error(source_text)); + source_error!(invalid_group_error(source_text)); + source_error!(invalid_def_type_params_error(source_text)); + source_error!(invalid_expression_error(source_text)); + source_error!(invalid_named_expression_error(source_text)); + source_error!(invalid_plain_assignment_error(source_text)); + source_error!(expression_assignment_error(source_text)); + source_error!(invalid_annotation_target_error(source_text)); + source_error!(invalid_assignment_target_error(source_text)); + source_error!(invalid_augassign_target_error(source_text)); + source_error!(invalid_for_target_error(source_text)); + source_error!(invalid_with_target_error(source_text)); + source_error!(invalid_delete_target_error(source_text)); + source_error!(invalid_standalone_except_error(source_text)); + source_error!(invalid_import_statement_error(source_text)); + source_error!(invalid_import_target_error(source_text)); + source_error!(invalid_except_as_target_error(source_text)); + source_error!(invalid_match_mapping_rest_wildcard_error(source_text)); + source_error!(invalid_match_as_target_error(source_text)); + source_error!(invalid_for_if_clause_error(source_text)); + source_error!(invalid_if_expression_statement_error(source_text)); + source_error!(invalid_else_elif_error(source_text)); + source_error!(mixed_except_handlers_error(source_text)); + + if matches!( + &error.error, + parser::ParseErrorType::Lexical(parser::LexicalErrorType::IndentationError) + ) { + let end_loc = source_line_end_location(source_file, error.location.start()); + return Some(NormalizedParseDiagnostic::new( + error.error.clone(), + end_loc, + end_loc, + )); + } + + if matches!( + &error.error, + parser::ParseErrorType::InvalidAssignmentTarget + ) { + return Some(invalid_assignment_target_diagnostic(error, source_file)); + } + + if matches!( + &error.error, + parser::ParseErrorType::InvalidNamedAssignmentTarget + ) { + let (loc, end_loc) = adjusted_error_locations(source_file, error.location); + let target = source_file.source_text().slice(error.location); + let msg = format!("cannot use assignment expressions with {target}"); + return Some(NormalizedParseDiagnostic::new( + parser::ParseErrorType::OtherError(msg), + loc, + end_loc, + )); + } + + None +} + +fn eof_parse_diagnostic( + error: &parser::ParseError, + source_file: &SourceFile, +) -> NormalizedParseDiagnostic { + let source_text = source_file.source_text(); + if let Some((bracket_char, bracket_offset)) = find_unclosed_bracket(source_text) { + let loc = source_location(source_file, TextSize::new(bracket_offset as u32)); + let end_loc = SourceLocation { + line: loc.line, + character_offset: loc.character_offset.saturating_add(1), + }; + let msg = format!("'{bracket_char}' was never closed"); + NormalizedParseDiagnostic::new(parser::ParseErrorType::OtherError(msg), loc, end_loc) + .with_unclosed_bracket(true) + } else { + let end_loc = source_line_end_location(source_file, error.location.start()); + NormalizedParseDiagnostic::new(error.error.clone(), end_loc, end_loc) + } +} + +fn invalid_assignment_target_diagnostic( + error: &parser::ParseError, + source_file: &SourceFile, +) -> NormalizedParseDiagnostic { + let (loc, end_loc) = adjusted_error_locations(source_file, error.location); + let expr_str = source_file.source_text().slice(error.location); + + let msg = parser::parse_expression(expr_str).map_or_else( + |_| match expr_str { + "yield" => "assignment to yield expression not possible".into(), + _ => format!("cannot assign to {expr_str}"), + }, + |parsed| match *parsed.syntax().body { + ast::Expr::Call(_) => "cannot assign to function call".into(), + ast::Expr::BinOp(_) => "cannot assign to expression".into(), + ast::Expr::If(_) => "cannot assign to conditional expression".into(), + ast::Expr::Generator(_) => "cannot assign to generator expression".into(), + ast::Expr::FString(_) => "invalid syntax".into(), + ast::Expr::StringLiteral(_) + | ast::Expr::BytesLiteral(_) + | ast::Expr::NumberLiteral(_) => { + "cannot assign to literal here. Maybe you meant '==' instead of '='?".into() + } + ast::Expr::EllipsisLiteral(_) => { + "cannot assign to ellipsis here. Maybe you meant '==' instead of '='?".into() + } + _ => format!("cannot assign to {expr_str}"), + }, + ); + + NormalizedParseDiagnostic::new(parser::ParseErrorType::OtherError(msg), loc, end_loc) +} + +fn default_parse_diagnostic( + error: parser::ParseError, + source_file: &SourceFile, +) -> NormalizedParseDiagnostic { + let (loc, end_loc) = adjusted_error_locations(source_file, error.location); + NormalizedParseDiagnostic::new(error.error, loc, end_loc) +} + +fn adjusted_error_locations( + source_file: &SourceFile, + range: ruff_text_size::TextRange, +) -> (SourceLocation, SourceLocation) { + let mut locations = source_locations(source_file, range.start(), range.end()); + if locations.1.character_offset.get() == 1 && locations.1.line > locations.0.line { + locations.1 = source_location(source_file, range.end() - TextSize::from(1)); + locations.1.character_offset = locations.1.character_offset.saturating_add(1); + } + locations +} + +fn source_line_end_location(source_file: &SourceFile, offset: TextSize) -> SourceLocation { + let loc = source_location(source_file, offset); + let line_idx = loc.line.to_zero_indexed(); + let line = source_file + .source_text() + .split('\n') + .nth(line_idx) + .unwrap_or(""); + let line_end_col = line.chars().count() + 1; + SourceLocation { + line: loc.line, + character_offset: ruff_source_file::OneIndexed::new(line_end_col) + .unwrap_or(loc.character_offset), + } +} + +fn is_missing_comma_between_literals(error: &parser::ParseError) -> bool { + matches!( + &error.error, + parser::ParseErrorType::ExpectedToken { expected, found } + if matches!((expected, found), (TokenKind::Comma, TokenKind::Int)) + ) +} + +fn is_ascii_identifier_char(byte: u8) -> bool { + byte == b'_' || byte.is_ascii_alphanumeric() +} + +fn numeric_keyword_suffix(rest: &[u8]) -> bool { + rest.starts_with(b"and") + || rest.starts_with(b"else") + || rest.starts_with(b"for") + || rest.starts_with(b"if") + || rest.starts_with(b"in") + || rest.starts_with(b"is") + || rest.starts_with(b"or") + || rest.starts_with(b"not") +} + +fn consume_decimal_digits(bytes: &[u8], mut index: usize) -> usize { + while index < bytes.len() { + match bytes[index] { + b'0'..=b'9' => index += 1, + b'_' if bytes + .get(index + 1) + .is_some_and(|byte| byte.is_ascii_digit()) => + { + index += 2; + } + _ => break, + } + } + index +} + +fn consume_radix_digits(bytes: &[u8], mut index: usize, is_digit: impl Fn(u8) -> bool) -> usize { + while index < bytes.len() { + if is_digit(bytes[index]) { + index += 1; + } else if bytes.get(index) == Some(&b'_') + && bytes.get(index + 1).is_some_and(|&byte| is_digit(byte)) + { + index += 2; + } else { + break; + } + } + index +} + +fn invalid_radix_literal_error( + bytes: &[u8], + start: usize, + kind: &'static str, + is_digit: impl Fn(u8) -> bool, +) -> Option<(String, usize)> { + let mut index = start + 2; + let mut has_digit = false; + loop { + let Some(&byte) = bytes.get(index) else { + return Some((format!("invalid {kind} literal"), start + 1)); + }; + if byte == b'_' { + let Some(&next) = bytes.get(index + 1) else { + return Some((format!("invalid {kind} literal"), index)); + }; + if is_digit(next) { + has_digit = true; + index += 2; + continue; + } + if next.is_ascii_digit() && matches!(kind, "binary" | "octal") { + return Some(( + format!("invalid digit '{}' in {kind} literal", next as char), + index + 1, + )); + } + return Some((format!("invalid {kind} literal"), index)); + } + if is_digit(byte) { + has_digit = true; + index += 1; + continue; + } + if byte.is_ascii_digit() && matches!(kind, "binary" | "octal") { + return Some(( + format!("invalid digit '{}' in {kind} literal", byte as char), + index, + )); + } + if has_digit { + return None; + } + return Some((format!("invalid {kind} literal"), start + 1)); + } +} + +fn decimal_tail_error(bytes: &[u8], mut index: usize) -> Option { + loop { + while bytes.get(index).is_some_and(|byte| byte.is_ascii_digit()) { + index += 1; + } + if bytes.get(index) != Some(&b'_') { + return None; + } + let underscore = index; + index += 1; + if !bytes.get(index).is_some_and(|byte| byte.is_ascii_digit()) { + return Some(underscore); + } + } +} + +fn decimal_tail_end(bytes: &[u8], mut index: usize) -> usize { + loop { + while bytes.get(index).is_some_and(|byte| byte.is_ascii_digit()) { + index += 1; + } + if bytes.get(index) == Some(&b'_') + && bytes + .get(index + 1) + .is_some_and(|byte| byte.is_ascii_digit()) + { + index += 2; + } else { + return index; + } + } +} + +fn invalid_decimal_literal_error(bytes: &[u8], start: usize) -> Option<(String, usize)> { + if bytes.get(start) == Some(&b'.') { + return None; + } + let message = "invalid decimal literal".to_owned(); + if let Some(offset) = decimal_tail_error(bytes, start) { + return Some((message, offset)); + } + + let mut index = decimal_tail_end(bytes, start); + if bytes.get(index) == Some(&b'.') { + if bytes.get(index + 1) == Some(&b'_') { + return Some((message, index)); + } + if let Some(offset) = decimal_tail_error(bytes, index + 1) { + return Some((message, offset)); + } + index = decimal_tail_end(bytes, index + 1); + } + if matches!(bytes.get(index), Some(b'e' | b'E')) { + let exponent = index; + index += 1; + let sign = if matches!(bytes.get(index), Some(b'+' | b'-')) { + let sign = index; + index += 1; + Some(sign) + } else { + None + }; + if !bytes.get(index).is_some_and(|byte| byte.is_ascii_digit()) { + return Some((message, sign.unwrap_or(exponent))); + } + if let Some(offset) = decimal_tail_error(bytes, index) { + return Some((message, offset)); + } + } + None +} + +fn leading_zero_decimal_literal_error(bytes: &[u8], start: usize) -> Option<(String, usize)> { + if bytes.get(start) != Some(&b'0') { + return None; + } + let mut index = start; + loop { + match bytes.get(index) { + Some(b'0') => index += 1, + Some(b'_') + if bytes + .get(index + 1) + .is_some_and(|byte| byte.is_ascii_digit()) => + { + index += 1; + } + _ => break, + } + } + if bytes.get(index).is_some_and(|byte| byte.is_ascii_digit()) { + let after_digits = decimal_tail_end(bytes, index); + if !matches!( + bytes.get(after_digits), + Some(b'.' | b'e' | b'E' | b'j' | b'J') + ) { + return Some(( + "leading zeros in decimal integer literals are not permitted; use an 0o prefix for octal integers".to_owned(), + start, + )); + } + } + None +} + +fn invalid_numeric_literal_error(bytes: &[u8], start: usize) -> Option<(String, usize)> { + if bytes.get(start) == Some(&b'0') { + match bytes.get(start + 1) { + Some(b'x' | b'X') => { + return invalid_radix_literal_error(bytes, start, "hexadecimal", |byte| { + byte.is_ascii_hexdigit() + }); + } + Some(b'o' | b'O') => { + return invalid_radix_literal_error(bytes, start, "octal", |byte| { + matches!(byte, b'0'..=b'7') + }); + } + Some(b'b' | b'B') => { + return invalid_radix_literal_error(bytes, start, "binary", |byte| { + matches!(byte, b'0' | b'1') + }); + } + _ => {} + } + if let Some(err) = leading_zero_decimal_literal_error(bytes, start) { + return Some(err); + } + } + invalid_decimal_literal_error(bytes, start) +} + +fn consume_exponent(bytes: &[u8], index: usize) -> usize { + if !matches!(bytes.get(index), Some(b'e' | b'E')) { + return index; + } + let mut cursor = index + 1; + if matches!(bytes.get(cursor), Some(b'+' | b'-')) { + cursor += 1; + } + if bytes.get(cursor).is_some_and(|byte| byte.is_ascii_digit()) { + consume_decimal_digits(bytes, cursor) + } else { + index + } +} + +fn number_literal_end(bytes: &[u8], start: usize) -> Option<(&'static str, usize)> { + if bytes.get(start) == Some(&b'.') { + if !bytes + .get(start + 1) + .is_some_and(|byte| byte.is_ascii_digit()) + { + return None; + } + let mut index = consume_decimal_digits(bytes, start + 1); + index = consume_exponent(bytes, index); + if matches!(bytes.get(index), Some(b'j' | b'J')) { + return Some(("imaginary", index + 1)); + } + return Some(("decimal", index)); + } + + if !bytes.get(start).is_some_and(|byte| byte.is_ascii_digit()) { + return None; + } + + if bytes.get(start) == Some(&b'0') { + match bytes.get(start + 1) { + Some(b'x' | b'X') => { + let end = consume_radix_digits(bytes, start + 2, |byte| byte.is_ascii_hexdigit()); + return Some(("hexadecimal", end)); + } + Some(b'o' | b'O') => { + let end = + consume_radix_digits(bytes, start + 2, |byte| matches!(byte, b'0'..=b'7')); + return Some(("octal", end)); + } + Some(b'b' | b'B') => { + let end = + consume_radix_digits(bytes, start + 2, |byte| matches!(byte, b'0' | b'1')); + return Some(("binary", end)); + } + _ => {} + } + } + + let mut index = consume_decimal_digits(bytes, start); + if bytes.get(index) == Some(&b'.') { + index = consume_decimal_digits(bytes, index + 1); + } + index = consume_exponent(bytes, index); + if matches!(bytes.get(index), Some(b'j' | b'J')) { + return Some(("imaginary", index + 1)); + } + Some(("decimal", index)) +} + +fn skip_quoted_string(bytes: &[u8], mut index: usize) -> usize { + let quote = bytes[index]; + let triple = bytes.get(index + 1) == Some("e) && bytes.get(index + 2) == Some("e); + let quote_len = if triple { 3 } else { 1 }; + index += quote_len; + while index < bytes.len() { + if bytes[index] == b'\\' { + index = (index + 2).min(bytes.len()); + } else if triple + && bytes.get(index) == Some("e) + && bytes.get(index + 1) == Some("e) + && bytes.get(index + 2) == Some("e) + { + return index + 3; + } else if !triple && bytes[index] == quote { + return index + 1; + } else { + index += 1; + } + } + index +} + +fn invalid_number_literal_error(source: &str) -> Option<(String, usize)> { + let bytes = source.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => { + index = skip_quoted_string(bytes, index); + } + byte if byte >= 0x80 || byte == b'_' || byte.is_ascii_alphabetic() => { + index += 1; + while index < bytes.len() + && (bytes[index] >= 0x80 || is_ascii_identifier_char(bytes[index])) + { + index += 1; + } + } + b'.' | b'0'..=b'9' => { + if let Some(err) = invalid_numeric_literal_error(bytes, index) { + return Some(err); + } + let Some((kind, end)) = number_literal_end(bytes, index) else { + index += 1; + continue; + }; + if end > index { + if source[end..].starts_with('⁄') { + return Some(("invalid character '⁄' (U+2044)".to_owned(), end)); + } + if bytes + .get(end) + .is_some_and(|byte| *byte < 128 && is_ascii_identifier_char(*byte)) + && !numeric_keyword_suffix(&bytes[end..]) + { + return Some((format!("invalid {kind} literal"), end.saturating_sub(1))); + } + } + index = end.max(index + 1); + } + _ => index += 1, + } + } + None +} + +fn cpython_indented_block_clause(message: &str) -> Option<&'static str> { + let clause = message.strip_prefix("Expected an indented block after ")?; + Some(match clause { + "`if` statement" => "'if' statement", + "`elif` clause" => "'elif' statement", + "`else` clause" => "'else' statement", + "`for` statement" => "'for' statement", + "`with` statement" => "'with' statement", + "`while` statement" => "'while' statement", + "`try` statement" => "'try' statement", + "`except` clause" => "'except' statement", + "`finally` clause" => "'finally' statement", + "`match` statement" => "'match' statement", + "`case` block" => "'case' statement", + "`class` definition" => "class definition", + "function definition" => "function definition", + _ => return None, + }) +} + +fn previous_non_empty_line_number(source: &str, offset: usize) -> Option { + let bytes = source.as_bytes(); + let mut index = offset.min(bytes.len()); + while index > 0 { + let line_end = index; + while index > 0 && bytes[index - 1] != b'\n' { + index -= 1; + } + let line_start = index; + let content_start = skip_horizontal_whitespace(bytes, line_start); + let mut content_end = line_end; + while content_end > content_start + && matches!( + bytes.get(content_end - 1), + Some(b' ' | b'\t' | b'\r' | b'\x0c') + ) + { + content_end -= 1; + } + if content_start < content_end { + return Some( + source[..line_start] + .bytes() + .filter(|byte| *byte == b'\n') + .count() + + 1, + ); + } + index = line_start.saturating_sub(1); + } + None +} + +fn expected_indented_block_error( + error: &parser::ParseError, + source: &str, +) -> Option<(String, usize, usize)> { + let parser::ParseErrorType::OtherError(message) = &error.error else { + return None; + }; + let mut clause = cpython_indented_block_clause(message)?; + let start = error.location.start().to_usize(); + let end = error.location.end().to_usize(); + let line = previous_non_empty_line_number(source, start)?; + if clause == "'except' statement" + && let Some(previous_line) = previous_non_empty_line(source, start) + && matches!( + previous_line.trim_start(), + line if line.starts_with("except*") || line.starts_with("except *") + ) + { + clause = "'except*' statement"; + } + Some(( + format!("expected an indented block after {clause} on line {line}"), + start, + end, + )) +} + +fn previous_non_empty_line(source: &str, offset: usize) -> Option<&str> { + let bytes = source.as_bytes(); + let mut index = offset.min(bytes.len()); + while index > 0 { + let line_end = index; + while index > 0 && bytes[index - 1] != b'\n' { + index -= 1; + } + let line_start = index; + let mut content_start = line_start; + while content_start < line_end + && matches!(bytes[content_start], b' ' | b'\t' | b'\n' | b'\r' | b'\x0c') + { + content_start += 1; + } + let mut content_end = line_end; + while content_end > content_start + && matches!(bytes[content_end - 1], b' ' | b'\t' | b'\r' | b'\x0c') + { + content_end -= 1; + } + if content_start < content_end { + return source.get(line_start..line_end); + } + index = line_start.saturating_sub(1); + } + None +} + +fn starts_identifier(bytes: &[u8], index: usize, word: &[u8]) -> bool { + bytes.get(index..index + word.len()) == Some(word) + && index + .checked_sub(1) + .and_then(|before| bytes.get(before)) + .is_none_or(|byte| !is_ascii_identifier_char(*byte)) + && bytes + .get(index + word.len()) + .is_none_or(|byte| !is_ascii_identifier_char(*byte)) +} + +fn is_plain_assignment_operator(bytes: &[u8], index: usize) -> bool { + bytes.get(index) == Some(&b'=') + && bytes.get(index + 1) != Some(&b'=') + && !matches!( + index.checked_sub(1).and_then(|before| bytes.get(before)), + Some(b'=' | b'!' | b'<' | b'>' | b':') + ) +} + +fn is_simple_keyword_name(bytes: &[u8], mut start: usize, mut end: usize) -> bool { + while matches!( + bytes.get(start), + Some(b' ' | b'\t' | b'\n' | b'\r' | b'\x0c') + ) { + start += 1; + } + while end > start + && matches!( + bytes.get(end - 1), + Some(b' ' | b'\t' | b'\n' | b'\r' | b'\x0c') + ) + { + end -= 1; + } + let Some(&first) = bytes.get(start) else { + return false; + }; + if !(first == b'_' || first.is_ascii_alphabetic() || first >= 0x80) { + return false; + } + let mut index = start + 1; + while index < end { + if bytes[index] < 0x80 && !is_ascii_identifier_char(bytes[index]) { + return false; + } + index += 1; + } + true +} + +fn is_function_parameter_list(bytes: &[u8], paren: usize) -> bool { + let mut cursor = paren; + while cursor > 0 && matches!(bytes.get(cursor - 1), Some(b' ' | b'\t' | b'\x0c')) { + cursor -= 1; + } + if cursor > 0 && bytes.get(cursor - 1) == Some(&b']') { + let mut bracket = cursor; + let mut level = 0usize; + while bracket > 0 { + bracket -= 1; + match bytes[bracket] { + b']' => level += 1, + b'[' => { + level = level.saturating_sub(1); + if level == 0 { + cursor = bracket; + break; + } + } + _ => {} + } + } + while cursor > 0 && matches!(bytes.get(cursor - 1), Some(b' ' | b'\t' | b'\x0c')) { + cursor -= 1; + } + } + while cursor > 0 + && bytes + .get(cursor - 1) + .is_some_and(|byte| *byte >= 0x80 || is_ascii_identifier_char(*byte)) + { + cursor -= 1; + } + while cursor > 0 && matches!(bytes.get(cursor - 1), Some(b' ' | b'\t' | b'\x0c')) { + cursor -= 1; + } + cursor >= 3 + && starts_identifier(bytes, cursor - 3, b"def") + && cursor + .checked_sub(4) + .and_then(|before| bytes.get(before)) + .is_none_or(|byte| !is_ascii_identifier_char(*byte)) +} + +#[derive(Clone, Copy)] +enum ParameterListKind { + Function, + Lambda, +} + +fn matching_delimiter(bytes: &[u8], open: usize, close: u8) -> Option { + let mut index = open; + let mut level = 0usize; + while index < bytes.len() { + match bytes[index] { + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + byte if byte == close => { + level = level.saturating_sub(1); + if level == 0 { + return Some(index); + } + index += 1; + } + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + _ => index += 1, + } + } + None +} + +fn find_lambda_parameter_end(bytes: &[u8], mut index: usize) -> Option { + let mut level = 0usize; + while index < bytes.len() { + match bytes[index] { + b'#' if level == 0 => return None, + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + b':' if level == 0 => return Some(index), + _ => index += 1, + } + } + None +} + +fn top_level_byte(bytes: &[u8], mut index: usize, end: usize, needle: u8) -> Option { + let mut level = 0usize; + while index < end { + match bytes[index] { + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + byte if level == 0 && byte == needle => return Some(index), + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + _ => index += 1, + } + } + None +} + +fn identifier_end(bytes: &[u8], mut index: usize, end: usize) -> usize { + if !bytes + .get(index) + .is_some_and(|byte| *byte >= 0x80 || *byte == b'_' || byte.is_ascii_alphabetic()) + { + return index; + } + index += 1; + while index < end + && bytes + .get(index) + .is_some_and(|byte| *byte >= 0x80 || is_ascii_identifier_char(*byte)) + { + index += 1; + } + index +} + +fn expression_slice_is_tuple(source: &str, start: usize, end: usize) -> bool { + let bytes = source.as_bytes(); + let (start, end) = trim_target_range(bytes, start, end); + if start >= end { + return false; + } + let Ok(parsed) = parser::parse(&source[start..end], parser::Mode::Expression.into()) else { + return false; + }; + matches!(parsed.into_syntax(), ast::Mod::Expression(expression) if matches!(*expression.body, ast::Expr::Tuple(_))) +} + +fn type_param_list_open(bytes: &[u8], open: usize) -> bool { + let mut cursor = open; + while cursor > 0 && matches!(bytes.get(cursor - 1), Some(b' ' | b'\t' | b'\x0c')) { + cursor -= 1; + } + while cursor > 0 + && bytes + .get(cursor - 1) + .is_some_and(|byte| *byte >= 0x80 || is_ascii_identifier_char(*byte)) + { + cursor -= 1; + } + while cursor > 0 && matches!(bytes.get(cursor - 1), Some(b' ' | b'\t' | b'\x0c')) { + cursor -= 1; + } + (cursor >= 3 && starts_identifier(bytes, cursor - 3, b"def")) + || (cursor >= 5 && starts_identifier(bytes, cursor - 5, b"class")) + || (cursor >= 4 && starts_identifier(bytes, cursor - 4, b"type")) +} + +fn invalid_type_param_item_error( + source: &str, + start: usize, + end: usize, +) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let (start, end) = trim_target_range(bytes, start, end); + if start >= end || bytes.get(start) != Some(&b'*') { + return None; + } + let is_param_spec = bytes.get(start + 1) == Some(&b'*'); + let name_start = start + if is_param_spec { 2 } else { 1 }; + let name_end = identifier_end(bytes, name_start, end); + if name_start == name_end { + return None; + } + let colon = next_non_horizontal_whitespace(bytes, name_end); + if colon >= end || bytes.get(colon) != Some(&b':') { + return None; + } + let has_constraints = expression_slice_is_tuple(source, colon + 1, end); + let message = match (is_param_spec, has_constraints) { + (false, false) => "cannot use bound with TypeVarTuple", + (false, true) => "cannot use constraints with TypeVarTuple", + (true, false) => "cannot use bound with ParamSpec", + (true, true) => "cannot use constraints with ParamSpec", + }; + Some((message.to_owned(), colon, colon + 1)) +} + +fn invalid_type_param_list_error( + source: &str, + open: usize, + close: usize, +) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut item_start = open + 1; + let mut index = item_start; + let mut level = 0usize; + while index <= close { + if index == close || (level == 0 && bytes.get(index) == Some(&b',')) { + if let Some(error) = invalid_type_param_item_error(source, item_start, index) { + return Some(error); + } + item_start = index + 1; + index += 1; + continue; + } + match bytes[index] { + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + _ => index += 1, + } + } + None +} + +fn invalid_type_param_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0usize; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'[' if type_param_list_open(bytes, index) => { + let Some(close) = matching_delimiter(bytes, index, b']') else { + index += 1; + continue; + }; + if let Some(error) = invalid_type_param_list_error(source, index, close) { + return Some(error); + } + index = close + 1; + } + _ => index += 1, + } + } + None +} + +fn invalid_comprehension_in_slice( + bytes: &[u8], + open: usize, + close: usize, +) -> Option<(String, usize, usize)> { + let for_index = find_keyword_at_level(bytes, open + 1, close, b"for")?; + let item_start = next_non_horizontal_whitespace(bytes, open + 1); + if item_start >= for_index { + return None; + } + if bytes.get(item_start..item_start + 2) == Some(b"**") && bytes.get(open) == Some(&b'{') { + return Some(( + "dict unpacking cannot be used in dict comprehension".to_owned(), + item_start, + item_start + 2, + )); + } + if bytes.get(item_start..item_start + 2) == Some(b"**") && bytes.get(open) == Some(&b'(') { + return Some(("invalid syntax".to_owned(), for_index, for_index + 3)); + } + if bytes.get(item_start) == Some(&b'*') { + return Some(( + "iterable unpacking cannot be used in comprehension".to_owned(), + item_start, + item_start + 1, + )); + } + if !matches!(bytes.get(open), Some(b'[' | b'{')) { + return None; + } + if top_level_colon(bytes, open + 1, for_index).is_none() + && let Some(comma) = top_level_byte(bytes, open + 1, for_index, b',') + { + let (start, _) = trim_target_range(bytes, open + 1, comma); + return Some(( + "did you forget parentheses around the comprehension target?".to_owned(), + start, + comma + 1, + )); + } + None +} + +fn invalid_comprehension_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0usize; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' | b'[' | b'{' => { + let close_byte = match bytes[index] { + b'(' => b')', + b'[' => b']', + _ => b'}', + }; + let Some(close) = matching_delimiter(bytes, index, close_byte) else { + index += 1; + continue; + }; + if let Some(error) = invalid_comprehension_in_slice(bytes, index, close) { + return Some(error); + } + index = close + 1; + } + _ => index += 1, + } + } + None +} + +fn invalid_group_in_slice( + bytes: &[u8], + open: usize, + close: usize, +) -> Option<(String, usize, usize)> { + let (item_start, item_end) = trim_target_range(bytes, open + 1, close); + if item_start >= item_end + || top_level_byte(bytes, item_start, item_end, b',').is_some() + || top_level_colon(bytes, item_start, item_end).is_some() + || find_keyword_at_level(bytes, item_start, item_end, b"for").is_some() + { + return None; + } + if bytes.get(item_start..item_start + 2) == Some(b"**") { + return Some(( + "cannot use double starred expression here".to_owned(), + item_start, + item_start + 2, + )); + } + if bytes.get(item_start) == Some(&b'*') { + return Some(( + "cannot use starred expression here".to_owned(), + item_start, + item_start + 1, + )); + } + None +} + +fn invalid_group_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0usize; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' => { + let Some(close) = matching_delimiter(bytes, index, b')') else { + index += 1; + continue; + }; + if let Some(error) = invalid_group_in_slice(bytes, index, close) { + return Some(error); + } + index = close + 1; + } + _ => index += 1, + } + } + None +} + +fn invalid_parameter_star_annotation_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0usize; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' => { + let Some(close) = matching_delimiter(bytes, index, b')') else { + index += 1; + continue; + }; + let mut param_start = index + 1; + while param_start < close { + let param_end = + find_byte_at_level(bytes, param_start, close, b',').unwrap_or(close); + if let Some(colon) = top_level_colon(bytes, param_start, param_end) { + let value_start = next_non_horizontal_whitespace(bytes, colon + 1); + if bytes.get(value_start) == Some(&b'*') { + return Some(( + "invalid syntax".to_owned(), + value_start, + value_start + 1, + )); + } + } + param_start = param_end.saturating_add(1); + } + index = close + 1; + } + _ => index += 1, + } + } + None +} + +fn invalid_def_type_params_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + _ if starts_identifier(bytes, index, b"def") => { + let name_start = skip_horizontal_whitespace(bytes, index + 3); + let name_end = identifier_end(bytes, name_start, bytes.len()); + let bracket = skip_horizontal_whitespace(bytes, name_end); + if bytes.get(bracket) == Some(&b'[') { + let Some(close) = matching_delimiter(bytes, bracket, b']') else { + index = bracket + 1; + continue; + }; + let after_close = skip_horizontal_whitespace(bytes, close + 1); + if bytes.get(after_close) == Some(&b'(') + && type_param_list_is_malformed(bytes, bracket + 1, close) + { + return Some(("expected '('".to_owned(), bracket, bracket + 1)); + } + } + index = name_end.max(index + 3); + } + _ => index += 1, + } + } + None +} + +fn type_param_list_is_malformed(bytes: &[u8], start: usize, end: usize) -> bool { + let mut index = start; + let mut expect_item = true; + while index < end { + index = skip_horizontal_whitespace(bytes, index); + if index >= end { + break; + } + if bytes[index] == b',' { + if expect_item { + return true; + } + expect_item = true; + index += 1; + continue; + } + if !expect_item { + return true; + } + if bytes.get(index..index + 2) == Some(b"**") { + index += 2; + } else if bytes.get(index) == Some(&b'*') { + index += 1; + } + let item_start = skip_horizontal_whitespace(bytes, index); + let item_end = identifier_end(bytes, item_start, end); + if item_end == item_start { + return true; + } + index = item_end; + if bytes.get(skip_horizontal_whitespace(bytes, index)) == Some(&b':') { + index = skip_horizontal_whitespace(bytes, index) + 1; + while index < end && bytes[index] != b',' { + index = match bytes[index] { + b'\'' | b'"' => skip_quoted_string(bytes, index), + _ => index + 1, + }; + } + } + expect_item = false; + } + false +} + +fn invalid_parameter_list_slice_error( + source: &str, + start: usize, + end: usize, + kind: ParameterListKind, +) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = start; + let mut level = 0usize; + let mut default_seen = false; + let mut keyword_only = false; + let mut slash_seen = false; + let mut var_keyword_seen = false; + while index < end { + match bytes[index] { + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + _ if level == 0 + && var_keyword_seen + && bytes.get(index).is_some_and(|byte| { + *byte >= 0x80 || *byte == b'_' || byte.is_ascii_alphabetic() + }) => + { + let name_end = identifier_end(bytes, index, end); + return Some(( + "arguments cannot follow var-keyword argument".to_owned(), + index, + name_end, + )); + } + _ if level == 0 + && !keyword_only + && bytes.get(index).is_some_and(|byte| { + *byte >= 0x80 || *byte == b'_' || byte.is_ascii_alphabetic() + }) => + { + let param_end = find_byte_at_level(bytes, index, end, b',') + .or_else(|| top_level_byte(bytes, index, end, b')')) + .or_else(|| { + matches!(kind, ParameterListKind::Lambda) + .then(|| top_level_byte(bytes, index, end, b':')) + .flatten() + }) + .unwrap_or(end); + let name_end = identifier_end(bytes, index, param_end); + if top_level_byte(bytes, index, param_end, b'=').is_some() { + default_seen = true; + } else if default_seen { + return Some(( + "parameter without a default follows parameter with a default".to_owned(), + index, + name_end, + )); + } + index = name_end; + } + b'(' if level == 0 => { + let close = matching_delimiter(bytes, index, b')') + .filter(|close| *close <= end) + .unwrap_or(index + 1); + let message = match kind { + ParameterListKind::Function => "Function parameters cannot be parenthesized", + ParameterListKind::Lambda => { + "Lambda expression parameters cannot be parenthesized" + } + }; + return Some((message.to_owned(), index, close + 1)); + } + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + b'/' if level == 0 => { + if var_keyword_seen { + return Some(( + "arguments cannot follow var-keyword argument".to_owned(), + index, + index + 1, + )); + } + if slash_seen { + return Some(("/ may appear only once".to_owned(), index, index + 1)); + } + slash_seen = true; + let next = next_non_horizontal_whitespace(bytes, index + 1); + if bytes.get(next) == Some(&b'*') { + return Some(("expected comma between / and *".to_owned(), next, next + 1)); + } + index += 1; + } + b'*' if level == 0 => { + if var_keyword_seen { + return Some(( + "arguments cannot follow var-keyword argument".to_owned(), + index, + index + 1, + )); + } + keyword_only = true; + let stars = usize::from(bytes.get(index + 1) == Some(&b'*')) + 1; + let name_start = next_non_horizontal_whitespace(bytes, index + stars); + for keyword in [b"True".as_slice(), b"False".as_slice(), b"None".as_slice()] { + if starts_identifier(bytes, name_start, keyword) { + return Some(( + "invalid syntax".to_owned(), + name_start, + name_start + keyword.len(), + )); + } + } + let param_end = find_byte_at_level(bytes, name_start, end, b',') + .or_else(|| top_level_byte(bytes, name_start, end, b')')) + .or_else(|| { + matches!(kind, ParameterListKind::Lambda) + .then(|| top_level_byte(bytes, name_start, end, b':')) + .flatten() + }) + .unwrap_or(end); + if stars == 1 && matches!(bytes.get(name_start), Some(b')' | b',' | b':')) { + return Some(( + "named arguments must follow bare *".to_owned(), + index, + index + 1, + )); + } + if stars == 1 && top_level_byte(bytes, name_start, param_end, b'=').is_some() { + return Some(( + "var-positional argument cannot have default value".to_owned(), + index, + index + 1, + )); + } + if stars == 2 && top_level_byte(bytes, name_start, param_end, b'=').is_some() { + return Some(( + "var-keyword argument cannot have default value".to_owned(), + index, + index + 2, + )); + } + if stars == 2 { + var_keyword_seen = true; + index = param_end; + continue; + } + index += stars; + } + b'=' if level == 0 => { + let value_start = next_non_horizontal_whitespace(bytes, index + 1); + if value_start >= end || matches!(bytes.get(value_start), Some(b',' | b')' | b':')) + { + if matches!(kind, ParameterListKind::Lambda) + && matches!(bytes.get(value_start), Some(b':')) + { + return Some(("invalid syntax".to_owned(), index, index + 1)); + } + return Some(( + "expected default value expression".to_owned(), + index, + index + 1, + )); + } + index += 1; + } + _ => index += 1, + } + } + None +} + +fn invalid_parameter_list_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0usize; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + _ if starts_identifier(bytes, index, b"def") => { + let Some(paren) = top_level_byte(bytes, index + 3, bytes.len(), b'(') else { + index += 3; + continue; + }; + let Some(close) = matching_delimiter(bytes, paren, b')') else { + index = paren + 1; + continue; + }; + if let Some(error) = invalid_parameter_list_slice_error( + source, + paren + 1, + close, + ParameterListKind::Function, + ) { + return Some(error); + } + index = close + 1; + } + _ if starts_identifier(bytes, index, b"lambda") => { + let params_start = index + 6; + let Some(params_end) = find_lambda_parameter_end(bytes, params_start) else { + index = params_start; + continue; + }; + if let Some(error) = invalid_parameter_list_slice_error( + source, + params_start, + params_end, + ParameterListKind::Lambda, + ) { + return Some(error); + } + index = params_end + 1; + } + _ => index += 1, + } + } + None +} + +#[derive(Clone, Copy)] +struct CallArgFrame { + level: usize, + arg_start: Option, + in_call: bool, +} + +fn next_non_horizontal_whitespace(bytes: &[u8], mut index: usize) -> usize { + while matches!(bytes.get(index), Some(b' ' | b'\t' | b'\x0c')) { + index += 1; + } + index +} + +fn invalid_call_argument_assignment_error( + source: &str, + arg_start: usize, + equal: usize, +) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let start = bytes[arg_start..equal] + .iter() + .rposition(|byte| *byte == b'\n') + .map_or(arg_start, |newline| arg_start + newline + 1); + let (target_start, target_end) = trim_target_range(bytes, start, equal); + if target_start >= target_end { + return None; + } + let value_start = next_non_horizontal_whitespace(bytes, equal + 1); + if matches!(bytes.get(value_start), None | Some(b',' | b')')) { + return Some(( + "expected argument value expression".to_owned(), + target_start, + equal + 1, + )); + } + if bytes.get(target_start..target_start + 2) == Some(b"**") { + return Some(( + "cannot assign to keyword argument unpacking".to_owned(), + target_start, + value_start, + )); + } + if bytes.get(target_start) == Some(&b'*') { + return Some(( + "cannot assign to iterable argument unpacking".to_owned(), + target_start, + value_start, + )); + } + for keyword in [b"True".as_slice(), b"False".as_slice(), b"None".as_slice()] { + if bytes.get(target_start..target_end) == Some(keyword) { + let keyword = ::core::str::from_utf8(keyword).ok()?; + return Some(( + format!("cannot assign to {keyword}"), + target_start, + target_end, + )); + } + } + if is_simple_keyword_name(bytes, target_start, target_end) { + return None; + } + Some(( + "expression cannot contain assignment, perhaps you meant \"==\"?".to_owned(), + target_start, + equal, + )) +} + +fn invalid_call_star_expression_error( + bytes: &[u8], + arg_start: usize, + index: usize, +) -> Option<(String, usize, usize)> { + let start = next_non_horizontal_whitespace(bytes, arg_start); + if start != index || bytes.get(index) != Some(&b'*') { + return None; + } + let after_star = next_non_horizontal_whitespace(bytes, index + 1); + if matches!(bytes.get(after_star), None | Some(b',' | b')' | b':')) { + return Some(( + "Invalid star expression".to_owned(), + index, + (index + 1).min(bytes.len()), + )); + } + None +} + +fn invalid_call_argument_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0usize; + let mut level = 0usize; + let mut frames: Vec = Vec::new(); + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + _ if starts_identifier(bytes, index, b"lambda") => { + let params_start = index + 6; + if let Some(params_end) = find_lambda_parameter_end(bytes, params_start) { + index = params_end + 1; + } else { + index = params_start; + } + } + b'(' => { + level += 1; + let in_call = opening_paren_is_call(bytes, index) + || frames.last().is_some_and(|frame| frame.in_call); + frames.push(CallArgFrame { + level, + arg_start: (in_call && !is_function_parameter_list(bytes, index)) + .then_some(index + 1), + in_call, + }); + index += 1; + } + b')' => { + if matches!(frames.last(), Some(frame) if frame.level == level) { + frames.pop(); + } + level = level.saturating_sub(1); + index += 1; + } + b'[' | b'{' => { + level += 1; + index += 1; + } + b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + b',' => { + if let Some(frame) = frames.last_mut() + && frame.level == level + && frame.arg_start.is_some() + { + frame.arg_start = Some(index + 1); + } + index += 1; + } + b'*' => { + if let Some(CallArgFrame { + level: frame_level, + arg_start: Some(arg_start), + in_call: true, + }) = frames.last().copied() + && frame_level == level + && let Some(error) = invalid_call_star_expression_error(bytes, arg_start, index) + { + return Some(error); + } + index += 1; + } + b'=' if is_plain_assignment_operator(bytes, index) => { + if let Some(CallArgFrame { + level: frame_level, + arg_start: Some(arg_start), + in_call: true, + }) = frames.last().copied() + && frame_level == level + && let Some(error) = + invalid_call_argument_assignment_error(source, arg_start, index) + { + return Some(error); + } + index += 1; + } + _ => index += 1, + } + } + None +} + +fn top_level_colon(bytes: &[u8], mut index: usize, end: usize) -> Option { + let mut level = 0usize; + while index < end { + match bytes[index] { + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + b':' if level == 0 => return Some(index), + _ => index += 1, + } + } + None +} + +fn expression_slice_is_valid(source: &str, start: usize, end: usize) -> bool { + let bytes = source.as_bytes(); + let (start, end) = trim_target_range(bytes, start, end); + start < end + && parser::parse(&source[start..end], parser::Mode::Expression.into()) + .is_ok_and(|parsed| matches!(parsed.into_syntax(), ast::Mod::Expression(_))) +} + +fn invalid_dict_entry_error( + source: &str, + item_start: usize, + item_end: usize, + colon: Option, + saw_dict_item: bool, +) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let (item_start, item_end) = trim_target_range(bytes, item_start, item_end); + if item_start >= item_end { + return None; + } + if let Some(colon) = colon { + let value_start = next_non_horizontal_whitespace(bytes, colon + 1); + if value_start >= item_end { + return Some(( + "expression expected after dictionary key and ':'".to_owned(), + colon, + colon + 1, + )); + } + if bytes.get(value_start) == Some(&b'*') { + return Some(( + "cannot use a starred expression in a dictionary value".to_owned(), + value_start, + value_start + 1, + )); + } + if !expression_slice_is_valid(source, value_start, item_end) { + return Some(("invalid syntax".to_owned(), value_start, value_start)); + } + } else if saw_dict_item { + return Some(( + "':' expected after dictionary key".to_owned(), + item_end.saturating_sub(1), + item_end, + )); + } + None +} + +fn invalid_dict_literal_error( + source: &str, + open: usize, + close: usize, +) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut item_start = open + 1; + let mut index = item_start; + let mut level = 0usize; + let mut saw_dict_item = false; + let mut item_colon = None; + while index <= close { + if index == close || (level == 0 && bytes.get(index) == Some(&b',')) { + if let Some(error) = + invalid_dict_entry_error(source, item_start, index, item_colon, saw_dict_item) + { + return Some(error); + } + saw_dict_item |= item_colon.is_some(); + item_start = index + 1; + item_colon = None; + index += 1; + continue; + } + match bytes[index] { + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + b':' if level == 0 && item_colon.is_none() => { + item_colon = Some(index); + saw_dict_item = true; + index += 1; + } + _ => index += 1, + } + } + None +} + +fn invalid_dict_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0usize; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'{' => { + let Some(close) = matching_delimiter(bytes, index, b'}') else { + index += 1; + continue; + }; + if top_level_colon(bytes, index + 1, close).is_some() + && let Some(error) = invalid_dict_literal_error(source, index, close) + { + return Some(error); + } + index = close + 1; + } + _ => index += 1, + } + } + None +} + +fn collection_open_is_call(bytes: &[u8], open: usize) -> bool { + if bytes.get(open) != Some(&b'(') { + return false; + } + let mut cursor = open; + while cursor > 0 && matches!(bytes.get(cursor - 1), Some(b' ' | b'\t' | b'\x0c')) { + cursor -= 1; + } + matches!( + cursor.checked_sub(1).and_then(|before| bytes.get(before)), + Some(b')' | b']' | b'_' | b'a'..=b'z' | b'A'..=b'Z' | 0x80..=0xff) + ) +} + +fn invalid_collection_assignment_in_slice( + source: &str, + bytes: &[u8], + start: usize, + end: usize, +) -> Option<(String, usize, usize)> { + let mut item_start = start; + let mut index = start; + let mut level = 0usize; + while index < end { + match bytes[index] { + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + b',' if level == 0 => { + item_start = index + 1; + index += 1; + } + b'=' if level == 0 && is_plain_assignment_operator(bytes, index) => { + if top_level_colon(bytes, item_start, index).is_none() { + let start = next_non_horizontal_whitespace(bytes, item_start); + let target_end = trim_end_horizontal_whitespace(bytes, start, index); + if start < target_end + && let Some((expr_name, expr_start, expr_end, _)) = + expression_name_and_range(&source[start..target_end]) + { + if matches!(expr_name, "list" | "tuple") { + return None; + } + if matches!(expr_name, "expression" | "attribute" | "subscript") { + return Some(( + format!( + "cannot assign to {expr_name} here. Maybe you meant '==' instead of '='?" + ), + start + expr_start, + start + expr_end, + )); + } + } + return Some(( + "invalid syntax. Maybe you meant '==' or ':=' instead of '='?".to_owned(), + start, + index + 1, + )); + } + index += 1; + } + _ => index += 1, + } + } + None +} + +fn invalid_collection_assignment_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0usize; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' | b'[' | b'{' => { + let close_byte = match bytes[index] { + b'(' => b')', + b'[' => b']', + _ => b'}', + }; + let Some(close) = matching_delimiter(bytes, index, close_byte) else { + index += 1; + continue; + }; + if !collection_open_is_call(bytes, index) + && let Some(error) = + invalid_collection_assignment_in_slice(source, bytes, index + 1, close) + { + return Some(error); + } + index = close + 1; + } + _ => index += 1, + } + } + None +} + +fn expression_assignment_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0; + let mut paren_arg_starts: Vec<(Option, bool)> = Vec::new(); + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => { + index = skip_quoted_string(bytes, index); + } + _ if starts_identifier(bytes, index, b"lambda") => { + let params_start = index + 6; + if let Some(params_end) = find_lambda_parameter_end(bytes, params_start) { + index = params_end + 1; + } else { + index = params_start; + } + } + b'(' => { + let in_call_context = opening_paren_is_call(bytes, index) + || paren_arg_starts.last().is_some_and(|(_, in_call)| *in_call); + paren_arg_starts.push(( + (!is_function_parameter_list(bytes, index)).then_some(index + 1), + in_call_context, + )); + index += 1; + } + b')' => { + paren_arg_starts.pop(); + index += 1; + } + b',' => { + if let Some((start, _)) = paren_arg_starts.last_mut() + && start.is_some() + { + *start = Some(index + 1); + } + index += 1; + } + b'=' if is_plain_assignment_operator(bytes, index) => { + if let Some((Some(start), true)) = paren_arg_starts.last().copied() + && !is_simple_keyword_name(bytes, start, index) + { + let mut expr_start = start; + while matches!(bytes.get(expr_start), Some(b' ' | b'\t' | b'\x0c')) { + expr_start += 1; + } + return Some(( + "expression cannot contain assignment, perhaps you meant \"==\"?" + .to_owned(), + expr_start, + index, + )); + } + index += 1; + } + _ => index += 1, + } + } + None +} + +fn invalid_named_expression_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0; + while index + 1 < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b':' if bytes.get(index + 1) == Some(&b'=') => { + let target_start = named_expression_target_start(bytes, index); + let target_end = trim_end_horizontal_whitespace(bytes, target_start, index); + if target_start < target_end + && let Some((expr_name, start, end, is_name)) = + expression_name_and_range(&source[target_start..target_end]) + && !is_name + { + return Some(( + format!("cannot use assignment expressions with {expr_name}"), + target_start + start, + target_start + end, + )); + } + index += 2; + } + _ => index += 1, + } + } + None +} + +#[derive(Clone, Copy)] +struct AssignmentContext { + start: usize, + call: bool, +} + +fn invalid_plain_assignment_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut stack: Vec = Vec::new(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' | b'[' | b'{' => { + stack.push(AssignmentContext { + start: index + 1, + call: bytes[index] == b'(' && opening_paren_is_call(bytes, index), + }); + index += 1; + } + b')' | b']' | b'}' => { + stack.pop(); + index += 1; + } + b',' => { + if let Some(context) = stack.last_mut() + && !context.call + { + context.start = index + 1; + } + index += 1; + } + b'=' if is_plain_assignment_operator(bytes, index) => { + if let Some(context) = stack.last().copied() + && !context.call + { + let target_start = skip_horizontal_whitespace(bytes, context.start); + let target_end = trim_end_horizontal_whitespace(bytes, target_start, index); + if target_start < target_end + && let Some((expr_name, start, end, _)) = + expression_name_and_range(&source[target_start..target_end]) + && matches!(expr_name, "expression" | "attribute" | "subscript") + { + return Some(( + format!( + "cannot assign to {expr_name} here. Maybe you meant '==' instead of '='?" + ), + target_start + start, + target_start + end, + )); + } + } + index += 1; + } + _ => index += 1, + } + } + None +} + +fn opening_paren_is_call(bytes: &[u8], paren: usize) -> bool { + let mut cursor = paren; + while cursor > 0 && matches!(bytes[cursor - 1], b' ' | b'\t' | b'\x0c') { + cursor -= 1; + } + cursor > 0 + && (bytes[cursor - 1] >= 0x80 + || is_ascii_identifier_char(bytes[cursor - 1]) + || matches!(bytes[cursor - 1], b')' | b']')) +} + +fn named_expression_target_start(bytes: &[u8], walrus: usize) -> usize { + let mut index = walrus; + let mut level = 0usize; + while index > 0 { + index -= 1; + match bytes[index] { + b')' | b']' | b'}' => level += 1, + b'(' | b'[' | b'{' if level > 0 => level -= 1, + b'(' | b'[' | b'{' if level == 0 => return index + 1, + b',' | b'\n' | b';' if level == 0 => return index + 1, + _ => {} + } + } + 0 +} + +fn trim_end_horizontal_whitespace(bytes: &[u8], start: usize, mut end: usize) -> usize { + while end > start && matches!(bytes[end - 1], b' ' | b'\t' | b'\x0c') { + end -= 1; + } + end +} + +fn annotation_target_error_for_slice( + source: &str, + start: usize, + colon: usize, +) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let (target_start, target_end) = trim_target_range(bytes, start, colon); + if target_start >= target_end { + return None; + } + let target_text = &source[target_start..target_end]; + let Ok(parsed) = parser::parse(target_text, parser::Mode::Expression.into()) else { + return None; + }; + let ast::Mod::Expression(expression) = parsed.into_syntax() else { + return None; + }; + match expression.body.as_ref() { + ast::Expr::Name(_) | ast::Expr::Attribute(_) | ast::Expr::Subscript(_) => None, + ast::Expr::List(_) => Some(( + "only single target (not list) can be annotated".to_owned(), + target_start, + target_end, + )), + ast::Expr::Tuple(_) => Some(( + "only single target (not tuple) can be annotated".to_owned(), + target_start, + target_end, + )), + _ => Some(( + "illegal target for annotation".to_owned(), + target_start, + target_end, + )), + } +} + +fn invalid_annotation_line_start(bytes: &[u8], line_start: usize) -> bool { + let column = skip_horizontal_whitespace(bytes, line_start); + for keyword in [ + b"async".as_slice(), + b"case", + b"class", + b"def", + b"elif", + b"else", + b"except", + b"finally", + b"for", + b"if", + b"match", + b"try", + b"while", + b"with", + ] { + if starts_identifier(bytes, column, keyword) { + return false; + } + } + true +} + +fn invalid_annotation_target_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut line_start = 0usize; + for line in source.split_inclusive('\n') { + let line_end = line_start + line.len(); + if invalid_annotation_line_start(bytes, line_start) + && let Some(colon) = find_byte_at_level(bytes, line_start, line_end, b':') + && bytes.get(colon + 1) != Some(&b'=') + && colon.checked_sub(1).and_then(|before| bytes.get(before)) != Some(&b':') + && let Some(error) = annotation_target_error_for_slice(source, line_start, colon) + { + return Some(error); + } + line_start = line_end; + } + None +} + +fn statement_target_end(bytes: &[u8], mut index: usize) -> usize { + let mut level = 0usize; + while index < bytes.len() { + match bytes[index] { + b'#' if level == 0 => return index, + b'\n' | b';' if level == 0 => return index, + b'\'' | b'"' => { + index = skip_quoted_string(bytes, index); + } + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + _ => index += 1, + } + } + index +} + +fn invalid_assignment_target(expression: &ast::Expr) -> Option<&ast::Expr> { + match expression { + ast::Expr::List(ast::ExprList { elts, .. }) + | ast::Expr::Tuple(ast::ExprTuple { elts, .. }) => { + elts.iter().find_map(invalid_assignment_target) + } + ast::Expr::Starred(ast::ExprStarred { value, .. }) => invalid_assignment_target(value), + ast::Expr::Name(_) | ast::Expr::Subscript(_) | ast::Expr::Attribute(_) => None, + _ => Some(expression), + } +} + +fn invalid_for_target(expression: &ast::Expr) -> Option<&ast::Expr> { + match expression { + ast::Expr::List(ast::ExprList { elts, .. }) + | ast::Expr::Tuple(ast::ExprTuple { elts, .. }) => elts.iter().find_map(invalid_for_target), + ast::Expr::Starred(ast::ExprStarred { value, .. }) => invalid_for_target(value), + ast::Expr::Compare(ast::ExprCompare { left, ops, .. }) => { + if matches!(ops.first(), Some(ast::CmpOp::In)) { + invalid_for_target(left) + } else { + None + } + } + ast::Expr::Name(_) | ast::Expr::Subscript(_) | ast::Expr::Attribute(_) => None, + _ => Some(expression), + } +} + +fn invalid_delete_target(expression: &ast::Expr) -> Option<&ast::Expr> { + match expression { + ast::Expr::List(ast::ExprList { elts, .. }) + | ast::Expr::Tuple(ast::ExprTuple { elts, .. }) => { + elts.iter().find_map(invalid_delete_target) + } + ast::Expr::Name(_) | ast::Expr::Subscript(_) | ast::Expr::Attribute(_) => None, + ast::Expr::Starred(_) => Some(expression), + ast::Expr::Compare(_) => Some(expression), + _ => Some(expression), + } +} + +fn delete_target_expr_name(expression: &ast::Expr) -> &'static str { + match expression { + ast::Expr::Attribute(_) => "attribute", + ast::Expr::Subscript(_) => "subscript", + ast::Expr::Starred(_) => "starred", + ast::Expr::Name(_) => "name", + ast::Expr::List(_) => "list", + ast::Expr::Tuple(_) => "tuple", + ast::Expr::Lambda(_) => "lambda", + ast::Expr::Call(_) => "function call", + ast::Expr::BoolOp(_) | ast::Expr::BinOp(_) | ast::Expr::UnaryOp(_) => "expression", + ast::Expr::Generator(_) => "generator expression", + ast::Expr::Yield(_) | ast::Expr::YieldFrom(_) => "yield expression", + ast::Expr::Await(_) => "await expression", + ast::Expr::ListComp(_) => "list comprehension", + ast::Expr::SetComp(_) => "set comprehension", + ast::Expr::DictComp(_) => "dict comprehension", + ast::Expr::Dict(_) => "dict literal", + ast::Expr::Set(_) => "set display", + ast::Expr::FString(_) => "f-string expression", + ast::Expr::TString(_) => "t-string expression", + ast::Expr::NumberLiteral(_) | ast::Expr::StringLiteral(_) | ast::Expr::BytesLiteral(_) => { + "literal" + } + ast::Expr::Constant(expr) => match &expr.value { + ast::ConstantValue::None => "None", + ast::ConstantValue::Boolean(true) => "True", + ast::ConstantValue::Boolean(false) => "False", + ast::ConstantValue::Ellipsis => "ellipsis", + ast::ConstantValue::Tuple(_) => "tuple", + ast::ConstantValue::Frozenset(_) => "literal", + ast::ConstantValue::Str(_) + | ast::ConstantValue::Bytes(_) + | ast::ConstantValue::Integer(_) + | ast::ConstantValue::Float(_) + | ast::ConstantValue::Complex { .. } => "literal", + }, + ast::Expr::BooleanLiteral(boolean) => { + if boolean.value { + "True" + } else { + "False" + } + } + ast::Expr::NoneLiteral(_) => "None", + ast::Expr::EllipsisLiteral(_) => "ellipsis", + ast::Expr::Compare(_) => "comparison", + ast::Expr::If(_) => "conditional expression", + ast::Expr::Named(_) => "named expression", + ast::Expr::Slice(_) | ast::Expr::IpyEscapeCommand(_) => "expression", + } +} + +fn parenthesized_single_starred_delete_target(bytes: &[u8], start: usize, end: usize) -> bool { + let mut cursor = start; + while matches!(bytes.get(cursor), Some(b' ' | b'\t' | b'\x0c')) { + cursor += 1; + } + if bytes.get(cursor) != Some(&b'(') { + return false; + } + cursor += 1; + while matches!(bytes.get(cursor), Some(b' ' | b'\t' | b'\x0c')) { + cursor += 1; + } + if bytes.get(cursor) != Some(&b'*') { + return false; + } + let mut level = 1usize; + cursor += 1; + while cursor < end { + match bytes[cursor] { + b'\'' | b'"' => { + cursor = skip_quoted_string(bytes, cursor); + } + b'(' | b'[' | b'{' => { + level += 1; + cursor += 1; + } + b')' => { + level = level.saturating_sub(1); + if level == 0 { + cursor += 1; + while matches!(bytes.get(cursor), Some(b' ' | b'\t' | b'\x0c')) { + cursor += 1; + } + return cursor == end; + } + cursor += 1; + } + b',' if level == 1 => return false, + b']' | b'}' => { + level = level.saturating_sub(1); + cursor += 1; + } + _ => cursor += 1, + } + } + false +} + +fn trim_target_range(bytes: &[u8], mut start: usize, mut end: usize) -> (usize, usize) { + while start < end + && matches!( + bytes.get(start), + Some(b' ' | b'\t' | b'\n' | b'\r' | b'\x0c') + ) + { + start += 1; + } + while end > start + && matches!( + bytes.get(end - 1), + Some(b' ' | b'\t' | b'\n' | b'\r' | b'\x0c') + ) + { + end -= 1; + } + (start, end) +} + +fn invalid_assignment_message(name: &'static str, top_level_bitwise: bool) -> String { + if top_level_bitwise { + format!("cannot assign to {name} here. Maybe you meant '==' instead of '='?") + } else { + format!("cannot assign to {name}") + } +} + +fn assignment_target_error_for_slice( + source: &str, + start: usize, + end: usize, +) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let (target_start, target_end) = trim_target_range(bytes, start, end); + if target_start >= target_end { + return None; + } + if starts_identifier(bytes, target_start, b"yield") { + return Some(( + "assignment to yield expression not possible".to_owned(), + target_start, + target_start + 5, + )); + } + let target_text = &source[target_start..target_end]; + let Ok(parsed) = parser::parse(target_text, parser::Mode::Expression.into()) else { + return None; + }; + let ast::Mod::Expression(expression) = parsed.into_syntax() else { + return None; + }; + let invalid_target = invalid_assignment_target(&expression.body)?; + let invalid_start = target_start + invalid_target.range().start().to_usize(); + let invalid_end = target_start + invalid_target.range().end().to_usize(); + if matches!(invalid_target, ast::Expr::FString(_)) { + return Some(("invalid syntax".to_owned(), invalid_start, invalid_end)); + } + let name = delete_target_expr_name(invalid_target); + let top_level = invalid_target.range() == expression.body.range(); + let bitwise_like = matches!( + invalid_target, + ast::Expr::Call(_) + | ast::Expr::BoolOp(_) + | ast::Expr::BinOp(_) + | ast::Expr::UnaryOp(_) + | ast::Expr::NumberLiteral(_) + | ast::Expr::StringLiteral(_) + | ast::Expr::BytesLiteral(_) + | ast::Expr::EllipsisLiteral(_) + ); + Some(( + invalid_assignment_message(name, top_level && bitwise_like), + invalid_start, + invalid_end, + )) +} + +fn star_target_error_for_slice( + source: &str, + start: usize, + end: usize, +) -> Option<(String, usize, usize)> { + invalid_target_error_for_slice(source, start, end, invalid_assignment_target) +} + +fn for_target_error_for_slice( + source: &str, + start: usize, + end: usize, +) -> Option<(String, usize, usize)> { + invalid_target_error_for_slice(source, start, end, invalid_for_target) +} + +fn invalid_target_error_for_slice( + source: &str, + start: usize, + end: usize, + invalid_target: for<'a> fn(&'a ast::Expr) -> Option<&'a ast::Expr>, +) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let (target_start, target_end) = trim_target_range(bytes, start, end); + if target_start >= target_end { + return None; + } + let target_text = &source[target_start..target_end]; + let Ok(parsed) = parser::parse(target_text, parser::Mode::Expression.into()) else { + return None; + }; + let ast::Mod::Expression(expression) = parsed.into_syntax() else { + return None; + }; + let invalid_target = invalid_target(&expression.body)?; + let name = delete_target_expr_name(invalid_target); + let invalid_start = target_start + invalid_target.range().start().to_usize(); + let invalid_end = target_start + invalid_target.range().end().to_usize(); + Some(( + format!("cannot assign to {name}"), + invalid_start, + invalid_end, + )) +} + +fn first_compare_operator_at_level(bytes: &[u8], mut index: usize, end: usize) -> Option { + let mut level = 0usize; + while index < end { + match bytes[index] { + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + b'<' | b'>' if level == 0 => return Some(index), + b'=' if level == 0 && bytes.get(index + 1) == Some(&b'=') => return Some(index), + b'!' if level == 0 && bytes.get(index + 1) == Some(&b'=') => return Some(index), + _ if level == 0 && starts_identifier(bytes, index, b"is") => return Some(index), + _ if level == 0 && starts_identifier(bytes, index, b"not") => return Some(index), + _ => index += 1, + } + } + None +} + +fn non_in_compare_for_target_error( + source: &str, + start: usize, + end: usize, +) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let (target_start, target_end) = trim_target_range(bytes, start, end); + if target_start >= target_end { + return None; + } + let target_text = &source[target_start..target_end]; + let Ok(parsed) = parser::parse(target_text, parser::Mode::Expression.into()) else { + return None; + }; + let ast::Mod::Expression(expression) = parsed.into_syntax() else { + return None; + }; + let ast::Expr::Compare(ast::ExprCompare { ops, .. }) = expression.body.as_ref() else { + return None; + }; + if matches!(ops.first(), Some(ast::CmpOp::In)) { + return None; + } + let operator = first_compare_operator_at_level(bytes, target_start, target_end)?; + Some(( + "invalid syntax".to_owned(), + operator, + (operator + 1).min(target_end), + )) +} + +fn top_level_plain_assignment_offsets(bytes: &[u8]) -> Vec { + let mut offsets = Vec::new(); + let mut index = 0usize; + let mut level = 0usize; + while index < bytes.len() { + match bytes[index] { + b'#' if level == 0 => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + b'=' if level == 0 && is_plain_assignment_operator(bytes, index) => { + offsets.push(index); + index += 1; + } + _ => index += 1, + } + } + offsets +} + +fn invalid_assignment_target_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let offsets = top_level_plain_assignment_offsets(bytes); + if offsets.is_empty() { + return None; + } + let mut start = 0usize; + for offset in offsets { + if let Some(error) = assignment_target_error_for_slice(source, start, offset) { + return Some(error); + } + start = offset + 1; + } + None +} + +fn top_level_augassign_offset(bytes: &[u8]) -> Option<(usize, usize)> { + let mut index = 0usize; + let mut level = 0usize; + while index < bytes.len() { + match bytes[index] { + b'#' if level == 0 => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + b'+' | b'-' | b'*' | b'@' | b'/' | b'%' | b'&' | b'|' | b'^' + if level == 0 && bytes.get(index + 1) == Some(&b'=') => + { + return Some((index, 2)); + } + b'<' | b'>' + if level == 0 + && bytes.get(index + 1) == Some(&bytes[index]) + && bytes.get(index + 2) == Some(&b'=') => + { + return Some((index, 3)); + } + b'*' if level == 0 + && bytes.get(index + 1) == Some(&b'*') + && bytes.get(index + 2) == Some(&b'=') => + { + return Some((index, 3)); + } + b'/' if level == 0 + && bytes.get(index + 1) == Some(&b'/') + && bytes.get(index + 2) == Some(&b'=') => + { + return Some((index, 3)); + } + _ => index += 1, + } + } + None +} + +fn invalid_augassign_target_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let (operator, _) = top_level_augassign_offset(bytes)?; + let (target_start, target_end) = trim_target_range(bytes, 0, operator); + if target_start >= target_end { + return None; + } + let target_text = &source[target_start..target_end]; + let Ok(parsed) = parser::parse(target_text, parser::Mode::Expression.into()) else { + return None; + }; + let ast::Mod::Expression(expression) = parsed.into_syntax() else { + return None; + }; + let name = delete_target_expr_name(&expression.body); + Some(( + format!("'{name}' is an illegal expression for augmented assignment"), + target_start, + target_end, + )) +} + +fn find_for_target_delimiter(bytes: &[u8], mut index: usize, end: usize) -> Option { + let mut level = 0usize; + while index < end { + match bytes[index] { + b'#' if level == 0 => return None, + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + _ if level == 0 && starts_identifier(bytes, index, b"in") => return Some(index), + b':' if level == 0 => return Some(index), + _ => index += 1, + } + } + None +} + +fn invalid_for_target_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0usize; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + _ if starts_identifier(bytes, index, b"for") => { + let target_start = skip_horizontal_whitespace(bytes, index + 3); + let line_end = source[index..] + .find('\n') + .map_or(bytes.len(), |newline| index + newline); + if let Some(target_end) = find_for_target_delimiter(bytes, target_start, line_end) { + if let Some(error) = + for_target_error_for_slice(source, target_start, target_end) + { + return Some(error); + } + if let Some(error) = + non_in_compare_for_target_error(source, target_start, target_end) + { + return Some(error); + } + } + index = target_start.max(index + 3); + } + _ => index += 1, + } + } + None +} + +fn find_with_target_delimiter(bytes: &[u8], mut index: usize, end: usize) -> Option { + let mut level = 0usize; + while index < end { + match bytes[index] { + b'#' if level == 0 => return None, + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' if level == 0 => return Some(index), + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + b',' | b':' if level == 0 => return Some(index), + _ => index += 1, + } + } + None +} + +fn invalid_with_target_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut line_start = 0usize; + for line in source.split_inclusive('\n') { + let line_end = line_start + line.len(); + let mut column = skip_horizontal_whitespace(bytes, line_start); + if starts_identifier(bytes, column, b"async") { + column = skip_horizontal_whitespace(bytes, column + 5); + } + if !starts_identifier(bytes, column, b"with") { + line_start = line_end; + continue; + } + let mut index = column + 4; + while let Some(as_index) = find_keyword_at_level(bytes, index, line_end, b"as") { + let target_start = skip_horizontal_whitespace(bytes, as_index + 2); + if let Some(target_end) = find_with_target_delimiter(bytes, target_start, line_end) { + if let Some(error) = star_target_error_for_slice(source, target_start, target_end) { + return Some(error); + } + index = target_end.saturating_add(1); + } else { + break; + } + } + line_start = line_end; + } + None +} + +fn find_missing_in_if_keyword(bytes: &[u8], mut index: usize, end: usize) -> Option { + let mut level = 0usize; + while index < end { + match bytes[index] { + b'#' if level == 0 => return None, + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' if level == 0 => return None, + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + _ if level == 0 && starts_identifier(bytes, index, b"in") => return None, + _ if level == 0 && starts_identifier(bytes, index, b"if") => return Some(index), + _ => index += 1, + } + } + None +} + +fn invalid_for_if_clause_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0usize; + let mut level = 0usize; + while index < bytes.len() { + match bytes[index] { + b'#' if level == 0 => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + _ if level > 0 && starts_identifier(bytes, index, b"for") => { + let target_start = skip_horizontal_whitespace(bytes, index + 3); + let line_end = source[index..] + .find('\n') + .map_or(bytes.len(), |newline| index + newline); + if let Some(if_index) = find_missing_in_if_keyword(bytes, target_start, line_end) { + return Some(( + "'in' expected after for-loop variables".to_owned(), + if_index, + (if_index + 2).min(line_end), + )); + } + index = target_start.max(index + 3); + } + _ => index += 1, + } + } + None +} + +fn invalid_delete_target_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => { + index = skip_quoted_string(bytes, index); + } + b'd' if starts_identifier(bytes, index, b"del") => { + let mut target_start = index + 3; + if !matches!(bytes.get(target_start), Some(b' ' | b'\t' | b'\x0c')) { + index += 3; + continue; + } + while matches!(bytes.get(target_start), Some(b' ' | b'\t' | b'\x0c')) { + target_start += 1; + } + let mut target_end = statement_target_end(bytes, target_start); + while target_end > target_start + && matches!(bytes.get(target_end - 1), Some(b' ' | b'\t' | b'\x0c')) + { + target_end -= 1; + } + if target_start >= target_end { + index = target_end.max(index + 3); + continue; + } + if parenthesized_single_starred_delete_target(bytes, target_start, target_end) { + return Some(( + "cannot use starred expression here".to_owned(), + target_start, + target_end, + )); + } + if bytes.get(target_start) == Some(&b'*') { + return Some(( + "cannot delete starred".to_owned(), + target_start, + (target_start + 1).min(target_end), + )); + } + let target_text = &source[target_start..target_end]; + let Ok(parsed) = parser::parse(target_text, parser::Mode::Expression.into()) else { + index = target_end; + continue; + }; + let ast::Mod::Expression(expression) = parsed.into_syntax() else { + index = target_end; + continue; + }; + let Some(invalid_target) = invalid_delete_target(&expression.body) else { + index = target_end; + continue; + }; + let start = target_start + invalid_target.range().start().to_usize(); + let end = target_start + invalid_target.range().end().to_usize(); + if matches!(invalid_target, ast::Expr::FString(_)) { + return Some(("invalid syntax".to_owned(), start, end)); + } + let name = delete_target_expr_name(invalid_target); + return Some((format!("cannot delete {name}"), start, end)); + } + _ => index += 1, + } + } + None +} + +fn skip_horizontal_whitespace(bytes: &[u8], mut index: usize) -> usize { + while matches!(bytes.get(index), Some(b' ' | b'\t' | b'\x0c')) { + index += 1; + } + index +} + +fn find_keyword_at_level( + bytes: &[u8], + mut index: usize, + end: usize, + keyword: &[u8], +) -> Option { + let mut level = 0usize; + while index < end { + match bytes[index] { + b'#' if level == 0 => return None, + b'\'' | b'"' => { + index = skip_quoted_string(bytes, index); + } + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + _ if level == 0 && starts_identifier(bytes, index, keyword) => return Some(index), + _ => index += 1, + } + } + None +} + +fn find_byte_at_level(bytes: &[u8], mut index: usize, end: usize, needle: u8) -> Option { + let mut level = 0usize; + while index < end { + match bytes[index] { + b'#' if level == 0 => return None, + b'\'' | b'"' => { + index = skip_quoted_string(bytes, index); + } + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + byte if level == 0 && byte == needle => return Some(index), + _ => index += 1, + } + } + None +} + +fn expression_name_and_range(source: &str) -> Option<(&'static str, usize, usize, bool)> { + let parsed = parser::parse(source, parser::Mode::Expression.into()).ok()?; + let ast::Mod::Expression(expression) = parsed.into_syntax() else { + return None; + }; + let is_name = matches!(expression.body.as_ref(), ast::Expr::Name(_)); + Some(( + delete_target_expr_name(&expression.body), + expression.body.range().start().to_usize(), + expression.body.range().end().to_usize(), + is_name, + )) +} + +fn invalid_standalone_except_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut line_start = 0usize; + let mut seen_try = false; + for line in source.split_inclusive('\n') { + let line_end = line_start + line.len(); + let column = skip_horizontal_whitespace(bytes, line_start); + if column >= line_end { + line_start = line_end; + continue; + } + if starts_identifier(bytes, column, b"try") { + seen_try = true; + } else if (bytes.get(column..column + 7) == Some(b"except*") + || starts_identifier(bytes, column, b"except")) + && !seen_try + { + let end = if bytes.get(column..column + 7) == Some(b"except*") { + column + 7 + } else { + column + 6 + }; + return Some(("invalid syntax".to_owned(), column, end)); + } + line_start = line_end; + } + None +} + +fn invalid_import_statement_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut line_start = 0usize; + for line in source.split_inclusive('\n') { + let line_end = line_start + line.len(); + let column = skip_horizontal_whitespace(bytes, line_start); + if column < line_end + && starts_identifier(bytes, column, b"import") + && find_keyword_at_level(bytes, column + 6, line_end, b"from").is_some() + { + return Some(( + "Did you mean to use 'from ... import ...' instead?".to_owned(), + column, + column + 6, + )); + } + line_start = line_end; + } + None +} + +fn import_as_target_end(bytes: &[u8], mut index: usize) -> usize { + let mut level = 0usize; + while index < bytes.len() { + match bytes[index] { + b'#' if level == 0 => return index, + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' if level == 0 => return index, + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + b',' | b';' | b'\n' if level == 0 => return index, + _ => index += 1, + } + } + index +} + +fn valid_import_alias_name(bytes: &[u8], mut start: usize, end: usize) -> bool { + start = skip_horizontal_whitespace(bytes, start); + let Some(&first) = bytes.get(start) else { + return false; + }; + if !(first == b'_' || first.is_ascii_alphabetic() || first >= 0x80) { + return false; + } + let mut index = start + 1; + while index < end { + match bytes[index] { + b' ' | b'\t' | b'\x0c' => break, + byte if byte >= 0x80 || is_ascii_identifier_char(byte) => index += 1, + _ => return false, + } + } + let index = skip_horizontal_whitespace(bytes, index); + matches!( + bytes.get(index), + None | Some(b',' | b')' | b';' | b'\n' | b'\r') + ) +} + +fn import_target_error_for_slice( + source: &str, + start: usize, + end: usize, +) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let (target_start, target_end) = trim_target_range(bytes, start, end); + if target_start >= target_end || valid_import_alias_name(bytes, target_start, target_end) { + return None; + } + let parsed = parser::parse( + &source[target_start..target_end], + parser::Mode::Expression.into(), + ) + .ok()?; + let ast::Mod::Expression(expression) = parsed.into_syntax() else { + return None; + }; + let name = delete_target_expr_name(&expression.body); + let start = target_start + expression.body.range().start().to_usize(); + let end = target_start + expression.body.range().end().to_usize(); + Some((format!("cannot use {name} as import target"), start, end)) +} + +fn statement_starts_import(bytes: &[u8], line_start: usize, line_end: usize) -> bool { + let column = skip_horizontal_whitespace(bytes, line_start); + if starts_identifier(bytes, column, b"import") { + return true; + } + starts_identifier(bytes, column, b"from") + && find_keyword_at_level(bytes, column + 4, line_end, b"import").is_some() +} + +fn invalid_import_target_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut line_start = 0usize; + let mut in_parenthesized_from_import = false; + for line in source.split_inclusive('\n') { + let line_end = line_start + line.len(); + let starts_import = statement_starts_import(bytes, line_start, line_end); + if starts_import && bytes[line_start..line_end].contains(&b'(') { + in_parenthesized_from_import = true; + } + if starts_import || in_parenthesized_from_import { + let mut index = line_start; + while index < line_end { + if starts_identifier(bytes, index, b"as") { + let target_start = skip_horizontal_whitespace(bytes, index + 2); + let target_end = import_as_target_end(bytes, target_start); + if let Some(error) = + import_target_error_for_slice(source, target_start, target_end) + { + return Some(error); + } + index = target_end.max(index + 2); + } else { + index += 1; + } + } + } + if in_parenthesized_from_import && bytes[line_start..line_end].contains(&b')') { + in_parenthesized_from_import = false; + } + line_start = line_end; + } + None +} + +fn invalid_except_as_target_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut line_start = 0usize; + let mut seen_try = false; + for line in source.split_inclusive('\n') { + let line_end = line_start + line.len(); + let mut column = skip_horizontal_whitespace(bytes, line_start); + if column >= line_end { + line_start = line_end; + continue; + } + if starts_identifier(bytes, column, b"try") { + seen_try = true; + line_start = line_end; + continue; + } + let (keyword_len, starred) = if bytes.get(column..column + 7) == Some(b"except*") { + (7, true) + } else if starts_identifier(bytes, column, b"except") { + (6, false) + } else { + line_start = line_end; + continue; + }; + if !seen_try { + line_start = line_end; + continue; + } + column += keyword_len; + let Some(as_index) = find_keyword_at_level(bytes, column, line_end, b"as") else { + line_start = line_end; + continue; + }; + let target_start = skip_horizontal_whitespace(bytes, as_index + 2); + let Some(delimiter) = find_byte_at_level(bytes, target_start, line_end, b':') + .into_iter() + .chain(find_byte_at_level(bytes, target_start, line_end, b',')) + .min() + else { + line_start = line_end; + continue; + }; + let mut target_end = delimiter; + while target_end > target_start + && matches!(bytes.get(target_end - 1), Some(b' ' | b'\t' | b'\x0c')) + { + target_end -= 1; + } + let Some((expr_name, start, end, is_name)) = + expression_name_and_range(&source[target_start..target_end]) + else { + line_start = line_end; + continue; + }; + if !is_name { + let statement = if starred { "except*" } else { "except" }; + return Some(( + format!("cannot use {statement} statement with {expr_name}"), + target_start + start, + target_start + end, + )); + } + line_start = line_end; + } + None +} + +fn invalid_match_as_target_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let quoted_ranges = quoted_string_ranges(bytes); + let mut quoted_range = 0usize; + let mut line_start = 0usize; + for line in source.split_inclusive('\n') { + let line_end = line_start + line.len(); + let mut column = skip_horizontal_whitespace(bytes, line_start); + if column >= line_end + || offset_in_ranges("ed_ranges, &mut quoted_range, column) + || !starts_identifier(bytes, column, b"case") + { + line_start = line_end; + continue; + } + column += 4; + let Some(as_index) = find_keyword_at_level(bytes, column, line_end, b"as") else { + line_start = line_end; + continue; + }; + let target_start = skip_horizontal_whitespace(bytes, as_index + 2); + let Some(delimiter) = find_byte_at_level(bytes, target_start, line_end, b':') + .into_iter() + .chain(find_byte_at_level(bytes, target_start, line_end, b',')) + .min() + else { + line_start = line_end; + continue; + }; + let mut target_end = delimiter; + while target_end > target_start + && matches!(bytes.get(target_end - 1), Some(b' ' | b'\t' | b'\x0c')) + { + target_end -= 1; + } + if source[target_start..target_end].trim() == "_" { + return Some(( + "cannot use '_' as a target".to_owned(), + target_start, + target_end, + )); + } + let Some((expr_name, start, end, is_name)) = + expression_name_and_range(&source[target_start..target_end]) + else { + line_start = line_end; + continue; + }; + if !is_name { + if matches!(expr_name, "expression" | "subscript") { + line_start = line_end; + continue; + } + return Some(( + format!("cannot use {expr_name} as pattern target"), + target_start + start, + target_start + end, + )); + } + line_start = line_end; + } + None +} + +fn quoted_string_ranges(bytes: &[u8]) -> Vec<(usize, usize)> { + let mut ranges = Vec::new(); + let mut index = 0usize; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => { + let end = skip_quoted_string(bytes, index); + ranges.push((index, end)); + index = end; + } + _ => index += 1, + } + } + ranges +} + +fn offset_in_ranges(ranges: &[(usize, usize)], range_index: &mut usize, offset: usize) -> bool { + while ranges + .get(*range_index) + .is_some_and(|(_, end)| *end <= offset) + { + *range_index += 1; + } + ranges + .get(*range_index) + .is_some_and(|(start, end)| *start <= offset && offset < *end) +} + +fn invalid_match_mapping_rest_wildcard_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let next_line_end = |line_start: usize| { + line_start + + bytes[line_start..] + .iter() + .position(|byte| *byte == b'\n') + .unwrap_or(bytes.len() - line_start) + }; + let mut index = 0usize; + let mut line_start = 0usize; + let mut line_end = next_line_end(line_start); + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'\n' => { + index += 1; + line_start = index; + line_end = next_line_end(line_start); + } + _ => { + let column = skip_horizontal_whitespace(bytes, line_start); + if index != column + || column >= line_end + || !starts_identifier(bytes, column, b"case") + { + index += 1; + continue; + } + let mut cursor = column + 4; + while cursor < line_end { + match bytes[cursor] { + b'#' => break, + b'\'' | b'"' => cursor = skip_quoted_string(bytes, cursor), + b'{' => { + let rest = next_non_horizontal_whitespace(bytes, cursor + 1); + if bytes.get(rest..rest + 2) == Some(b"**") { + let name_start = next_non_horizontal_whitespace(bytes, rest + 2); + let name_end = identifier_end(bytes, name_start, line_end); + if source.get(name_start..name_end) == Some("_") { + return Some(( + "invalid syntax".to_owned(), + name_start, + name_end, + )); + } + } + cursor += 1; + } + _ => cursor += 1, + } + } + index = line_end; + } + } + } + None +} + +fn invalid_if_expression_statement_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut line_start = 0usize; + for line in source.split_inclusive('\n') { + let line_end = line_start + line.len(); + if let Some(if_index) = find_keyword_at_level(bytes, line_start, line_end, b"if") + && let Some((start, end)) = statement_before_if_expression(bytes, line_start, if_index) + && find_keyword_at_level(bytes, if_index + 2, line_end, b"else").is_some() + { + return Some(( + "expected expression before 'if', but statement is given".to_owned(), + start, + end, + )); + } + if let Some(else_index) = find_keyword_at_level(bytes, line_start, line_end, b"else") + && find_keyword_at_level(bytes, line_start, else_index, b"if").is_some() + && let Some((start, end)) = + statement_after_else_expression(bytes, else_index + 4, line_end) + { + return Some(( + "expected expression after 'else', but statement is given".to_owned(), + start, + end, + )); + } + line_start = line_end; + } + None +} + +fn statement_before_if_expression( + bytes: &[u8], + line_start: usize, + if_index: usize, +) -> Option<(usize, usize)> { + let mut start = if_index; + while start > line_start && matches!(bytes.get(start - 1), Some(b' ' | b'\t' | b'\x0c')) { + start -= 1; + } + while start > line_start + && !matches!( + bytes.get(start - 1), + Some(b'=' | b':' | b',' | b'(' | b'[' | b'{') + ) + { + start -= 1; + } + start = skip_horizontal_whitespace(bytes, start); + for keyword in [b"pass".as_slice(), b"break", b"continue"] { + if starts_identifier(bytes, start, keyword) { + return Some((start, start + keyword.len())); + } + } + None +} + +fn statement_after_else_expression( + bytes: &[u8], + else_end: usize, + line_end: usize, +) -> Option<(usize, usize)> { + let start = skip_horizontal_whitespace(bytes, else_end); + for keyword in [ + b"pass".as_slice(), + b"return", + b"raise", + b"del", + b"yield", + b"assert", + b"break", + b"continue", + b"import", + b"from", + ] { + if starts_identifier(bytes, start, keyword) { + let end = statement_target_end(bytes, start).min(line_end); + return Some((start, end.max(start + keyword.len()))); + } + } + None +} + +fn invalid_else_elif_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut line_start = 0usize; + let mut else_indents: Vec = Vec::new(); + for line in source.split_inclusive('\n') { + let line_end = line_start + line.len(); + let column = skip_horizontal_whitespace(bytes, line_start); + let line_column = column.saturating_sub(line_start); + if column >= line_end { + line_start = line_end; + continue; + } + while else_indents + .last() + .is_some_and(|indent| line_column < *indent) + { + else_indents.pop(); + } + if starts_identifier(bytes, column, b"else") + && find_byte_at_level(bytes, column + 4, line_end, b':').is_some() + { + else_indents.push(line_column); + } else if starts_identifier(bytes, column, b"elif") && else_indents.contains(&line_column) { + return Some(( + "'elif' block follows an 'else' block".to_owned(), + column, + column + 4, + )); + } + line_start = line_end; + } + None +} + +fn mixed_except_handlers_error(source: &str) -> Option<(String, usize, usize)> { + let message = "cannot have both 'except' and 'except*' on the same 'try'".to_owned(); + let mut seen_except = false; + let mut seen_except_star = false; + let mut line_start = 0usize; + for line in source.split_inclusive('\n') { + let bytes = line.as_bytes(); + let mut column = 0usize; + while matches!(bytes.get(column), Some(b' ' | b'\t' | b'\x0c')) { + column += 1; + } + let token_start = line_start + column; + if bytes.get(column..column + 7) == Some(b"except*") { + if seen_except { + return Some((message, token_start, token_start + 7)); + } + seen_except_star = true; + } else if starts_identifier(bytes, column, b"except") { + if seen_except_star { + return Some((message, token_start, token_start + 6)); + } + seen_except = true; + } + line_start += line.len(); + } + None +} + +fn non_printable_character_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => { + index = skip_quoted_string(bytes, index); + } + byte if byte.is_ascii_control() && !matches!(byte, b'\t' | b'\n' | b'\r' | b'\x0c') => { + return Some(( + format!("invalid non-printable character U+{byte:04X}"), + index, + index + 1, + )); + } + byte if byte >= 0x80 => { + let ch = source[index..].chars().next()?; + if ch.is_control() { + return Some(( + format!("invalid non-printable character U+{:04X}", ch as u32), + index, + index + ch.len_utf8(), + )); + } + index += ch.len_utf8(); + } + _ => index += 1, + } + } + None +} + +fn unterminated_string_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0; + let mut line = 1usize; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\n' => { + line += 1; + index += 1; + } + quote @ (b'\'' | b'"') => { + let start = index; + let start_line = line; + let quote_size = if bytes.get(index + 1) == Some("e) + && bytes.get(index + 2) == Some("e) + { + 3 + } else { + 1 + }; + index += quote_size; + let mut has_escaped_quote = false; + let mut closed = false; + while index < bytes.len() { + let c = bytes[index]; + if c == b'\n' { + if quote_size == 1 { + return Some(( + unterminated_string_message(line, false, has_escaped_quote), + start, + start + 1, + )); + } + line += 1; + index += 1; + } else if c == quote { + if quote_size == 3 { + if bytes.get(index + 1) == Some("e) + && bytes.get(index + 2) == Some("e) + { + index += 3; + closed = true; + break; + } + index += 1; + } else { + index += 1; + closed = true; + break; + } + } else if c == b'\\' { + if bytes.get(index + 1) == Some("e) { + has_escaped_quote = true; + } + index = (index + 2).min(bytes.len()); + } else { + index += 1; + } + } + if !closed { + let detected_line = if quote_size == 3 { line } else { start_line }; + return Some(( + unterminated_string_message( + detected_line, + quote_size == 3, + has_escaped_quote, + ), + start, + start + 1, + )); + } + } + _ => index += 1, + } + } + None +} + +fn invalid_interpolated_string_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + quote @ (b'\'' | b'"') => { + let Some(prefix) = interpolated_string_prefix(bytes, index) else { + index = skip_quoted_string(bytes, index); + continue; + }; + if let Some(error) = + single_quoted_format_spec_newline_error(bytes, index, quote, prefix) + { + return Some(error); + } + let Some((content_start, content_end)) = + quoted_string_content_range(bytes, index, quote) + else { + index = skip_quoted_string(bytes, index); + continue; + }; + if let Some(error) = + invalid_replacement_field_error(bytes, content_start, content_end, prefix) + { + return Some(error); + } + index = skip_quoted_string(bytes, index); + } + _ => index += 1, + } + } + None +} + +fn single_quoted_format_spec_newline_error( + bytes: &[u8], + quote_index: usize, + quote: u8, + prefix: &str, +) -> Option<(String, usize, usize)> { + if bytes.get(quote_index + 1) == Some("e) && bytes.get(quote_index + 2) == Some("e) { + return None; + } + + let (content_start, content_end) = quoted_string_content_range(bytes, quote_index, quote)?; + let mut index = content_start; + while index < content_end { + match bytes[index] { + b'{' if bytes.get(index + 1) == Some(&b'{') => index += 2, + b'}' if bytes.get(index + 1) == Some(&b'}') => index += 2, + b'{' => { + let expr_start = skip_ascii_whitespace(bytes, index + 1, content_end); + if let Some(separator) = replacement_field_separator(bytes, expr_start, content_end) + && bytes[separator] == b':' + { + let format_end = + replacement_field_closing_brace(bytes, separator + 1, content_end) + .unwrap_or(content_end); + if bytes[separator + 1..format_end].contains(&b'\n') { + return Some(( + format!( + "{prefix}: newlines are not allowed in format specifiers for single quoted {prefix}s" + ), + quote_index, + quote_index + 1, + )); + } + } + index += 1; + } + _ => index += 1, + } + } + None +} + +fn interpolated_string_prefix(bytes: &[u8], quote: usize) -> Option<&'static str> { + let prev = quote.checked_sub(1).and_then(|index| bytes.get(index))?; + let lower_prev = prev.to_ascii_lowercase(); + let (prefix_start, marker) = if matches!(lower_prev, b'f' | b't') { + if quote >= 2 && bytes[quote - 2].eq_ignore_ascii_case(&b'r') { + (quote - 2, lower_prev) + } else { + (quote - 1, lower_prev) + } + } else if lower_prev == b'r' + && quote >= 2 + && matches!(bytes[quote - 2].to_ascii_lowercase(), b'f' | b't') + { + (quote - 2, bytes[quote - 2].to_ascii_lowercase()) + } else { + return None; + }; + + if prefix_start > 0 && is_ascii_identifier_char(bytes[prefix_start - 1]) { + return None; + } + + Some(if marker == b'f' { + "f-string" + } else { + "t-string" + }) +} + +fn quoted_string_content_range( + bytes: &[u8], + quote_index: usize, + quote: u8, +) -> Option<(usize, usize)> { + let triple = + bytes.get(quote_index + 1) == Some("e) && bytes.get(quote_index + 2) == Some("e); + let quote_len = if triple { 3 } else { 1 }; + let content_start = quote_index + quote_len; + let mut index = content_start; + while index < bytes.len() { + if bytes[index] == b'\\' { + index = (index + 2).min(bytes.len()); + } else if (triple + && bytes.get(index) == Some("e) + && bytes.get(index + 1) == Some("e) + && bytes.get(index + 2) == Some("e)) + || (!triple && bytes[index] == quote) + { + return Some((content_start, index)); + } else { + index += 1; + } + } + None +} + +fn invalid_replacement_field_error( + bytes: &[u8], + start: usize, + end: usize, + prefix: &str, +) -> Option<(String, usize, usize)> { + let mut index = start; + while index < end { + match bytes[index] { + b'{' if bytes.get(index + 1) == Some(&b'{') => index += 2, + b'}' if bytes.get(index + 1) == Some(&b'}') => index += 2, + b'{' => { + if let Some(error) = replacement_field_error(bytes, index, end, prefix) { + return Some(error); + } + index += 1; + } + _ => index += 1, + } + } + None +} + +fn replacement_field_error( + bytes: &[u8], + open: usize, + end: usize, + prefix: &str, +) -> Option<(String, usize, usize)> { + let expr_start = skip_ascii_whitespace(bytes, open + 1, end); + if let Some(backslash) = replacement_field_line_continuation(bytes, expr_start, end) { + return Some(( + "unexpected character after line continuation character".to_owned(), + backslash + 1, + (backslash + 2).min(end), + )); + } + if let Some(quote) = unterminated_string_in_replacement_field(bytes, expr_start, end) { + return Some(( + unterminated_string_message(1, false, false), + quote, + quote + 1, + )); + } + match bytes.get(expr_start).copied() { + Some(marker @ (b'=' | b'!' | b':' | b'}')) => { + return Some(( + format!( + "{prefix}: valid expression required before '{}'", + marker as char + ), + expr_start, + expr_start + 1, + )); + } + Some(_) => {} + None => { + return Some(( + format!("{prefix}: expecting a valid expression after '{{'"), + open, + open + 1, + )); + } + } + + if starts_identifier(bytes, expr_start, b"lambda") { + return Some(( + format!("{prefix}: lambda expressions are not allowed without parentheses"), + expr_start, + expr_start + b"lambda".len(), + )); + } + + if invalid_replacement_expression_start(bytes, expr_start, end) { + return Some(( + format!("{prefix}: expecting a valid expression after '{{'"), + open, + open + 1, + )); + } + + let Some(separator) = replacement_field_separator(bytes, expr_start, end) else { + return Some((format!("{prefix}: expecting '}}'"), open, open + 1)); + }; + + if bytes[separator] == b':' + && replacement_expression_has_parse_error(bytes, expr_start, separator) + { + return Some(("invalid syntax".to_owned(), expr_start, separator)); + } + + match bytes[separator] { + b'=' => invalid_debug_expression_error(bytes, separator, end, prefix), + b'!' => invalid_conversion_error(bytes, separator, end, prefix), + b':' => invalid_format_spec_error(bytes, separator, end, prefix), + b'}' => None, + _ => unreachable!(), + } +} + +fn replacement_field_line_continuation( + bytes: &[u8], + mut index: usize, + end: usize, +) -> Option { + let mut level = 0usize; + while index < end { + match bytes[index] { + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'\\' => return Some(index), + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' if level > 0 => { + level -= 1; + index += 1; + } + b'=' | b'!' | b':' | b'}' if level == 0 => return None, + _ => index += 1, + } + } + None +} + +fn unterminated_string_in_replacement_field( + bytes: &[u8], + mut index: usize, + end: usize, +) -> Option { + while index < end { + match bytes[index] { + quote @ (b'\'' | b'"') => { + let string_end = skip_quoted_string(bytes, index); + if string_end >= end && !bytes[index + 1..end].contains("e) { + return Some(index); + } + index = string_end; + } + _ => index += 1, + } + } + None +} + +fn replacement_expression_has_parse_error(bytes: &[u8], start: usize, end: usize) -> bool { + let Ok(expression) = ::core::str::from_utf8(&bytes[start..end]) else { + return false; + }; + parser::parse_expression(expression).is_err() +} + +fn invalid_replacement_expression_start(bytes: &[u8], index: usize, end: usize) -> bool { + if index >= end { + return true; + } + + if matches!( + bytes[index], + b'.' | b',' | b'*' | b'/' | b'%' | b'&' | b'|' | b'^' | b'<' | b'>' | b'@' + ) { + return true; + } + + if matches!(bytes[index], b'+' | b'-' | b'~') { + let operand = skip_ascii_whitespace(bytes, index + 1, end); + return !bytes.get(operand).is_some_and(|byte| { + *byte >= 0x80 + || *byte == b'_' + || byte.is_ascii_alphabetic() + || byte.is_ascii_digit() + || matches!(*byte, b'\'' | b'"' | b'(' | b'[' | b'{') + }); + } + + [ + b"and".as_slice(), + b"as".as_slice(), + b"else".as_slice(), + b"for".as_slice(), + b"if".as_slice(), + b"in".as_slice(), + b"is".as_slice(), + b"or".as_slice(), + ] + .iter() + .any(|keyword| starts_identifier(bytes, index, keyword)) +} + +fn replacement_field_separator(bytes: &[u8], mut index: usize, end: usize) -> Option { + let mut level = 0usize; + while index < end { + match bytes[index] { + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' if level > 0 => { + level -= 1; + index += 1; + } + b'=' | b'!' | b':' | b'}' if level == 0 => return Some(index), + _ => index += 1, + } + } + None +} + +fn invalid_debug_expression_error( + bytes: &[u8], + equals: usize, + end: usize, + prefix: &str, +) -> Option<(String, usize, usize)> { + let next = equals + 1; + if next >= end || matches!(bytes[next], b'!' | b':' | b'}') { + return None; + } + Some(( + format!("{prefix}: expecting '!', or ':', or '}}'"), + next, + next.saturating_add(1).min(end), + )) +} + +fn invalid_conversion_error( + bytes: &[u8], + bang: usize, + end: usize, + prefix: &str, +) -> Option<(String, usize, usize)> { + let next = bang + 1; + if next >= end { + return Some((format!("{prefix}: expecting '}}'"), bang, bang + 1)); + } + + if bytes[next].is_ascii_whitespace() { + let following = skip_ascii_whitespace(bytes, next, end); + let message = if bytes + .get(following) + .is_some_and(|byte| byte.is_ascii_alphabetic() || *byte == b'_') + { + "conversion type must come right after the exclamation mark" + } else { + "missing conversion character" + }; + return Some((format!("{prefix}: {message}"), next, next + 1)); + } + + if matches!(bytes[next], b':' | b'}') { + return Some(( + format!("{prefix}: missing conversion character"), + next, + next + 1, + )); + } + + if !bytes[next].is_ascii_alphabetic() && bytes[next] != b'_' { + return Some(( + format!("{prefix}: invalid conversion character"), + next, + next + 1, + )); + } + + let conversion_end = identifier_end(bytes, next, end); + let conversion = &bytes[next..conversion_end]; + if !matches!(conversion, b"s" | b"r" | b"a") { + let conversion = ::core::str::from_utf8(conversion).unwrap_or(""); + return Some(( + format!( + "{prefix}: invalid conversion character '{conversion}': expected 's', 'r', or 'a'" + ), + next, + conversion_end, + )); + } + + if conversion_end >= end || matches!(bytes[conversion_end], b':' | b'}') { + return None; + } + + Some(( + format!("{prefix}: expecting ':' or '}}'"), + conversion_end, + conversion_end + 1, + )) +} + +fn invalid_format_spec_error( + bytes: &[u8], + colon: usize, + end: usize, + prefix: &str, +) -> Option<(String, usize, usize)> { + if replacement_field_closing_brace(bytes, colon + 1, end).is_some() { + return None; + } + Some(( + format!("{prefix}: expecting '}}', or format specs"), + colon, + colon + 1, + )) +} + +fn replacement_field_closing_brace(bytes: &[u8], mut index: usize, end: usize) -> Option { + let mut level = 0usize; + while index < end { + match bytes[index] { + b'\'' | b'"' => index = skip_quoted_string(bytes, index), + b'{' => { + level += 1; + index += 1; + } + b'}' if level > 0 => { + level -= 1; + index += 1; + } + b'}' => return Some(index), + _ => index += 1, + } + } + None +} + +fn skip_ascii_whitespace(bytes: &[u8], mut index: usize, end: usize) -> usize { + while index < end && matches!(bytes[index], b' ' | b'\t' | b'\r' | b'\n' | 0x0c) { + index += 1; + } + index +} + +fn string_literal_end_at(bytes: &[u8], index: usize) -> Option { + match bytes.get(index).copied()? { + b'\'' | b'"' => Some(skip_quoted_string(bytes, index)), + first if first.is_ascii_alphabetic() => { + if matches!(bytes.get(index + 1), Some(b'\'' | b'"')) { + return string_literal_prefix(bytes, index, index + 1) + .then(|| skip_quoted_string(bytes, index + 1)); + } + if matches!(bytes.get(index + 2), Some(b'\'' | b'"')) { + return string_literal_prefix(bytes, index, index + 2) + .then(|| skip_quoted_string(bytes, index + 2)); + } + None + } + _ => None, + } +} + +fn string_literal_prefix(bytes: &[u8], start: usize, quote: usize) -> bool { + let prefix = &bytes[start..quote]; + let valid = matches!( + prefix, + b"b" | b"B" + | b"r" + | b"R" + | b"u" + | b"U" + | b"f" + | b"F" + | b"t" + | b"T" + | b"br" + | b"bR" + | b"Br" + | b"BR" + | b"rb" + | b"rB" + | b"Rb" + | b"RB" + | b"fr" + | b"fR" + | b"Fr" + | b"FR" + | b"rf" + | b"rF" + | b"Rf" + | b"RF" + | b"tr" + | b"tR" + | b"Tr" + | b"TR" + | b"rt" + | b"rT" + | b"Rt" + | b"RT" + ); + valid && (start == 0 || !is_ascii_identifier_char(bytes[start - 1])) +} + +fn invalid_expression_error(source: &str) -> Option<(String, usize, usize)> { + invalid_string_expression_error(source).or_else(|| missing_comma_expression_error(source)) +} + +fn invalid_string_expression_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if let Some(first_string_end) = string_literal_end_at(bytes, index) { + let expr_start = skip_ascii_whitespace(bytes, first_string_end, bytes.len()); + if expression_atom_start(bytes, expr_start) + && let Some(expr_end) = adjacent_atom_end(bytes, expr_start) + { + let next = skip_ascii_whitespace(bytes, expr_end, bytes.len()); + if string_literal_end_at(bytes, next).is_some() { + return Some(( + "invalid syntax. Is this intended to be part of the string?".to_owned(), + expr_start, + expr_end, + )); + } + } + index = first_string_end; + } else { + index += 1; + } + } + None +} + +fn missing_comma_expression_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut stack: Vec = Vec::new(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'#' { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } else if let Some(string_end) = string_literal_end_at(bytes, index) { + index = string_end; + } else { + match bytes[index] { + b'(' | b'[' | b'{' => { + if bytes[index] == b'[' && opening_bracket_is_class_type_params(bytes, index) { + let Some(close) = matching_delimiter(bytes, index, b']') else { + index += 1; + continue; + }; + index = close + 1; + continue; + } + stack.push(bytes[index]); + index += 1; + } + b')' | b']' | b'}' => { + stack.pop(); + index += 1; + } + _ if !stack.is_empty() && expression_continuation_keyword(bytes, index) => { + index = identifier_end(bytes, index, bytes.len()); + } + byte if !stack.is_empty() && expression_atom_start_byte(byte) => { + let atom_end = adjacent_atom_end(bytes, index).unwrap_or(index + 1); + let next = skip_ascii_whitespace(bytes, atom_end, bytes.len()); + if next > atom_end + && expression_atom_start(bytes, next) + && !expression_continuation_keyword(bytes, next) + { + return Some(( + "invalid syntax. Perhaps you forgot a comma?".to_owned(), + index, + next + 1, + )); + } + index = atom_end; + } + _ => index += 1, + } + } + } + None +} + +fn opening_bracket_is_class_type_params(bytes: &[u8], bracket: usize) -> bool { + let mut cursor = bracket; + while cursor > 0 && matches!(bytes[cursor - 1], b' ' | b'\t' | b'\x0c') { + cursor -= 1; + } + while cursor > 0 + && bytes + .get(cursor - 1) + .is_some_and(|byte| *byte >= 0x80 || is_ascii_identifier_char(*byte)) + { + cursor -= 1; + } + while cursor > 0 && matches!(bytes[cursor - 1], b' ' | b'\t' | b'\x0c') { + cursor -= 1; + } + cursor >= 5 && starts_identifier(bytes, cursor - 5, b"class") +} + +fn expression_continuation_keyword(bytes: &[u8], index: usize) -> bool { + [ + b"and".as_slice(), + b"else".as_slice(), + b"for".as_slice(), + b"if".as_slice(), + b"in".as_slice(), + b"is".as_slice(), + b"not".as_slice(), + b"or".as_slice(), + ] + .iter() + .any(|keyword| starts_identifier(bytes, index, keyword)) +} + +fn expression_atom_start(bytes: &[u8], index: usize) -> bool { + bytes + .get(index) + .is_some_and(|byte| expression_atom_start_byte(*byte)) + || string_literal_end_at(bytes, index).is_some() +} + +fn expression_atom_start_byte(byte: u8) -> bool { + byte >= 0x80 + || byte == b'_' + || byte.is_ascii_alphabetic() + || byte.is_ascii_digit() + || matches!(byte, b'\'' | b'"' | b'(' | b'[' | b'{') +} + +fn adjacent_atom_end(bytes: &[u8], index: usize) -> Option { + if let Some(string_end) = string_literal_end_at(bytes, index) { + return Some(string_end); + } + match bytes.get(index).copied()? { + byte if byte >= 0x80 || byte == b'_' || byte.is_ascii_alphabetic() => { + Some(identifier_end(bytes, index, bytes.len())) + } + byte if byte.is_ascii_digit() => { + let mut end = index + 1; + while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') { + end += 1; + } + Some(end) + } + b'(' | b'[' | b'{' => Some(index + 1), + _ => None, + } +} + +fn unterminated_string_message( + detected_line: usize, + triple: bool, + has_escaped_quote: bool, +) -> String { + if triple { + format!("unterminated triple-quoted string literal (detected at line {detected_line})") + } else if has_escaped_quote { + format!( + "unterminated string literal (detected at line {detected_line}); perhaps you escaped the end quote?" + ) + } else { + format!("unterminated string literal (detected at line {detected_line})") + } +} + +fn expected_opening_bracket(closing: char) -> char { + match closing { + ')' => '(', + ']' => '[', + '}' => '{', + _ => unreachable!(), + } +} + +fn bracket_syntax_error(source: &str) -> Option<(String, usize, usize, bool)> { + let mut stack: Vec<(char, usize, usize)> = Vec::new(); + let mut in_string = false; + let mut string_quote = '\0'; + let mut triple_quote = false; + let mut escape_next = false; + let mut is_raw_string = false; + let mut line = 1usize; + + let chars: Vec<(usize, char)> = source.char_indices().collect(); + let mut index = 0; + while index < chars.len() { + let (byte_offset, ch) = chars[index]; + + if ch == '\n' { + line += 1; + } + + if escape_next { + escape_next = false; + index += 1; + continue; + } + + if in_string { + if ch == '\\' && !is_raw_string { + escape_next = true; + } else if triple_quote { + if ch == string_quote + && index + 2 < chars.len() + && chars[index + 1].1 == string_quote + && chars[index + 2].1 == string_quote + { + in_string = false; + index += 3; + continue; + } + } else if ch == string_quote { + in_string = false; + } + index += 1; + continue; + } -pub use rustpython_codegen::compile::CompileOpts; -pub use rustpython_compiler_core::{Mode, bytecode::CodeObject}; + if ch == '#' { + while index < chars.len() && chars[index].1 != '\n' { + index += 1; + } + continue; + } -// these modules are out of repository. re-exporting them here for convenience. -pub use ruff_python_ast as ast; -pub use ruff_python_parser as parser; -pub use rustpython_codegen as codegen; -pub use rustpython_compiler_core as core; + if ch == '\'' || ch == '"' { + is_raw_string = false; + for look_back in 1..=2.min(index) { + let prev = chars[index - look_back].1; + if matches!(prev, 'r' | 'R') { + is_raw_string = true; + break; + } + if !matches!(prev, 'b' | 'B' | 'f' | 'F' | 'u' | 'U') { + break; + } + } + string_quote = ch; + if index + 2 < chars.len() && chars[index + 1].1 == ch && chars[index + 2].1 == ch { + triple_quote = true; + in_string = true; + index += 3; + continue; + } + triple_quote = false; + in_string = true; + index += 1; + continue; + } -#[derive(Error, Debug)] -pub enum CompileErrorType { - #[error(transparent)] - Codegen(#[from] codegen::error::CodegenErrorType), - #[error(transparent)] - Parse(#[from] ParseErrorType), + match ch { + '(' | '[' | '{' => stack.push((ch, byte_offset, line)), + ')' | ']' | '}' => { + let expected = expected_opening_bracket(ch); + let Some(&(opening, _, opening_line)) = stack.last() else { + return Some((format!("unmatched '{ch}'"), byte_offset, byte_offset, false)); + }; + if opening == expected { + stack.pop(); + } else { + let suffix = if opening_line != line { + format!(" on line {opening_line}") + } else { + String::new() + }; + return Some(( + format!( + "closing parenthesis '{ch}' does not match opening parenthesis '{opening}'{suffix}" + ), + byte_offset, + byte_offset, + false, + )); + } + } + _ => {} + } + + index += 1; + } + + stack.last().map(|(opening, byte_offset, _)| { + ( + format!("'{opening}' was never closed"), + *byte_offset, + *byte_offset, + true, + ) + }) } -#[derive(Error, Debug)] -pub struct ParseError { - #[source] - pub error: ParseErrorType, - pub raw_location: ruff_text_size::TextRange, - pub location: SourceLocation, - pub end_location: SourceLocation, - pub source_path: String, - /// Set when the error is an unclosed bracket (converted from EOF). - pub is_unclosed_bracket: bool, +fn is_legacy_statement_expression_start(byte: u8) -> bool { + byte >= 0x80 + || byte == b'_' + || byte.is_ascii_alphabetic() + || byte.is_ascii_digit() + || matches!(byte, b'\'' | b'"' | b'{' | b'[') } -impl ::core::fmt::Display for ParseError { - fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { - self.error.fmt(f) +fn legacy_statement_container_has_invalid_attribute(bytes: &[u8], start: usize) -> bool { + let Some(&opening) = bytes.get(start) else { + return false; + }; + if !matches!(opening, b'{' | b'[') { + return false; } -} -#[derive(Error, Debug)] -pub enum CompileError { - #[error(transparent)] - Codegen(#[from] codegen::error::CodegenError), - #[error(transparent)] - Parse(#[from] ParseError), + let mut index = start; + let mut level = 0usize; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\n' | b';' if level == 0 => return false, + b'\'' | b'"' => { + index = skip_quoted_string(bytes, index); + } + b'(' | b'[' | b'{' => { + level += 1; + index += 1; + } + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + if level == 0 { + return false; + } + } + b'.' => { + let mut cursor = index + 1; + while matches!(bytes.get(cursor), Some(b' ' | b'\t' | b'\x0c')) { + cursor += 1; + } + if matches!(bytes.get(cursor), Some(b')' | b']' | b'}')) { + return true; + } + index += 1; + } + _ => index += 1, + } + } + false } -impl CompileError { - #[must_use] - pub fn from_ruff_parse_error(error: parser::ParseError, source_file: &SourceFile) -> Self { - let source_code = source_file.to_source_code(); - let source_text = source_file.source_text(); - - // For EOF errors (unclosed brackets), find the unclosed bracket position - // and adjust both the error location and message - let mut is_unclosed_bracket = false; - let (error_type, location, end_location) = match &error.error { - ParseErrorType::Lexical(LexicalErrorType::Eof) => { - if let Some((bracket_char, bracket_offset)) = find_unclosed_bracket(source_text) { - let bracket_text_size = ruff_text_size::TextSize::new(bracket_offset as u32); - let loc = - source_code.source_location(bracket_text_size, PositionEncoding::Utf8); - let end_loc = SourceLocation { - line: loc.line, - character_offset: loc.character_offset.saturating_add(1), - }; - let msg = format!("'{bracket_char}' was never closed"); - is_unclosed_bracket = true; - (ParseErrorType::OtherError(msg), loc, end_loc) +fn invalid_legacy_statement_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => { + index = skip_quoted_string(bytes, index); + } + b'p' | b'e' => { + let keyword = if starts_identifier(bytes, index, b"print") { + Some("print") + } else if starts_identifier(bytes, index, b"exec") { + Some("exec") } else { - let loc = - source_code.source_location(error.location.start(), PositionEncoding::Utf8); - let end_loc = - source_code.source_location(error.location.end(), PositionEncoding::Utf8); - (error.error, loc, end_loc) - } - } - - ParseErrorType::Lexical(LexicalErrorType::IndentationError) => { - // For IndentationError, point the offset to the end of the line content - // instead of the beginning - let loc = - source_code.source_location(error.location.start(), PositionEncoding::Utf8); - let line_idx = loc.line.to_zero_indexed(); - let line = source_text.split('\n').nth(line_idx).unwrap_or(""); - let line_end_col = line.chars().count() + 1; // 1-indexed, past last char - let end_loc = SourceLocation { - line: loc.line, - character_offset: ruff_source_file::OneIndexed::new(line_end_col) - .unwrap_or(loc.character_offset), + None }; - (error.error, end_loc, end_loc) + let Some(keyword) = keyword else { + index += 1; + continue; + }; + let after_keyword = index + keyword.len(); + if !matches!(bytes.get(after_keyword), Some(b' ' | b'\t' | b'\x0c')) { + index = after_keyword; + continue; + } + let mut cursor = after_keyword; + while matches!(bytes.get(cursor), Some(b' ' | b'\t' | b'\x0c')) { + cursor += 1; + } + if legacy_statement_container_has_invalid_attribute(bytes, cursor) { + index = after_keyword; + continue; + } + if bytes.get(cursor).is_some_and(|byte| { + *byte != b'(' && is_legacy_statement_expression_start(*byte) + }) { + return Some(( + format!( + "Missing parentheses in call to '{keyword}'. Did you mean {keyword}(...)?" + ), + index, + after_keyword, + )); + } + index = after_keyword; } - ParseErrorType::ExpectedToken { expected, found } - if matches!((expected, found), (TokenKind::Comma, TokenKind::Int)) => - { - let loc = - source_code.source_location(error.location.start(), PositionEncoding::Utf8); - let mut end_loc = - source_code.source_location(error.location.end(), PositionEncoding::Utf8); - - // If the error range ends at the start of a new line (column 1), - // adjust it to the end of the previous line - if end_loc.character_offset.get() == 1 && end_loc.line > loc.line { - let prev_line_end = error.location.end() - ruff_text_size::TextSize::from(1); - end_loc = source_code.source_location(prev_line_end, PositionEncoding::Utf8); - end_loc.character_offset = end_loc.character_offset.saturating_add(1); - } - let msg = "invalid syntax. Perhaps you forgot a comma?".into(); - (ParseErrorType::OtherError(msg), loc, end_loc) - } - - ParseErrorType::InvalidAssignmentTarget => { - let loc = - source_code.source_location(error.location.start(), PositionEncoding::Utf8); - let mut end_loc = - source_code.source_location(error.location.end(), PositionEncoding::Utf8); - - // If the error range ends at the start of a new line (column 1), - // adjust it to the end of the previous line - if end_loc.character_offset.get() == 1 && end_loc.line > loc.line { - let prev_line_end = error.location.end() - ruff_text_size::TextSize::from(1); - end_loc = source_code.source_location(prev_line_end, PositionEncoding::Utf8); - end_loc.character_offset = end_loc.character_offset.saturating_add(1); - } - - let expr_str = source_file.source_text().slice(error.location); - - let msg = parser::parse_expression(expr_str).map_or_else( - |_| match expr_str { - "yield" => "assignment to yield expression not possible".into(), - _ => format!("cannot assign to {expr_str}"), - }, - |parsed| match *parsed.syntax().body { - ast::Expr::Call(_) => "cannot assign to function call".into(), - ast::Expr::BinOp(_) => "cannot assign to expression".into(), - ast::Expr::If(_) => "cannot assign to conditional expression".into(), - ast::Expr::Generator(_) => "cannot assign to generator expression".into(), - ast::Expr::StringLiteral(_) - | ast::Expr::BytesLiteral(_) - | ast::Expr::NumberLiteral(_) => { - "cannot assign to literal here. Maybe you meant '==' instead of '='?" - .into() - } - ast::Expr::EllipsisLiteral(_) => { - "cannot assign to ellipsis here. Maybe you meant '==' instead of '='?" - .into() - } - _ => format!("cannot assign to {expr_str}"), - }, - ); + _ => index += 1, + } + } + None +} - (ParseErrorType::OtherError(msg), loc, end_loc) +fn long_decimal_integer_literal_error( + source: &str, + max_str_digits: usize, +) -> Option<(String, usize, usize)> { + if max_str_digits == 0 { + return None; + } + let bytes = source.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } } + b'\'' | b'"' => { + index = skip_quoted_string(bytes, index); + } + byte if byte >= 0x80 || byte == b'_' || byte.is_ascii_alphabetic() => { + index += 1; + while index < bytes.len() + && (bytes[index] >= 0x80 || is_ascii_identifier_char(bytes[index])) + { + index += 1; + } + } + b'.' => { + if bytes + .get(index + 1) + .is_some_and(|byte| byte.is_ascii_digit()) + { + let (_, end) = number_literal_end(bytes, index)?; + index = end.max(index + 1); + } else { + index += 1; + } + } + b'0'..=b'9' => { + if bytes.get(index) == Some(&b'0') + && matches!( + bytes.get(index + 1), + Some(b'x' | b'X' | b'o' | b'O' | b'b' | b'B') + ) + { + let Some((_, end)) = number_literal_end(bytes, index) else { + index += 1; + continue; + }; + index = end.max(index + 1); + continue; + } - ParseErrorType::InvalidNamedAssignmentTarget => { - let loc = - source_code.source_location(error.location.start(), PositionEncoding::Utf8); - let mut end_loc = - source_code.source_location(error.location.end(), PositionEncoding::Utf8); - - // If the error range ends at the start of a new line (column 1), - // adjust it to the end of the previous line - if end_loc.character_offset.get() == 1 && end_loc.line > loc.line { - let prev_line_end = error.location.end() - ruff_text_size::TextSize::from(1); - end_loc = source_code.source_location(prev_line_end, PositionEncoding::Utf8); - end_loc.character_offset = end_loc.character_offset.saturating_add(1); + let start = index; + let mut digits = 0usize; + while index < bytes.len() { + match bytes[index] { + b'0'..=b'9' => { + digits += 1; + index += 1; + } + b'_' if bytes + .get(index + 1) + .is_some_and(|byte| byte.is_ascii_digit()) => + { + index += 1; + } + _ => break, + } } + if matches!(bytes.get(index), Some(b'.' | b'e' | b'E' | b'j' | b'J')) { + let Some((_, end)) = number_literal_end(bytes, start) else { + continue; + }; + index = end.max(index + 1); + continue; + } + if digits > max_str_digits { + return Some(( + format!( + "Exceeds the limit ({max_str_digits} digits) for integer string conversion: value has {digits} digits; use sys.set_int_max_str_digits() to increase the limit - Consider hexadecimal for huge integer literals to avoid decimal conversion limits." + ), + start, + start, + )); + } + } + _ => index += 1, + } + } + None +} - let target = source_file.source_text().slice(error.location); - let msg = format!("cannot use assignment expressions with {target}"); - (ParseErrorType::OtherError(msg), loc, end_loc) +fn invalid_parenthesized_import_star_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => { + index = skip_quoted_string(bytes, index); + } + b'f' if starts_identifier(bytes, index, b"from") => { + let mut cursor = index + 4; + while cursor < bytes.len() && !matches!(bytes[cursor], b'\n' | b';') { + if starts_identifier(bytes, cursor, b"import") { + cursor += 6; + while matches!(bytes.get(cursor), Some(b' ' | b'\t' | b'\r')) { + cursor += 1; + } + if bytes.get(cursor) == Some(&b'(') { + cursor += 1; + while cursor < bytes.len() + && !matches!(bytes[cursor], b')' | b'\n' | b';') + { + if bytes[cursor] == b'*' { + return Some(("invalid syntax".to_owned(), cursor, cursor + 1)); + } + cursor += 1; + } + } + break; + } + cursor += 1; + } + index = cursor; } + _ => index += 1, + } + } + None +} - _ => { - let loc = - source_code.source_location(error.location.start(), PositionEncoding::Utf8); - let mut end_loc = - source_code.source_location(error.location.end(), PositionEncoding::Utf8); +fn too_many_nested_parentheses_error(source: &str) -> Option<(String, usize, usize)> { + const MAXLEVEL: usize = 200; - // If the error range ends at the start of a new line (column 1), - // adjust it to the end of the previous line - if end_loc.character_offset.get() == 1 && end_loc.line > loc.line { - let prev_line_end = error.location.end() - ruff_text_size::TextSize::from(1); - end_loc = source_code.source_location(prev_line_end, PositionEncoding::Utf8); - end_loc.character_offset = end_loc.character_offset.saturating_add(1); + let bytes = source.as_bytes(); + let mut index = 0; + let mut level = 0usize; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => { + index = skip_quoted_string(bytes, index); + } + b'(' | b'[' | b'{' => { + if level >= MAXLEVEL { + return Some(("too many nested parentheses".to_owned(), index, index + 1)); } + level += 1; + index += 1; + } + b')' | b']' | b'}' => { + level = level.saturating_sub(1); + index += 1; + } + _ => index += 1, + } + } + None +} - (error.error, loc, end_loc) +fn invalid_unparenthesized_yield_after_comma_error(source: &str) -> Option<(String, usize, usize)> { + let bytes = source.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } } - }; + b'\'' | b'"' => { + index = skip_quoted_string(bytes, index); + } + b',' => { + let mut cursor = index + 1; + while matches!(bytes.get(cursor), Some(b' ' | b'\t' | b'\x0c')) { + cursor += 1; + } + if starts_identifier(bytes, cursor, b"yield") { + return Some(("invalid syntax".to_owned(), cursor, cursor + 5)); + } + index += 1; + } + _ => index += 1, + } + } + None +} - Self::Parse(ParseError { - error: error_type, - raw_location: error.location, - location, - end_location, - source_path: source_file.name().to_owned(), - is_unclosed_bracket, +fn post_parse_source_error(source_file: &SourceFile, opts: &CompileOpts) -> Option { + too_many_nested_parentheses_error(source_file.source_text()) + .or_else(|| { + long_decimal_integer_literal_error(source_file.source_text(), opts.int_max_str_digits) }) - } + .or_else(|| invalid_call_argument_error(source_file.source_text())) + .or_else(|| invalid_match_mapping_rest_wildcard_error(source_file.source_text())) + .or_else(|| invalid_match_as_target_error(source_file.source_text())) + .or_else(|| invalid_unparenthesized_yield_after_comma_error(source_file.source_text())) + .or_else(|| invalid_parenthesized_import_star_error(source_file.source_text())) + .map(|(message, start, end)| { + CompileError::from_source_error(source_file, message, start, end) + }) +} - #[must_use] - pub const fn location(&self) -> Option { - match self { - Self::Codegen(codegen_error) => codegen_error.location, - Self::Parse(parse_error) => Some(parse_error.location), - } +fn is_compound_stmt(stmt: &ast::Stmt) -> bool { + matches!( + stmt, + ast::Stmt::FunctionDef(_) + | ast::Stmt::ClassDef(_) + | ast::Stmt::If(_) + | ast::Stmt::For(_) + | ast::Stmt::While(_) + | ast::Stmt::With(_) + | ast::Stmt::Try(_) + | ast::Stmt::Match(_) + ) +} + +fn single_mode_body_error(body: &[ast::Stmt], source_file: &SourceFile) -> Option { + let first = body.first()?; + let source_code = source_file.to_source_code(); + let first_start = source_code.source_location(first.range().start(), PositionEncoding::Utf8); + let first_end = source_code.source_location(first.range().end(), PositionEncoding::Utf8); + + if body.iter().skip(1).any(|stmt| { + source_code + .source_location(stmt.range().start(), PositionEncoding::Utf8) + .line + > first_start.line + }) { + return Some(CompileError::from_source_error( + source_file, + "multiple statements found while compiling a single statement".to_owned(), + first.range().end().to_usize(), + first.range().end().to_usize(), + )); } - #[must_use] - pub const fn python_location(&self) -> (usize, usize) { - if let Some(location) = self.location() { - (location.line.get(), location.character_offset.get()) - } else { - (0, 0) - } + if is_compound_stmt(first) + && first_start.line == first_end.line + && !ends_with_line_break(source_file.source_text()) + { + return Some(CompileError::from_source_error( + source_file, + "invalid syntax".to_owned(), + first.range().start().to_usize(), + first.range().start().to_usize(), + )); } + None +} - #[must_use] - pub fn python_end_location(&self) -> Option<(usize, usize)> { - match self { - Self::Codegen(_) => None, - Self::Parse(parse_error) => Some(( - parse_error.end_location.line.get(), - parse_error.end_location.character_offset.get(), - )), +fn single_mode_source_error(ast: &ast::Mod, source_file: &SourceFile) -> Option { + let ast::Mod::Module(module) = ast else { + return None; + }; + single_mode_body_error(&module.body, source_file) +} + +fn ends_with_line_break(source: &str) -> bool { + source.ends_with('\n') || source.ends_with('\r') +} + +fn ends_with_implied_dedent(source: &str) -> bool { + let mut lexer = parser::lexer::lex(source, parser::Mode::Module); + let mut last_kind = TokenKind::EndOfFile; + loop { + let kind = lexer.next_token(); + if kind.is_eof() { + break; } + last_kind = kind; } + matches!(last_kind, TokenKind::Dedent) +} - #[must_use] - pub fn source_path(&self) -> &str { - match self { - Self::Codegen(codegen_error) => &codegen_error.source_path, - Self::Parse(parse_error) => &parse_error.source_path, - } +/// Detect input that only parses because Ruff's lexer closes indentation at EOF. +/// +/// `PyCF_DONT_IMPLY_DEDENT` is used by `codeop` and interactive compile +/// paths to keep an indented block incomplete until a terminating newline is seen. +#[must_use] +pub fn dont_imply_dedent_source_error(source_file: &SourceFile) -> Option { + let source = source_file.source_text(); + if ends_with_line_break(source) || !ends_with_implied_dedent(source) { + return None; } + let eof = source.len(); + Some(CompileError::from_source_error( + source_file, + "incomplete input".to_owned(), + eof, + eof, + )) } /// Find the last unclosed opening bracket in source code. @@ -378,6 +5194,15 @@ fn _compile( source_file: SourceFile, mode: Mode, opts: CompileOpts, +) -> Result { + _compile_with_syntax_warning_handler(source_file, mode, opts, None) +} + +fn _compile_with_syntax_warning_handler<'a>( + source_file: SourceFile, + mode: Mode, + opts: CompileOpts, + syntax_warning_handler: Option<&'a mut compile::SyntaxWarningHandler<'a>>, ) -> Result { let parser_mode = match mode { Mode::Exec => parser::Mode::Module, @@ -386,10 +5211,49 @@ fn _compile( // since these are only different in terms of compilation Mode::Single | Mode::BlockExpr => parser::Mode::Module, }; - let parsed = parser::parse(source_file.source_text(), parser_mode.into()) + let parser_options = parser::ParseOptions::from(parser_mode); + let parsed = parser::parse(source_file.source_text(), parser_options) .map_err(|err| CompileError::from_ruff_parse_error(err, &source_file))?; + if opts.dont_imply_dedent + && matches!(mode, Mode::Single) + && let Some(error) = dont_imply_dedent_source_error(&source_file) + { + return Err(error); + } + if let Some(error) = post_parse_source_error(&source_file, &opts) { + return Err(error); + } let ast = parsed.into_syntax(); - compile::compile_top(ast, source_file, mode, opts).map_err(|e| e.into()) + let single_mode_error = matches!(mode, Mode::Single) + .then(|| single_mode_source_error(&ast, &source_file)) + .flatten(); + let code = compile::compile_top_with_syntax_warning_handler( + ast, + source_file, + mode, + opts, + syntax_warning_handler, + ) + .map_err(CompileError::from)?; + if let Some(error) = single_mode_error { + return Err(error); + } + Ok(code) +} + +pub fn compile_with_syntax_warning_handler<'a>( + source: &str, + mode: Mode, + source_path: &str, + opts: CompileOpts, + syntax_warning_handler: &'a mut compile::SyntaxWarningHandler<'a>, +) -> Result { + let source = source.replace("\r\n", "\n"); + #[cfg(windows)] + let source = source.as_str(); + + let source_file = SourceFileBuilder::new(source_path, source).finish(); + _compile_with_syntax_warning_handler(source_file, mode, opts, Some(syntax_warning_handler)) } pub fn compile_symtable( @@ -409,7 +5273,16 @@ pub fn _compile_symtable( Mode::Exec | Mode::Single | Mode::BlockExpr => { let ast = ruff_python_parser::parse_module(source_file.source_text()) .map_err(|e| CompileError::from_ruff_parse_error(e, &source_file))?; - symboltable::SymbolTable::scan_program(&ast.into_syntax(), source_file.clone()) + if let Some(error) = post_parse_source_error(&source_file, &CompileOpts::default()) { + return Err(error); + } + let ast = ast.into_syntax(); + if matches!(mode, Mode::Single) + && let Some(error) = single_mode_body_error(&ast.body, &source_file) + { + return Err(error); + } + symboltable::SymbolTable::scan_program(&ast, source_file.clone()) } Mode::Eval => { let ast = ruff_python_parser::parse( @@ -417,6 +5290,9 @@ pub fn _compile_symtable( parser::Mode::Expression.into(), ) .map_err(|e| CompileError::from_ruff_parse_error(e, &source_file))?; + if let Some(error) = post_parse_source_error(&source_file, &CompileOpts::default()) { + return Err(error); + } symboltable::SymbolTable::scan_expr( &ast.into_syntax().expect_expression(), source_file.clone(), @@ -437,6 +5313,21 @@ mod tests { dbg!(compiled.expect("compile error")); } + #[test] + fn dont_imply_dedent_requires_terminating_newline() { + let code = "if True:\n pass"; + + let opts = CompileOpts { + dont_imply_dedent: true, + ..CompileOpts::default() + }; + let err = compile(code, Mode::Single, "<>", opts.clone()).expect_err("compile succeeded"); + assert_eq!(err.to_string(), "incomplete input"); + + compile("if True:\n pass\n", Mode::Single, "<>", opts).expect("compile error"); + compile(code, Mode::Single, "<>", CompileOpts::default()).expect("compile error"); + } + #[test] fn compile_phello() { let code = r#" @@ -501,6 +5392,20 @@ def f(): dbg!(compiled.expect("compile error")); } + #[test] + fn compile_call_arg_lambda_default() { + let code = "signature((lambda a=10: a))"; + let compiled = compile(code, Mode::Exec, "<>", CompileOpts::default()); + dbg!(compiled.expect("compile error")); + } + + #[test] + fn compile_generic_function_parameter_default() { + let code = "def __repr__[T: str](self, default: T = '') -> str: pass"; + let compiled = compile(code, Mode::Exec, "<>", CompileOpts::default()); + dbg!(compiled.expect("compile error")); + } + #[test] fn compile_int() { let code = r#" diff --git a/crates/stdlib/src/_opcode.rs b/crates/stdlib/src/_opcode.rs index 2b2a70b5572..e6fc3276c31 100644 --- a/crates/stdlib/src/_opcode.rs +++ b/crates/stdlib/src/_opcode.rs @@ -205,7 +205,7 @@ mod tests { let scope = vm.new_scope_with_builtins(); let code_obj = vm .compile(source.trim(), Mode::Exec, FNAME) - .map_err(|err| vm.new_syntax_error(&err, Some(source))) + .map_err(|err| err.into_pyexception(vm, Some(source))) .unwrap(); scope.globals.set_item("code", code_obj.into(), vm).unwrap(); @@ -228,7 +228,7 @@ output = re.sub(r'(0xdeadbeef', tmp let py_code_obj = vm .compile(py_source, Mode::Exec, FNAME) - .map_err(|err| vm.new_syntax_error(&err, Some(py_source))) + .map_err(|err| err.into_pyexception(vm, Some(py_source))) .unwrap(); vm.run_code_obj(py_code_obj, scope.clone()).unwrap(); diff --git a/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__bare_function_annotations_check_attribute_and_subscript_expressions.snap b/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__bare_function_annotations_check_attribute_and_subscript_expressions.snap index 3274352b920..4d78128b5e6 100644 --- a/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__bare_function_annotations_check_attribute_and_subscript_expressions.snap +++ b/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__bare_function_annotations_check_attribute_and_subscript_expressions.snap @@ -1,5 +1,6 @@ --- source: crates/stdlib/src/_opcode.rs +assertion_line: 318 expression: "dis(r#\"\ndef f(one: int):\n int.new_attr: int\n [list][0].new_attr: [int, str]\n my_lst = [1]\n my_lst[one]: int\n return my_lst\n\"#)" --- 0 RESUME 0 @@ -15,7 +16,7 @@ expression: "dis(r#\"\ndef f(one: int):\n int.new_attr: int\n [list][0].ne Disassembly of ", line 1>: 1 RESUME 0 - LOAD_FAST_BORROW 0 (format) + LOAD_FAST_CHECK 0 (format) LOAD_SMALL_INT 2 COMPARE_OP 132 (>) POP_JUMP_IF_FALSE 3 (to L1) diff --git a/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__const_no_op.snap b/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__const_no_op.snap index 347e58767ae..dc97f6b79c1 100644 --- a/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__const_no_op.snap +++ b/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__const_no_op.snap @@ -1,6 +1,7 @@ --- source: crates/stdlib/src/_opcode.rs -expression: x = not True +assertion_line: 281 +expression: "dis(r#\"\nx = not True\n\"#)" --- 0 RESUME 0 diff --git a/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__constant_true_if_pass_keeps_line_anchor_nop.snap b/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__constant_true_if_pass_keeps_line_anchor_nop.snap index 02e2473501d..3de37ce2009 100644 --- a/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__constant_true_if_pass_keeps_line_anchor_nop.snap +++ b/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__constant_true_if_pass_keeps_line_anchor_nop.snap @@ -1,6 +1,7 @@ --- source: crates/stdlib/src/_opcode.rs -expression: "if 1:\n pass" +assertion_line: 290 +expression: "dis(r#\"\nif 1:\n pass\n\"#)" --- 0 RESUME 0 diff --git a/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__if_ands.snap b/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__if_ands.snap index b5957dda5e5..5c58a2b6b85 100644 --- a/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__if_ands.snap +++ b/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__if_ands.snap @@ -1,6 +1,7 @@ --- source: crates/stdlib/src/_opcode.rs -expression: "if True and False and False:\n pass" +assertion_line: 252 +expression: "dis(r#\"\nif True and False and False:\n pass\n\"#)" --- 0 RESUME 0 diff --git a/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__if_mixed.snap b/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__if_mixed.snap index f8976b8c6e5..6bef04ee143 100644 --- a/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__if_mixed.snap +++ b/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__if_mixed.snap @@ -1,6 +1,7 @@ --- source: crates/stdlib/src/_opcode.rs -expression: "if (True and False) or (False and True):\n pass" +assertion_line: 262 +expression: "dis(r#\"\nif (True and False) or (False and True):\n pass\n\"#)" --- 0 RESUME 0 diff --git a/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__if_ors.snap b/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__if_ors.snap index f8cc3a1f28f..065d893732e 100644 --- a/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__if_ors.snap +++ b/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__if_ors.snap @@ -1,6 +1,7 @@ --- source: crates/stdlib/src/_opcode.rs -expression: "if True or False or False:\n pass" +assertion_line: 242 +expression: "dis(r#\"\nif True or False or False:\n pass\n\"#)" --- 0 RESUME 0 diff --git a/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__nested_bool_op.snap b/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__nested_bool_op.snap index c0e3659487b..00eeb277455 100644 --- a/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__nested_bool_op.snap +++ b/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__nested_bool_op.snap @@ -1,6 +1,7 @@ --- source: crates/stdlib/src/_opcode.rs -expression: x = Test() and False or False +assertion_line: 272 +expression: "dis(r#\"\nx = Test() and False or False\n\"#)" --- 0 RESUME 0 diff --git a/crates/vm/Cargo.toml b/crates/vm/Cargo.toml index 83e41fa1f5f..b3479e017a1 100644 --- a/crates/vm/Cargo.toml +++ b/crates/vm/Cargo.toml @@ -70,6 +70,7 @@ static_assertions = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } thiserror = { workspace = true } +thin-vec = { workspace = true } memchr = { workspace = true } flamer = { workspace = true, optional = true } diff --git a/crates/vm/src/builtins/type.rs b/crates/vm/src/builtins/type.rs index 89321189b09..19fca5cf473 100644 --- a/crates/vm/src/builtins/type.rs +++ b/crates/vm/src/builtins/type.rs @@ -34,7 +34,7 @@ use core::{ ops::Deref, pin::Pin, ptr::NonNull, - sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, Ordering}, + sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, AtomicU64, Ordering}, }; use indexmap::{IndexMap, map::Entry}; use itertools::Itertools; @@ -53,6 +53,7 @@ pub struct PyType { pub heaptype_ext: Option>>, /// Type version tag for inline caching. 0 means unassigned/invalidated. pub tp_version_tag: AtomicU32, + pub abc_tpflags: AtomicU64, } /// Monotonic counter for type version tags. Once it reaches `u32::MAX`, @@ -590,7 +591,9 @@ impl PyType { // Check each base in order and inherit the first collection flag found for base in bases { - let base_flags = base.slots.flags & COLLECTION_FLAGS; + let base_flags = (base.slots.flags + | PyTypeFlags::from_bits_truncate(base.abc_tpflags.load(Ordering::Acquire))) + & COLLECTION_FLAGS; if !base_flags.is_empty() { slots.flags |= base_flags; return; @@ -598,6 +601,58 @@ impl PyType { } } + fn inherited_abc_tpflags(bases: &[PyRef]) -> u64 { + const COLLECTION_FLAGS: PyTypeFlags = PyTypeFlags::from_bits_truncate( + PyTypeFlags::SEQUENCE.bits() | PyTypeFlags::MAPPING.bits(), + ); + for base in bases { + let base_flags = + PyTypeFlags::from_bits_truncate(base.abc_tpflags.load(Ordering::Acquire)) + & COLLECTION_FLAGS; + if !base_flags.is_empty() { + return base_flags.bits(); + } + } + 0 + } + + pub fn has_patma_collection_flag(&self, flag: PyTypeFlags) -> bool { + debug_assert!(matches!(flag, PyTypeFlags::SEQUENCE | PyTypeFlags::MAPPING)); + const COLLECTION_FLAGS: PyTypeFlags = PyTypeFlags::from_bits_truncate( + PyTypeFlags::SEQUENCE.bits() | PyTypeFlags::MAPPING.bits(), + ); + let slot_flags = self.slots.flags & COLLECTION_FLAGS; + if !slot_flags.is_empty() { + return slot_flags.contains(flag); + } + PyTypeFlags::from_bits_truncate(self.abc_tpflags.load(Ordering::Acquire)).contains(flag) + } + + pub fn set_abc_collection_flags_recursive(&self, flags: PyTypeFlags) { + const COLLECTION_FLAGS: PyTypeFlags = PyTypeFlags::from_bits_truncate( + PyTypeFlags::SEQUENCE.bits() | PyTypeFlags::MAPPING.bits(), + ); + let flags = flags & COLLECTION_FLAGS; + if flags.is_empty() { + return; + } + let collection_bits = COLLECTION_FLAGS.bits(); + let flags_bits = flags.bits(); + let _ = self + .abc_tpflags + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |old| { + Some((old & !collection_bits) | flags_bits) + }); + self.modified(); + for weak_ref in self.subclasses.read().iter() { + if let Some(subclass) = weak_ref.upgrade() + && let Some(subclass) = subclass.downcast_ref::() + { + subclass.set_abc_collection_flags_recursive(flags); + } + } + } + /// Check for __abc_tpflags__ and set the appropriate flags /// This checks in attrs and all base classes for __abc_tpflags__ fn check_abc_tpflags( @@ -626,21 +681,19 @@ impl PyType { .to_owned(), ); } - // Don't override flags already inherited from a base class. - if !slots.flags.intersects(COLLECTION_FLAGS) { - slots.flags |= masked; - } + slots.flags.remove(COLLECTION_FLAGS); + slots.flags |= masked; return Ok(()); } - // No __abc_tpflags__ on this class — inheritance already happened - // in inherit_patma_flags, so nothing more to do if those bits are set. + // No __abc_tpflags__ on this class. Inheritance already happened in + // inherit_patma_flags, using base order and including ABC markers. if slots.flags.intersects(COLLECTION_FLAGS) { return Ok(()); } - // Then check in base classes (legacy path for cases that bypass - // inherit_patma_flags). + // Then check in base classes for legacy paths that bypassed + // inherit_patma_flags. for base in bases { if let Some(abc_tpflags_obj) = base.find_name_in_mro(abc_tpflags_name) && let Some(int_obj) = abc_tpflags_obj.downcast_ref::() @@ -654,6 +707,7 @@ impl PyType { .to_owned(), ); } + slots.flags.remove(COLLECTION_FLAGS); slots.flags |= masked; return Ok(()); } @@ -716,6 +770,7 @@ impl PyType { )); } + let inherited_abc_tpflags = Self::inherited_abc_tpflags(&bases); let new_type = PyRef::new_ref( Self { base: Some(base), @@ -726,6 +781,7 @@ impl PyType { slots, heaptype_ext: Some(Pin::new(Box::new(heaptype_ext))), tp_version_tag: AtomicU32::new(0), + abc_tpflags: AtomicU64::new(inherited_abc_tpflags), }, metaclass, None, @@ -775,6 +831,7 @@ impl PyType { slots.flags |= PyTypeFlags::MANAGED_WEAKREF; } + let inherited_abc_tpflags = Self::inherited_abc_tpflags(core::slice::from_ref(&base)); let bases = PyRwLock::new(vec![base.clone()]); let mro = base.mro_map_collect(|x| x.to_owned()); @@ -788,6 +845,7 @@ impl PyType { slots, heaptype_ext: None, tp_version_tag: AtomicU32::new(0), + abc_tpflags: AtomicU64::new(inherited_abc_tpflags), }, metaclass, None, diff --git a/crates/vm/src/eval.rs b/crates/vm/src/eval.rs index 5f52799d0b9..5a3a804688f 100644 --- a/crates/vm/src/eval.rs +++ b/crates/vm/src/eval.rs @@ -6,7 +6,7 @@ pub fn eval(vm: &VirtualMachine, source: &str, scope: Scope, source_path: &str) debug!("Code object: {bytecode:?}"); vm.run_code_obj(bytecode, scope) } - Err(err) => Err(vm.new_syntax_error(&err, Some(source))), + Err(err) => Err(err.into_pyexception(vm, Some(source))), } } diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index df22f4d822d..1ad86a35aed 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -2480,12 +2480,18 @@ pub(super) mod types { let maybe_lineno = zelf .as_object() .get_attr("lineno", vm) - .and_then(|obj| obj.str_utf8(vm)) - .ok(); - let maybe_filename = zelf.as_object().get_attr("filename", vm).ok().map(|obj| { - obj.str(vm) - .unwrap_or_else(|_| vm.ctx.new_str("")) - }); + .ok() + .filter(|obj| !vm.is_none(obj)) + .and_then(|obj| obj.str_utf8(vm).ok()); + let maybe_filename = zelf + .as_object() + .get_attr("filename", vm) + .ok() + .filter(|obj| !vm.is_none(obj)) + .map(|obj| { + obj.str(vm) + .unwrap_or_else(|_| vm.ctx.new_str("")) + }); let msg = match zelf.as_object().get_attr("msg", vm) { Ok(obj) => obj diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index 7050d851743..85b15aaac49 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -3050,9 +3050,17 @@ impl ExecutingFrame<'_> { let subject = self.pop_value(); let nargs_val = nargs.get(arg) as usize; + let Some(cls_type) = cls.downcast_ref::() else { + return Err(vm.new_type_error("called match pattern must be a class")); + }; + // Only the error paths need the class name; compute it lazily so a + // successful match does not take the name lock or allocate. + let type_name = || cls_type.name().to_string(); + // Check if subject is an instance of cls if subject.is_instance(cls.as_ref(), vm)? { let mut extracted = vec![]; + let seen_attrs = PySet::default().into_ref(&vm.ctx); // Get __match_args__ for positional arguments if nargs > 0 if nargs_val > 0 { @@ -3066,12 +3074,7 @@ impl ExecutingFrame<'_> { Ok(tuple) => tuple, Err(match_args) => { // __match_args__ must be a tuple - // Get type names for error message - let type_name = cls - .downcast::() - .ok() - .and_then(|t| t.__name__(vm).to_str().map(str::to_owned)) - .unwrap_or_else(|| String::from("?")); + let type_name = type_name(); let match_args_type_name = match_args.class().__name__(vm); return Err(vm.new_type_error(format!( "{type_name}.__match_args__ must be a tuple (got {match_args_type_name})" @@ -3081,9 +3084,12 @@ impl ExecutingFrame<'_> { // Check if we have enough match args if match_args.len() < nargs_val { + let type_name = type_name(); + let plural = if match_args.len() == 1 { "" } else { "s" }; return Err(vm.new_type_error(format!( - "class pattern accepts at most {} positional sub-patterns ({} given)", + "{type_name}() accepts {} positional sub-pattern{} ({} given)", match_args.len(), + plural, nargs_val ))); } @@ -3094,11 +3100,20 @@ impl ExecutingFrame<'_> { let attr_name_str = match attr_name.downcast_ref::() { Some(s) => s, None => { - return Err(vm.new_type_error( - "__match_args__ elements must be strings", - )); + let attr_type_name = attr_name.class().name(); + return Err(vm.new_type_error(format!( + "__match_args__ elements must be strings (got {attr_type_name})" + ))); } }; + if seen_attrs.__contains__(attr_name.as_object(), vm)? { + let type_name = type_name(); + let attr_repr = attr_name.as_object().repr(vm)?; + return Err(vm.new_type_error(format!( + "{type_name}() got multiple sub-patterns for attribute {attr_repr}" + ))); + } + seen_attrs.add(attr_name.clone(), vm)?; match subject.get_attr(attr_name_str, vm) { Ok(value) => extracted.push(value), Err(e) @@ -3115,9 +3130,8 @@ impl ExecutingFrame<'_> { // No __match_args__, check if this is a type with MATCH_SELF behavior // For built-in types like bool, int, str, list, tuple, dict, etc. // they match the subject itself as the single positional argument - let is_match_self_type = cls - .downcast::() - .is_ok_and(|t| t.slots.flags.contains(PyTypeFlags::_MATCH_SELF)); + let is_match_self_type = + cls_type.slots.flags.contains(PyTypeFlags::_MATCH_SELF); if is_match_self_type { if nargs_val == 1 { @@ -3125,16 +3139,18 @@ impl ExecutingFrame<'_> { extracted.push(subject.clone()); } else if nargs_val > 1 { // Too many positional arguments for MATCH_SELF - return Err(vm.new_type_error( - "class pattern accepts at most 1 positional sub-pattern for MATCH_SELF types", - )); + let type_name = type_name(); + return Err(vm.new_type_error(format!( + "{type_name}() accepts 1 positional sub-pattern ({nargs_val} given)" + ))); } } else { // No __match_args__ and not a MATCH_SELF type if nargs_val > 0 { - return Err(vm.new_type_error( - "class pattern defines no positional sub-patterns (__match_args__ missing)", - )); + let type_name = type_name(); + return Err(vm.new_type_error(format!( + "{type_name}() accepts 0 positional sub-patterns ({nargs_val} given)" + ))); } } } @@ -3143,6 +3159,14 @@ impl ExecutingFrame<'_> { // Extract keyword attributes for name in kwd_attrs { let name_str = name.downcast_ref::().unwrap(); + if seen_attrs.__contains__(name_str.as_object(), vm)? { + let type_name = type_name(); + let attr_repr = name.as_object().repr(vm)?; + return Err(vm.new_type_error(format!( + "{type_name}() got multiple sub-patterns for attribute {attr_repr}" + ))); + } + seen_attrs.add(name.clone(), vm)?; match subject.get_attr(name_str, vm) { Ok(value) => extracted.push(value), Err(e) if e.fast_isinstance(vm.ctx.exceptions.attribute_error) => { @@ -3166,10 +3190,14 @@ impl ExecutingFrame<'_> { let subject = self.nth_value(1); // stack[-2] // Check if subject is a mapping and extract values for keys - if subject.class().slots.flags.contains(PyTypeFlags::MAPPING) { + if subject + .class() + .has_patma_collection_flag(PyTypeFlags::MAPPING) + { let keys = keys_tuple.downcast_ref::().unwrap(); let mut values = Vec::new(); let mut all_match = true; + let seen_keys = PySet::default().into_ref(&vm.ctx); // We use the two argument form of map.get(key, default) for two reasons: // - Atomically check for a key and get its value without error handling. @@ -3186,6 +3214,13 @@ impl ExecutingFrame<'_> { .new_base_object(vm.ctx.types.object_type.to_owned(), None); for key in keys { + if seen_keys.__contains__(key.as_object(), vm)? { + return Err(vm.new_value_error(format!( + "mapping pattern checks duplicate key ({})", + key.as_object().repr(vm)? + ))); + } + seen_keys.add(key.as_object().to_owned(), vm)?; // value = map.get(key, dummy) match get_method.call((key.as_object(), dummy.clone()), vm) { Ok(value) => { @@ -3202,6 +3237,13 @@ impl ExecutingFrame<'_> { } else { // Fallback if .get() method is not available (shouldn't happen for mappings) for key in keys { + if seen_keys.__contains__(key.as_object(), vm)? { + return Err(vm.new_value_error(format!( + "mapping pattern checks duplicate key ({})", + key.as_object().repr(vm)? + ))); + } + seen_keys.add(key.as_object().to_owned(), vm)?; match subject.get_item(key.as_object(), vm) { Ok(value) => values.push(value), Err(e) if e.fast_isinstance(vm.ctx.exceptions.key_error) => { @@ -3231,7 +3273,9 @@ impl ExecutingFrame<'_> { let subject = self.pop_value(); // Check if the type has the MAPPING flag - let is_mapping = subject.class().slots.flags.contains(PyTypeFlags::MAPPING); + let is_mapping = subject + .class() + .has_patma_collection_flag(PyTypeFlags::MAPPING); self.push_value(subject); self.push_value(vm.ctx.new_bool(is_mapping).into()); @@ -3242,7 +3286,9 @@ impl ExecutingFrame<'_> { let subject = self.pop_value(); // Check if the type has the SEQUENCE flag - let is_sequence = subject.class().slots.flags.contains(PyTypeFlags::SEQUENCE); + let is_sequence = subject + .class() + .has_patma_collection_flag(PyTypeFlags::SEQUENCE); self.push_value(subject); self.push_value(vm.ctx.new_bool(is_sequence).into()); @@ -6845,7 +6891,7 @@ impl ExecutingFrame<'_> { } } - fn execute_unpack_ex(&mut self, vm: &VirtualMachine, before: u8, after: u8) -> FrameResult { + fn execute_unpack_ex(&mut self, vm: &VirtualMachine, before: u8, after: u32) -> FrameResult { let (before, after) = (before as usize, after as usize); let value = self.pop_value(); let not_iterable = value.class().slots.iter.load().is_none() diff --git a/crates/vm/src/import.rs b/crates/vm/src/import.rs index 5c418b35d67..f7cc03d991e 100644 --- a/crates/vm/src/import.rs +++ b/crates/vm/src/import.rs @@ -141,7 +141,7 @@ pub fn import_file( file_path, vm.compile_opts(), ) - .map_err(|err| vm.new_syntax_error(&err, Some(content)))?; + .map_err(|err| err.into_pyexception(vm, Some(content)))?; import_code_obj(vm, module_name, code, true) } @@ -154,7 +154,7 @@ pub fn import_source(vm: &VirtualMachine, module_name: &str, content: &str) -> P "", vm.compile_opts(), ) - .map_err(|err| vm.new_syntax_error(&err, Some(content)))?; + .map_err(|err| err.into_pyexception(vm, Some(content)))?; import_code_obj(vm, module_name, code, false) } diff --git a/crates/vm/src/object/core.rs b/crates/vm/src/object/core.rs index 36bc0df0c74..88ca646a4f1 100644 --- a/crates/vm/src/object/core.rs +++ b/crates/vm/src/object/core.rs @@ -2479,6 +2479,7 @@ pub(crate) fn init_type_hierarchy() -> (PyTypeRef, PyTypeRef, PyTypeRef) { slots: PyType::make_slots(), heaptype_ext: None, tp_version_tag: core::sync::atomic::AtomicU32::new(0), + abc_tpflags: core::sync::atomic::AtomicU64::new(0), }; let object_payload = PyType { base: None, @@ -2489,6 +2490,7 @@ pub(crate) fn init_type_hierarchy() -> (PyTypeRef, PyTypeRef, PyTypeRef) { slots: object::PyBaseObject::make_slots(), heaptype_ext: None, tp_version_tag: core::sync::atomic::AtomicU32::new(0), + abc_tpflags: core::sync::atomic::AtomicU64::new(0), }; // Both type_type and object_type are instances of `type`, which has // HAS_DICT and HAS_WEAKREF, so they need both ObjExt and WeakRefList prefixes. @@ -2585,6 +2587,7 @@ pub(crate) fn init_type_hierarchy() -> (PyTypeRef, PyTypeRef, PyTypeRef) { slots: PyWeak::make_slots(), heaptype_ext: None, tp_version_tag: core::sync::atomic::AtomicU32::new(0), + abc_tpflags: core::sync::atomic::AtomicU64::new(0), }; let weakref_type = PyRef::new_ref(weakref_type, type_type.clone(), None); // Static type: untrack from GC (was tracked by new_ref because PyType has HAS_TRAVERSE) diff --git a/crates/vm/src/protocol/callable.rs b/crates/vm/src/protocol/callable.rs index c9afbd5afb0..70e0a54dcec 100644 --- a/crates/vm/src/protocol/callable.rs +++ b/crates/vm/src/protocol/callable.rs @@ -207,7 +207,7 @@ impl VirtualMachine { event: TraceEvent, arg: Option, ) -> PyResult> { - if self.use_tracing.get() { + if self.use_tracing.get() && !self.tracing_is_suppressed() { self._trace_event_inner(event, arg) } else { Ok(None) @@ -247,7 +247,9 @@ impl VirtualMachine { // tracing function itself. if is_trace_event && !self.is_none(&trace_func) { self.use_tracing.set(false); + self.enter_tracing(); let res = trace_func.call(args.clone(), self); + self.leave_tracing(); self.use_tracing.set(true); match res { Ok(result) => { @@ -268,7 +270,9 @@ impl VirtualMachine { if is_profile_event && !self.is_none(&profile_func) { self.use_tracing.set(false); + self.enter_tracing(); let res = profile_func.call(args, self); + self.leave_tracing(); self.use_tracing.set(true); if res.is_err() { *self.profile_func.borrow_mut() = self.ctx.none(); diff --git a/crates/vm/src/stdlib/_abc.rs b/crates/vm/src/stdlib/_abc.rs index 6cdef861253..3b09fefaad3 100644 --- a/crates/vm/src/stdlib/_abc.rs +++ b/crates/vm/src/stdlib/_abc.rs @@ -9,11 +9,11 @@ pub(crate) use _abc::module_def; mod _abc { use crate::{ AsObject, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, - builtins::{PyFrozenSet, PyList, PySet, PyStr, PyTupleRef, PyTypeRef, PyWeak}, + builtins::{PyFrozenSet, PyList, PySet, PyStr, PyTupleRef, PyType, PyTypeRef, PyWeak}, common::lock::PyRwLock, convert::ToPyObject, protocol::PyIterReturn, - types::Constructor, + types::{Constructor, PyTypeFlags}, }; use core::sync::atomic::{AtomicU64, Ordering}; @@ -238,6 +238,23 @@ mod _abc { // Invalidate negative cache increment_invalidation_counter(); + if let Some(cls_type) = cls.downcast_ref::() + && let Some(subclass_type) = subclass.downcast_ref::() + { + // _abc_register propagates Py_TPFLAGS_SEQUENCE/MAPPING + // recursively so MATCH_SEQUENCE/MATCH_MAPPING see ABC registration. + let collection_mask = PyTypeFlags::SEQUENCE | PyTypeFlags::MAPPING; + let collection_flags = (cls_type.slots.flags + | PyTypeFlags::from_bits_truncate(cls_type.abc_tpflags.load(Ordering::Acquire))) + & collection_mask; + if !subclass_type.is(vm.ctx.types.str_type) + && !subclass_type.is(vm.ctx.types.bytes_type) + && !subclass_type.is(vm.ctx.types.bytearray_type) + { + subclass_type.set_abc_collection_flags_recursive(collection_flags); + } + } + Ok(subclass) } diff --git a/crates/vm/src/stdlib/_ast.rs b/crates/vm/src/stdlib/_ast.rs index 6bbbbefe504..f6ce6af86ae 100644 --- a/crates/vm/src/stdlib/_ast.rs +++ b/crates/vm/src/stdlib/_ast.rs @@ -9,13 +9,12 @@ pub(crate) use python::_ast::module_def; mod pyast; use crate::builtins::{PyInt, PyStr}; -use crate::stdlib::_ast::module::{Mod, ModFunctionType, ModInteractive}; +use crate::stdlib::_ast::module::{Mod, ModFunctionType, ModInteractive, ModModule}; use crate::stdlib::_ast::node::BoxedSlice; use crate::{ - AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyRefExact, PyResult, - TryFromObject, VirtualMachine, - builtins::PyIntRef, - builtins::{PyDict, PyModule, PyType, PyUtf8StrRef}, + AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, + VirtualMachine, + builtins::{PyDict, PyList, PyModule, PyTuple, PyType, PyUtf8StrRef}, class::{PyClassImpl, StaticType}, compiler::{CompileError, ParseError}, convert::ToPyObject, @@ -68,36 +67,65 @@ fn singleton_node_to_object(vm: &VirtualMachine, node_type: &'static Py) .into() } +fn is_node_instance( + vm: &VirtualMachine, + object: &PyObjectRef, + node_type: &'static Py, +) -> PyResult { + object.is_instance(node_type.as_object(), vm) +} + +fn is_ast_instance(vm: &VirtualMachine, object: &PyObjectRef) -> PyResult { + let ast_type = NodeAst::make_static_type(); + object.is_instance(ast_type.as_object(), vm) +} + fn get_node_field(vm: &VirtualMachine, obj: &PyObject, field: &'static str, typ: &str) -> PyResult { vm.get_attribute_opt(obj.to_owned(), field)? .ok_or_else(|| vm.new_type_error(format!(r#"required field "{field}" missing from {typ}"#))) } -/// Read a required scalar field, rejecting both attribute absence and `None` value -/// with CPython-compatible error messages. Pairs with `get_node_field_opt` (which -/// returns `Option::None` for the same conditions): both filter `None`, but diverge -/// on whether to raise or return `None`. -/// -/// Errors: -/// - Attribute absent: `TypeError("required field \"X\" missing from Y")` (via `get_node_field`). -/// - Attribute present but `None`: `ValueError("field 'X' is required for Y")`, -/// matching CPython's `Python/ast.c` validator output. -/// -/// Use for required scalar fields where `None` is invalid (e.g. `comprehension.target`, -/// `keyword.value`, `match_case.pattern`). Do NOT use for fields where `None` is -/// legitimate (e.g. `Constant.value` representing the `None` literal — use plain -/// `get_node_field`); or for optional fields (use `get_node_field_opt`). +/// Read a required scalar field. The generated `obj2ast_*` converters only +/// reject a missing required attribute here; if the field exists but is `None`, +/// the nested converter handles it. fn get_node_field_required( vm: &VirtualMachine, obj: &PyObject, field: &'static str, typ: &str, ) -> PyResult { - let value = get_node_field(vm, obj, field, typ)?; + get_node_field(vm, obj, field, typ) +} + +fn get_required_identifier_field( + vm: &VirtualMachine, + source_file: &SourceFile, + obj: &PyObject, + field: &'static str, + typ: &str, +) -> PyResult { + let value = get_node_field_required(vm, obj, field, typ)?; if vm.is_none(&value) { return Err(vm.new_value_error(format!("field '{field}' is required for {typ}"))); } - Ok(value) + Node::ast_from_object(vm, source_file, value) +} + +fn get_required_node_field( + vm: &VirtualMachine, + source_file: &SourceFile, + obj: &PyObject, + field: &'static str, + typ: &str, +) -> PyResult { + let value = get_node_field_required(vm, obj, field, typ)?; + if vm.is_none(&value) { + return Err(vm.new_value_error(format!("field '{field}' is required for {typ}"))); + } + let recursion_context = format!(" while traversing '{typ}' node"); + vm.with_recursion(&recursion_context, || { + Node::ast_from_object(vm, source_file, value) + }) } fn get_node_field_opt( @@ -110,15 +138,187 @@ fn get_node_field_opt( .filter(|obj| !vm.is_none(obj))) } +fn get_node_list_field( + vm: &VirtualMachine, + source_file: &SourceFile, + obj: &PyObject, + field: &'static str, + typ: &str, +) -> PyResult> { + let value = get_node_list_field_object(vm, obj, field, typ)?; + let list = value.downcast_ref::().unwrap(); + convert_node_list_field(vm, source_file, list, field, typ) +} + +fn get_node_list_field_object( + vm: &VirtualMachine, + obj: &PyObject, + field: &'static str, + typ: &str, +) -> PyResult { + let Some(value) = vm.get_attribute_opt(obj.to_owned(), field)? else { + return Ok(vm.ctx.new_list(Vec::new()).into()); + }; + value.downcast_ref::().ok_or_else(|| { + vm.new_type_error(format!( + r#"{typ} field "{field}" must be a list, not a {}"#, + value.class().name() + )) + })?; + Ok(value) +} + +fn convert_node_list_field( + vm: &VirtualMachine, + source_file: &SourceFile, + list: &PyList, + field: &'static str, + typ: &str, +) -> PyResult> { + let len = list.borrow_vec().len(); + let mut result = Vec::with_capacity(len); + let recursion_context = format!(" while traversing '{typ}' node"); + for i in 0..len { + let item = { + let items = list.borrow_vec(); + if items.len() != len { + return Err(vm.new_runtime_error(format!( + r#"{typ} field "{field}" changed size during iteration"# + ))); + } + items[i].clone() + }; + result.push(vm.with_recursion(&recursion_context, || { + Node::ast_from_object(vm, source_file, item) + })?); + if list.borrow_vec().len() != len { + return Err(vm.new_runtime_error(format!( + r#"{typ} field "{field}" changed size during iteration"# + ))); + } + } + Ok(result) +} + +fn get_node_boxed_slice_field( + vm: &VirtualMachine, + source_file: &SourceFile, + obj: &PyObject, + field: &'static str, + typ: &str, +) -> PyResult> { + Ok(get_node_list_field(vm, source_file, obj, field, typ)?.into_boxed_slice()) +} + +fn runtime_expr_list_from_values( + values: Vec>, +) -> (Option>>, Vec) { + let metadata = runtime_expr_list_metadata(&values); + (metadata, lower_runtime_expr_list(values)) +} + +fn runtime_expr_boxed_slice_from_values( + values: Vec>, +) -> (Option>>, Box<[ast::Expr]>) { + let (metadata, values) = runtime_expr_list_from_values(values); + (metadata, values.into_boxed_slice()) +} + +fn runtime_expr_list_metadata(values: &[Option]) -> Option>> { + values.iter().any(Option::is_none).then(|| values.to_vec()) +} + +fn runtime_stmt_list_from_values( + values: Vec>, +) -> (Option>>, ast::Suite) { + let metadata = runtime_stmt_list_metadata(&values); + (metadata, lower_runtime_stmt_list(values)) +} + +fn runtime_stmt_list_metadata(values: &[Option]) -> Option>> { + values.iter().any(Option::is_none).then(|| values.to_vec()) +} + +fn runtime_except_handler_list_metadata( + values: &[Option], +) -> Option>> { + values.iter().any(Option::is_none).then(|| values.to_vec()) +} + +fn lower_runtime_stmt_list(values: Vec>) -> ast::Suite { + values + .into_iter() + .map(|value| value.unwrap_or_else(runtime_null_stmt_placeholder)) + .collect() +} + +fn lower_runtime_expr_list(values: Vec>) -> Vec { + values + .into_iter() + .map(|value| value.unwrap_or_else(runtime_null_expr_placeholder)) + .collect() +} + +fn runtime_null_stmt_placeholder() -> ast::Stmt { + ast::Stmt::Pass(ast::StmtPass { + range: Default::default(), + node_index: Default::default(), + }) +} + +fn runtime_null_expr_placeholder() -> ast::Expr { + ast::Expr::NoneLiteral(ast::ExprNoneLiteral { + range: Default::default(), + node_index: Default::default(), + }) +} + fn get_int_field( vm: &VirtualMachine, obj: &PyObject, field: &'static str, typ: &str, -) -> PyResult> { - get_node_field(vm, obj, field, typ)? - .downcast_exact(vm) - .map_err(|_| vm.new_type_error(format!(r#"field "{field}" must have integer type"#))) +) -> PyResult { + node_object_to_i32(vm, get_node_field(vm, obj, field, typ)?) +} + +pub(super) fn node_object_to_i32(vm: &VirtualMachine, obj: PyObjectRef) -> PyResult { + if obj.is(&vm.ctx.true_value) { + return Ok(1); + } + if obj.is(&vm.ctx.false_value) { + return Ok(0); + } + let int: PyRef = match obj.clone().try_into_value(vm) { + Ok(int) => int, + Err(_) => { + return Err(vm.new_value_error(format!("invalid integer value: {}", obj.repr(vm)?))); + } + }; + i32::try_from(int.as_bigint()) + .map_err(|_| vm.new_overflow_error("Python int too large to convert to C int")) +} + +pub(super) fn node_object_to_ast_string( + vm: &VirtualMachine, + obj: PyObjectRef, +) -> PyResult { + let cls = obj.class(); + if cls.is(vm.ctx.types.str_type) || cls.is(vm.ctx.types.bytes_type) { + Ok(obj) + } else { + Err(vm.new_type_error("AST string must be of type str or bytes")) + } +} + +fn get_ast_string_field_opt( + vm: &VirtualMachine, + obj: &PyObject, + field: &'static str, +) -> PyResult> { + get_node_field_opt(vm, obj, field)? + .map(|obj| node_object_to_ast_string(vm, obj)) + .transpose() } struct PySourceRange { @@ -188,7 +388,17 @@ fn text_range_to_source_range(source_file: &SourceFile, text_range: TextRange) - let start_row = index.line_index(text_range.start()); let end_row = index.line_index(text_range.end()); let start_col = text_range.start() - index.line_start(start_row, source); - let end_col = text_range.end() - index.line_start(end_row, source); + let (end_row, end_col) = { + let end_col = text_range.end() - index.line_start(end_row, source); + if end_col == TextSize::new(0) && end_row > start_row { + let prev_line_end = text_range.end() - TextSize::new(1); + let row = index.line_index(prev_line_end); + let col = prev_line_end - index.line_start(row, source) + TextSize::new(1); + (row, col) + } else { + (end_row, end_col) + } + }; PySourceRange { start: PySourceLocation { @@ -202,140 +412,1362 @@ fn text_range_to_source_range(source_file: &SourceFile, text_range: TextRange) - } } -fn get_opt_int_field( +fn get_opt_int_field( + vm: &VirtualMachine, + obj: &PyObject, + field: &'static str, +) -> PyResult> { + match get_node_field_opt(vm, obj, field)? { + Some(val) => node_object_to_i32(vm, val).map(Some), + None => Ok(None), + } +} + +fn get_attribute_from_field( + vm: &VirtualMachine, + obj: &PyObjectRef, + field: PyObjectRef, +) -> PyResult> { + let field = field + .downcast::() + .map_err(|_| vm.new_type_error("attribute name must be string"))?; + vm.get_attribute_opt(obj.clone(), &field) +} + +#[derive(Default)] +struct AstSourceExtent { + max_line: usize, + max_col: usize, +} + +impl AstSourceExtent { + fn update_location(&mut self, vm: &VirtualMachine, obj: &PyObject) -> PyResult<()> { + if let Some(lineno) = get_opt_int_field(vm, obj, "lineno")? + && lineno > 0 + { + self.max_line = self.max_line.max(lineno as usize); + } + if let Some(end_lineno) = get_opt_int_field(vm, obj, "end_lineno")? + && end_lineno > 0 + { + self.max_line = self.max_line.max(end_lineno as usize); + } + if let Some(col_offset) = get_opt_int_field(vm, obj, "col_offset")? + && col_offset > 0 + { + self.max_col = self.max_col.max(col_offset as usize); + } + if let Some(end_col_offset) = get_opt_int_field(vm, obj, "end_col_offset")? + && end_col_offset > 0 + { + self.max_col = self.max_col.max(end_col_offset as usize); + } + Ok(()) + } +} + +fn scan_ast_source_extent( + vm: &VirtualMachine, + object: &PyObjectRef, + extent: &mut AstSourceExtent, +) -> PyResult<()> { + if is_ast_instance(vm, object)? { + extent.update_location(vm, object)?; + if let Some(fields) = object.class().get_attr(vm.ctx.intern_str("_fields")) { + let fields = fields.sequence_unchecked(); + let len = fields.length(vm)?; + for i in 0..len { + let field = fields.get_item(i as isize, vm)?; + if let Some(value) = get_attribute_from_field(vm, object, field)? { + vm.with_recursion(" while scanning AST node", || { + scan_ast_source_extent(vm, &value, extent) + })?; + } + } + } + } else if let Some(list) = object.downcast_ref::() { + let items = list.borrow_vec().to_vec(); + for item in items { + vm.with_recursion(" while scanning AST node", || { + scan_ast_source_extent(vm, &item, extent) + })?; + } + } else if let Some(tuple) = object.downcast_ref::() { + for item in tuple.as_slice() { + vm.with_recursion(" while scanning AST node", || { + scan_ast_source_extent(vm, item, extent) + })?; + } + } + Ok(()) +} + +fn copy_ast_passthrough_fields( + vm: &VirtualMachine, + source: &PyObjectRef, + target: &PyObjectRef, +) -> PyResult<()> { + if !is_ast_instance(vm, source)? + || !is_ast_instance(vm, target)? + || !source.is_instance(target.class().as_object(), vm)? + { + return Ok(()); + } + + let fields: &[&str] = + if is_node_instance(vm, target, pyast::NodeStmtFunctionDef::static_type())? + || is_node_instance(vm, target, pyast::NodeStmtAsyncFunctionDef::static_type())? + || is_node_instance(vm, target, pyast::NodeStmtAssign::static_type())? + || is_node_instance(vm, target, pyast::NodeStmtFor::static_type())? + || is_node_instance(vm, target, pyast::NodeStmtAsyncFor::static_type())? + || is_node_instance(vm, target, pyast::NodeStmtWith::static_type())? + || is_node_instance(vm, target, pyast::NodeStmtAsyncWith::static_type())? + || is_node_instance(vm, target, pyast::NodeArg::static_type())? + { + &["type_comment"] + } else if is_node_instance(vm, target, pyast::NodeComprehension::static_type())? { + &["is_async"] + } else if is_node_instance(vm, target, pyast::NodeExprConstant::static_type())? { + &["kind"] + } else if is_node_instance(vm, target, pyast::NodeExprInterpolation::static_type())? { + &["str"] + } else { + &[] + }; + + for field in fields { + if let Some(value) = vm.get_attribute_opt(source.clone(), *field)? { + target.set_attr(*field, value, vm)?; + } + } + + let Some(source_fields) = source.class().get_attr(vm.ctx.intern_str("_fields")) else { + return Ok(()); + }; + let Some(target_fields) = target.class().get_attr(vm.ctx.intern_str("_fields")) else { + return Ok(()); + }; + let source_fields = source_fields.sequence_unchecked(); + let target_fields = target_fields.sequence_unchecked(); + let len = source_fields.length(vm)?; + if len != target_fields.length(vm)? { + return Ok(()); + } + + for i in 0..len { + let source_field = source_fields.get_item(i as isize, vm)?; + let target_field = target_fields.get_item(i as isize, vm)?; + if !vm.bool_eq(&source_field, &target_field)? { + return Ok(()); + } + let Some(source_value) = get_attribute_from_field(vm, source, source_field)? else { + continue; + }; + let Some(target_value) = get_attribute_from_field(vm, target, target_field)? else { + continue; + }; + copy_ast_passthrough_children(vm, &source_value, &target_value)?; + } + + Ok(()) +} + +fn get_ast_location_field( + vm: &VirtualMachine, + object: &PyObjectRef, + field: &'static str, +) -> PyResult> { + Ok(vm + .get_attribute_opt(object.clone(), field)? + .filter(|value| !vm.is_none(value))) +} + +fn ast_start_location_matches( + vm: &VirtualMachine, + source: &PyObjectRef, + target: &PyObjectRef, +) -> PyResult { + for field in ["lineno", "col_offset"] { + let Some(source_value) = get_ast_location_field(vm, source, field)? else { + return Ok(false); + }; + let Some(target_value) = get_ast_location_field(vm, target, field)? else { + return Ok(false); + }; + if !vm.bool_eq(&source_value, &target_value)? { + return Ok(false); + } + } + + for field in ["end_lineno", "end_col_offset"] { + let Some(source_value) = get_ast_location_field(vm, source, field)? else { + continue; + }; + let Some(target_value) = get_ast_location_field(vm, target, field)? else { + continue; + }; + if !vm.bool_eq(&source_value, &target_value)? { + return Ok(false); + } + } + + Ok(true) +} + +fn ast_passthrough_location_candidate_matches( + vm: &VirtualMachine, + source: &PyObjectRef, + target: &PyObjectRef, +) -> PyResult { + Ok(is_ast_instance(vm, source)? + && is_ast_instance(vm, target)? + && source.is_instance(target.class().as_object(), vm)? + && ast_start_location_matches(vm, source, target)?) +} + +fn copy_ast_passthrough_list_items_by_location( + vm: &VirtualMachine, + source_items: &[PyObjectRef], + target_items: &[PyObjectRef], +) -> PyResult<()> { + let mut used_source_items = vec![false; source_items.len()]; + for target_item in target_items { + for (index, source_item) in source_items.iter().enumerate() { + if used_source_items[index] { + continue; + } + if ast_passthrough_location_candidate_matches(vm, source_item, target_item)? { + used_source_items[index] = true; + copy_ast_passthrough_fields(vm, source_item, target_item)?; + break; + } + } + } + Ok(()) +} + +fn copy_ast_passthrough_children( + vm: &VirtualMachine, + source: &PyObjectRef, + target: &PyObjectRef, +) -> PyResult<()> { + if is_ast_instance(vm, source)? && is_ast_instance(vm, target)? { + return copy_ast_passthrough_fields(vm, source, target); + } + + if let (Some(source_list), Some(target_list)) = ( + source.downcast_ref::(), + target.downcast_ref::(), + ) { + let source_items = source_list.borrow_vec().to_vec(); + let target_items = target_list.borrow_vec().to_vec(); + if source_items.len() == target_items.len() { + for (source_item, target_item) in source_items.iter().zip(target_items.iter()) { + copy_ast_passthrough_children(vm, source_item, target_item)?; + } + } else { + copy_ast_passthrough_list_items_by_location(vm, &source_items, &target_items)?; + } + } else if let (Some(source_tuple), Some(target_tuple)) = ( + source.downcast_ref::(), + target.downcast_ref::(), + ) && source_tuple.as_slice().len() == target_tuple.as_slice().len() + { + for (source_item, target_item) in source_tuple + .as_slice() + .iter() + .zip(target_tuple.as_slice().iter()) + { + copy_ast_passthrough_children(vm, source_item, target_item)?; + } + } + + Ok(()) +} + +fn synthetic_source_from_ast_object(vm: &VirtualMachine, object: &PyObjectRef) -> PyResult { + let mut extent = AstSourceExtent::default(); + scan_ast_source_extent(vm, object, &mut extent)?; + if extent.max_line == 0 { + return Ok(String::new()); + } + + let line_len = extent.max_col.saturating_add(1); + let line_width = line_len + .checked_add(1) + .ok_or_else(|| vm.new_memory_error("source location is too large"))?; + let capacity = line_width + .checked_mul(extent.max_line) + .ok_or_else(|| vm.new_memory_error("source location is too large"))?; + let mut source = String::new(); + source + .try_reserve(capacity) + .map_err(|_| vm.new_memory_error("source location is too large"))?; + + for _ in 0..extent.max_line { + source.extend(core::iter::repeat_n(' ', line_len)); + source.push('\n'); + } + Ok(source) +} + +fn range_from_object( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + name: &str, +) -> PyResult { + range_from_object_impl(vm, source_file, object, name, false) +} + +fn type_param_range_from_object( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, +) -> PyResult { + range_from_object_impl(vm, source_file, object, "type_param", true) +} + +fn expr_range_from_object( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, +) -> PyResult { + range_from_object_impl(vm, source_file, object, "expr", false) +} + +fn stmt_range_from_object( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, +) -> PyResult { + range_from_object_impl(vm, source_file, object, "stmt", false) +} + +fn pattern_range_from_object( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, +) -> PyResult { + range_from_object_impl(vm, source_file, object, "pattern", true) +} + +fn excepthandler_range_from_object( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, +) -> PyResult { + range_from_object_impl(vm, source_file, object, "excepthandler", false) +} + +fn excepthandler_range_from_object_unvalidated( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, +) -> PyResult { + let start_row = get_int_field(vm, &object, "lineno", "excepthandler")?; + let start_column = get_int_field(vm, &object, "col_offset", "excepthandler")?; + let end_row = get_opt_int_field(vm, &object, "end_lineno")?.unwrap_or(start_row); + let end_column = get_opt_int_field(vm, &object, "end_col_offset")?.unwrap_or(start_column); + + let location = PySourceRange { + start: PySourceLocation { + row: Row(if start_row > 0 { + OneIndexed::new(start_row as usize).unwrap_or(OneIndexed::MIN) + } else { + OneIndexed::MIN + }), + column: Column(TextSize::new(start_column.max(0) as u32)), + }, + end: PySourceLocation { + row: Row(if end_row > 0 { + OneIndexed::new(end_row as usize).unwrap_or(OneIndexed::MIN) + } else { + OneIndexed::MIN + }), + column: Column(TextSize::new(end_column.max(0) as u32)), + }, + }; + + Ok(source_range_to_text_range_unvalidated( + source_file, + location, + )) +} + +fn range_from_object_impl( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + name: &str, + end_required: bool, +) -> PyResult { + let start_row = get_int_field(vm, &object, "lineno", name)?; + let start_column = get_int_field(vm, &object, "col_offset", name)?; + let end_row = if end_required { + get_int_field(vm, &object, "end_lineno", name)? + } else { + get_opt_int_field(vm, &object, "end_lineno")?.unwrap_or(start_row) + }; + let end_column = if end_required { + get_int_field(vm, &object, "end_col_offset", name)? + } else { + get_opt_int_field(vm, &object, "end_col_offset")?.unwrap_or(start_column) + }; + + // lineno=0 or negative values as a special case (no location info). + // Use default values (line 1, col 0) when lineno <= 0. + let start_row_val = start_row; + let end_row_val = end_row; + let start_col_val = start_column; + let end_col_val = end_column; + + if start_row_val > end_row_val { + return Err(vm.new_value_error(format!( + "AST node line range ({start_row_val}, {end_row_val}) is not valid" + ))); + } + if (start_row_val < 0 && end_row_val != start_row_val) + || (start_col_val < 0 && end_col_val != start_col_val) + { + return Err(vm.new_value_error(format!( + "AST node column range ({start_col_val}, {end_col_val}) for line range ({start_row_val}, {end_row_val}) is not valid" + ))); + } + if start_row_val == end_row_val && start_col_val > end_col_val { + return Err(vm.new_value_error(format!( + "line {start_row_val}, column {start_col_val}-{end_col_val} is not a valid range" + ))); + } + + let location = PySourceRange { + start: PySourceLocation { + row: Row(if start_row_val > 0 { + OneIndexed::new(start_row_val as usize).unwrap_or(OneIndexed::MIN) + } else { + OneIndexed::MIN + }), + column: Column(TextSize::new(start_col_val.max(0) as u32)), + }, + end: PySourceLocation { + row: Row(if end_row_val > 0 { + OneIndexed::new(end_row_val as usize).unwrap_or(OneIndexed::MIN) + } else { + OneIndexed::MIN + }), + column: Column(TextSize::new(end_col_val.max(0) as u32)), + }, + }; + + Ok(source_range_to_text_range(source_file, location)) +} + +fn source_range_to_text_range(source_file: &SourceFile, location: PySourceRange) -> TextRange { + let index = LineIndex::from_source_text(source_file.clone().source_text()); + let source = &source_file.source_text(); + + if source.is_empty() { + return TextRange::new(TextSize::new(0), TextSize::new(0)); + } + + let start = index.offset( + location.start.to_source_location(), + source, + PositionEncoding::Utf8, + ); + let end = index.offset( + location.end.to_source_location(), + source, + PositionEncoding::Utf8, + ); + + TextRange::new(start, end) +} + +fn source_range_to_text_range_unvalidated( + source_file: &SourceFile, + location: PySourceRange, +) -> TextRange { + let index = LineIndex::from_source_text(source_file.clone().source_text()); + let source = &source_file.source_text(); + + if source.is_empty() { + return TextRange::new(TextSize::new(0), TextSize::new(0)); + } + + let start = index.offset( + location.start.to_source_location(), + source, + PositionEncoding::Utf8, + ); + let end = index.offset( + location.end.to_source_location(), + source, + PositionEncoding::Utf8, + ); + + if start <= end { + TextRange::new(start, end) + } else { + TextRange::empty(start) + } +} + +fn node_add_location( + dict: &Py, + range: TextRange, + vm: &VirtualMachine, + source_file: &SourceFile, +) { + let range = text_range_to_source_range(source_file, range); + dict.set_item("lineno", vm.ctx.new_int(range.start.row.get()).into(), vm) + .unwrap(); + dict.set_item( + "col_offset", + vm.ctx.new_int(range.start.column.get()).into(), + vm, + ) + .unwrap(); + dict.set_item("end_lineno", vm.ctx.new_int(range.end.row.get()).into(), vm) + .unwrap(); + dict.set_item( + "end_col_offset", + vm.ctx.new_int(range.end.column.get()).into(), + vm, + ) + .unwrap(); +} + +/// Return the expected Python AST root type class for a compile() mode string. +/// +/// builtin compile() accepts func_type only with PyCF_ONLY_AST. +/// Source-string func_type parsing is handled separately, but Python AST +/// FunctionType still uses the mode check before obj-to-AST conversion. +pub(crate) fn mode_type_and_name(mode: &str) -> Option<(PyRef, &'static str)> { + match mode { + "exec" => Some((pyast::NodeModModule::make_static_type(), "Module")), + "eval" => Some((pyast::NodeModExpression::make_static_type(), "Expression")), + "single" => Some((pyast::NodeModInteractive::make_static_type(), "Interactive")), + "func_type" => Some(( + pyast::NodeModFunctionType::make_static_type(), + "FunctionType", + )), + _ => None, + } +} + +struct TypeCommentLine<'a> { + text: &'a str, + comment_start: Option, +} + +struct TypeCommentSource<'a> { + lines: Vec>, +} + +impl<'a> TypeCommentSource<'a> { + fn new(source: &'a str, tokens: &ast::token::Tokens) -> Self { + let mut comment_offsets = Vec::new(); + for token in tokens { + if matches!(token.kind(), ast::token::TokenKind::Comment) { + comment_offsets.push(token.start().to_usize()); + } + } + + let mut comment_offsets = comment_offsets.into_iter().peekable(); + let mut line_start = 0usize; + let mut lines = Vec::new(); + for line in source.split_inclusive('\n') { + let line_end = line_start + line.len(); + let comment_start = comment_offsets.next_if(|offset| *offset < line_end); + lines.push(TypeCommentLine { + text: line, + comment_start: comment_start.map(|offset| offset - line_start), + }); + line_start = line_end; + } + + Self { lines } + } +} + +fn type_comment_position(line: &TypeCommentLine<'_>) -> Option { + let comment = line.comment_start?; + line.text[comment + 1..] + .trim_start() + .starts_with("type:") + .then_some(comment) +} + +fn type_comment_text<'a>(line: &'a TypeCommentLine<'a>) -> Option<&'a str> { + let comment = line.comment_start?; + let text = line.text.trim_end_matches(['\n', '\r']); + let mut rest = text[comment + 1..].trim_start_matches([' ', '\t']); + rest = rest.strip_prefix("type:")?; + Some(rest.trim_start_matches([' ', '\t'])) +} + +fn type_ignore_tag(comment: &str) -> Option<&str> { + let rest = comment.strip_prefix("ignore")?; + if let Some(next) = rest.as_bytes().first() + && (next.is_ascii_alphanumeric() || !next.is_ascii()) + { + return None; + } + Some(rest) +} + +fn regular_type_comment_text<'a>(line: &'a TypeCommentLine<'a>) -> Option<&'a str> { + let comment = type_comment_text(line)?; + type_ignore_tag(comment).is_none().then_some(comment) +} + +fn type_comment_parse_error( + source_file: &SourceFile, + message: &str, + start: usize, + end: usize, +) -> CompileError { + let range = TextRange::new(TextSize::new(start as u32), TextSize::new(end as u32)); + let source_range = text_range_to_source_range(source_file, range); + ParseError { + error: parser::ParseErrorType::OtherError(message.to_owned()), + raw_location: range, + location: source_range.start.to_source_location(), + end_location: source_range.end.to_source_location(), + source_path: "".to_string(), + is_unclosed_bracket: false, + } + .into() +} + +#[cfg(feature = "codegen")] +fn future_feature_compile_error( + source_file: &SourceFile, + error: codegen::preprocess::FutureFeatureError, +) -> CompileError { + let location = source_file + .to_source_code() + .source_location(error.range.start(), PositionEncoding::Utf8); + let error = match error.kind { + codegen::preprocess::FutureFeatureErrorKind::InvalidFeature(feature) => { + codegen::error::CodegenErrorType::InvalidFutureFeature(feature) + } + codegen::preprocess::FutureFeatureErrorKind::InvalidBraces => { + codegen::error::CodegenErrorType::InvalidFutureBraces + } + }; + codegen::error::CodegenError { + location: Some(location), + error, + source_path: source_file.name().to_owned(), + } + .into() +} + +fn trimmed_line_end(line: &str) -> usize { + line.trim_end_matches(['\n', '\r']).len() +} + +fn line_end_error( + source_file: &SourceFile, + message: &str, + line_start: usize, + line: &str, +) -> CompileError { + let start = line_start + trimmed_line_end(line); + type_comment_parse_error(source_file, message, start, start + 1) +} + +fn point_error_end(source: &str, start: usize) -> usize { + match source.as_bytes().get(start) { + None => start, + Some(_) => start + 1, + } +} + +fn line_after_colon_error( + source_file: &SourceFile, + message: &str, + line_start: usize, + line: &str, +) -> Option { + let code = &line[..trimmed_line_end(line)]; + let colon = code.rfind(':')?; + (!code[colon + 1..].trim().is_empty()) + .then(|| line_end_error(source_file, message, line_start, line)) +} + +fn find_line_containing_offset(source: &str, offset: usize) -> Option<(usize, &str)> { + let mut line_start = 0usize; + for line in source.split_inclusive('\n') { + let line_end = line_start + line.len(); + if offset < line_end { + return Some((line_start, line)); + } + line_start = line_end; + } + (offset == source.len()).then_some((line_start, "")) +} + +fn find_next_nonempty_line_end(source: &str, offset: usize) -> Option { + let (mut line_start, line) = find_line_containing_offset(source, offset)?; + line_start += line.len(); + for line in source[line_start..].split_inclusive('\n') { + if !line.trim().is_empty() { + return Some(line_start + trimmed_line_end(line)); + } + line_start += line.len(); + } + None +} + +fn find_numeric_literal_containing_underscore(code: &str) -> Option<(usize, usize)> { + let bytes = code.as_bytes(); + for idx in 1..bytes.len().saturating_sub(1) { + if bytes[idx] == b'_' && bytes[idx - 1].is_ascii_digit() && bytes[idx + 1].is_ascii_digit() + { + let mut start = idx - 1; + while start > 0 + && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_') + { + start -= 1; + } + let mut end = idx + 2; + while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') { + end += 1; + } + return Some((start, end)); + } + } + None +} + +fn bracket_delta(code: &str) -> i32 { + code.chars().fold(0, |depth, ch| match ch { + '(' | '[' | '{' => depth + 1, + ')' | ']' | '}' => depth - 1, + _ => depth, + }) +} + +fn def_header_complete(code: &str, depth: i32) -> bool { + depth <= 0 && code.trim_end().ends_with(':') +} + +fn is_assignment_stmt_line(code: &str) -> bool { + let bytes = code.as_bytes(); + for (idx, byte) in bytes.iter().enumerate() { + if *byte != b'=' { + continue; + } + let prev = idx.checked_sub(1).and_then(|idx| bytes.get(idx)).copied(); + let next = bytes.get(idx + 1).copied(); + if matches!( + prev, + Some( + b'=' | b'!' + | b'<' + | b'>' + | b':' + | b'+' + | b'-' + | b'*' + | b'/' + | b'%' + | b'&' + | b'|' + | b'^' + ) + ) || matches!(next, Some(b'=')) + { + continue; + } + return true; + } + false +} + +fn line_allows_stmt_type_comment(code: &str) -> bool { + let stripped = code.trim_start(); + (stripped.starts_with("for ") || stripped.starts_with("async for ")) && stripped.ends_with(':') + || (stripped.starts_with("with ") || stripped.starts_with("async with ")) + && stripped.ends_with(':') + || is_assignment_stmt_line(code) +} + +fn invalid_type_comment_syntax_error( + source_file: &SourceFile, + type_comment_source: &TypeCommentSource<'_>, +) -> Option { + let mut line_start = 0usize; + let mut in_def_header = false; + let mut def_depth = 0i32; + let mut pending_func_type_comment = false; + let mut previous_def_had_type_comment = false; + for line in &type_comment_source.lines { + let line_end = line_start + line.text.len(); + let stripped = line.text.trim_start(); + let code_end = type_comment_position(line).unwrap_or(line.text.len()); + let code = line.text[..code_end].trim(); + let has_regular_type_comment = regular_type_comment_text(line).is_some(); + + if let Some(comment) = type_comment_position(line) { + if code == "*" || code == "*," || code.ends_with("*,") { + return Some(type_comment_parse_error( + source_file, + "bare * has associated type comment", + line_start + comment, + line_start + line.text.len(), + )); + } + if previous_def_had_type_comment && code.is_empty() { + return Some(type_comment_parse_error( + source_file, + "Cannot have two type comments on def", + line_start + comment, + line_start + line.text.len(), + )); + } + let allowed = !has_regular_type_comment + || in_def_header + || line_allows_stmt_type_comment(code) + || stripped.starts_with("def ") + || stripped.starts_with("async def ") + || (pending_func_type_comment && code.is_empty()); + if !allowed { + return Some(type_comment_parse_error( + source_file, + "invalid syntax", + line_start + comment, + line_start + line.text.len(), + )); + } + } + + let starts_def = stripped.starts_with("def ") || stripped.starts_with("async def "); + if starts_def && !in_def_header { + def_depth = bracket_delta(code); + let complete = def_header_complete(code, def_depth); + in_def_header = !complete; + previous_def_had_type_comment = complete && has_regular_type_comment; + pending_func_type_comment = complete && !has_regular_type_comment; + } else if in_def_header { + def_depth += bracket_delta(code); + let complete = def_header_complete(code, def_depth); + if complete { + in_def_header = false; + previous_def_had_type_comment = has_regular_type_comment; + pending_func_type_comment = !has_regular_type_comment; + } + } else if (pending_func_type_comment && code.is_empty() && has_regular_type_comment) + || (!stripped.trim().is_empty() && !starts_def && !code.is_empty()) + { + pending_func_type_comment = false; + previous_def_had_type_comment = false; + } + + line_start = line_end; + } + None +} + +fn feature_version_syntax_error( + source: &str, + source_file: &SourceFile, + target_version: ast::PythonVersion, +) -> Option { + let mut line_start = 0usize; + let mut async_def_error = None; + let mut pending_async_def = false; + let mut pending_block_error = None; + for line in source.split_inclusive('\n') { + let code_end = line.find('#').unwrap_or(line.len()); + let code = &line[..code_end]; + let stripped = code.trim_start(); + if pending_async_def && !stripped.trim().is_empty() { + if async_def_error.is_none() { + async_def_error = Some(line_end_error( + source_file, + "Async functions are only supported in Python 3.5 and greater", + line_start, + line, + )); + } + pending_async_def = false; + } + if let Some(message) = pending_block_error.take() { + if !stripped.trim().is_empty() { + return Some(line_end_error(source_file, message, line_start, line)); + } + pending_block_error = Some(message); + } + + if target_version.minor < 5 { + if stripped.starts_with("async def ") && async_def_error.is_none() { + let message = "Async functions are only supported in Python 3.5 and greater"; + if let Some(error) = line_after_colon_error(source_file, message, line_start, line) + { + async_def_error = Some(error); + } else { + pending_async_def = true; + } + } + if stripped.starts_with("async for ") { + let message = "Async for loops are only supported in Python 3.5 and greater"; + if let Some(error) = line_after_colon_error(source_file, message, line_start, line) + { + return Some(error); + } + pending_block_error = Some(message); + } + if stripped.starts_with("async with ") { + let message = "Async with statements are only supported in Python 3.5 and greater"; + if let Some(error) = line_after_colon_error(source_file, message, line_start, line) + { + return Some(error); + } + pending_block_error = Some(message); + } + if stripped.starts_with("await ") { + return Some(line_end_error( + source_file, + "Await expressions are only supported in Python 3.5 and greater", + line_start, + line, + )); + } + if let Some(pos) = code.find('@') + && !stripped.starts_with('@') + { + let is_augassign = code.as_bytes().get(pos + 1) == Some(&b'='); + let (start, end) = if is_augassign { + (line_start + pos, line_start + pos + 2) + } else { + let start = line_start + trimmed_line_end(line); + (start, start + 1) + }; + return Some(type_comment_parse_error( + source_file, + "The '@' operator is only supported in Python 3.5 and greater", + start, + end, + )); + } + } + + if target_version.minor < 6 { + if !stripped.starts_with("async for ") && code.contains(" async for ") { + let start = line_start + trimmed_line_end(line).saturating_sub(1); + return Some(type_comment_parse_error( + source_file, + "Async comprehensions are only supported in Python 3.6 and greater", + start, + point_error_end(source_file.source_text(), start), + )); + } + if let Some((start, end)) = find_numeric_literal_containing_underscore(code) { + return Some(type_comment_parse_error( + source_file, + "Underscores in numeric literals are only supported in Python 3.6 and greater", + line_start + start, + line_start + end, + )); + } + } + + line_start += line.len(); + } + async_def_error +} + +fn ann_assign_feature_error(stmts: &[ast::Stmt], source_file: &SourceFile) -> Option { + for stmt in stmts { + match stmt { + ast::Stmt::AnnAssign(ann) => { + let start = ann.range().end().to_usize(); + return Some(type_comment_parse_error( + source_file, + "Variable annotation syntax is only supported in Python 3.6 and greater", + start, + point_error_end(source_file.source_text(), start), + )); + } + ast::Stmt::FunctionDef(def) => { + if let Some(error) = ann_assign_feature_error(&def.body, source_file) { + return Some(error); + } + } + ast::Stmt::ClassDef(class_def) => { + if let Some(error) = ann_assign_feature_error(&class_def.body, source_file) { + return Some(error); + } + } + ast::Stmt::For(for_stmt) => { + if let Some(error) = ann_assign_feature_error(&for_stmt.body, source_file) + .or_else(|| ann_assign_feature_error(&for_stmt.orelse, source_file)) + { + return Some(error); + } + } + ast::Stmt::While(while_stmt) => { + if let Some(error) = ann_assign_feature_error(&while_stmt.body, source_file) + .or_else(|| ann_assign_feature_error(&while_stmt.orelse, source_file)) + { + return Some(error); + } + } + ast::Stmt::If(if_stmt) => { + if let Some(error) = ann_assign_feature_error(&if_stmt.body, source_file) { + return Some(error); + } + for clause in &if_stmt.elif_else_clauses { + if let Some(error) = ann_assign_feature_error(&clause.body, source_file) { + return Some(error); + } + } + } + ast::Stmt::With(with_stmt) => { + if let Some(error) = ann_assign_feature_error(&with_stmt.body, source_file) { + return Some(error); + } + } + ast::Stmt::Match(match_stmt) => { + for case in &match_stmt.cases { + if let Some(error) = ann_assign_feature_error(&case.body, source_file) { + return Some(error); + } + } + } + ast::Stmt::Try(try_stmt) => { + if let Some(error) = ann_assign_feature_error(&try_stmt.body, source_file) + .or_else(|| ann_assign_feature_error(&try_stmt.orelse, source_file)) + .or_else(|| ann_assign_feature_error(&try_stmt.finalbody, source_file)) + { + return Some(error); + } + for handler in &try_stmt.handlers { + let ast::ExceptHandler::ExceptHandler(handler) = handler; + if let Some(error) = ann_assign_feature_error(&handler.body, source_file) { + return Some(error); + } + } + } + _ => {} + } + } + None +} + +fn feature_version_ast_syntax_error( + top: &ast::Mod, + source_file: &SourceFile, + target_version: ast::PythonVersion, +) -> Option { + if target_version.minor >= 6 { + return None; + } + match top { + ast::Mod::Module(module) => ann_assign_feature_error(&module.body, source_file), + ast::Mod::Expression(_) => None, + } +} + +fn cpython_unsupported_syntax_message( + error: &parser::UnsupportedSyntaxError, +) -> Option<&'static str> { + match error.kind { + parser::UnsupportedSyntaxErrorKind::Match => { + Some("Pattern matching is only supported in Python 3.10 and greater") + } + parser::UnsupportedSyntaxErrorKind::Walrus => { + Some("Assignment expressions are only supported in Python 3.8 and greater") + } + parser::UnsupportedSyntaxErrorKind::ExceptStar => { + Some("Exception groups are only supported in Python 3.11 and greater") + } + parser::UnsupportedSyntaxErrorKind::PositionalOnlyParameter => { + Some("Positional-only parameters are only supported in Python 3.8 and greater") + } + parser::UnsupportedSyntaxErrorKind::TypeParameterList => { + Some("Type parameter lists are only supported in Python 3.12 and greater") + } + parser::UnsupportedSyntaxErrorKind::TypeAliasStatement => { + Some("Type statement is only supported in Python 3.12 and greater") + } + parser::UnsupportedSyntaxErrorKind::TypeParamDefault => { + Some("Type parameter defaults are only supported in Python 3.13 and greater") + } + parser::UnsupportedSyntaxErrorKind::TemplateStrings => { + Some("t-strings are only supported in Python 3.14 and greater") + } + parser::UnsupportedSyntaxErrorKind::UnparenthesizedExceptionTypes => Some( + "except expressions without parentheses are only supported in Python 3.14 and greater", + ), + _ => None, + } +} + +fn cpython_unsupported_syntax_error( + error: &parser::UnsupportedSyntaxError, + source: &str, + source_file: &SourceFile, +) -> Option { + let message = cpython_unsupported_syntax_message(error)?; + let start = match error.kind { + parser::UnsupportedSyntaxErrorKind::Match + | parser::UnsupportedSyntaxErrorKind::ExceptStar + | parser::UnsupportedSyntaxErrorKind::UnparenthesizedExceptionTypes => { + find_next_nonempty_line_end(source, error.range.start().to_usize()) + .unwrap_or_else(|| error.range.end().to_usize()) + } + parser::UnsupportedSyntaxErrorKind::Walrus + | parser::UnsupportedSyntaxErrorKind::PositionalOnlyParameter + | parser::UnsupportedSyntaxErrorKind::TypeParamDefault => error.range.end().to_usize(), + parser::UnsupportedSyntaxErrorKind::TypeAliasStatement => { + let (line_start, line) = + find_line_containing_offset(source, error.range.start().to_usize())?; + line_start + trimmed_line_end(line) + } + parser::UnsupportedSyntaxErrorKind::TypeParameterList => { + let (line_start, line) = + find_line_containing_offset(source, error.range.start().to_usize())?; + let code = &line[..trimmed_line_end(line)]; + line_start + + code + .as_bytes() + .iter() + .rposition(|byte| *byte == b']') + .unwrap_or_else(|| error.range.end().to_usize() - line_start) + } + parser::UnsupportedSyntaxErrorKind::TemplateStrings => { + let (line_start, line) = + find_line_containing_offset(source, error.range.start().to_usize())?; + line_start + trimmed_line_end(line).saturating_sub(1) + } + _ => error.range.start().to_usize(), + }; + Some(type_comment_parse_error( + source_file, + message, + start, + point_error_end(source, start), + )) +} + +fn should_report_unsupported_syntax_error(error: &parser::UnsupportedSyntaxError) -> bool { + cpython_unsupported_syntax_message(error).is_some() + || matches!( + error.kind, + parser::UnsupportedSyntaxErrorKind::LazyImportStatement + | parser::UnsupportedSyntaxErrorKind::ParenthesizedKeywordArgumentName + ) +} + +fn node_list_field( vm: &VirtualMachine, - obj: &PyObject, + object: &PyObjectRef, field: &'static str, -) -> PyResult>> { - match get_node_field_opt(vm, obj, field)? { - Some(val) => val - .downcast_exact(vm) - .map(Some) - .map_err(|_| vm.new_type_error(format!(r#"field "{field}" must have integer type"#))), - None => Ok(None), - } +) -> Vec { + vm.get_attribute_opt(object.clone(), field) + .ok() + .flatten() + .and_then(|value| { + value + .downcast_ref::() + .map(|list| list.borrow_vec().to_vec()) + }) + .unwrap_or_default() } -fn range_from_object( +fn node_optional_field( vm: &VirtualMachine, - source_file: &SourceFile, - object: PyObjectRef, - name: &str, -) -> PyResult { - let start_row = get_int_field(vm, &object, "lineno", name)?; - let start_column = get_int_field(vm, &object, "col_offset", name)?; - // end_lineno and end_col_offset are optional, default to start values - let end_row = - get_opt_int_field(vm, &object, "end_lineno")?.unwrap_or_else(|| start_row.clone()); - let end_column = - get_opt_int_field(vm, &object, "end_col_offset")?.unwrap_or_else(|| start_column.clone()); + object: &PyObjectRef, + field: &'static str, +) -> Option { + vm.get_attribute_opt(object.clone(), field) + .ok() + .flatten() + .filter(|value| !vm.is_none(value)) +} - // lineno=0 or negative values as a special case (no location info). - // Use default values (line 1, col 0) when lineno <= 0. - let start_row_val: i32 = start_row.try_to_primitive(vm)?; - let end_row_val: i32 = end_row.try_to_primitive(vm)?; - let start_col_val: i32 = start_column.try_to_primitive(vm)?; - let end_col_val: i32 = end_column.try_to_primitive(vm)?; +fn node_lineno(vm: &VirtualMachine, object: &PyObjectRef) -> Option { + node_optional_field(vm, object, "lineno")? + .try_into_value(vm) + .ok() +} - if start_row_val > end_row_val { - return Err(vm.new_value_error(format!( - "AST node line range ({start_row_val}, {end_row_val}) is not valid" - ))); - } - if (start_row_val < 0 && end_row_val != start_row_val) - || (start_col_val < 0 && end_col_val != start_col_val) - { - return Err(vm.new_value_error(format!( - "AST node column range ({start_col_val}, {end_col_val}) for line range ({start_row_val}, {end_row_val}) is not valid" - ))); - } - if start_row_val == end_row_val && start_col_val > end_col_val { - return Err(vm.new_value_error(format!( - "line {start_row_val}, column {start_col_val}-{end_col_val} is not a valid range" - ))); - } +fn source_line<'a>( + lines: &'a TypeCommentSource<'a>, + lineno: usize, +) -> Option<&'a TypeCommentLine<'a>> { + lineno.checked_sub(1).and_then(|idx| lines.lines.get(idx)) +} - let location = PySourceRange { - start: PySourceLocation { - row: Row(if start_row_val > 0 { - OneIndexed::new(start_row_val as usize).unwrap_or(OneIndexed::MIN) - } else { - OneIndexed::MIN - }), - column: Column(TextSize::new(start_col_val.max(0) as u32)), - }, - end: PySourceLocation { - row: Row(if end_row_val > 0 { - OneIndexed::new(end_row_val as usize).unwrap_or(OneIndexed::MIN) - } else { - OneIndexed::MIN - }), - column: Column(TextSize::new(end_col_val.max(0) as u32)), - }, - }; +fn set_type_comment(vm: &VirtualMachine, object: &PyObjectRef, comment: Option<&str>) { + let value = comment.map_or_else(|| vm.ctx.none(), |comment| vm.ctx.new_str(comment).into()); + object + .as_object() + .dict() + .unwrap() + .set_item("type_comment", value, vm) + .unwrap(); +} - Ok(source_range_to_text_range(source_file, location)) +fn same_line_type_comment<'a>( + vm: &VirtualMachine, + lines: &'a TypeCommentSource<'a>, + object: &PyObjectRef, +) -> Option<&'a str> { + let lineno = node_lineno(vm, object)?; + regular_type_comment_text(source_line(lines, lineno)?) } -fn source_range_to_text_range(source_file: &SourceFile, location: PySourceRange) -> TextRange { - let index = LineIndex::from_source_text(source_file.clone().source_text()); - let source = &source_file.source_text(); +fn function_type_comment<'a>( + vm: &VirtualMachine, + lines: &'a TypeCommentSource<'a>, + object: &PyObjectRef, +) -> Option<&'a str> { + let lineno = node_lineno(vm, object)?; + if let Some(comment) = regular_type_comment_text(source_line(lines, lineno)?) { + return Some(comment); + } - if source.is_empty() { - return TextRange::new(TextSize::new(0), TextSize::new(0)); + let next_line = source_line(lines, lineno + 1)?; + let comment_pos = type_comment_position(next_line)?; + next_line.text[..comment_pos] + .trim() + .is_empty() + .then(|| regular_type_comment_text(next_line)) + .flatten() +} + +fn apply_type_comments_to_arguments( + vm: &VirtualMachine, + lines: &TypeCommentSource<'_>, + arguments: &PyObjectRef, +) { + for field in ["posonlyargs", "args", "kwonlyargs"] { + for arg in node_list_field(vm, arguments, field) { + set_type_comment(vm, &arg, same_line_type_comment(vm, lines, &arg)); + } + } + for field in ["vararg", "kwarg"] { + if let Some(arg) = node_optional_field(vm, arguments, field) { + set_type_comment(vm, &arg, same_line_type_comment(vm, lines, &arg)); + } } +} - let start = index.offset( - location.start.to_source_location(), - source, - PositionEncoding::Utf8, - ); - let end = index.offset( - location.end.to_source_location(), - source, - PositionEncoding::Utf8, - ); +fn apply_type_comments_to_node( + vm: &VirtualMachine, + lines: &TypeCommentSource<'_>, + object: &PyObjectRef, +) { + let cls = object.class(); + if cls.is(pyast::NodeStmtFunctionDef::static_type()) + || cls.is(pyast::NodeStmtAsyncFunctionDef::static_type()) + { + set_type_comment(vm, object, function_type_comment(vm, lines, object)); + if let Some(arguments) = node_optional_field(vm, object, "args") { + apply_type_comments_to_arguments(vm, lines, &arguments); + } + } else if cls.is(pyast::NodeStmtAssign::static_type()) + || cls.is(pyast::NodeStmtFor::static_type()) + || cls.is(pyast::NodeStmtAsyncFor::static_type()) + || cls.is(pyast::NodeStmtWith::static_type()) + || cls.is(pyast::NodeStmtAsyncWith::static_type()) + { + set_type_comment(vm, object, same_line_type_comment(vm, lines, object)); + } - TextRange::new(start, end) + for field in ["body", "orelse", "finalbody"] { + for child in node_list_field(vm, object, field) { + apply_type_comments_to_node(vm, lines, &child); + } + } + for field in ["handlers", "cases"] { + for child in node_list_field(vm, object, field) { + apply_type_comments_to_node(vm, lines, &child); + } + } } -fn node_add_location( - dict: &Py, - range: TextRange, +fn apply_type_comments_to_module( vm: &VirtualMachine, - source_file: &SourceFile, + lines: &TypeCommentSource<'_>, + module: &PyObjectRef, ) { - let range = text_range_to_source_range(source_file, range); - dict.set_item("lineno", vm.ctx.new_int(range.start.row.get()).into(), vm) - .unwrap(); - dict.set_item( - "col_offset", - vm.ctx.new_int(range.start.column.get()).into(), - vm, - ) - .unwrap(); - dict.set_item("end_lineno", vm.ctx.new_int(range.end.row.get()).into(), vm) - .unwrap(); - dict.set_item( - "end_col_offset", - vm.ctx.new_int(range.end.column.get()).into(), - vm, - ) - .unwrap(); + for statement in node_list_field(vm, module, "body") { + apply_type_comments_to_node(vm, lines, &statement); + } } -/// Return the expected AST mod type class for a compile() mode string. -pub(crate) fn mode_type_and_name(mode: &str) -> Option<(PyRef, &'static str)> { - match mode { - "exec" => Some((pyast::NodeModModule::make_static_type(), "Module")), - "eval" => Some((pyast::NodeModExpression::make_static_type(), "Expression")), - "single" => Some((pyast::NodeModInteractive::make_static_type(), "Interactive")), - "func_type" => Some(( - pyast::NodeModFunctionType::make_static_type(), - "FunctionType", - )), - _ => None, +#[cfg(feature = "parser")] +fn ipython_escape_command_syntax_error( + top: &ast::Mod, + source_file: &SourceFile, +) -> Option { + use ast::visitor::{Visitor, walk_expr, walk_stmt}; + + #[derive(Default)] + struct IpyEscapeCommandVisitor { + range: Option, + } + + impl Visitor<'_> for IpyEscapeCommandVisitor { + fn visit_stmt(&mut self, stmt: &ast::Stmt) { + if self.range.is_some() { + return; + } + match stmt { + ast::Stmt::IpyEscapeCommand(stmt) => { + self.range = Some(stmt.range); + } + _ => walk_stmt(self, stmt), + } + } + + fn visit_expr(&mut self, expr: &ast::Expr) { + if self.range.is_some() { + return; + } + match expr { + ast::Expr::IpyEscapeCommand(expr) => { + self.range = Some(expr.range); + } + _ => walk_expr(self, expr), + } + } + } + + let mut visitor = IpyEscapeCommandVisitor::default(); + match top { + ast::Mod::Module(module) => { + for statement in &module.body { + visitor.visit_stmt(statement); + if visitor.range.is_some() { + break; + } + } + } + ast::Mod::Expression(expression) => { + visitor.visit_expr(&expression.body); + } } + let range = visitor.range?; + let source_range = text_range_to_source_range(source_file, range); + Some( + ParseError { + error: parser::ParseErrorType::OtherError("invalid syntax".to_owned()), + raw_location: range, + location: source_range.start.to_source_location(), + end_location: source_range.end.to_source_location(), + source_path: "".to_owned(), + is_unclosed_bracket: false, + } + .into(), + ) } /// Create an empty `arguments` AST node (no parameters). @@ -361,6 +1793,7 @@ fn empty_arguments_object(vm: &VirtualMachine) -> PyObjectRef { } #[cfg(feature = "parser")] +#[allow(clippy::too_many_arguments)] pub(crate) fn parse( vm: &VirtualMachine, source: &str, @@ -368,14 +1801,30 @@ pub(crate) fn parse( optimize: u8, target_version: Option, type_comments: bool, + optimized_ast: bool, + interactive: bool, + explicit_future_annotations: bool, + dont_imply_dedent: bool, ) -> Result { let source_file = SourceFileBuilder::new("".to_owned(), source.to_owned()).finish(); let mut options = parser::ParseOptions::from(mode); let target_version = target_version.unwrap_or(ast::PythonVersion::PY314); + if let Some(error) = feature_version_syntax_error(source, &source_file, target_version) { + return Err(error); + } options = options.with_target_version(target_version); - let parsed = parser::parse(source, options).map_err(|parse_error| { + let parsed = parser::parse_unchecked(source, options); + let type_comment_source = + type_comments.then(|| TypeCommentSource::new(source, parsed.tokens())); + if let Some(lines) = &type_comment_source + && let Some(error) = invalid_type_comment_syntax_error(&source_file, lines) + { + return Err(error); + } + if let Err(errors) = parsed.as_result() { + let parse_error = errors[0].clone(); let range = text_range_to_source_range(&source_file, parse_error.location); - ParseError { + return Err(ParseError { error: parse_error.error, raw_location: parse_error.location, location: range.start.to_source_location(), @@ -383,9 +1832,23 @@ pub(crate) fn parse( source_path: "".to_string(), is_unclosed_bracket: false, } - })?; + .into()); + } + if dont_imply_dedent + && interactive + && let Some(error) = rustpython_compiler::dont_imply_dedent_source_error(&source_file) + { + return Err(error); + } - if let Some(error) = parsed.unsupported_syntax_errors().first() { + if let Some(error) = parsed + .unsupported_syntax_errors() + .iter() + .find(|error| should_report_unsupported_syntax_error(error)) + { + if let Some(error) = cpython_unsupported_syntax_error(error, source, &source_file) { + return Err(error); + } let range = text_range_to_source_range(&source_file, error.range()); return Err(ParseError { error: parser::ParseErrorType::OtherError(error.to_string()), @@ -399,19 +1862,56 @@ pub(crate) fn parse( } let mut top = parsed.into_syntax(); - if optimize > 0 { - fold_match_value_constants(&mut top); + if let Some(error) = ipython_escape_command_syntax_error(&top, &source_file) { + return Err(error); + } + if let Some(error) = feature_version_ast_syntax_error(&top, &source_file, target_version) { + return Err(error); + } + #[cfg(feature = "codegen")] + { + let future_features = codegen::preprocess::checked_future_features(&top) + .map_err(|err| future_feature_compile_error(&source_file, err))?; + let future_annotations = explicit_future_annotations + || future_features.contains(crate::bytecode::CodeFlags::FUTURE_ANNOTATIONS); + if interactive && let ast::Mod::Module(module) = &mut top { + codegen::preprocess::preprocess_statements( + &mut module.body, + optimize, + future_annotations, + !optimized_ast, + ); + } else { + codegen::preprocess::preprocess_mod( + &mut top, + optimize, + future_annotations, + !optimized_ast, + ); + } } - if optimize >= 2 { - strip_docstrings(&mut top); + #[cfg(not(feature = "codegen"))] + { + if optimized_ast && optimize > 0 { + fold_match_value_constants(&mut top); + } + if optimize >= 2 { + strip_docstrings(&mut top); + } } let top = match top { - ast::Mod::Module(m) => Mod::Module(m), + ast::Mod::Module(m) => Mod::Module(ModModule { + module: m, + type_ignores: Vec::new(), + }), ast::Mod::Expression(e) => Mod::Expression(e), }; let obj = top.ast_to_object(vm, &source_file); - if type_comments && obj.class().is(pyast::NodeModModule::static_type()) { - let type_ignores = type_ignores_from_source(vm, source); + if let Some(lines) = &type_comment_source + && obj.class().is(pyast::NodeModModule::static_type()) + { + apply_type_comments_to_module(vm, lines, &obj); + let type_ignores = type_ignores_from_source(vm, lines); let dict = obj.as_object().dict().unwrap(); dict.set_item("type_ignores", vm.ctx.new_list(type_ignores).into(), vm) .unwrap(); @@ -441,8 +1941,18 @@ pub(crate) fn parse_func_type( target_version: Option, ) -> Result { let _ = optimize; - let _ = target_version; let source = source.trim(); + let invalid_func_type = || -> CompileError { + ParseError { + error: parser::ParseErrorType::OtherError("invalid syntax".to_owned()), + raw_location: TextRange::default(), + location: SourceLocation::default(), + end_location: SourceLocation::default(), + source_path: "".to_owned(), + is_unclosed_bracket: false, + } + .into() + }; let mut depth = 0i32; let mut split_at = None; let mut chars = source.chars().peekable(); @@ -477,7 +1987,9 @@ pub(crate) fn parse_func_type( let parse_expr = |expr_src: &str| -> Result { let source_file = SourceFileBuilder::new("".to_owned(), expr_src.to_owned()).finish(); - let parsed = parser::parse_expression(expr_src).map_err(|parse_error| { + let options = parser::ParseOptions::from(parser::Mode::Expression) + .with_target_version(target_version.unwrap_or(ast::PythonVersion::PY314)); + let parsed = parser::parse(expr_src, options).map_err(|parse_error| { let range = text_range_to_source_range(&source_file, parse_error.location); ParseError { error: parse_error.error, @@ -488,43 +2000,92 @@ pub(crate) fn parse_func_type( is_unclosed_bracket: false, } })?; - Ok(*parsed.into_syntax().body) + let ast::Mod::Expression(expression) = parsed.into_syntax() else { + unreachable!(); + }; + Ok(*expression.body) }; - let arg_expr = parse_expr(left)?; - let returns = parse_expr(right)?; - - let argtypes: Vec = match arg_expr { - ast::Expr::Tuple(tup) => tup.elts, - ast::Expr::Name(_) | ast::Expr::Subscript(_) | ast::Expr::Attribute(_) => vec![arg_expr], - other => vec![other], + if !left.starts_with('(') || !left.ends_with(')') { + return Err(invalid_func_type()); + } + let inner = left[1..left.len() - 1].trim(); + let argtypes = if inner.is_empty() { + Vec::new() + } else { + if inner.ends_with(',') { + return Err(invalid_func_type()); + } + let call_source = format!("__rustpython_func_type__({inner})"); + let source_file = SourceFileBuilder::new("".to_owned(), call_source.clone()).finish(); + let options = parser::ParseOptions::from(parser::Mode::Expression) + .with_target_version(target_version.unwrap_or(ast::PythonVersion::PY314)); + let parsed = parser::parse(&call_source, options).map_err(|parse_error| { + let range = text_range_to_source_range(&source_file, parse_error.location); + ParseError { + error: parse_error.error, + raw_location: parse_error.location, + location: range.start.to_source_location(), + end_location: range.end.to_source_location(), + source_path: "".to_string(), + is_unclosed_bracket: false, + } + })?; + let ast::Mod::Expression(expression) = parsed.into_syntax() else { + unreachable!(); + }; + let ast::Expr::Call(call) = *expression.body else { + return Err(invalid_func_type()); + }; + let mut args = Vec::new(); + let positional_len = call.arguments.args.len(); + let mut seen_star = false; + for (index, arg) in call.arguments.args.into_iter().enumerate() { + match arg { + ast::Expr::Starred(starred) => { + if seen_star || index + 1 != positional_len { + return Err(invalid_func_type()); + } + seen_star = true; + args.push(*starred.value); + } + expr => args.push(expr), + } + } + let mut seen_kw_star = false; + for keyword in call.arguments.keywords { + if keyword.arg.is_some() || seen_kw_star { + return Err(invalid_func_type()); + } + seen_kw_star = true; + args.push(keyword.value); + } + args }; + let returns = parse_expr(right)?; + let func_type = ModFunctionType { argtypes: argtypes.into_boxed_slice(), returns, - range: TextRange::default(), + runtime_argtypes: None, }; let source_file = SourceFileBuilder::new("".to_owned(), source.to_owned()).finish(); Ok(func_type.ast_to_object(vm, &source_file)) } -fn type_ignores_from_source(vm: &VirtualMachine, source: &str) -> Vec { +fn type_ignores_from_source( + vm: &VirtualMachine, + lines: &TypeCommentSource<'_>, +) -> Vec { let mut ignores = Vec::new(); - for (idx, line) in source.lines().enumerate() { - let Some(pos) = line.find('#') else { + for (idx, line) in lines.lines.iter().enumerate() { + let Some(comment) = type_comment_text(line) else { continue; }; - - let comment = &line[pos + 1..]; - let comment = comment.trim_start(); - - let Some(rest) = comment.strip_prefix("type: ignore") else { + let Some(tag) = type_ignore_tag(comment) else { continue; }; - - let tag = rest.trim_start(); - let tag = if tag.is_empty() { "" } else { tag }; let node = NodeAst .into_ref_with_type( vm, @@ -542,7 +2103,7 @@ fn type_ignores_from_source(vm: &VirtualMachine, source: &str) -> Vec fold_stmts(&mut module.body), @@ -550,7 +2111,7 @@ fn fold_match_value_constants(top: &mut ast::Mod) { } } -#[cfg(feature = "parser")] +#[cfg(all(feature = "parser", not(feature = "codegen")))] fn strip_docstrings(top: &mut ast::Mod) { match top { ast::Mod::Module(module) => strip_docstring_in_body(&mut module.body), @@ -558,8 +2119,8 @@ fn strip_docstrings(top: &mut ast::Mod) { } } -#[cfg(feature = "parser")] -fn strip_docstring_in_body(body: &mut Vec) { +#[cfg(all(feature = "parser", not(feature = "codegen")))] +fn strip_docstring_in_body(body: &mut ast::Suite) { if let Some(range) = take_docstring(body) && body.is_empty() { @@ -580,12 +2141,19 @@ fn strip_docstring_in_body(body: &mut Vec) { } } -#[cfg(feature = "parser")] -fn take_docstring(body: &mut Vec) -> Option { +#[cfg(all(feature = "parser", not(feature = "codegen")))] +fn take_docstring(body: &mut ast::Suite) -> Option { let ast::Stmt::Expr(expr_stmt) = body.first()? else { return None; }; - if matches!(expr_stmt.value.as_ref(), ast::Expr::StringLiteral(_)) { + if matches!( + expr_stmt.value.as_ref(), + ast::Expr::StringLiteral(_) + | ast::Expr::Constant(ast::ExprConstant { + value: ast::ConstantValue::Str(_), + .. + }) + ) { let range = expr_stmt.range; body.remove(0); return Some(range); @@ -593,14 +2161,14 @@ fn take_docstring(body: &mut Vec) -> Option { None } -#[cfg(feature = "parser")] +#[cfg(all(feature = "parser", not(feature = "codegen")))] fn fold_stmts(stmts: &mut [ast::Stmt]) { for stmt in stmts { fold_stmt(stmt); } } -#[cfg(feature = "parser")] +#[cfg(all(feature = "parser", not(feature = "codegen")))] fn fold_stmt(stmt: &mut ast::Stmt) { use ast::Stmt; match stmt { @@ -641,7 +2209,7 @@ fn fold_stmt(stmt: &mut ast::Stmt) { } } -#[cfg(feature = "parser")] +#[cfg(all(feature = "parser", not(feature = "codegen")))] fn fold_pattern(pattern: &mut ast::Pattern) { use ast::Pattern; match pattern { @@ -681,7 +2249,7 @@ fn fold_pattern(pattern: &mut ast::Pattern) { } } -#[cfg(feature = "parser")] +#[cfg(all(feature = "parser", not(feature = "codegen")))] fn fold_expr(expr: &mut ast::Expr) { use ast::Expr; if let Expr::UnaryOp(unary) = expr { @@ -735,7 +2303,7 @@ fn fold_expr(expr: &mut ast::Expr) { } } -#[cfg(feature = "parser")] +#[cfg(all(feature = "parser", not(feature = "codegen")))] fn fold_number_binop( left: &ast::Number, op: ast::Operator, @@ -761,7 +2329,7 @@ fn fold_number_binop( } } -#[cfg(feature = "parser")] +#[cfg(all(feature = "parser", not(feature = "codegen")))] fn number_to_complex(number: &ast::Number) -> Option<(f64, f64, bool)> { match number { ast::Number::Complex { real, imag } => Some((*real, *imag, true)), @@ -770,94 +2338,174 @@ fn number_to_complex(number: &ast::Number) -> Option<(f64, f64, bool)> { } } +#[cfg(feature = "codegen")] +pub(crate) fn preprocess_ast_object( + vm: &VirtualMachine, + object: PyObjectRef, + filename: &str, + optimize: u8, + optimized_ast: bool, + explicit_future_annotations: bool, +) -> PyResult { + let original_object = object.clone(); + let text = synthetic_source_from_ast_object(vm, &object)?; + let source_file = SourceFileBuilder::new(filename.to_owned(), text).finish(); + let ast = Node::ast_from_object(vm, &source_file, object)?; + validate::validate_mod(vm, &ast)?; + let syntax_check_only = !optimized_ast; + + let ast = match ast { + Mod::Module(mut module) => { + let mut ast = ast::Mod::Module(module.module); + let future_features = + codegen::preprocess::checked_future_features(&ast).map_err(|err| { + vm.new_syntax_error(&future_feature_compile_error(&source_file, err), None) + })?; + let future_annotations = explicit_future_annotations + || future_features.contains(crate::bytecode::CodeFlags::FUTURE_ANNOTATIONS); + codegen::preprocess::preprocess_mod( + &mut ast, + optimize, + future_annotations, + syntax_check_only, + ); + let ast::Mod::Module(processed_module) = ast else { + unreachable!(); + }; + module.module = processed_module; + Mod::Module(module) + } + Mod::Interactive(mut interactive) => { + let future_features = codegen::preprocess::checked_future_features_in_body( + &interactive.body, + ) + .map_err(|err| { + vm.new_syntax_error(&future_feature_compile_error(&source_file, err), None) + })?; + let future_annotations = explicit_future_annotations + || future_features.contains(crate::bytecode::CodeFlags::FUTURE_ANNOTATIONS); + codegen::preprocess::preprocess_statements( + &mut interactive.body, + optimize, + future_annotations, + syntax_check_only, + ); + Mod::Interactive(interactive) + } + Mod::Expression(expression) => { + let mut ast = ast::Mod::Expression(expression); + codegen::preprocess::preprocess_mod( + &mut ast, + optimize, + explicit_future_annotations, + syntax_check_only, + ); + let ast::Mod::Expression(expression) = ast else { + unreachable!(); + }; + Mod::Expression(expression) + } + Mod::FunctionType(function_type) => Mod::FunctionType(function_type), + }; + let result = ast.ast_to_object(vm, &source_file); + copy_ast_passthrough_fields(vm, &original_object, &result)?; + Ok(result) +} + #[cfg(feature = "codegen")] pub(crate) fn compile( vm: &VirtualMachine, object: PyObjectRef, filename: &str, mode: crate::compiler::Mode, - optimize: Option, + mut opts: codegen::CompileOpts, ) -> PyResult { - let mut opts = vm.compile_opts(); - if let Some(optimize) = optimize { - opts.optimize = optimize; - } - - let source_file = SourceFileBuilder::new(filename.to_owned(), "".to_owned()).finish(); - let ast: Mod = Node::ast_from_object(vm, &source_file, object)?; + let text = synthetic_source_from_ast_object(vm, &object)?; + let source_file = SourceFileBuilder::new(filename.to_owned(), text.clone()).finish(); + let ast = Node::ast_from_object(vm, &source_file, object)?; validate::validate_mod(vm, &ast)?; let ast = match ast { - Mod::Module(m) => ast::Mod::Module(m), - Mod::Interactive(ModInteractive { range, body }) => ast::Mod::Module(ast::ModModule { + Mod::Module(m) => ast::Mod::Module(m.module), + Mod::Interactive(ModInteractive { range, body, .. }) => ast::Mod::Module(ast::ModModule { node_index: Default::default(), range, body, + runtime_body: None, }), Mod::Expression(e) => ast::Mod::Expression(e), - Mod::FunctionType(_) => todo!(), + Mod::FunctionType(_) => { + return Err(vm.new_runtime_error("this compiler does not handle FunctionTypes")); + } }; - // TODO: create a textual representation of the ast - let text = ""; + opts.future_features |= codegen::preprocess::future_features(&ast); let source_file = SourceFileBuilder::new(filename, text).finish(); - let code = codegen::compile::compile_top(ast, source_file, mode, opts) - .map_err(|err| vm.new_syntax_error(&err.into(), None))?; // FIXME source + #[cfg(feature = "parser")] + let code = { + let source_path = filename.to_owned(); + // A warning the filter escalates to an exception is stashed here so a + // non-SyntaxWarning category propagates unchanged, matching + // PyErr_ExceptionMatches(SyntaxWarning) in compiler_warn. + let escalated: core::cell::Cell> = + core::cell::Cell::new(None); + let mut syntax_warning_handler = |location: SourceLocation, message: String| { + let fname = vm.ctx.new_str(source_path.as_str()); + let message = vm.ctx.new_str(message); + crate::warn::warn_explicit( + Some(vm.ctx.exceptions.syntax_warning.to_owned()), + message.into(), + fname, + location.line.get(), + None, + vm.ctx.none(), + None, + None, + vm, + ) + .map_err(|exception| { + let message = exception.as_object().str(vm).map_or_else( + |_| "compiler warning raised as an exception".to_owned(), + |message| message.as_wtf8().to_string(), + ); + let marker = codegen::error::CodegenError { + location: Some(location), + error: codegen::error::CodegenErrorType::SyntaxError(message), + source_path: source_path.clone(), + }; + escalated.set(Some(exception)); + marker + }) + }; + let result = codegen::compile::compile_top_with_syntax_warning_handler( + ast, + source_file, + mode, + opts, + Some(&mut syntax_warning_handler), + ); + match escalated.take() { + Some(exception) if !exception.fast_isinstance(vm.ctx.exceptions.syntax_warning) => { + return Err(exception); + } + _ => result, + } + }; + #[cfg(not(feature = "parser"))] + let code = codegen::compile::compile_top(ast, source_file, mode, opts); + let code = code.map_err(|err| vm.new_syntax_error(&err.into(), None))?; // FIXME source Ok(crate::builtins::PyCode::new_ref_from_bytecode(vm, code).into()) } -#[cfg(feature = "codegen")] +#[cfg(not(feature = "rustpython-codegen"))] pub(crate) fn validate_ast_object(vm: &VirtualMachine, object: PyObjectRef) -> PyResult<()> { let source_file = SourceFileBuilder::new("".to_owned(), "".to_owned()).finish(); - let ast: Mod = Node::ast_from_object(vm, &source_file, object)?; + let ast = Node::ast_from_object(vm, &source_file, object)?; validate::validate_mod(vm, &ast)?; Ok(()) } -// Used by builtins::compile() -pub(crate) const PY_CF_ONLY_AST: i32 = 0x0400; - // The following flags match the values from Include/cpython/compile.h -// Caveat emptor: These flags are undocumented on purpose and depending -// on their effect outside the standard library is **unsupported**. -pub(crate) const PY_CF_SOURCE_IS_UTF8: i32 = 0x0100; -pub(crate) const PY_CF_DONT_IMPLY_DEDENT: i32 = 0x200; -pub(crate) const PY_CF_IGNORE_COOKIE: i32 = 0x0800; -pub(crate) const PY_CF_ALLOW_INCOMPLETE_INPUT: i32 = 0x4000; -pub(crate) const PY_CF_OPTIMIZED_AST: i32 = 0x8000 | PY_CF_ONLY_AST; -pub(crate) const PY_CF_TYPE_COMMENTS: i32 = 0x1000; -pub(crate) const PY_CF_ALLOW_TOP_LEVEL_AWAIT: i32 = 0x2000; - -// __future__ flags - sync with Lib/__future__.py -// TODO: These flags aren't being used in rust code -// CO_FUTURE_ANNOTATIONS does make a difference in the codegen, -// so it should be used in compile(). -// see compiler/codegen/src/compile.rs -const CO_NESTED: i32 = 0x0010; -const CO_GENERATOR_ALLOWED: i32 = 0; -const CO_FUTURE_DIVISION: i32 = 0x20000; -const CO_FUTURE_ABSOLUTE_IMPORT: i32 = 0x40000; -const CO_FUTURE_WITH_STATEMENT: i32 = 0x80000; -const CO_FUTURE_PRINT_FUNCTION: i32 = 0x100000; -const CO_FUTURE_UNICODE_LITERALS: i32 = 0x200000; -const CO_FUTURE_BARRY_AS_BDFL: i32 = 0x400000; -const CO_FUTURE_GENERATOR_STOP: i32 = 0x800000; -const CO_FUTURE_ANNOTATIONS: i32 = 0x1000000; - -// Used by builtins::compile() - the summary of all flags -pub(crate) const PY_COMPILE_FLAGS_MASK: i32 = PY_CF_ONLY_AST - | PY_CF_SOURCE_IS_UTF8 - | PY_CF_DONT_IMPLY_DEDENT - | PY_CF_IGNORE_COOKIE - | PY_CF_ALLOW_TOP_LEVEL_AWAIT - | PY_CF_ALLOW_INCOMPLETE_INPUT - | PY_CF_OPTIMIZED_AST - | PY_CF_TYPE_COMMENTS - | CO_NESTED - | CO_GENERATOR_ALLOWED - | CO_FUTURE_DIVISION - | CO_FUTURE_ABSOLUTE_IMPORT - | CO_FUTURE_WITH_STATEMENT - | CO_FUTURE_PRINT_FUNCTION - | CO_FUTURE_UNICODE_LITERALS - | CO_FUTURE_BARRY_AS_BDFL - | CO_FUTURE_GENERATOR_STOP - | CO_FUTURE_ANNOTATIONS; +pub(crate) use crate::vm::compile_mode::{ + PY_CF_ALLOW_INCOMPLETE_INPUT, PY_CF_ALLOW_TOP_LEVEL_AWAIT, PY_CF_DONT_IMPLY_DEDENT, + PY_CF_IGNORE_COOKIE, PY_CF_ONLY_AST, PY_CF_OPTIMIZED_AST, PY_CF_SOURCE_IS_UTF8, + PY_CF_TYPE_COMMENTS, +}; diff --git a/crates/vm/src/stdlib/_ast/argument.rs b/crates/vm/src/stdlib/_ast/argument.rs index 626024f5bd6..8bd1507cd39 100644 --- a/crates/vm/src/stdlib/_ast/argument.rs +++ b/crates/vm/src/stdlib/_ast/argument.rs @@ -2,14 +2,78 @@ use super::*; use rustpython_compiler_core::SourceFile; pub(super) struct PositionalArguments { - pub range: TextRange, - pub args: Box<[ast::Expr]>, + range: TextRange, + kind: PositionalArgumentsKind, +} + +enum PositionalArgumentsKind { + Args(Box<[ast::Expr]>), + RuntimeValues(Vec>), +} + +impl PositionalArguments { + pub(super) fn ast_from_field( + vm: &VirtualMachine, + source_file: &SourceFile, + object: &PyObject, + field: &'static str, + typ: &str, + ) -> PyResult { + let values: Vec> = + get_node_list_field(vm, source_file, object, field, typ)?; + Ok(Self::from_values(TextRange::default(), values)) + } + + fn from_args(range: TextRange, args: Box<[ast::Expr]>) -> Self { + Self { + range, + kind: PositionalArgumentsKind::Args(args), + } + } + + fn from_runtime_values(range: TextRange, values: Vec>) -> Self { + Self { + range, + kind: PositionalArgumentsKind::RuntimeValues(values), + } + } + + fn from_values(range: TextRange, values: Vec>) -> Self { + if values.iter().any(Option::is_none) { + Self::from_runtime_values(range, values) + } else { + Self::from_args( + range, + values + .into_iter() + .flatten() + .collect::>() + .into_boxed_slice(), + ) + } + } + + fn range(&self) -> TextRange { + self.range + } + + fn into_args_and_runtime_values(self) -> (Box<[ast::Expr]>, Option>>) { + match self.kind { + PositionalArgumentsKind::Args(args) => (args, None), + PositionalArgumentsKind::RuntimeValues(values) => ( + lower_runtime_expr_list(values.clone()).into_boxed_slice(), + Some(values), + ), + } + } } impl Node for PositionalArguments { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { - let Self { args, range: _ } = self; - BoxedSlice(args).ast_to_object(vm, source_file) + match self.kind { + PositionalArgumentsKind::Args(args) => BoxedSlice(args).ast_to_object(vm, source_file), + PositionalArgumentsKind::RuntimeValues(values) => values.ast_to_object(vm, source_file), + } } fn ast_from_object( @@ -18,10 +82,7 @@ impl Node for PositionalArguments { object: PyObjectRef, ) -> PyResult { let args: BoxedSlice<_> = Node::ast_from_object(vm, source_file, object)?; - Ok(Self { - args: args.0, - range: TextRange::default(), // TODO - }) + Ok(Self::from_args(TextRange::default(), args.0)) } } @@ -30,6 +91,21 @@ pub(super) struct KeywordArguments { pub keywords: Box<[ast::Keyword]>, } +impl KeywordArguments { + pub(super) fn ast_from_field( + vm: &VirtualMachine, + source_file: &SourceFile, + object: &PyObject, + field: &'static str, + typ: &str, + ) -> PyResult { + Ok(Self { + keywords: get_node_boxed_slice_field(vm, source_file, object, field, typ)?, + range: TextRange::default(), + }) + } +} + impl Node for KeywordArguments { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { keywords, range: _ } = self; @@ -45,7 +121,7 @@ impl Node for KeywordArguments { let keywords: BoxedSlice<_> = Node::ast_from_object(vm, source_file, object)?; Ok(Self { keywords: keywords.0, - range: TextRange::default(), // TODO + range: TextRange::default(), }) } } @@ -54,13 +130,16 @@ pub(super) fn merge_function_call_arguments( pos_args: PositionalArguments, key_args: KeywordArguments, ) -> ast::Arguments { - let range = pos_args.range.cover(key_args.range); + let range = pos_args.range().cover(key_args.range); + let (args, runtime_args) = pos_args.into_args_and_runtime_values(); ast::Arguments { node_index: Default::default(), range, - args: pos_args.args, + args, keywords: key_args.keywords, + runtime_args, + runtime_bases: None, } } @@ -68,10 +147,12 @@ pub(super) fn split_function_call_arguments( args: ast::Arguments, ) -> (PositionalArguments, KeywordArguments) { let ast::Arguments { - node_index: _, range: _, args, keywords, + runtime_args, + runtime_bases: _, + .. } = args; let positional_arguments_range = args @@ -80,10 +161,10 @@ pub(super) fn split_function_call_arguments( .reduce(|acc, next| acc.cover(next)) .unwrap_or_default(); // debug_assert!(range.contains_range(positional_arguments_range)); - let positional_arguments = PositionalArguments { - range: positional_arguments_range, - args, - }; + let positional_arguments = runtime_args.map_or_else( + || PositionalArguments::from_args(positional_arguments_range, args), + |values| PositionalArguments::from_runtime_values(positional_arguments_range, values), + ); let keyword_arguments_range = keywords .iter() @@ -107,10 +188,12 @@ pub(super) fn split_class_def_args( Some(args) => *args, }; let ast::Arguments { - node_index: _, range: _, args, keywords, + runtime_args: _, + runtime_bases, + .. } = args; let positional_arguments_range = args @@ -119,10 +202,10 @@ pub(super) fn split_class_def_args( .reduce(|acc, next| acc.cover(next)) .unwrap_or_default(); // debug_assert!(range.contains_range(positional_arguments_range)); - let positional_arguments = PositionalArguments { - range: positional_arguments_range, - args, - }; + let positional_arguments = runtime_bases.map_or_else( + || PositionalArguments::from_args(positional_arguments_range, args), + |values| PositionalArguments::from_runtime_values(positional_arguments_range, values), + ); let keyword_arguments_range = keywords .iter() @@ -146,10 +229,10 @@ pub(super) fn merge_class_def_args( return None; } - let args = if let Some(positional_arguments) = positional_arguments { - positional_arguments.args + let (args, runtime_bases) = if let Some(positional_arguments) = positional_arguments { + positional_arguments.into_args_and_runtime_values() } else { - vec![].into_boxed_slice() + (vec![].into_boxed_slice(), None) }; let keywords = if let Some(keyword_arguments) = keyword_arguments { keyword_arguments.keywords @@ -162,5 +245,7 @@ pub(super) fn merge_class_def_args( range: Default::default(), // TODO args, keywords, + runtime_args: None, + runtime_bases, })) } diff --git a/crates/vm/src/stdlib/_ast/basic.rs b/crates/vm/src/stdlib/_ast/basic.rs index 28e4a6803ee..c25dd3c55f6 100644 --- a/crates/vm/src/stdlib/_ast/basic.rs +++ b/crates/vm/src/stdlib/_ast/basic.rs @@ -1,4 +1,5 @@ use super::*; +use crate::builtins::PyIntRef; use rustpython_codegen::compile::ruff_int_to_bigint; use rustpython_compiler_core::SourceFile; @@ -13,7 +14,11 @@ impl Node for ast::Identifier { _source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let py_str = PyUtf8StrRef::try_from_object(vm, object)?; + if !object.class().is(vm.ctx.types.str_type) { + return Err(vm.new_type_error("AST identifier must be of type str")); + } + let py_str = PyUtf8StrRef::try_from_object(vm, object) + .map_err(|_| vm.new_type_error("AST identifier must be of type str"))?; Ok(Self::new(py_str.as_str(), TextRange::default())) } } @@ -28,7 +33,6 @@ impl Node for ast::Int { _source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - // FIXME: performance let value: PyIntRef = object.try_into_value(vm)?; let value = value.as_bigint().to_string(); Ok(value.parse().unwrap()) @@ -45,6 +49,6 @@ impl Node for bool { _source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - i32::try_from_object(vm, object).map(|i| i != 0) + node_object_to_i32(vm, object).map(|i| i != 0) } } diff --git a/crates/vm/src/stdlib/_ast/constant.rs b/crates/vm/src/stdlib/_ast/constant.rs index b1a8a015689..6debbf5c0d1 100644 --- a/crates/vm/src/stdlib/_ast/constant.rs +++ b/crates/vm/src/stdlib/_ast/constant.rs @@ -1,12 +1,15 @@ use super::*; use crate::builtins::{PyComplex, PyFrozenSet, PyTuple}; use ast::str_prefix::StringLiteralPrefix; -use rustpython_compiler_core::SourceFile; +use rustpython_codegen::compile::ruff_int_to_bigint; +use rustpython_compiler_core::{SourceFile, bytecode::ConstantData}; #[derive(Debug)] pub(super) struct Constant { pub(super) range: TextRange, pub(super) value: ConstantLiteral, + kind: Option>, + invalid_type: Option, } impl Constant { @@ -19,6 +22,8 @@ impl Constant { Self { range, value: ConstantLiteral::Str { value, prefix }, + kind: None, + invalid_type: None, } } @@ -26,6 +31,8 @@ impl Constant { Self { range, value: ConstantLiteral::Int(value), + kind: None, + invalid_type: None, } } @@ -33,6 +40,8 @@ impl Constant { Self { range, value: ConstantLiteral::Float(value), + kind: None, + invalid_type: None, } } @@ -40,6 +49,8 @@ impl Constant { Self { range, value: ConstantLiteral::Complex { real, imag }, + kind: None, + invalid_type: None, } } @@ -47,6 +58,8 @@ impl Constant { Self { range, value: ConstantLiteral::Bytes(value), + kind: None, + invalid_type: None, } } @@ -54,6 +67,8 @@ impl Constant { Self { range, value: ConstantLiteral::Bool(value), + kind: None, + invalid_type: None, } } @@ -61,6 +76,8 @@ impl Constant { Self { range, value: ConstantLiteral::None, + kind: None, + invalid_type: None, } } @@ -68,15 +85,29 @@ impl Constant { Self { range, value: ConstantLiteral::Ellipsis, + kind: None, + invalid_type: None, } } pub(crate) fn into_expr(self) -> ast::Expr { - constant_to_ruff_expr(self) + let Self { + range, + value, + kind, + invalid_type, + } = self; + ast::Expr::Constant(ast::ExprConstant { + node_index: Default::default(), + range, + value: constant_data_to_ast_constant_value(constant_literal_to_constant_data(&value)), + kind: kind.or_else(|| constant_literal_kind(&value)), + invalid_type: invalid_type.map(String::into_boxed_str), + }) } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub(crate) enum ConstantLiteral { None, Bool(bool), @@ -96,20 +127,361 @@ pub(crate) enum ConstantLiteral { Ellipsis, } +pub(super) fn invalid_constant_type(expr: &ast::Expr) -> Option> { + match expr { + ast::Expr::Constant(expr) => expr.invalid_type.clone(), + _ => None, + } +} + +pub(super) fn runtime_string_from_pyobject( + vm: &VirtualMachine, + object: PyObjectRef, +) -> (Option>, Option>) { + runtime_string_from_object(vm, object) +} + +pub(super) fn runtime_string_object( + vm: &VirtualMachine, + value: Option>, + bytes: Option>, +) -> Option { + runtime_string_to_object(vm, value, bytes) +} + +pub(super) fn expr_constant_to_object( + vm: &VirtualMachine, + source_file: &SourceFile, + expr: ast::ExprConstant, +) -> PyObjectRef { + let ast::ExprConstant { + node_index: _, + range, + value, + kind, + invalid_type: _, + } = expr; + let constant = ast_constant_value_to_constant_data(value); + let node = NodeAst + .into_ref_with_type(vm, pyast::NodeExprConstant::static_type().to_owned()) + .unwrap(); + let dict = node.as_object().dict().unwrap(); + dict.set_item("value", constant_data_to_object(vm, constant), vm) + .unwrap(); + let kind = kind.map_or_else(|| vm.ctx.none(), |kind| vm.ctx.new_str(kind).into()); + dict.set_item("kind", kind, vm).unwrap(); + node_add_location(&dict, range, vm, source_file); + node.into() +} + +pub(super) fn runtime_interpolation_object( + vm: &VirtualMachine, + str: Option, + format_spec: Option>, +) -> Option<(PyObjectRef, Option>)> { + let str = str?; + Some(( + constant_data_to_object(vm, ast_constant_value_to_constant_data(str)), + format_spec, + )) +} + +pub(super) fn runtime_stmt_type_comment_object( + vm: &VirtualMachine, + value: Option>, + bytes: Option>, +) -> Option { + runtime_string_object(vm, value, bytes) +} + +fn constant_literal_to_constant_data(value: &ConstantLiteral) -> ConstantData { + match value { + ConstantLiteral::None => ConstantData::None, + ConstantLiteral::Bool(value) => ConstantData::Boolean { value: *value }, + ConstantLiteral::Str { value, .. } => ConstantData::Str { + value: value.as_ref().into(), + }, + ConstantLiteral::Bytes(value) => ConstantData::Bytes { + value: value.to_vec(), + }, + ConstantLiteral::Int(value) => ConstantData::Integer { + value: ruff_int_to_bigint(value).unwrap(), + }, + ConstantLiteral::Tuple(value) => ConstantData::Tuple { + elements: value + .iter() + .map(constant_literal_to_constant_data) + .collect(), + }, + ConstantLiteral::FrozenSet(value) => ConstantData::Frozenset { + elements: value + .iter() + .map(constant_literal_to_constant_data) + .collect(), + }, + ConstantLiteral::Float(value) => ConstantData::Float { value: *value }, + ConstantLiteral::Complex { real, imag } => ConstantData::Complex { + value: num_complex::Complex::new(*real, *imag), + }, + ConstantLiteral::Ellipsis => ConstantData::Ellipsis, + } +} + +fn constant_literal_kind(value: &ConstantLiteral) -> Option> { + match value { + ConstantLiteral::Str { + prefix: StringLiteralPrefix::Unicode, + .. + } => Some("u".into()), + _ => None, + } +} + +pub(super) fn constant_data_to_ast_constant_value(value: ConstantData) -> ast::ConstantValue { + match value { + ConstantData::None => ast::ConstantValue::None, + ConstantData::Boolean { value } => ast::ConstantValue::Boolean(value), + ConstantData::Str { value } => ast::ConstantValue::Str(value.to_string().into_boxed_str()), + ConstantData::Bytes { value } => ast::ConstantValue::Bytes(value.into_boxed_slice()), + ConstantData::Integer { value } => ast::ConstantValue::Integer(value.to_string().into()), + ConstantData::Tuple { elements } => ast::ConstantValue::Tuple( + elements + .into_iter() + .map(constant_data_to_ast_constant_value) + .collect(), + ), + ConstantData::Frozenset { elements } => ast::ConstantValue::Frozenset( + elements + .into_iter() + .map(constant_data_to_ast_constant_value) + .collect(), + ), + ConstantData::Float { value } => ast::ConstantValue::Float(value), + ConstantData::Complex { value } => ast::ConstantValue::Complex { + real: value.re, + imag: value.im, + }, + ConstantData::Ellipsis => ast::ConstantValue::Ellipsis, + ConstantData::Code { .. } | ConstantData::Slice { .. } => { + unreachable!("ast.Constant values cannot contain code objects or slices") + } + } +} + +pub(super) fn ast_constant_value_to_constant_data(value: ast::ConstantValue) -> ConstantData { + match value { + ast::ConstantValue::None => ConstantData::None, + ast::ConstantValue::Boolean(value) => ConstantData::Boolean { value }, + ast::ConstantValue::Str(value) => ConstantData::Str { + value: value.to_string().into(), + }, + ast::ConstantValue::Bytes(value) => ConstantData::Bytes { + value: value.into_vec(), + }, + ast::ConstantValue::Integer(value) => ConstantData::Integer { + value: value + .parse() + .expect("RustPython ast.Constant integer values are decimal integers"), + }, + ast::ConstantValue::Tuple(elements) => ConstantData::Tuple { + elements: elements + .into_iter() + .map(ast_constant_value_to_constant_data) + .collect(), + }, + ast::ConstantValue::Frozenset(elements) => ConstantData::Frozenset { + elements: elements + .into_iter() + .map(ast_constant_value_to_constant_data) + .collect(), + }, + ast::ConstantValue::Float(value) => ConstantData::Float { value }, + ast::ConstantValue::Complex { real, imag } => ConstantData::Complex { + value: num_complex::Complex::new(real, imag), + }, + ast::ConstantValue::Ellipsis => ConstantData::Ellipsis, + } +} + +pub(super) fn constant_object_to_constant_data( + vm: &VirtualMachine, + source_file: &SourceFile, + value_object: PyObjectRef, +) -> PyResult { + let value = ConstantLiteral::ast_from_object(vm, source_file, value_object)?; + Ok(constant_literal_to_constant_data(&value)) +} + +fn runtime_string_from_object( + vm: &VirtualMachine, + object: PyObjectRef, +) -> (Option>, Option>) { + if object.class().is(vm.ctx.types.str_type) { + ( + Some( + object + .try_to_value::(vm) + .expect("AST string field was validated as str") + .into_boxed_str(), + ), + None, + ) + } else { + ( + None, + Some( + object + .try_to_value::>(vm) + .expect("AST string field was validated as bytes"), + ), + ) + } +} + +fn runtime_string_to_object( + vm: &VirtualMachine, + value: Option>, + bytes: Option>, +) -> Option { + if let Some(bytes) = bytes { + Some(vm.ctx.new_bytes(bytes).into()) + } else { + value.map(|value| vm.ctx.new_str(value).into()) + } +} + +fn first_invalid_constant_type(vm: &VirtualMachine, value_object: PyObjectRef) -> PyResult { + let cls = value_object.class(); + let class_name = cls.name().to_owned(); + if cls.is(vm.ctx.types.tuple_type) { + vm.with_recursion(" during compilation", || { + let tuple = value_object.clone().downcast::().map_err(|obj| { + vm.new_type_error(format!( + "Expected type {}, not {}", + PyTuple::static_type().name(), + obj.class().name() + )) + })?; + for item in tuple.iter() { + if let Some(invalid_type) = first_invalid_constant_type_opt(vm, item.clone())? { + return Ok(invalid_type); + } + } + Ok(class_name) + }) + } else if cls.is(vm.ctx.types.frozenset_type) { + vm.with_recursion(" during compilation", || { + let set = value_object.clone().downcast::().unwrap(); + for item in set.elements() { + if let Some(invalid_type) = first_invalid_constant_type_opt(vm, item)? { + return Ok(invalid_type); + } + } + Ok(class_name) + }) + } else { + Ok(class_name) + } +} + +fn first_invalid_constant_type_opt( + vm: &VirtualMachine, + value_object: PyObjectRef, +) -> PyResult> { + let cls = value_object.class(); + if cls.is(vm.ctx.types.none_type) + || cls.is(vm.ctx.types.bool_type) + || cls.is(vm.ctx.types.str_type) + || cls.is(vm.ctx.types.bytes_type) + || cls.is(vm.ctx.types.int_type) + || cls.is(vm.ctx.types.float_type) + || cls.is(vm.ctx.types.complex_type) + || cls.is(vm.ctx.types.ellipsis_type) + { + return Ok(None); + } + if cls.is(vm.ctx.types.tuple_type) || cls.is(vm.ctx.types.frozenset_type) { + return first_invalid_constant_type(vm, value_object).map(Some); + } + Ok(Some(cls.name().to_owned())) +} + +fn constant_data_to_object(vm: &VirtualMachine, constant: ConstantData) -> PyObjectRef { + match constant { + ConstantData::None => vm.ctx.none(), + ConstantData::Boolean { value } => vm.ctx.new_bool(value).to_pyobject(vm), + ConstantData::Str { value } => vm.ctx.new_str(value.to_string()).to_pyobject(vm), + ConstantData::Bytes { value } => vm.ctx.new_bytes(value).to_pyobject(vm), + ConstantData::Integer { value } => vm.ctx.new_int(value).into(), + ConstantData::Tuple { elements } => { + let value = elements + .into_iter() + .map(|c| constant_data_to_object(vm, c)) + .collect(); + vm.ctx.new_tuple(value).to_pyobject(vm) + } + ConstantData::Frozenset { elements } => PyFrozenSet::from_iter( + vm, + elements.into_iter().map(|c| constant_data_to_object(vm, c)), + ) + .unwrap() + .into_pyobject(vm), + ConstantData::Float { value } => vm.ctx.new_float(value).into_pyobject(vm), + ConstantData::Complex { value } => vm.ctx.new_complex(value).into_pyobject(vm), + ConstantData::Ellipsis => vm.ctx.ellipsis.clone().into(), + ConstantData::Code { .. } | ConstantData::Slice { .. } => { + unreachable!("ast.Constant values cannot contain code objects or slices") + } + } +} + // constructor +pub(super) fn constant_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let value_object = get_node_field(vm, &object, "value", "Constant")?; + let (value, invalid_type) = + match ConstantLiteral::ast_from_object(vm, source_file, value_object.clone()) { + Ok(value) => (value, None), + Err(_) => ( + ConstantLiteral::None, + Some(first_invalid_constant_type(vm, value_object)?), + ), + }; + let kind = get_node_field_opt(vm, &object, "kind")? + .map(|object| { + if !object.class().is(vm.ctx.types.str_type) { + return Err(vm.new_type_error("AST string must be of type str")); + } + Ok(object.try_to_value::(vm)?.into_boxed_str()) + }) + .transpose()?; + + Ok(Constant { + range, + value, + kind, + invalid_type, + }) +} + impl Node for Constant { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { - let Self { range, value } = self; + let Self { + range, + value, + kind, + invalid_type: _, + } = self; let node = NodeAst .into_ref_with_type(vm, pyast::NodeExprConstant::static_type().to_owned()) .unwrap(); - let kind = match &value { - ConstantLiteral::Str { - prefix: StringLiteralPrefix::Unicode, - .. - } => vm.ctx.new_str("u").into(), - _ => vm.ctx.none(), - }; + let kind = kind + .or_else(|| constant_literal_kind(&value)) + .map_or_else(|| vm.ctx.none(), |kind| vm.ctx.new_str(kind).into()); let value = value.ast_to_object(vm, source_file); let dict = node.as_object().dict().unwrap(); dict.set_item("value", value, vm).unwrap(); @@ -123,16 +495,8 @@ impl Node for Constant { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let value_object = get_node_field(vm, &object, "value", "Constant")?; - let value = Node::ast_from_object(vm, source_file, value_object)?; - - Ok(Self { - value, - // kind: get_node_field_opt(_vm, &_object, "kind")? - // .map(|obj| Node::ast_from_object(_vm, obj)) - // .transpose()?, - range: range_from_object(vm, source_file, object, "Constant")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "Constant")?; + constant_from_object_with_range(vm, source_file, object, range) } } @@ -201,8 +565,12 @@ impl Node for ConstantLiteral { })?; let tuple = tuple .into_iter() - .cloned() - .map(|object| Node::ast_from_object(vm, source_file, object)) + .map(|object| { + let object = object.clone(); + vm.with_recursion(" during compilation", || { + Node::ast_from_object(vm, source_file, object) + }) + }) .collect::>()?; Self::Tuple(tuple) } else if cls.is(vm.ctx.types.frozenset_type) { @@ -210,7 +578,11 @@ impl Node for ConstantLiteral { let elements = set .elements() .into_iter() - .map(|object| Node::ast_from_object(vm, source_file, object)) + .map(|object| { + vm.with_recursion(" during compilation", || { + Node::ast_from_object(vm, source_file, object) + }) + }) .collect::>()?; Self::FrozenSet(elements) } else if cls.is(vm.ctx.types.float_type) { @@ -244,117 +616,6 @@ impl Node for ConstantLiteral { } } -fn constant_to_ruff_expr(value: Constant) -> ast::Expr { - let Constant { value, range } = value; - match value { - ConstantLiteral::None => ast::Expr::NoneLiteral(ast::ExprNoneLiteral { - node_index: Default::default(), - range, - }), - ConstantLiteral::Bool(value) => ast::Expr::BooleanLiteral(ast::ExprBooleanLiteral { - node_index: Default::default(), - range, - value, - }), - ConstantLiteral::Str { value, prefix } => { - ast::Expr::StringLiteral(ast::ExprStringLiteral { - node_index: Default::default(), - range, - value: ast::StringLiteralValue::single(ast::StringLiteral { - node_index: Default::default(), - range, - value, - flags: ast::StringLiteralFlags::empty().with_prefix(prefix), - }), - }) - } - ConstantLiteral::Bytes(value) => { - ast::Expr::BytesLiteral(ast::ExprBytesLiteral { - node_index: Default::default(), - range, - value: ast::BytesLiteralValue::single(ast::BytesLiteral { - node_index: Default::default(), - range, - value, - flags: ast::BytesLiteralFlags::empty(), // TODO - }), - }) - } - ConstantLiteral::Int(value) => ast::Expr::NumberLiteral(ast::ExprNumberLiteral { - node_index: Default::default(), - range, - value: ast::Number::Int(value), - }), - ConstantLiteral::Tuple(value) => ast::Expr::Tuple(ast::ExprTuple { - node_index: Default::default(), - range, - elts: value - .into_iter() - .map(|value| { - constant_to_ruff_expr(Constant { - range: TextRange::default(), - value, - }) - }) - .collect(), - ctx: ast::ExprContext::Load, - // TODO: Does this matter? - parenthesized: true, - }), - ConstantLiteral::FrozenSet(value) => { - let args = if value.is_empty() { - Vec::new() - } else { - vec![ast::Expr::Set(ast::ExprSet { - node_index: Default::default(), - range: TextRange::default(), - elts: value - .into_iter() - .map(|value| { - constant_to_ruff_expr(Constant { - range: TextRange::default(), - value, - }) - }) - .collect(), - })] - }; - ast::Expr::Call(ast::ExprCall { - node_index: Default::default(), - range, - func: Box::new(ast::Expr::Name(ast::ExprName { - node_index: Default::default(), - range: TextRange::default(), - id: ast::name::Name::new_static("frozenset"), - ctx: ast::ExprContext::Load, - })), - arguments: ast::Arguments { - node_index: Default::default(), - range, - args: args.into(), - keywords: Box::default(), - }, - }) - } - ConstantLiteral::Float(value) => ast::Expr::NumberLiteral(ast::ExprNumberLiteral { - node_index: Default::default(), - range, - value: ast::Number::Float(value), - }), - ConstantLiteral::Complex { real, imag } => { - ast::Expr::NumberLiteral(ast::ExprNumberLiteral { - node_index: Default::default(), - range, - value: ast::Number::Complex { real, imag }, - }) - } - ConstantLiteral::Ellipsis => ast::Expr::EllipsisLiteral(ast::ExprEllipsisLiteral { - node_index: Default::default(), - range, - }), - } -} - pub(super) fn number_literal_to_object( vm: &VirtualMachine, source_file: &SourceFile, @@ -364,6 +625,7 @@ pub(super) fn number_literal_to_object( node_index: _, range, value, + .. } = constant; let c = match value { ast::Number::Int(n) => Constant::new_int(n, range), @@ -382,6 +644,7 @@ pub(super) fn string_literal_to_object( node_index: _, range, value, + .. } = constant; let prefix = value .iter() @@ -400,6 +663,7 @@ pub(super) fn bytes_literal_to_object( node_index: _, range, value, + .. } = constant; let bytes = value.as_slice().iter().flat_map(|b| b.value.iter()); let c = Constant::new_bytes(bytes.copied().collect(), range); @@ -415,6 +679,7 @@ pub(super) fn boolean_literal_to_object( node_index: _, range, value, + .. } = constant; let c = Constant::new_bool(value, range); c.ast_to_object(vm, source_file) @@ -428,6 +693,7 @@ pub(super) fn none_literal_to_object( let ast::ExprNoneLiteral { node_index: _, range, + .. } = constant; let c = Constant::new_none(range); c.ast_to_object(vm, source_file) @@ -441,6 +707,7 @@ pub(super) fn ellipsis_literal_to_object( let ast::ExprEllipsisLiteral { node_index: _, range, + .. } = constant; let c = Constant::new_ellipsis(range); c.ast_to_object(vm, source_file) diff --git a/crates/vm/src/stdlib/_ast/elif_else_clause.rs b/crates/vm/src/stdlib/_ast/elif_else_clause.rs index 0afdbc02ac1..2097c349c3f 100644 --- a/crates/vm/src/stdlib/_ast/elif_else_clause.rs +++ b/crates/vm/src/stdlib/_ast/elif_else_clause.rs @@ -12,10 +12,15 @@ pub(super) fn ast_to_object( range, test, body, + runtime_body, + runtime_orelse, } = clause; let Some(test) = test else { assert!(rest.len() == 0); - return body.ast_to_object(vm, source_file); + return runtime_body.map_or_else( + || body.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); }; let node = NodeAst .into_ref_with_type(vm, pyast::NodeStmtIf::static_type().to_owned()) @@ -24,10 +29,15 @@ pub(super) fn ast_to_object( dict.set_item("test", test.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("body", body.ast_to_object(vm, source_file), vm) - .unwrap(); + let body = runtime_body.map_or_else( + || body.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("body", body, vm).unwrap(); - let orelse = if let Some(next) = rest.next() { + let orelse = if let Some(values) = runtime_orelse { + values.ast_to_object(vm, source_file) + } else if let Some(next) = rest.next() { if next.test.is_some() { let next = ast::ElifElseClause { range: TextRange::new(next.range.start(), range.end()), @@ -37,7 +47,10 @@ pub(super) fn ast_to_object( .new_list(vec![ast_to_object(next, rest, vm, source_file)]) .into() } else { - next.body.ast_to_object(vm, source_file) + next.runtime_body.map_or_else( + || next.body.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ) } } else { vm.ctx.new_list(vec![]).into() @@ -48,40 +61,45 @@ pub(super) fn ast_to_object( node.into() } -pub(super) fn ast_from_object( +pub(super) fn ast_from_object_with_range( vm: &VirtualMachine, source_file: &SourceFile, object: PyObjectRef, + range: TextRange, ) -> PyResult { - let test = Node::ast_from_object(vm, source_file, get_node_field(vm, &object, "test", "If")?)?; - let body = Node::ast_from_object(vm, source_file, get_node_field(vm, &object, "body", "If")?)?; - let orelse: Vec = Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "orelse", "If")?, - )?; - let range = range_from_object(vm, source_file, object, "If")?; + let test = get_required_node_field(vm, source_file, &object, "test", "If")?; + let body: Vec> = get_node_list_field(vm, source_file, &object, "body", "If")?; + let orelse: Vec> = + get_node_list_field(vm, source_file, &object, "orelse", "If")?; + let runtime_body = runtime_stmt_list_metadata(&body); + let runtime_orelse = runtime_stmt_list_metadata(&orelse); + let body = lower_runtime_stmt_list(body); + let orelse = lower_runtime_stmt_list(orelse); let elif_else_clauses = if orelse.is_empty() { vec![] } else if let [ast::Stmt::If(_)] = &*orelse { let Some(ast::Stmt::If(ast::StmtIf { - node_index: _, + node_index, range, test, body, mut elif_else_clauses, + runtime_body, })) = orelse.into_iter().next() else { unreachable!() }; + debug_assert!(runtime_orelse.is_none()); elif_else_clauses.insert( 0, ast::ElifElseClause { - node_index: Default::default(), + node_index, range, test: Some(*test), body, + runtime_body, + runtime_orelse: None, }, ); elif_else_clauses @@ -91,6 +109,8 @@ pub(super) fn ast_from_object( range, test: None, body: orelse, + runtime_body: runtime_orelse, + runtime_orelse: None, }] }; @@ -100,5 +120,6 @@ pub(super) fn ast_from_object( body, elif_else_clauses, range, + runtime_body, }) } diff --git a/crates/vm/src/stdlib/_ast/exception.rs b/crates/vm/src/stdlib/_ast/exception.rs index 2daabecc84c..b79e52d05e6 100644 --- a/crates/vm/src/stdlib/_ast/exception.rs +++ b/crates/vm/src/stdlib/_ast/exception.rs @@ -1,6 +1,22 @@ use super::*; use rustpython_compiler_core::SourceFile; +fn ensure_excepthandler_node(vm: &VirtualMachine, object: &PyObjectRef) -> PyResult<()> { + if vm.is_none(object) + || !is_node_instance( + vm, + object, + pyast::NodeExceptHandlerExceptHandler::static_type(), + )? + { + return Err(vm.new_type_error(format!( + "expected some sort of excepthandler, but got {}", + object.repr(vm)? + ))); + } + Ok(()) +} + // sum impl Node for ast::ExceptHandler { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { @@ -13,25 +29,53 @@ impl Node for ast::ExceptHandler { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let cls = object.class(); - Ok( - if cls.is(pyast::NodeExceptHandlerExceptHandler::static_type()) { - Self::ExceptHandler(ast::ExceptHandlerExceptHandler::ast_from_object( - vm, - source_file, - object, - )?) - } else { - return Err(vm.new_type_error(format!( - "expected some sort of excepthandler, but got {}", - object.repr(vm)? - ))); - }, - ) + ensure_excepthandler_node(vm, &object)?; + let range = excepthandler_range_from_object(vm, source_file, object.clone())?; + Ok(Self::ExceptHandler(except_handler_from_object_with_range( + vm, + source_file, + object, + range, + )?)) } } // constructor +fn except_handler_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let body: Vec> = + get_node_list_field(vm, source_file, &object, "body", "ExceptHandler")?; + let (runtime_body, body) = runtime_stmt_list_from_values(body); + Ok(ast::ExceptHandlerExceptHandler { + node_index: Default::default(), + type_: get_node_field_opt(vm, &object, "type")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + name: get_node_field_opt(vm, &object, "name")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + body, + range, + runtime_body, + }) +} + +pub(super) fn except_handler_from_object_unvalidated_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, +) -> PyResult { + ensure_excepthandler_node(vm, &object)?; + let range = excepthandler_range_from_object_unvalidated(vm, source_file, object.clone())?; + Ok(ast::ExceptHandler::ExceptHandler( + except_handler_from_object_with_range(vm, source_file, object, range)?, + )) +} + impl Node for ast::ExceptHandlerExceptHandler { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -40,6 +84,7 @@ impl Node for ast::ExceptHandlerExceptHandler { name, body, range, + runtime_body, } = self; let node = NodeAst .into_ref_with_type( @@ -52,8 +97,11 @@ impl Node for ast::ExceptHandlerExceptHandler { .unwrap(); dict.set_item("name", name.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("body", body.ast_to_object(vm, source_file), vm) - .unwrap(); + let body = runtime_body.map_or_else( + || body.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("body", body, vm).unwrap(); node_add_location(&dict, range, vm, source_file); node.into() } @@ -63,20 +111,7 @@ impl Node for ast::ExceptHandlerExceptHandler { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - type_: get_node_field_opt(vm, &object, "type")? - .map(|obj| Node::ast_from_object(vm, source_file, obj)) - .transpose()?, - name: get_node_field_opt(vm, &object, "name")? - .map(|obj| Node::ast_from_object(vm, source_file, obj)) - .transpose()?, - body: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "body", "ExceptHandler")?, - )?, - range: range_from_object(vm, source_file, object, "ExceptHandler")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "ExceptHandler")?; + except_handler_from_object_with_range(vm, source_file, object, range) } } diff --git a/crates/vm/src/stdlib/_ast/expression.rs b/crates/vm/src/stdlib/_ast/expression.rs index 2b32a33f34d..39f42652cc7 100644 --- a/crates/vm/src/stdlib/_ast/expression.rs +++ b/crates/vm/src/stdlib/_ast/expression.rs @@ -1,8 +1,7 @@ use super::*; -use crate::stdlib::_ast::{ - argument::{merge_function_call_arguments, split_function_call_arguments}, - constant::Constant, - string::JoinedStr, +use crate::stdlib::_ast::argument::{ + KeywordArguments, PositionalArguments, merge_function_call_arguments, + split_function_call_arguments, }; use rustpython_compiler_core::SourceFile; @@ -27,6 +26,7 @@ impl Node for ast::Expr { Self::YieldFrom(cons) => cons.ast_to_object(vm, source_file), Self::Compare(cons) => cons.ast_to_object(vm, source_file), Self::Call(cons) => cons.ast_to_object(vm, source_file), + Self::Constant(cons) => constant::expr_constant_to_object(vm, source_file, cons), Self::Attribute(cons) => cons.ast_to_object(vm, source_file), Self::Subscript(cons) => cons.ast_to_object(vm, source_file), Self::Starred(cons) => cons.ast_to_object(vm, source_file), @@ -47,7 +47,7 @@ impl Node for ast::Expr { } Self::Named(cons) => cons.ast_to_object(vm, source_file), Self::IpyEscapeCommand(_) => { - unimplemented!("IPython escape command is not allowed in Python AST") + unreachable!("IPython escape command is not part of Python AST") } } } @@ -57,94 +57,307 @@ impl Node for ast::Expr { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let cls = object.class(); - Ok(if cls.is(pyast::NodeExprBoolOp::static_type()) { - Self::BoolOp(ast::ExprBoolOp::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprNamedExpr::static_type()) { - Self::Named(ast::ExprNamed::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprBinOp::static_type()) { - Self::BinOp(ast::ExprBinOp::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprUnaryOp::static_type()) { - Self::UnaryOp(ast::ExprUnaryOp::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprLambda::static_type()) { - Self::Lambda(ast::ExprLambda::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprIfExp::static_type()) { - Self::If(ast::ExprIf::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprDict::static_type()) { - Self::Dict(ast::ExprDict::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprSet::static_type()) { - Self::Set(ast::ExprSet::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprListComp::static_type()) { - Self::ListComp(ast::ExprListComp::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprSetComp::static_type()) { - Self::SetComp(ast::ExprSetComp::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprDictComp::static_type()) { - Self::DictComp(ast::ExprDictComp::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprGeneratorExp::static_type()) { - Self::Generator(ast::ExprGenerator::ast_from_object( + if vm.is_none(&object) { + return Err(vm.new_type_error(format!( + "expected some sort of expr, but got {}", + object.repr(vm)? + ))); + } + enum ExprKind { + BoolOp, + Named, + BinOp, + UnaryOp, + Lambda, + If, + Dict, + Set, + ListComp, + SetComp, + DictComp, + Generator, + Await, + Yield, + YieldFrom, + Compare, + Call, + FormattedValue, + Interpolation, + JoinedStr, + TemplateStr, + Constant, + Attribute, + Subscript, + Starred, + Name, + List, + Tuple, + Slice, + } + let kind = if is_node_instance(vm, &object, pyast::NodeExprBoolOp::static_type())? { + ExprKind::BoolOp + } else if is_node_instance(vm, &object, pyast::NodeExprNamedExpr::static_type())? { + ExprKind::Named + } else if is_node_instance(vm, &object, pyast::NodeExprBinOp::static_type())? { + ExprKind::BinOp + } else if is_node_instance(vm, &object, pyast::NodeExprUnaryOp::static_type())? { + ExprKind::UnaryOp + } else if is_node_instance(vm, &object, pyast::NodeExprLambda::static_type())? { + ExprKind::Lambda + } else if is_node_instance(vm, &object, pyast::NodeExprIfExp::static_type())? { + ExprKind::If + } else if is_node_instance(vm, &object, pyast::NodeExprDict::static_type())? { + ExprKind::Dict + } else if is_node_instance(vm, &object, pyast::NodeExprSet::static_type())? { + ExprKind::Set + } else if is_node_instance(vm, &object, pyast::NodeExprListComp::static_type())? { + ExprKind::ListComp + } else if is_node_instance(vm, &object, pyast::NodeExprSetComp::static_type())? { + ExprKind::SetComp + } else if is_node_instance(vm, &object, pyast::NodeExprDictComp::static_type())? { + ExprKind::DictComp + } else if is_node_instance(vm, &object, pyast::NodeExprGeneratorExp::static_type())? { + ExprKind::Generator + } else if is_node_instance(vm, &object, pyast::NodeExprAwait::static_type())? { + ExprKind::Await + } else if is_node_instance(vm, &object, pyast::NodeExprYield::static_type())? { + ExprKind::Yield + } else if is_node_instance(vm, &object, pyast::NodeExprYieldFrom::static_type())? { + ExprKind::YieldFrom + } else if is_node_instance(vm, &object, pyast::NodeExprCompare::static_type())? { + ExprKind::Compare + } else if is_node_instance(vm, &object, pyast::NodeExprCall::static_type())? { + ExprKind::Call + } else if is_node_instance(vm, &object, pyast::NodeExprFormattedValue::static_type())? { + ExprKind::FormattedValue + } else if is_node_instance(vm, &object, pyast::NodeExprInterpolation::static_type())? { + ExprKind::Interpolation + } else if is_node_instance(vm, &object, pyast::NodeExprJoinedStr::static_type())? { + ExprKind::JoinedStr + } else if is_node_instance(vm, &object, pyast::NodeExprTemplateStr::static_type())? { + ExprKind::TemplateStr + } else if is_node_instance(vm, &object, pyast::NodeExprConstant::static_type())? { + ExprKind::Constant + } else if is_node_instance(vm, &object, pyast::NodeExprAttribute::static_type())? { + ExprKind::Attribute + } else if is_node_instance(vm, &object, pyast::NodeExprSubscript::static_type())? { + ExprKind::Subscript + } else if is_node_instance(vm, &object, pyast::NodeExprStarred::static_type())? { + ExprKind::Starred + } else if is_node_instance(vm, &object, pyast::NodeExprName::static_type())? { + ExprKind::Name + } else if is_node_instance(vm, &object, pyast::NodeExprList::static_type())? { + ExprKind::List + } else if is_node_instance(vm, &object, pyast::NodeExprTuple::static_type())? { + ExprKind::Tuple + } else if is_node_instance(vm, &object, pyast::NodeExprSlice::static_type())? { + ExprKind::Slice + } else { + return Err(vm.new_type_error(format!( + "expected some sort of expr, but got {}", + object.repr(vm)? + ))); + }; + let range = expr_range_from_object(vm, source_file, object.clone())?; + Ok(match kind { + ExprKind::BoolOp => Self::BoolOp(expr_bool_op_from_object_with_range( vm, source_file, object, - )?) - } else if cls.is(pyast::NodeExprAwait::static_type()) { - Self::Await(ast::ExprAwait::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprYield::static_type()) { - Self::Yield(ast::ExprYield::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprYieldFrom::static_type()) { - Self::YieldFrom(ast::ExprYieldFrom::ast_from_object( + range, + )?), + ExprKind::Named => Self::Named(expr_named_from_object_with_range( vm, source_file, object, - )?) - } else if cls.is(pyast::NodeExprCompare::static_type()) { - Self::Compare(ast::ExprCompare::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprCall::static_type()) { - Self::Call(ast::ExprCall::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprAttribute::static_type()) { - Self::Attribute(ast::ExprAttribute::ast_from_object( + range, + )?), + ExprKind::BinOp => Self::BinOp(expr_bin_op_from_object_with_range( vm, source_file, object, - )?) - } else if cls.is(pyast::NodeExprSubscript::static_type()) { - Self::Subscript(ast::ExprSubscript::ast_from_object( + range, + )?), + ExprKind::UnaryOp => Self::UnaryOp(expr_unary_op_from_object_with_range( vm, source_file, object, - )?) - } else if cls.is(pyast::NodeExprStarred::static_type()) { - Self::Starred(ast::ExprStarred::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprName::static_type()) { - Self::Name(ast::ExprName::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprList::static_type()) { - Self::List(ast::ExprList::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprTuple::static_type()) { - Self::Tuple(ast::ExprTuple::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprSlice::static_type()) { - Self::Slice(ast::ExprSlice::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeExprConstant::static_type()) { - Constant::ast_from_object(vm, source_file, object)?.into_expr() - } else if cls.is(pyast::NodeExprJoinedStr::static_type()) { - JoinedStr::ast_from_object(vm, source_file, object)?.into_expr() - } else if cls.is(pyast::NodeExprTemplateStr::static_type()) { - let template = string::TemplateStr::ast_from_object(vm, source_file, object)?; - return string::template_str_to_expr(vm, template); - } else if cls.is(pyast::NodeExprInterpolation::static_type()) { - let interpolation = - string::TStringInterpolation::ast_from_object(vm, source_file, object)?; - return string::interpolation_to_expr(vm, interpolation); - } else if vm.is_none(&object) { - return Err(vm.new_value_error("None disallowed in expression list")); - } else { - return Err(vm.new_type_error(format!( - "expected some sort of expr, but got {}", - object.repr(vm)? - ))); + range, + )?), + ExprKind::Lambda => Self::Lambda(expr_lambda_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::If => Self::If(expr_if_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::Dict => Self::Dict(expr_dict_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::Set => Self::Set(expr_set_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::ListComp => Self::ListComp(expr_list_comp_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::SetComp => Self::SetComp(expr_set_comp_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::DictComp => Self::DictComp(expr_dict_comp_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::Generator => Self::Generator(expr_generator_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::Await => Self::Await(expr_await_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::Yield => Self::Yield(expr_yield_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::YieldFrom => Self::YieldFrom(expr_yield_from_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::Compare => Self::Compare(expr_compare_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::Call => Self::Call(expr_call_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::FormattedValue => { + let formatted = + string::formatted_value_from_object_with_range(vm, source_file, object, range)?; + string::formatted_value_to_expr(true, formatted) + } + ExprKind::Interpolation => { + let interpolation = string::tstring_interpolation_from_object_with_range( + vm, + source_file, + object, + range, + )?; + string::interpolation_to_expr(vm, source_file, interpolation)? + } + ExprKind::JoinedStr => { + string::joined_str_from_object_with_range(vm, source_file, object, range)? + .into_expr(true) + } + ExprKind::TemplateStr => { + let template = + string::template_str_from_object_with_range(vm, source_file, object, range)?; + string::template_str_to_expr(vm, source_file, template)? + } + ExprKind::Constant => { + constant::constant_from_object_with_range(vm, source_file, object, range)? + .into_expr() + } + ExprKind::Attribute => Self::Attribute(expr_attribute_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::Subscript => Self::Subscript(expr_subscript_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::Starred => Self::Starred(expr_starred_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::Name => Self::Name(expr_name_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::List => Self::List(expr_list_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::Tuple => Self::Tuple(expr_tuple_from_object_with_range( + vm, + source_file, + object, + range, + )?), + ExprKind::Slice => Self::Slice(expr_slice_from_object_with_range( + vm, + source_file, + object, + range, + )?), }) } } // constructor +fn expr_bool_op_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let values: Vec> = + get_node_list_field(vm, source_file, &object, "values", "BoolOp")?; + let (runtime_values, values) = runtime_expr_list_from_values(values); + Ok(ast::ExprBoolOp { + node_index: Default::default(), + op: Node::ast_from_object( + vm, + source_file, + get_node_field_required(vm, &object, "op", "BoolOp")?, + )?, + values, + range, + runtime_values, + }) +} + impl Node for ast::ExprBoolOp { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -152,6 +365,7 @@ impl Node for ast::ExprBoolOp { op, values, range, + runtime_values, } = self; let node = NodeAst .into_ref_with_type(vm, pyast::NodeExprBoolOp::static_type().to_owned()) @@ -159,8 +373,11 @@ impl Node for ast::ExprBoolOp { let dict = node.as_object().dict().unwrap(); dict.set_item("op", op.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("values", values.ast_to_object(vm, source_file), vm) - .unwrap(); + let values = runtime_values.map_or_else( + || values.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("values", values, vm).unwrap(); node_add_location(&dict, range, vm, source_file); node.into() } @@ -170,24 +387,26 @@ impl Node for ast::ExprBoolOp { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - op: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "op", "BoolOp")?, - )?, - values: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "values", "BoolOp")?, - )?, - range: range_from_object(vm, source_file, object, "BoolOp")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "BoolOp")?; + expr_bool_op_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_named_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::ExprNamed { + node_index: Default::default(), + target: get_required_node_field(vm, source_file, &object, "target", "NamedExpr")?, + value: get_required_node_field(vm, source_file, &object, "value", "NamedExpr")?, + range, + }) +} + impl Node for ast::ExprNamed { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -213,24 +432,31 @@ impl Node for ast::ExprNamed { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - target: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "target", "NamedExpr")?, - )?, - value: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "value", "NamedExpr")?, - )?, - range: range_from_object(vm, source_file, object, "NamedExpr")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "NamedExpr")?; + expr_named_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_bin_op_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::ExprBinOp { + node_index: Default::default(), + left: get_required_node_field(vm, source_file, &object, "left", "BinOp")?, + op: Node::ast_from_object( + vm, + source_file, + get_node_field_required(vm, &object, "op", "BinOp")?, + )?, + right: get_required_node_field(vm, source_file, &object, "right", "BinOp")?, + range, + }) +} + impl Node for ast::ExprBinOp { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -259,29 +485,30 @@ impl Node for ast::ExprBinOp { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - left: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "left", "BinOp")?, - )?, - op: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "op", "BinOp")?, - )?, - right: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "right", "BinOp")?, - )?, - range: range_from_object(vm, source_file, object, "BinOp")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "BinOp")?; + expr_bin_op_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_unary_op_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::ExprUnaryOp { + node_index: Default::default(), + op: Node::ast_from_object( + vm, + source_file, + get_node_field_required(vm, &object, "op", "UnaryOp")?, + )?, + operand: get_required_node_field(vm, source_file, &object, "operand", "UnaryOp")?, + range, + }) +} + impl Node for ast::ExprUnaryOp { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -306,24 +533,30 @@ impl Node for ast::ExprUnaryOp { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - op: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "op", "UnaryOp")?, - )?, - operand: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "operand", "UnaryOp")?, - )?, - range: range_from_object(vm, source_file, object, "UnaryOp")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "UnaryOp")?; + expr_unary_op_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_lambda_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::ExprLambda { + node_index: Default::default(), + parameters: Node::ast_from_object( + vm, + source_file, + get_node_field_required(vm, &object, "args", "Lambda")?, + )?, + body: get_required_node_field(vm, source_file, &object, "body", "Lambda")?, + range, + }) +} + impl Node for ast::ExprLambda { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -336,7 +569,6 @@ impl Node for ast::ExprLambda { .into_ref_with_type(vm, pyast::NodeExprLambda::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - // Lambda with no parameters should have an empty arguments object, not None let args = match parameters { Some(params) => params.ast_to_object(vm, source_file), None => empty_arguments_object(vm), @@ -353,24 +585,27 @@ impl Node for ast::ExprLambda { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - parameters: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "args", "Lambda")?, - )?, - body: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "body", "Lambda")?, - )?, - range: range_from_object(vm, source_file, object, "Lambda")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "Lambda")?; + expr_lambda_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_if_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::ExprIf { + node_index: Default::default(), + test: get_required_node_field(vm, source_file, &object, "test", "IfExp")?, + body: get_required_node_field(vm, source_file, &object, "body", "IfExp")?, + orelse: get_required_node_field(vm, source_file, &object, "orelse", "IfExp")?, + range, + }) +} + impl Node for ast::ExprIf { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -399,35 +634,46 @@ impl Node for ast::ExprIf { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - test: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "test", "IfExp")?, - )?, - body: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "body", "IfExp")?, - )?, - orelse: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "orelse", "IfExp")?, - )?, - range: range_from_object(vm, source_file, object, "IfExp")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "IfExp")?; + expr_if_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_dict_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let keys: Vec> = + get_node_list_field(vm, source_file, &object, "keys", "Dict")?; + let values: Vec> = + get_node_list_field(vm, source_file, &object, "values", "Dict")?; + if keys.len() != values.len() { + return Err(vm.new_value_error("Dict doesn't have the same number of keys as values")); + } + let runtime_values = runtime_expr_list_metadata(&values); + let items = keys + .into_iter() + .zip(lower_runtime_expr_list(values)) + .map(|(key, value)| ast::DictItem { key, value }) + .collect(); + Ok(ast::ExprDict { + node_index: Default::default(), + items, + range, + runtime_values, + }) +} + impl Node for ast::ExprDict { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, items, range, + runtime_values, } = self; let (keys, values) = items @@ -443,8 +689,11 @@ impl Node for ast::ExprDict { let dict = node.as_object().dict().unwrap(); dict.set_item("keys", keys.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("values", values.ast_to_object(vm, source_file), vm) - .unwrap(); + let values = runtime_values.map_or_else( + || values.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("values", values, vm).unwrap(); node_add_location(&dict, range, vm, source_file); node.into() } @@ -454,46 +703,46 @@ impl Node for ast::ExprDict { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let keys: Vec> = Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "keys", "Dict")?, - )?; - let values: Vec<_> = Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "values", "Dict")?, - )?; - if keys.len() != values.len() { - return Err(vm.new_value_error("Dict doesn't have the same number of keys as values")); - } - let items = keys - .into_iter() - .zip(values) - .map(|(key, value)| ast::DictItem { key, value }) - .collect(); - Ok(Self { - node_index: Default::default(), - items, - range: range_from_object(vm, source_file, object, "Dict")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "Dict")?; + expr_dict_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_set_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let elts: Vec> = + get_node_list_field(vm, source_file, &object, "elts", "Set")?; + let (runtime_elts, elts) = runtime_expr_list_from_values(elts); + Ok(ast::ExprSet { + node_index: Default::default(), + elts, + range, + runtime_elts, + }) +} + impl Node for ast::ExprSet { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, elts, range, + runtime_elts, } = self; let node = NodeAst .into_ref_with_type(vm, pyast::NodeExprSet::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("elts", elts.ast_to_object(vm, source_file), vm) - .unwrap(); + let elts = runtime_elts.map_or_else( + || elts.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("elts", elts, vm).unwrap(); node_add_location(&dict, range, vm, source_file); node.into() } @@ -502,19 +751,26 @@ impl Node for ast::ExprSet { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - elts: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "elts", "Set")?, - )?, - range: range_from_object(vm, source_file, object, "Set")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "Set")?; + expr_set_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_list_comp_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::ExprListComp { + node_index: Default::default(), + elt: get_required_node_field(vm, source_file, &object, "elt", "ListComp")?, + generators: get_node_list_field(vm, source_file, &object, "generators", "ListComp")?, + range, + }) +} + impl Node for ast::ExprListComp { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -540,24 +796,26 @@ impl Node for ast::ExprListComp { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - elt: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "elt", "ListComp")?, - )?, - generators: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "generators", "ListComp")?, - )?, - range: range_from_object(vm, source_file, object, "ListComp")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "ListComp")?; + expr_list_comp_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_set_comp_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::ExprSetComp { + node_index: Default::default(), + elt: get_required_node_field(vm, source_file, &object, "elt", "SetComp")?, + generators: get_node_list_field(vm, source_file, &object, "generators", "SetComp")?, + range, + }) +} + impl Node for ast::ExprSetComp { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -583,24 +841,27 @@ impl Node for ast::ExprSetComp { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - elt: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "elt", "SetComp")?, - )?, - generators: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "generators", "SetComp")?, - )?, - range: range_from_object(vm, source_file, object, "SetComp")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "SetComp")?; + expr_set_comp_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_dict_comp_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::ExprDictComp { + node_index: Default::default(), + key: get_required_node_field(vm, source_file, &object, "key", "DictComp")?, + value: get_required_node_field(vm, source_file, &object, "value", "DictComp")?, + generators: get_node_list_field(vm, source_file, &object, "generators", "DictComp")?, + range, + }) +} + impl Node for ast::ExprDictComp { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -629,29 +890,27 @@ impl Node for ast::ExprDictComp { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - key: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "key", "DictComp")?, - )?, - value: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "value", "DictComp")?, - )?, - generators: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "generators", "DictComp")?, - )?, - range: range_from_object(vm, source_file, object, "DictComp")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "DictComp")?; + expr_dict_comp_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_generator_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::ExprGenerator { + node_index: Default::default(), + elt: get_required_node_field(vm, source_file, &object, "elt", "GeneratorExp")?, + generators: get_node_list_field(vm, source_file, &object, "generators", "GeneratorExp")?, + range, + parenthesized: true, + }) +} + impl Node for ast::ExprGenerator { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -688,26 +947,25 @@ impl Node for ast::ExprGenerator { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - elt: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "elt", "GeneratorExp")?, - )?, - generators: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "generators", "GeneratorExp")?, - )?, - range: range_from_object(vm, source_file, object, "GeneratorExp")?, - // TODO: Is this correct? - parenthesized: true, - }) + let range = range_from_object(vm, source_file, object.clone(), "GeneratorExp")?; + expr_generator_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_await_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::ExprAwait { + node_index: Default::default(), + value: get_required_node_field(vm, source_file, &object, "value", "Await")?, + range, + }) +} + impl Node for ast::ExprAwait { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -729,19 +987,27 @@ impl Node for ast::ExprAwait { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - value: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "value", "Await")?, - )?, - range: range_from_object(vm, source_file, object, "Await")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "Await")?; + expr_await_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_yield_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::ExprYield { + node_index: Default::default(), + value: get_node_field_opt(vm, &object, "value")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + range, + }) +} + impl Node for ast::ExprYield { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -764,17 +1030,25 @@ impl Node for ast::ExprYield { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - value: get_node_field_opt(vm, &object, "value")? - .map(|obj| Node::ast_from_object(vm, source_file, obj)) - .transpose()?, - range: range_from_object(vm, source_file, object, "Yield")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "Yield")?; + expr_yield_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_yield_from_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::ExprYieldFrom { + node_index: Default::default(), + value: get_required_node_field(vm, source_file, &object, "value", "YieldFrom")?, + range, + }) +} + impl Node for ast::ExprYieldFrom { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -797,19 +1071,31 @@ impl Node for ast::ExprYieldFrom { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - value: Node::ast_from_object( - vm, - source_file, - get_node_field_required(vm, &object, "value", "YieldFrom")?, - )?, - range: range_from_object(vm, source_file, object, "YieldFrom")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "YieldFrom")?; + expr_yield_from_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_compare_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let comparators: Vec> = + get_node_list_field(vm, source_file, &object, "comparators", "Compare")?; + let (runtime_comparators, comparators) = runtime_expr_boxed_slice_from_values(comparators); + Ok(ast::ExprCompare { + node_index: Default::default(), + left: get_required_node_field(vm, source_file, &object, "left", "Compare")?, + ops: get_node_boxed_slice_field(vm, source_file, &object, "ops", "Compare")?, + comparators, + range, + runtime_comparators, + }) +} + impl Node for ast::ExprCompare { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -818,6 +1104,7 @@ impl Node for ast::ExprCompare { ops, comparators, range, + runtime_comparators, } = self; let node = NodeAst .into_ref_with_type(vm, pyast::NodeExprCompare::static_type().to_owned()) @@ -827,12 +1114,11 @@ impl Node for ast::ExprCompare { .unwrap(); dict.set_item("ops", BoxedSlice(ops).ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item( - "comparators", - BoxedSlice(comparators).ast_to_object(vm, source_file), - vm, - ) - .unwrap(); + let comparators = runtime_comparators.map_or_else( + || BoxedSlice(comparators).ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("comparators", comparators, vm).unwrap(); node_add_location(&dict, range, vm, source_file); node.into() } @@ -842,35 +1128,29 @@ impl Node for ast::ExprCompare { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - left: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "left", "Compare")?, - )?, - ops: { - let ops: BoxedSlice<_> = Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "ops", "Compare")?, - )?; - ops.0 - }, - comparators: { - let comparators: BoxedSlice<_> = Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "comparators", "Compare")?, - )?; - comparators.0 - }, - range: range_from_object(vm, source_file, object, "Compare")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "Compare")?; + expr_compare_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_call_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::ExprCall { + node_index: Default::default(), + func: get_required_node_field(vm, source_file, &object, "func", "Call")?, + arguments: merge_function_call_arguments( + PositionalArguments::ast_from_field(vm, source_file, &object, "args", "Call")?, + KeywordArguments::ast_from_field(vm, source_file, &object, "keywords", "Call")?, + ), + range, + }) +} + impl Node for ast::ExprCall { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -907,31 +1187,31 @@ impl Node for ast::ExprCall { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - func: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "func", "Call")?, - )?, - arguments: merge_function_call_arguments( - Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "args", "Call")?, - )?, - Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "keywords", "Call")?, - )?, - ), - range: range_from_object(vm, source_file, object, "Call")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "Call")?; + expr_call_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_attribute_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::ExprAttribute { + node_index: Default::default(), + value: get_required_node_field(vm, source_file, &object, "value", "Attribute")?, + attr: get_required_identifier_field(vm, source_file, &object, "attr", "Attribute")?, + ctx: Node::ast_from_object( + vm, + source_file, + get_node_field_required(vm, &object, "ctx", "Attribute")?, + )?, + range, + }) +} + impl Node for ast::ExprAttribute { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -960,29 +1240,31 @@ impl Node for ast::ExprAttribute { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - value: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "value", "Attribute")?, - )?, - attr: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "attr", "Attribute")?, - )?, - ctx: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "ctx", "Attribute")?, - )?, - range: range_from_object(vm, source_file, object, "Attribute")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "Attribute")?; + expr_attribute_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_subscript_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::ExprSubscript { + node_index: Default::default(), + value: get_required_node_field(vm, source_file, &object, "value", "Subscript")?, + slice: get_required_node_field(vm, source_file, &object, "slice", "Subscript")?, + ctx: Node::ast_from_object( + vm, + source_file, + get_node_field_required(vm, &object, "ctx", "Subscript")?, + )?, + range, + }) +} + impl Node for ast::ExprSubscript { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -1010,29 +1292,30 @@ impl Node for ast::ExprSubscript { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - value: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "value", "Subscript")?, - )?, - slice: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "slice", "Subscript")?, - )?, - ctx: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "ctx", "Subscript")?, - )?, - range: range_from_object(vm, source_file, object, "Subscript")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "Subscript")?; + expr_subscript_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_starred_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::ExprStarred { + node_index: Default::default(), + value: get_required_node_field(vm, source_file, &object, "value", "Starred")?, + ctx: Node::ast_from_object( + vm, + source_file, + get_node_field_required(vm, &object, "ctx", "Starred")?, + )?, + range, + }) +} + impl Node for ast::ExprStarred { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -1057,24 +1340,30 @@ impl Node for ast::ExprStarred { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - value: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "value", "Starred")?, - )?, - ctx: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "ctx", "Starred")?, - )?, - range: range_from_object(vm, source_file, object, "Starred")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "Starred")?; + expr_starred_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_name_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::ExprName { + node_index: Default::default(), + id: get_required_identifier_field(vm, source_file, &object, "id", "Name")?, + ctx: Node::ast_from_object( + vm, + source_file, + get_node_field_required(vm, &object, "ctx", "Name")?, + )?, + range, + }) +} + impl Node for ast::ExprName { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -1099,20 +1388,34 @@ impl Node for ast::ExprName { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - id: Node::ast_from_object(vm, source_file, get_node_field(vm, &object, "id", "Name")?)?, - ctx: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "ctx", "Name")?, - )?, - range: range_from_object(vm, source_file, object, "Name")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "Name")?; + expr_name_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_list_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let elts: Vec> = + get_node_list_field(vm, source_file, &object, "elts", "List")?; + let (runtime_elts, elts) = runtime_expr_list_from_values(elts); + Ok(ast::ExprList { + node_index: Default::default(), + elts, + ctx: Node::ast_from_object( + vm, + source_file, + get_node_field_required(vm, &object, "ctx", "List")?, + )?, + range, + runtime_elts, + }) +} + impl Node for ast::ExprList { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -1120,13 +1423,17 @@ impl Node for ast::ExprList { elts, ctx, range, + runtime_elts, } = self; let node = NodeAst .into_ref_with_type(vm, pyast::NodeExprList::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("elts", elts.ast_to_object(vm, source_file), vm) - .unwrap(); + let elts = runtime_elts.map_or_else( + || elts.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("elts", elts, vm).unwrap(); dict.set_item("ctx", ctx.ast_to_object(vm, source_file), vm) .unwrap(); node_add_location(&dict, range, vm, source_file); @@ -1138,24 +1445,35 @@ impl Node for ast::ExprList { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - elts: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "elts", "List")?, - )?, - ctx: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "ctx", "List")?, - )?, - range: range_from_object(vm, source_file, object, "List")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "List")?; + expr_list_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_tuple_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let elts: Vec> = + get_node_list_field(vm, source_file, &object, "elts", "Tuple")?; + let (runtime_elts, elts) = runtime_expr_list_from_values(elts); + Ok(ast::ExprTuple { + node_index: Default::default(), + elts, + ctx: Node::ast_from_object( + vm, + source_file, + get_node_field_required(vm, &object, "ctx", "Tuple")?, + )?, + range, + parenthesized: true, + runtime_elts, + }) +} + impl Node for ast::ExprTuple { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -1164,13 +1482,17 @@ impl Node for ast::ExprTuple { ctx, range: _range, parenthesized: _, + runtime_elts, } = self; let node = NodeAst .into_ref_with_type(vm, pyast::NodeExprTuple::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("elts", elts.ast_to_object(vm, source_file), vm) - .unwrap(); + let elts = runtime_elts.map_or_else( + || elts.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("elts", elts, vm).unwrap(); dict.set_item("ctx", ctx.ast_to_object(vm, source_file), vm) .unwrap(); node_add_location(&dict, _range, vm, source_file); @@ -1182,25 +1504,33 @@ impl Node for ast::ExprTuple { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - elts: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "elts", "Tuple")?, - )?, - ctx: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "ctx", "Tuple")?, - )?, - range: range_from_object(vm, source_file, object, "Tuple")?, - parenthesized: true, // TODO: is this correct? - }) + let range = range_from_object(vm, source_file, object.clone(), "Tuple")?; + expr_tuple_from_object_with_range(vm, source_file, object, range) } } // constructor +fn expr_slice_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::ExprSlice { + node_index: Default::default(), + lower: get_node_field_opt(vm, &object, "lower")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + upper: get_node_field_opt(vm, &object, "upper")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + step: get_node_field_opt(vm, &object, "step")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + range, + }) +} + impl Node for ast::ExprSlice { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -1229,19 +1559,8 @@ impl Node for ast::ExprSlice { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - lower: get_node_field_opt(vm, &object, "lower")? - .map(|obj| Node::ast_from_object(vm, source_file, obj)) - .transpose()?, - upper: get_node_field_opt(vm, &object, "upper")? - .map(|obj| Node::ast_from_object(vm, source_file, obj)) - .transpose()?, - step: get_node_field_opt(vm, &object, "step")? - .map(|obj| Node::ast_from_object(vm, source_file, obj)) - .transpose()?, - range: range_from_object(vm, source_file, object, "Slice")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "Slice")?; + expr_slice_from_object_with_range(vm, source_file, object, range) } } @@ -1253,7 +1572,7 @@ impl Node for ast::ExprContext { Self::Store => pyast::NodeExprContextStore::static_type(), Self::Del => pyast::NodeExprContextDel::static_type(), Self::Invalid => { - unimplemented!("Invalid expression context is not allowed in Python AST") + unreachable!() } }; singleton_node_to_object(vm, node_type) @@ -1264,19 +1583,20 @@ impl Node for ast::ExprContext { _source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let cls = object.class(); - Ok(if cls.is(pyast::NodeExprContextLoad::static_type()) { - Self::Load - } else if cls.is(pyast::NodeExprContextStore::static_type()) { - Self::Store - } else if cls.is(pyast::NodeExprContextDel::static_type()) { - Self::Del - } else { - return Err(vm.new_type_error(format!( - "expected some sort of expr_context, but got {}", - object.repr(vm)? - ))); - }) + Ok( + if is_node_instance(vm, &object, pyast::NodeExprContextLoad::static_type())? { + Self::Load + } else if is_node_instance(vm, &object, pyast::NodeExprContextStore::static_type())? { + Self::Store + } else if is_node_instance(vm, &object, pyast::NodeExprContextDel::static_type())? { + Self::Del + } else { + return Err(vm.new_type_error(format!( + "expected some sort of expr_context, but got {}", + object.repr(vm)? + ))); + }, + ) } } @@ -1290,6 +1610,8 @@ impl Node for ast::Comprehension { ifs, is_async, range: _range, + runtime_ifs, + runtime_is_async, } = self; let node = NodeAst .into_ref_with_type(vm, pyast::NodeComprehension::static_type().to_owned()) @@ -1299,10 +1621,16 @@ impl Node for ast::Comprehension { .unwrap(); dict.set_item("iter", iter.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("ifs", ifs.ast_to_object(vm, source_file), vm) - .unwrap(); - dict.set_item("is_async", is_async.ast_to_object(vm, source_file), vm) - .unwrap(); + let ifs = runtime_ifs.map_or_else( + || ifs.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("ifs", ifs, vm).unwrap(); + let is_async = runtime_is_async.map_or_else( + || is_async.ast_to_object(vm, source_file), + |value| vm.ctx.new_int(value).into(), + ); + dict.set_item("is_async", is_async, vm).unwrap(); node.into() } @@ -1311,29 +1639,23 @@ impl Node for ast::Comprehension { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { + let ifs: Vec> = + get_node_list_field(vm, source_file, &object, "ifs", "comprehension")?; + let is_async = node_object_to_i32( + vm, + get_node_field(vm, &object, "is_async", "comprehension")?, + )?; + let runtime_ifs = runtime_expr_list_metadata(&ifs); + let runtime_is_async = (is_async != 0 && is_async != 1).then_some(is_async); Ok(Self { node_index: Default::default(), - target: Node::ast_from_object( - vm, - source_file, - get_node_field_required(vm, &object, "target", "comprehension")?, - )?, - iter: Node::ast_from_object( - vm, - source_file, - get_node_field_required(vm, &object, "iter", "comprehension")?, - )?, - ifs: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "ifs", "comprehension")?, - )?, - is_async: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "is_async", "comprehension")?, - )?, + target: get_required_node_field(vm, source_file, &object, "target", "comprehension")?, + iter: get_required_node_field(vm, source_file, &object, "iter", "comprehension")?, + ifs: lower_runtime_expr_list(ifs), + is_async: is_async != 0, range: Default::default(), + runtime_ifs, + runtime_is_async, }) } } diff --git a/crates/vm/src/stdlib/_ast/module.rs b/crates/vm/src/stdlib/_ast/module.rs index b4c2468d33b..37a6bc62849 100644 --- a/crates/vm/src/stdlib/_ast/module.rs +++ b/crates/vm/src/stdlib/_ast/module.rs @@ -18,7 +18,7 @@ use rustpython_compiler_core::SourceFile; /// - `FunctionType`: A function signature with argument and return type /// annotations, representing the type hints of a function (e.g., `def add(x: int, y: int) -> int`). pub(super) enum Mod { - Module(ast::ModModule), + Module(ModModule), Interactive(ModInteractive), Expression(ast::ModExpression), FunctionType(ModFunctionType), @@ -40,53 +40,66 @@ impl Node for Mod { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let cls = object.class(); - Ok(if cls.is(pyast::NodeModModule::static_type()) { - Self::Module(ast::ModModule::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeModInteractive::static_type()) { - Self::Interactive(ModInteractive::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeModExpression::static_type()) { - Self::Expression(ast::ModExpression::ast_from_object( - vm, - source_file, - object, - )?) - } else if cls.is(pyast::NodeModFunctionType::static_type()) { - Self::FunctionType(ModFunctionType::ast_from_object(vm, source_file, object)?) - } else { - return Err(vm.new_type_error(format!( - "expected some sort of mod, but got {}", - object.repr(vm)? - ))); - }) + Ok( + if object.is_instance(pyast::NodeModModule::static_type().as_object(), vm)? { + Self::Module(ModModule::ast_from_object(vm, source_file, object)?) + } else if object + .is_instance(pyast::NodeModInteractive::static_type().as_object(), vm)? + { + Self::Interactive(ModInteractive::ast_from_object(vm, source_file, object)?) + } else if object.is_instance(pyast::NodeModExpression::static_type().as_object(), vm)? { + Self::Expression(ast::ModExpression::ast_from_object( + vm, + source_file, + object, + )?) + } else if object + .is_instance(pyast::NodeModFunctionType::static_type().as_object(), vm)? + { + Self::FunctionType(ModFunctionType::ast_from_object(vm, source_file, object)?) + } else { + return Err(vm.new_type_error(format!( + "expected some sort of mod, but got {}", + object.repr(vm)? + ))); + }, + ) } } +pub(super) struct ModModule { + pub(crate) module: ast::ModModule, + pub(crate) type_ignores: Vec, +} + // constructor -impl Node for ast::ModModule { +impl Node for ModModule { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { + module, + type_ignores, + } = self; + let ast::ModModule { node_index: _, body, - // type_ignores, - range, - } = self; + range: _, + runtime_body, + } = module; let node = NodeAst .into_ref_with_type(vm, pyast::NodeModModule::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("body", body.ast_to_object(vm, source_file), vm) - .unwrap(); - // TODO: Improve ruff API - // ruff ignores type_ignore comments currently. - let type_ignores: Vec = vec![]; + let body = runtime_body.map_or_else( + || body.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("body", body, vm).unwrap(); dict.set_item( "type_ignores", type_ignores.ast_to_object(vm, source_file), vm, ) .unwrap(); - let _ = range; node.into() } @@ -95,38 +108,45 @@ impl Node for ast::ModModule { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { + let body: Vec> = + get_node_list_field(vm, source_file, &object, "body", "Module")?; + let (runtime_body, body) = runtime_stmt_list_from_values(body); + let type_ignores = get_node_list_field(vm, source_file, &object, "type_ignores", "Module")?; Ok(Self { - node_index: Default::default(), - body: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "body", "Module")?, - )?, - // type_ignores: Node::ast_from_object( - // _vm, - // get_node_field(_vm, &_object, "type_ignores", "Module")?, - // )?, - range: Default::default(), + module: ast::ModModule { + node_index: Default::default(), + body, + range: Default::default(), + runtime_body, + }, + type_ignores, }) } } pub(super) struct ModInteractive { pub(crate) range: TextRange, - pub(crate) body: Vec, + pub(crate) body: ast::Suite, + pub(crate) runtime_body: Option>>, } // constructor impl Node for ModInteractive { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { - let Self { body, range } = self; + let Self { + body, + range: _, + runtime_body, + } = self; let node = NodeAst .into_ref_with_type(vm, pyast::NodeModInteractive::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("body", body.ast_to_object(vm, source_file), vm) - .unwrap(); - let _ = range; + let body = runtime_body.map_or_else( + || body.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("body", body, vm).unwrap(); node.into() } @@ -135,13 +155,13 @@ impl Node for ModInteractive { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { + let body: Vec> = + get_node_list_field(vm, source_file, &object, "body", "Interactive")?; + let (runtime_body, body) = runtime_stmt_list_from_values(body); Ok(Self { - body: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "body", "Interactive")?, - )?, + body, range: Default::default(), + runtime_body, }) } } @@ -152,7 +172,7 @@ impl Node for ast::ModExpression { let Self { node_index: _, body, - range, + range: _, } = self; let node = NodeAst .into_ref_with_type(vm, pyast::NodeModExpression::static_type().to_owned()) @@ -160,7 +180,6 @@ impl Node for ast::ModExpression { let dict = node.as_object().dict().unwrap(); dict.set_item("body", body.ast_to_object(vm, source_file), vm) .unwrap(); - let _ = range; node.into() } @@ -171,11 +190,7 @@ impl Node for ast::ModExpression { ) -> PyResult { Ok(Self { node_index: Default::default(), - body: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "body", "Expression")?, - )?, + body: get_required_node_field(vm, source_file, &object, "body", "Expression")?, range: Default::default(), }) } @@ -184,7 +199,7 @@ impl Node for ast::ModExpression { pub(super) struct ModFunctionType { pub(crate) argtypes: Box<[ast::Expr]>, pub(crate) returns: ast::Expr, - pub(crate) range: TextRange, + pub(crate) runtime_argtypes: Option>>, } // constructor @@ -193,21 +208,19 @@ impl Node for ModFunctionType { let Self { argtypes, returns, - range, + runtime_argtypes, } = self; let node = NodeAst .into_ref_with_type(vm, pyast::NodeModFunctionType::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item( - "argtypes", - BoxedSlice(argtypes).ast_to_object(vm, source_file), - vm, - ) - .unwrap(); + let argtypes = runtime_argtypes.map_or_else( + || BoxedSlice(argtypes).ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("argtypes", argtypes, vm).unwrap(); dict.set_item("returns", returns.ast_to_object(vm, source_file), vm) .unwrap(); - let _ = range; node.into() } @@ -216,21 +229,13 @@ impl Node for ModFunctionType { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { + let argtypes: Vec> = + get_node_list_field(vm, source_file, &object, "argtypes", "FunctionType")?; + let (runtime_argtypes, argtypes) = runtime_expr_list_from_values(argtypes); Ok(Self { - argtypes: { - let argtypes: BoxedSlice<_> = Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "argtypes", "FunctionType")?, - )?; - argtypes.0 - }, - returns: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "returns", "FunctionType")?, - )?, - range: Default::default(), + argtypes: argtypes.into_boxed_slice(), + returns: get_required_node_field(vm, source_file, &object, "returns", "FunctionType")?, + runtime_argtypes, }) } } diff --git a/crates/vm/src/stdlib/_ast/node.rs b/crates/vm/src/stdlib/_ast/node.rs index 4ee3893b665..7737ae076bb 100644 --- a/crates/vm/src/stdlib/_ast/node.rs +++ b/crates/vm/src/stdlib/_ast/node.rs @@ -1,5 +1,6 @@ -use crate::{PyObjectRef, PyResult, VirtualMachine}; +use crate::{PyObjectRef, PyResult, VirtualMachine, builtins::PyList}; use rustpython_compiler_core::SourceFile; +use thin_vec::ThinVec; pub(crate) trait Node: Sized { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef; @@ -31,14 +32,52 @@ impl Node for Vec { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - // Recursion guard for each element: prevents stack overflow when a - // sequence element transitively references the sequence itself - // (e.g. `l = ast.List(...); l.elts = [l]`). See issue #4862. - vm.extract_elements_with(&object, |obj| { - vm.with_recursion("while traversing AST node", || { - Node::ast_from_object(vm, source_file, obj) - }) - }) + let list = object.downcast_ref::().ok_or_else(|| { + vm.new_type_error(format!( + "AST list field must be a list, not a {}", + object.class().name() + )) + })?; + let len = list.borrow_vec().len(); + let mut result = Self::with_capacity(len); + for i in 0..len { + let item = { + let items = list.borrow_vec(); + if items.len() != len { + return Err( + vm.new_runtime_error("AST list field changed size during iteration") + ); + } + items[i].clone() + }; + result.push(vm.with_recursion("while traversing AST node", || { + Node::ast_from_object(vm, source_file, item) + })?); + if list.borrow_vec().len() != len { + return Err(vm.new_runtime_error("AST list field changed size during iteration")); + } + } + Ok(result) + } +} + +impl Node for ThinVec { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + vm.ctx + .new_list( + self.into_iter() + .map(|node| node.ast_to_object(vm, source_file)) + .collect(), + ) + .into() + } + + fn ast_from_object( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + ) -> PyResult { + Vec::::ast_from_object(vm, source_file, object).map(Into::into) } } @@ -52,10 +91,6 @@ impl Node for Box { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - // Recursion guard: every descent through a Box increments the - // VM's recursion depth so cyclic or pathologically deep ASTs raise - // RecursionError instead of overflowing the native stack. - // See issue #4862. vm.with_recursion("while traversing AST node", || { T::ast_from_object(vm, source_file, object).map(Self::new) }) diff --git a/crates/vm/src/stdlib/_ast/operator.rs b/crates/vm/src/stdlib/_ast/operator.rs index 09e63b5d6ce..e05e490bb84 100644 --- a/crates/vm/src/stdlib/_ast/operator.rs +++ b/crates/vm/src/stdlib/_ast/operator.rs @@ -16,17 +16,18 @@ impl Node for ast::BoolOp { _source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let cls = object.class(); - Ok(if cls.is(pyast::NodeBoolOpAnd::static_type()) { - Self::And - } else if cls.is(pyast::NodeBoolOpOr::static_type()) { - Self::Or - } else { - return Err(vm.new_type_error(format!( - "expected some sort of boolop, but got {}", - object.repr(vm)? - ))); - }) + Ok( + if is_node_instance(vm, &object, pyast::NodeBoolOpAnd::static_type())? { + Self::And + } else if is_node_instance(vm, &object, pyast::NodeBoolOpOr::static_type())? { + Self::Or + } else { + return Err(vm.new_type_error(format!( + "expected some sort of boolop, but got {}", + object.repr(vm)? + ))); + }, + ) } } @@ -56,39 +57,40 @@ impl Node for ast::Operator { _source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let cls = object.class(); - Ok(if cls.is(pyast::NodeOperatorAdd::static_type()) { - Self::Add - } else if cls.is(pyast::NodeOperatorSub::static_type()) { - Self::Sub - } else if cls.is(pyast::NodeOperatorMult::static_type()) { - Self::Mult - } else if cls.is(pyast::NodeOperatorMatMult::static_type()) { - Self::MatMult - } else if cls.is(pyast::NodeOperatorDiv::static_type()) { - Self::Div - } else if cls.is(pyast::NodeOperatorMod::static_type()) { - Self::Mod - } else if cls.is(pyast::NodeOperatorPow::static_type()) { - Self::Pow - } else if cls.is(pyast::NodeOperatorLShift::static_type()) { - Self::LShift - } else if cls.is(pyast::NodeOperatorRShift::static_type()) { - Self::RShift - } else if cls.is(pyast::NodeOperatorBitOr::static_type()) { - Self::BitOr - } else if cls.is(pyast::NodeOperatorBitXor::static_type()) { - Self::BitXor - } else if cls.is(pyast::NodeOperatorBitAnd::static_type()) { - Self::BitAnd - } else if cls.is(pyast::NodeOperatorFloorDiv::static_type()) { - Self::FloorDiv - } else { - return Err(vm.new_type_error(format!( - "expected some sort of operator, but got {}", - object.repr(vm)? - ))); - }) + Ok( + if is_node_instance(vm, &object, pyast::NodeOperatorAdd::static_type())? { + Self::Add + } else if is_node_instance(vm, &object, pyast::NodeOperatorSub::static_type())? { + Self::Sub + } else if is_node_instance(vm, &object, pyast::NodeOperatorMult::static_type())? { + Self::Mult + } else if is_node_instance(vm, &object, pyast::NodeOperatorMatMult::static_type())? { + Self::MatMult + } else if is_node_instance(vm, &object, pyast::NodeOperatorDiv::static_type())? { + Self::Div + } else if is_node_instance(vm, &object, pyast::NodeOperatorMod::static_type())? { + Self::Mod + } else if is_node_instance(vm, &object, pyast::NodeOperatorPow::static_type())? { + Self::Pow + } else if is_node_instance(vm, &object, pyast::NodeOperatorLShift::static_type())? { + Self::LShift + } else if is_node_instance(vm, &object, pyast::NodeOperatorRShift::static_type())? { + Self::RShift + } else if is_node_instance(vm, &object, pyast::NodeOperatorBitOr::static_type())? { + Self::BitOr + } else if is_node_instance(vm, &object, pyast::NodeOperatorBitXor::static_type())? { + Self::BitXor + } else if is_node_instance(vm, &object, pyast::NodeOperatorBitAnd::static_type())? { + Self::BitAnd + } else if is_node_instance(vm, &object, pyast::NodeOperatorFloorDiv::static_type())? { + Self::FloorDiv + } else { + return Err(vm.new_type_error(format!( + "expected some sort of operator, but got {}", + object.repr(vm)? + ))); + }, + ) } } @@ -109,21 +111,22 @@ impl Node for ast::UnaryOp { _source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let cls = object.class(); - Ok(if cls.is(pyast::NodeUnaryOpInvert::static_type()) { - Self::Invert - } else if cls.is(pyast::NodeUnaryOpNot::static_type()) { - Self::Not - } else if cls.is(pyast::NodeUnaryOpUAdd::static_type()) { - Self::UAdd - } else if cls.is(pyast::NodeUnaryOpUSub::static_type()) { - Self::USub - } else { - return Err(vm.new_type_error(format!( - "expected some sort of unaryop, but got {}", - object.repr(vm)? - ))); - }) + Ok( + if is_node_instance(vm, &object, pyast::NodeUnaryOpInvert::static_type())? { + Self::Invert + } else if is_node_instance(vm, &object, pyast::NodeUnaryOpNot::static_type())? { + Self::Not + } else if is_node_instance(vm, &object, pyast::NodeUnaryOpUAdd::static_type())? { + Self::UAdd + } else if is_node_instance(vm, &object, pyast::NodeUnaryOpUSub::static_type())? { + Self::USub + } else { + return Err(vm.new_type_error(format!( + "expected some sort of unaryop, but got {}", + object.repr(vm)? + ))); + }, + ) } } @@ -150,32 +153,33 @@ impl Node for ast::CmpOp { _source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let cls = object.class(); - Ok(if cls.is(pyast::NodeCmpOpEq::static_type()) { - Self::Eq - } else if cls.is(pyast::NodeCmpOpNotEq::static_type()) { - Self::NotEq - } else if cls.is(pyast::NodeCmpOpLt::static_type()) { - Self::Lt - } else if cls.is(pyast::NodeCmpOpLtE::static_type()) { - Self::LtE - } else if cls.is(pyast::NodeCmpOpGt::static_type()) { - Self::Gt - } else if cls.is(pyast::NodeCmpOpGtE::static_type()) { - Self::GtE - } else if cls.is(pyast::NodeCmpOpIs::static_type()) { - Self::Is - } else if cls.is(pyast::NodeCmpOpIsNot::static_type()) { - Self::IsNot - } else if cls.is(pyast::NodeCmpOpIn::static_type()) { - Self::In - } else if cls.is(pyast::NodeCmpOpNotIn::static_type()) { - Self::NotIn - } else { - return Err(vm.new_type_error(format!( - "expected some sort of cmpop, but got {}", - object.repr(vm)? - ))); - }) + Ok( + if is_node_instance(vm, &object, pyast::NodeCmpOpEq::static_type())? { + Self::Eq + } else if is_node_instance(vm, &object, pyast::NodeCmpOpNotEq::static_type())? { + Self::NotEq + } else if is_node_instance(vm, &object, pyast::NodeCmpOpLt::static_type())? { + Self::Lt + } else if is_node_instance(vm, &object, pyast::NodeCmpOpLtE::static_type())? { + Self::LtE + } else if is_node_instance(vm, &object, pyast::NodeCmpOpGt::static_type())? { + Self::Gt + } else if is_node_instance(vm, &object, pyast::NodeCmpOpGtE::static_type())? { + Self::GtE + } else if is_node_instance(vm, &object, pyast::NodeCmpOpIs::static_type())? { + Self::Is + } else if is_node_instance(vm, &object, pyast::NodeCmpOpIsNot::static_type())? { + Self::IsNot + } else if is_node_instance(vm, &object, pyast::NodeCmpOpIn::static_type())? { + Self::In + } else if is_node_instance(vm, &object, pyast::NodeCmpOpNotIn::static_type())? { + Self::NotIn + } else { + return Err(vm.new_type_error(format!( + "expected some sort of cmpop, but got {}", + object.repr(vm)? + ))); + }, + ) } } diff --git a/crates/vm/src/stdlib/_ast/other.rs b/crates/vm/src/stdlib/_ast/other.rs index 5009c588cfc..837c7d12094 100644 --- a/crates/vm/src/stdlib/_ast/other.rs +++ b/crates/vm/src/stdlib/_ast/other.rs @@ -11,14 +11,12 @@ impl Node for ast::ConversionFlag { _source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - // Python's AST uses ASCII codes: 's', 'r', 'a', -1=None - // Note: 255 is -1i8 as u8 (ruff's ConversionFlag::None) - match i32::try_from_object(vm, object)? { - -1 | 255 => Ok(Self::None), + match node_object_to_i32(vm, object)? { + -1 => Ok(Self::None), x if x == b's' as i32 => Ok(Self::Str), x if x == b'r' as i32 => Ok(Self::Repr), x if x == b'a' as i32 => Ok(Self::Ascii), - _ => Err(vm.new_value_error("invalid conversion flag")), + x => Err(vm.new_system_error(format!("Unrecognized conversion character {x}"))), } } } @@ -34,10 +32,13 @@ impl Node for ast::name::Name { _source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - match object.downcast::() { - Ok(name) => Ok(Self::new(name)), - Err(_) => Err(vm.new_value_error("expected str for name")), + if !object.class().is(vm.ctx.types.str_type) { + return Err(vm.new_type_error("AST identifier must be of type str")); } + object + .downcast::() + .map(Self::new) + .map_err(|_| vm.new_type_error("AST identifier must be of type str")) } } @@ -89,11 +90,7 @@ impl Node for ast::Alias { ) -> PyResult { Ok(Self { node_index: Default::default(), - name: Node::ast_from_object( - vm, - source_file, - get_node_field_required(vm, &object, "name", "alias")?, - )?, + name: get_required_identifier_field(vm, source_file, &object, "name", "alias")?, asname: get_node_field_opt(vm, &object, "asname")? .map(|obj| Node::ast_from_object(vm, source_file, obj)) .transpose()?, @@ -137,10 +134,12 @@ impl Node for ast::WithItem { ) -> PyResult { Ok(Self { node_index: Default::default(), - context_expr: Node::ast_from_object( + context_expr: get_required_node_field( vm, source_file, - get_node_field_required(vm, &object, "context_expr", "withitem")?, + &object, + "context_expr", + "withitem", )?, optional_vars: get_node_field_opt(vm, &object, "optional_vars")? .map(|obj| Node::ast_from_object(vm, source_file, obj)) diff --git a/crates/vm/src/stdlib/_ast/parameter.rs b/crates/vm/src/stdlib/_ast/parameter.rs index b0c807a2922..477ceeb6242 100644 --- a/crates/vm/src/stdlib/_ast/parameter.rs +++ b/crates/vm/src/stdlib/_ast/parameter.rs @@ -11,10 +11,12 @@ impl Node for ast::Parameters { vararg, kwonlyargs, kwarg, - range, + range: _, + runtime_defaults, } = self; - let (posonlyargs, args, defaults) = + let (posonlyargs, args, mut defaults) = extract_positional_parameter_defaults(posonlyargs, args); + defaults.runtime_defaults = runtime_defaults; let (kwonlyargs, kw_defaults) = extract_keyword_parameter_defaults(kwonlyargs); let node = NodeAst .into_ref_with_type(vm, pyast::NodeArguments::static_type().to_owned()) @@ -40,9 +42,12 @@ impl Node for ast::Parameters { .unwrap(); dict.set_item("kwarg", kwarg.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("defaults", defaults.ast_to_object(vm, source_file), vm) - .unwrap(); - let _ = range; + let runtime_defaults = defaults.runtime_defaults.take(); + let defaults = runtime_defaults.map_or_else( + || defaults.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("defaults", defaults, vm).unwrap(); node.into() } @@ -51,33 +56,44 @@ impl Node for ast::Parameters { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let kwonlyargs = Node::ast_from_object( + let posonlyargs = PositionalParameters::ast_from_field( vm, source_file, - get_node_field(vm, &object, "kwonlyargs", "arguments")?, - )?; - let kw_defaults = Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "kw_defaults", "arguments")?, - )?; - let kwonlyargs = merge_keyword_parameter_defaults(vm, kwonlyargs, kw_defaults)?; - - let posonlyargs = Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "posonlyargs", "arguments")?, + &object, + "posonlyargs", + "arguments", )?; - let args = Node::ast_from_object( + let args = + PositionalParameters::ast_from_field(vm, source_file, &object, "args", "arguments")?; + let vararg = get_node_field_opt(vm, &object, "vararg")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?; + let kwonlyargs = + KeywordParameters::ast_from_field(vm, source_file, &object, "kwonlyargs", "arguments")?; + let kw_defaults = ParameterDefaults::ast_from_field( vm, source_file, - get_node_field(vm, &object, "args", "arguments")?, + &object, + "kw_defaults", + "arguments", )?; - let defaults = Node::ast_from_object( + let kwarg = get_node_field_opt(vm, &object, "kwarg")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?; + let defaults = ParameterDefaults::ast_from_field_preserve_none( vm, source_file, - get_node_field(vm, &object, "defaults", "arguments")?, + &object, + "defaults", + "arguments", )?; + + let ParameterDefaults { + runtime_defaults, + defaults, + _range: _, + } = defaults; + let kwonlyargs = merge_keyword_parameter_defaults(vm, kwonlyargs, kw_defaults)?; let (posonlyargs, args) = merge_positional_parameter_defaults(vm, posonlyargs, args, defaults)?; @@ -85,14 +101,11 @@ impl Node for ast::Parameters { node_index: Default::default(), posonlyargs, args, - vararg: get_node_field_opt(vm, &object, "vararg")? - .map(|obj| Node::ast_from_object(vm, source_file, obj)) - .transpose()?, + vararg, kwonlyargs, - kwarg: get_node_field_opt(vm, &object, "kwarg")? - .map(|obj| Node::ast_from_object(vm, source_file, obj)) - .transpose()?, + kwarg, range: Default::default(), + runtime_defaults, }) } @@ -103,62 +116,68 @@ impl Node for ast::Parameters { // product impl Node for ast::Parameter { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, name, annotation, // type_comment, range, + runtime_type_comment, + runtime_type_comment_bytes, } = self; // ruff covers the ** in range but python expects it to start at the ident let range = TextRange::new(name.start(), range.end()); let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeArg::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeArg::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("arg", name.ast_to_object(_vm, source_file), _vm) + dict.set_item("arg", name.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item( - "annotation", - annotation.ast_to_object(_vm, source_file), - _vm, + dict.set_item("annotation", annotation.ast_to_object(vm, source_file), vm) + .unwrap(); + let type_comment = super::constant::runtime_string_object( + vm, + runtime_type_comment, + runtime_type_comment_bytes, ) - .unwrap(); - // Ruff AST doesn't track type_comment, so always set to None - dict.set_item("type_comment", _vm.ctx.none(), _vm).unwrap(); - node_add_location(&dict, range, _vm, source_file); + .unwrap_or_else(|| vm.ctx.none()); + dict.set_item("type_comment", type_comment, vm).unwrap(); + node_add_location(&dict, range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { + let name = get_required_identifier_field(vm, source_file, &_object, "arg", "arg")?; + let annotation = get_node_field_opt(vm, &_object, "annotation")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?; + let type_comment = get_ast_string_field_opt(vm, &_object, "type_comment")?; + let (runtime_type_comment, runtime_type_comment_bytes) = type_comment + .map_or((None, None), |type_comment| { + super::constant::runtime_string_from_pyobject(vm, type_comment) + }); + let range = range_from_object(vm, source_file, _object, "arg")?; Ok(Self { node_index: Default::default(), - name: Node::ast_from_object( - _vm, - source_file, - get_node_field_required(_vm, &_object, "arg", "arg")?, - )?, - annotation: get_node_field_opt(_vm, &_object, "annotation")? - .map(|obj| Node::ast_from_object(_vm, source_file, obj)) - .transpose()?, - // type_comment: get_node_field_opt(_vm, &_object, "type_comment")? - // .map(|obj| Node::ast_from_object(_vm, obj)) - // .transpose()?, - range: range_from_object(_vm, source_file, _object, "arg")?, + name, + annotation, + range, + runtime_type_comment, + runtime_type_comment_bytes, }) } } // product impl Node for ast::Keyword { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, arg, @@ -166,32 +185,28 @@ impl Node for ast::Keyword { range: _range, } = self; let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeKeyword::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeKeyword::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("arg", arg.ast_to_object(_vm, source_file), _vm) + dict.set_item("arg", arg.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("value", value.ast_to_object(_vm, source_file), _vm) + dict.set_item("value", value.ast_to_object(vm, source_file), vm) .unwrap(); - node_add_location(&dict, _range, _vm, source_file); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { Ok(Self { node_index: Default::default(), - arg: get_node_field_opt(_vm, &_object, "arg")? - .map(|obj| Node::ast_from_object(_vm, source_file, obj)) + arg: get_node_field_opt(vm, &_object, "arg")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) .transpose()?, - value: Node::ast_from_object( - _vm, - source_file, - get_node_field_required(_vm, &_object, "value", "keyword")?, - )?, - range: range_from_object(_vm, source_file, _object, "keyword")?, + value: get_required_node_field(vm, source_file, &_object, "value", "keyword")?, + range: range_from_object(vm, source_file, _object, "keyword")?, }) } } @@ -201,6 +216,21 @@ struct PositionalParameters { pub args: Box<[ast::Parameter]>, } +impl PositionalParameters { + fn ast_from_field( + vm: &VirtualMachine, + source_file: &SourceFile, + object: &PyObject, + field: &'static str, + typ: &str, + ) -> PyResult { + Ok(Self { + args: get_node_boxed_slice_field(vm, source_file, object, field, typ)?, + _range: TextRange::default(), + }) + } +} + impl Node for PositionalParameters { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { BoxedSlice(self.args).ast_to_object(vm, source_file) @@ -214,7 +244,7 @@ impl Node for PositionalParameters { let args: BoxedSlice<_> = Node::ast_from_object(vm, source_file, object)?; Ok(Self { args: args.0, - _range: TextRange::default(), // TODO + _range: TextRange::default(), }) } } @@ -224,6 +254,21 @@ struct KeywordParameters { pub keywords: Box<[ast::Parameter]>, } +impl KeywordParameters { + fn ast_from_field( + vm: &VirtualMachine, + source_file: &SourceFile, + object: &PyObject, + field: &'static str, + typ: &str, + ) -> PyResult { + Ok(Self { + keywords: get_node_boxed_slice_field(vm, source_file, object, field, typ)?, + _range: TextRange::default(), + }) + } +} + impl Node for KeywordParameters { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { BoxedSlice(self.keywords).ast_to_object(vm, source_file) @@ -237,16 +282,55 @@ impl Node for KeywordParameters { let keywords: BoxedSlice<_> = Node::ast_from_object(vm, source_file, object)?; Ok(Self { keywords: keywords.0, - _range: TextRange::default(), // TODO + _range: TextRange::default(), }) } } struct ParameterDefaults { pub _range: TextRange, // TODO: Use this + runtime_defaults: Option>>, defaults: Box<[Option>]>, } +impl ParameterDefaults { + fn ast_from_field( + vm: &VirtualMachine, + source_file: &SourceFile, + object: &PyObject, + field: &'static str, + typ: &str, + ) -> PyResult { + Ok(Self { + defaults: get_node_boxed_slice_field(vm, source_file, object, field, typ)?, + runtime_defaults: None, + _range: TextRange::default(), + }) + } + + fn ast_from_field_preserve_none( + vm: &VirtualMachine, + source_file: &SourceFile, + object: &PyObject, + field: &'static str, + typ: &str, + ) -> PyResult { + let defaults: Vec>> = + get_node_list_field(vm, source_file, object, field, typ)?; + let runtime_defaults = defaults.iter().any(Option::is_none).then(|| { + defaults + .iter() + .map(|default| default.as_deref().cloned()) + .collect() + }); + Ok(Self { + defaults: defaults.into_boxed_slice(), + runtime_defaults, + _range: TextRange::default(), + }) + } +} + impl Node for ParameterDefaults { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { BoxedSlice(self.defaults).ast_to_object(vm, source_file) @@ -260,7 +344,8 @@ impl Node for ParameterDefaults { let defaults: BoxedSlice<_> = Node::ast_from_object(vm, source_file, object)?; Ok(Self { defaults: defaults.0, - _range: TextRange::default(), // TODO + runtime_defaults: None, + _range: TextRange::default(), }) } } @@ -287,6 +372,7 @@ fn extract_positional_parameter_defaults( .map(|item| item.range()) .reduce(|acc, next| acc.cover(next)) .unwrap_or_default(), + runtime_defaults: None, defaults: defaults.into_boxed_slice(), }; @@ -325,14 +411,13 @@ fn merge_positional_parameter_defaults( vm: &VirtualMachine, posonlyargs: PositionalParameters, args: PositionalParameters, - defaults: ParameterDefaults, + defaults: Box<[Option>]>, ) -> PyResult<( Vec, Vec, )> { let posonlyargs = posonlyargs.args; let args = args.args; - let defaults = defaults.defaults; let mut posonlyargs: Vec<_> = as IntoIterator>::into_iter(posonlyargs) .map(|parameter| ast::ParameterWithDefault { @@ -383,6 +468,7 @@ fn extract_keyword_parameter_defaults( .map(|item| item.range()) .reduce(|acc, next| acc.cover(next)) .unwrap_or_default(), + runtime_defaults: None, defaults: defaults.into_boxed_slice(), }; @@ -422,5 +508,5 @@ fn merge_keyword_parameter_defaults( default, range: Default::default(), }) - .collect()) + .collect::>()) } diff --git a/crates/vm/src/stdlib/_ast/pattern.rs b/crates/vm/src/stdlib/_ast/pattern.rs index a383c25a6b7..387fb59004c 100644 --- a/crates/vm/src/stdlib/_ast/pattern.rs +++ b/crates/vm/src/stdlib/_ast/pattern.rs @@ -10,6 +10,7 @@ impl Node for ast::MatchCase { guard, body, range: _, + runtime_body, } = self; let node = NodeAst .into_ref_with_type(vm, pyast::NodeMatchCase::static_type().to_owned()) @@ -19,8 +20,11 @@ impl Node for ast::MatchCase { .unwrap(); dict.set_item("guard", guard.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("body", body.ast_to_object(vm, source_file), vm) - .unwrap(); + let body = runtime_body.map_or_else( + || body.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("body", body, vm).unwrap(); node.into() } @@ -29,22 +33,18 @@ impl Node for ast::MatchCase { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { + let body: Vec> = + get_node_list_field(vm, source_file, &object, "body", "match_case")?; + let (runtime_body, body) = runtime_stmt_list_from_values(body); Ok(Self { node_index: Default::default(), - pattern: Node::ast_from_object( - vm, - source_file, - get_node_field_required(vm, &object, "pattern", "match_case")?, - )?, + pattern: get_required_node_field(vm, source_file, &object, "pattern", "match_case")?, guard: get_node_field_opt(vm, &object, "guard")? .map(|obj| Node::ast_from_object(vm, source_file, obj)) .transpose()?, - body: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "body", "match_case")?, - )?, + body, range: Default::default(), + runtime_body, }) } } @@ -68,64 +68,152 @@ impl Node for ast::Pattern { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let cls = object.class(); - Ok(if cls.is(pyast::NodePatternMatchValue::static_type()) { - Self::MatchValue(ast::PatternMatchValue::ast_from_object( - vm, - source_file, - object, - )?) - } else if cls.is(pyast::NodePatternMatchSingleton::static_type()) { - Self::MatchSingleton(ast::PatternMatchSingleton::ast_from_object( - vm, - source_file, - object, - )?) - } else if cls.is(pyast::NodePatternMatchSequence::static_type()) { - Self::MatchSequence(ast::PatternMatchSequence::ast_from_object( - vm, - source_file, - object, - )?) - } else if cls.is(pyast::NodePatternMatchMapping::static_type()) { - Self::MatchMapping(ast::PatternMatchMapping::ast_from_object( + if vm.is_none(&object) { + return Err(vm.new_type_error(format!( + "expected some sort of pattern, but got {}", + object.repr(vm)? + ))); + } + enum PatternKind { + Value, + Singleton, + Sequence, + Mapping, + Class, + Star, + As, + Or, + } + let kind = if is_node_instance(vm, &object, pyast::NodePatternMatchValue::static_type())? { + PatternKind::Value + } else if is_node_instance(vm, &object, pyast::NodePatternMatchSingleton::static_type())? { + PatternKind::Singleton + } else if is_node_instance(vm, &object, pyast::NodePatternMatchSequence::static_type())? { + PatternKind::Sequence + } else if is_node_instance(vm, &object, pyast::NodePatternMatchMapping::static_type())? { + PatternKind::Mapping + } else if is_node_instance(vm, &object, pyast::NodePatternMatchClass::static_type())? { + PatternKind::Class + } else if is_node_instance(vm, &object, pyast::NodePatternMatchStar::static_type())? { + PatternKind::Star + } else if is_node_instance(vm, &object, pyast::NodePatternMatchAs::static_type())? { + PatternKind::As + } else if is_node_instance(vm, &object, pyast::NodePatternMatchOr::static_type())? { + PatternKind::Or + } else { + return Err(vm.new_type_error(format!( + "expected some sort of pattern, but got {}", + object.repr(vm)? + ))); + }; + let range = pattern_range_from_object(vm, source_file, object.clone())?; + Ok(match kind { + PatternKind::Value => Self::MatchValue(pattern_match_value_from_object_with_range( vm, source_file, object, - )?) - } else if cls.is(pyast::NodePatternMatchClass::static_type()) { - Self::MatchClass(ast::PatternMatchClass::ast_from_object( + range, + )?), + PatternKind::Singleton => Self::MatchSingleton( + pattern_match_singleton_from_object_with_range(vm, source_file, object, range)?, + ), + PatternKind::Sequence => Self::MatchSequence( + pattern_match_sequence_from_object_with_range(vm, source_file, object, range)?, + ), + PatternKind::Mapping => Self::MatchMapping( + pattern_match_mapping_from_object_with_range(vm, source_file, object, range)?, + ), + PatternKind::Class => Self::MatchClass(pattern_match_class_from_object_with_range( vm, source_file, object, - )?) - } else if cls.is(pyast::NodePatternMatchStar::static_type()) { - Self::MatchStar(ast::PatternMatchStar::ast_from_object( + range, + )?), + PatternKind::Star => Self::MatchStar(pattern_match_star_from_object_with_range( vm, source_file, object, - )?) - } else if cls.is(pyast::NodePatternMatchAs::static_type()) { - Self::MatchAs(ast::PatternMatchAs::ast_from_object( + range, + )?), + PatternKind::As => Self::MatchAs(pattern_match_as_from_object_with_range( vm, source_file, object, - )?) - } else if cls.is(pyast::NodePatternMatchOr::static_type()) { - Self::MatchOr(ast::PatternMatchOr::ast_from_object( + range, + )?), + PatternKind::Or => Self::MatchOr(pattern_match_or_from_object_with_range( vm, source_file, object, - )?) - } else { - return Err(vm.new_type_error(format!( - "expected some sort of pattern, but got {}", - object.repr(vm)? - ))); + range, + )?), }) } } + +fn null_pattern_placeholder(range: TextRange) -> ast::Pattern { + ast::Pattern::MatchAs(ast::PatternMatchAs { + node_index: Default::default(), + range, + pattern: None, + name: None, + }) +} + +fn lower_nullable_patterns(values: &[Option], range: TextRange) -> Vec { + values + .iter() + .cloned() + .map(|value| value.unwrap_or_else(|| null_pattern_placeholder(range))) + .collect() +} + +fn null_expr_placeholder(range: TextRange) -> ast::Expr { + ast::Expr::NoneLiteral(ast::ExprNoneLiteral { + node_index: Default::default(), + range, + }) +} + +fn lower_nullable_exprs(values: &[Option], range: TextRange) -> Vec { + values + .iter() + .cloned() + .map(|value| value.unwrap_or_else(|| null_expr_placeholder(range))) + .collect() +} + +type RuntimePatternList = Option>>; +type PatternListField = (RuntimePatternList, Vec); + +fn pattern_list_from_field( + vm: &VirtualMachine, + source_file: &SourceFile, + object: &PyObject, + field: &'static str, + typ: &str, + range: TextRange, +) -> PyResult { + let values: Vec> = + get_node_list_field(vm, source_file, object, field, typ)?; + let runtime_patterns = values.iter().any(Option::is_none).then(|| values.clone()); + Ok((runtime_patterns, lower_nullable_patterns(&values, range))) +} + // constructor +fn pattern_match_value_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::PatternMatchValue { + node_index: Default::default(), + value: get_required_node_field(vm, source_file, &object, "value", "MatchValue")?, + range, + }) +} + impl Node for ast::PatternMatchValue { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -148,19 +236,29 @@ impl Node for ast::PatternMatchValue { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - value: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "value", "MatchValue")?, - )?, - range: range_from_object(vm, source_file, object, "MatchValue")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "MatchValue")?; + pattern_match_value_from_object_with_range(vm, source_file, object, range) } } // constructor +fn pattern_match_singleton_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::PatternMatchSingleton { + node_index: Default::default(), + value: Node::ast_from_object( + vm, + source_file, + get_node_field(vm, &object, "value", "MatchSingleton")?, + )?, + range, + }) +} + impl Node for ast::PatternMatchSingleton { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -186,15 +284,8 @@ impl Node for ast::PatternMatchSingleton { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - value: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "value", "MatchSingleton")?, - )?, - range: range_from_object(vm, source_file, object, "MatchSingleton")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "MatchSingleton")?; + pattern_match_singleton_from_object_with_range(vm, source_file, object, range) } } @@ -219,21 +310,35 @@ impl Node for ast::Singleton { } else if object.is(&vm.ctx.false_value) { Ok(Self::False) } else { - Err(vm.new_value_error(format!( - "Expected None, True, or False, got {:?}", - object.class().name() - ))) + Err(vm.new_value_error("MatchSingleton can only contain True, False and None")) } } } // constructor +fn pattern_match_sequence_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let (runtime_patterns, patterns) = + pattern_list_from_field(vm, source_file, &object, "patterns", "MatchSequence", range)?; + Ok(ast::PatternMatchSequence { + node_index: Default::default(), + patterns: patterns.to_vec(), + range, + runtime_patterns, + }) +} + impl Node for ast::PatternMatchSequence { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, patterns, range, + runtime_patterns, } = self; let node = NodeAst .into_ref_with_type( @@ -242,8 +347,11 @@ impl Node for ast::PatternMatchSequence { ) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("patterns", patterns.ast_to_object(vm, source_file), vm) - .unwrap(); + let patterns = runtime_patterns.map_or_else( + || patterns.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("patterns", patterns, vm).unwrap(); node_add_location(&dict, range, vm, source_file); node.into() } @@ -253,19 +361,40 @@ impl Node for ast::PatternMatchSequence { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - patterns: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "patterns", "MatchSequence")?, - )?, - range: range_from_object(vm, source_file, object, "MatchSequence")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "MatchSequence")?; + pattern_match_sequence_from_object_with_range(vm, source_file, object, range) } } // constructor +fn pattern_match_mapping_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let keys: Vec> = + get_node_list_field(vm, source_file, &object, "keys", "MatchMapping")?; + let patterns: Vec> = + get_node_list_field(vm, source_file, &object, "patterns", "MatchMapping")?; + let runtime_keys = keys.iter().any(Option::is_none).then(|| keys.clone()); + let runtime_patterns = patterns + .iter() + .any(Option::is_none) + .then(|| patterns.clone()); + Ok(ast::PatternMatchMapping { + node_index: Default::default(), + keys: lower_nullable_exprs(&keys, range), + patterns: lower_nullable_patterns(&patterns, range), + rest: get_node_field_opt(vm, &object, "rest")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + range, + runtime_keys, + runtime_patterns, + }) +} + impl Node for ast::PatternMatchMapping { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -274,15 +403,23 @@ impl Node for ast::PatternMatchMapping { patterns, rest, range, + runtime_keys, + runtime_patterns, } = self; let node = NodeAst .into_ref_with_type(vm, pyast::NodePatternMatchMapping::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("keys", keys.ast_to_object(vm, source_file), vm) - .unwrap(); - dict.set_item("patterns", patterns.ast_to_object(vm, source_file), vm) - .unwrap(); + let keys = runtime_keys.map_or_else( + || keys.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("keys", keys, vm).unwrap(); + let patterns = runtime_patterns.map_or_else( + || patterns.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("patterns", patterns, vm).unwrap(); dict.set_item("rest", rest.ast_to_object(vm, source_file), vm) .unwrap(); node_add_location(&dict, range, vm, source_file); @@ -294,27 +431,57 @@ impl Node for ast::PatternMatchMapping { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - keys: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "keys", "MatchMapping")?, - )?, - patterns: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "patterns", "MatchMapping")?, - )?, - rest: get_node_field_opt(vm, &object, "rest")? - .map(|obj| Node::ast_from_object(vm, source_file, obj)) - .transpose()?, - range: range_from_object(vm, source_file, object, "MatchMapping")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "MatchMapping")?; + pattern_match_mapping_from_object_with_range(vm, source_file, object, range) } } // constructor +fn pattern_match_class_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let cls = get_required_node_field(vm, source_file, &object, "cls", "MatchClass")?; + let patterns: Vec> = + get_node_list_field(vm, source_file, &object, "patterns", "MatchClass")?; + let kwd_attrs = PatternMatchClassKeywordAttributes::ast_from_field( + vm, + source_file, + &object, + "kwd_attrs", + "MatchClass", + )?; + let kwd_patterns: Vec> = + get_node_list_field(vm, source_file, &object, "kwd_patterns", "MatchClass")?; + let has_runtime_shape = kwd_attrs.0.len() != kwd_patterns.len() + || patterns.iter().any(Option::is_none) + || kwd_patterns.iter().any(Option::is_none); + let runtime_patterns = has_runtime_shape.then(|| patterns.clone()); + let runtime_kwd_attrs = has_runtime_shape.then(|| kwd_attrs.0.clone()); + let runtime_kwd_patterns = has_runtime_shape.then(|| kwd_patterns.clone()); + let patterns = PatternMatchClassPatterns(lower_nullable_patterns(&patterns, range)); + let kwd_patterns = + PatternMatchClassKeywordPatterns(lower_nullable_patterns(&kwd_patterns, range)); + let (patterns, keywords) = merge_pattern_match_class(patterns, kwd_attrs, kwd_patterns); + + Ok(ast::PatternMatchClass { + node_index: Default::default(), + cls, + range, + arguments: ast::PatternArguments { + node_index: Default::default(), + range: Default::default(), + patterns, + keywords, + }, + runtime_patterns, + runtime_kwd_attrs, + runtime_kwd_patterns, + }) +} + impl Node for ast::PatternMatchClass { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -322,24 +489,36 @@ impl Node for ast::PatternMatchClass { cls, arguments, range, + runtime_patterns, + runtime_kwd_attrs, + runtime_kwd_patterns, } = self; - let (patterns, kwd_attrs, kwd_patterns) = split_pattern_match_class(arguments); let node = NodeAst .into_ref_with_type(vm, pyast::NodePatternMatchClass::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); dict.set_item("cls", cls.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("patterns", patterns.ast_to_object(vm, source_file), vm) - .unwrap(); - dict.set_item("kwd_attrs", kwd_attrs.ast_to_object(vm, source_file), vm) - .unwrap(); - dict.set_item( - "kwd_patterns", - kwd_patterns.ast_to_object(vm, source_file), - vm, - ) - .unwrap(); + let (patterns, kwd_attrs, kwd_patterns) = + if let (Some(patterns), Some(kwd_attrs), Some(kwd_patterns)) = + (runtime_patterns, runtime_kwd_attrs, runtime_kwd_patterns) + { + ( + patterns.ast_to_object(vm, source_file), + kwd_attrs.ast_to_object(vm, source_file), + kwd_patterns.ast_to_object(vm, source_file), + ) + } else { + let (patterns, kwd_attrs, kwd_patterns) = split_pattern_match_class(arguments); + ( + patterns.ast_to_object(vm, source_file), + kwd_attrs.ast_to_object(vm, source_file), + kwd_patterns.ast_to_object(vm, source_file), + ) + }; + dict.set_item("patterns", patterns, vm).unwrap(); + dict.set_item("kwd_attrs", kwd_attrs, vm).unwrap(); + dict.set_item("kwd_patterns", kwd_patterns, vm).unwrap(); node_add_location(&dict, range, vm, source_file); node.into() } @@ -349,41 +528,8 @@ impl Node for ast::PatternMatchClass { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let patterns = Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "patterns", "MatchClass")?, - )?; - let kwd_attrs: PatternMatchClassKeywordAttributes = Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "kwd_attrs", "MatchClass")?, - )?; - let kwd_patterns: PatternMatchClassKeywordPatterns = Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "kwd_patterns", "MatchClass")?, - )?; - if kwd_attrs.0.len() != kwd_patterns.0.len() { - return Err(vm.new_value_error("MatchClass has mismatched kwd_attrs and kwd_patterns")); - } - let (patterns, keywords) = merge_pattern_match_class(patterns, kwd_attrs, kwd_patterns); - - Ok(Self { - node_index: Default::default(), - cls: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "cls", "MatchClass")?, - )?, - range: range_from_object(vm, source_file, object, "MatchClass")?, - arguments: ast::PatternArguments { - node_index: Default::default(), - range: Default::default(), - patterns, - keywords, - }, - }) + let range = range_from_object(vm, source_file, object.clone(), "MatchClass")?; + pattern_match_class_from_object_with_range(vm, source_file, object, range) } } @@ -405,6 +551,24 @@ impl Node for PatternMatchClassPatterns { struct PatternMatchClassKeywordAttributes(Vec); +impl PatternMatchClassKeywordAttributes { + fn ast_from_field( + vm: &VirtualMachine, + source_file: &SourceFile, + object: &PyObject, + field: &'static str, + typ: &str, + ) -> PyResult { + Ok(Self(get_node_list_field( + vm, + source_file, + object, + field, + typ, + )?)) + } +} + impl Node for PatternMatchClassKeywordAttributes { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { self.0.ast_to_object(vm, source_file) @@ -435,6 +599,21 @@ impl Node for PatternMatchClassKeywordPatterns { } } // constructor +fn pattern_match_star_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::PatternMatchStar { + node_index: Default::default(), + name: get_node_field_opt(vm, &object, "name")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + range, + }) +} + impl Node for ast::PatternMatchStar { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -457,17 +636,30 @@ impl Node for ast::PatternMatchStar { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - name: get_node_field_opt(vm, &object, "name")? - .map(|obj| Node::ast_from_object(vm, source_file, obj)) - .transpose()?, - range: range_from_object(vm, source_file, object, "MatchStar")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "MatchStar")?; + pattern_match_star_from_object_with_range(vm, source_file, object, range) } } // constructor +fn pattern_match_as_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::PatternMatchAs { + node_index: Default::default(), + pattern: get_node_field_opt(vm, &object, "pattern")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + name: get_node_field_opt(vm, &object, "name")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + range, + }) +} + impl Node for ast::PatternMatchAs { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -493,33 +685,45 @@ impl Node for ast::PatternMatchAs { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - pattern: get_node_field_opt(vm, &object, "pattern")? - .map(|obj| Node::ast_from_object(vm, source_file, obj)) - .transpose()?, - name: get_node_field_opt(vm, &object, "name")? - .map(|obj| Node::ast_from_object(vm, source_file, obj)) - .transpose()?, - range: range_from_object(vm, source_file, object, "MatchAs")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "MatchAs")?; + pattern_match_as_from_object_with_range(vm, source_file, object, range) } } // constructor +fn pattern_match_or_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let (runtime_patterns, patterns) = + pattern_list_from_field(vm, source_file, &object, "patterns", "MatchOr", range)?; + Ok(ast::PatternMatchOr { + node_index: Default::default(), + patterns: patterns.to_vec(), + range, + runtime_patterns, + }) +} + impl Node for ast::PatternMatchOr { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, patterns, range, + runtime_patterns, } = self; let node = NodeAst .into_ref_with_type(vm, pyast::NodePatternMatchOr::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("patterns", patterns.ast_to_object(vm, source_file), vm) - .unwrap(); + let patterns = runtime_patterns.map_or_else( + || patterns.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("patterns", patterns, vm).unwrap(); node_add_location(&dict, range, vm, source_file); node.into() } @@ -528,15 +732,8 @@ impl Node for ast::PatternMatchOr { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - patterns: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "patterns", "MatchOr")?, - )?, - range: range_from_object(vm, source_file, object, "MatchOr")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "MatchOr")?; + pattern_match_or_from_object_with_range(vm, source_file, object, range) } } diff --git a/crates/vm/src/stdlib/_ast/pyast.rs b/crates/vm/src/stdlib/_ast/pyast.rs index 7eae6f00986..eb97eec8024 100644 --- a/crates/vm/src/stdlib/_ast/pyast.rs +++ b/crates/vm/src/stdlib/_ast/pyast.rs @@ -3,7 +3,6 @@ use crate::builtins::{PyGenericAlias, PyTuple, PyTupleRef, PyTypeRef, make_union use crate::common::ascii; use crate::convert::ToPyObject; use crate::function::FuncArgs; -use crate::types::Initializer; macro_rules! impl_node { ( @@ -61,6 +60,12 @@ macro_rules! impl_node { macro_rules! impl_base_node { // Base node without fields/attributes (e.g. NodeMod, NodeExpr) ($name:ident) => { + impl_base_node!($name, attributes: []); + }; + ($name:ident, attributes: [$($attr:expr),* $(,)?]) => { + impl_base_node!($name, attributes: [$($attr),*], optional_end_location: false); + }; + ($name:ident, attributes: [$($attr:expr),* $(,)?], optional_end_location: $optional_end_location:expr) => { #[pyclass(flags(HAS_DICT, BASETYPE))] impl $name { #[pymethod] @@ -83,9 +88,24 @@ macro_rules! impl_base_node { (*flags).remove(crate::types::PyTypeFlags::IMMUTABLETYPE); } class.set_attr( - identifier!(ctx, _attributes), + identifier!(ctx, _fields), ctx.empty_tuple.clone().into(), ); + class.set_str_attr("__match_args__", ctx.empty_tuple.clone(), ctx); + class.set_attr( + identifier!(ctx, _attributes), + ctx.new_tuple(vec![ + $( + ctx.new_str(ascii!($attr)).into() + ),* + ]) + .into(), + ); + if $optional_end_location { + let none = ctx.none(); + class.set_str_attr("end_lineno", none.clone(), ctx); + class.set_str_attr("end_col_offset", none, ctx); + } } } }; @@ -179,7 +199,11 @@ impl_node!( #[repr(transparent)] pub(crate) struct NodeStmt(NodeAst); -impl_base_node!(NodeStmt); +impl_base_node!( + NodeStmt, + attributes: ["lineno", "col_offset", "end_lineno", "end_col_offset"], + optional_end_location: true +); impl_node!( #[pyclass(module = "_ast", name = "FunctionType", base = NodeMod)] @@ -378,7 +402,11 @@ impl_node!( #[repr(transparent)] pub(crate) struct NodeExpr(NodeAst); -impl_base_node!(NodeExpr); +impl_base_node!( + NodeExpr, + attributes: ["lineno", "col_offset", "end_lineno", "end_col_offset"], + optional_end_location: true +); impl_node!( #[pyclass(module = "_ast", name = "Continue", base = NodeStmt)] @@ -533,12 +561,11 @@ impl_node!( attributes: ["lineno", "col_offset", "end_lineno", "end_col_offset"], ); -// NodeExprConstant needs custom Initializer to default kind to None #[pyclass(module = "_ast", name = "Constant", base = NodeExpr)] #[repr(transparent)] pub(crate) struct NodeExprConstant(NodeExpr); -#[pyclass(flags(HAS_DICT, BASETYPE), with(Initializer))] +#[pyclass(flags(HAS_DICT, BASETYPE))] impl NodeExprConstant { #[extend_class] fn extend_class_with_fields(ctx: &Context, class: &'static Py) { @@ -580,24 +607,6 @@ impl NodeExprConstant { } } -impl Initializer for NodeExprConstant { - type Args = FuncArgs; - - fn slot_init(zelf: PyObjectRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult<()> { - ::slot_init(zelf.clone(), args, vm)?; - // kind defaults to None if not provided - let dict = zelf.as_object().dict().unwrap(); - if !dict.contains_key("kind", vm) { - dict.set_item("kind", vm.ctx.none(), vm)?; - } - Ok(()) - } - - fn init(_zelf: PyRef, _args: Self::Args, _vm: &VirtualMachine) -> PyResult<()> { - unreachable!("slot_init is defined") - } -} - impl_node!( #[pyclass(module = "_ast", name = "Attribute", base = NodeExpr)] pub(crate) struct NodeExprAttribute, @@ -841,7 +850,11 @@ impl_node!( #[repr(transparent)] pub(crate) struct NodeExceptHandler(NodeAst); -impl_base_node!(NodeExceptHandler); +impl_base_node!( + NodeExceptHandler, + attributes: ["lineno", "col_offset", "end_lineno", "end_col_offset"], + optional_end_location: true +); impl_node!( #[pyclass(module = "_ast", name = "comprehension", base = NodeAst)] @@ -893,7 +906,10 @@ impl_node!( #[repr(transparent)] pub(crate) struct NodePattern(NodeAst); -impl_base_node!(NodePattern); +impl_base_node!( + NodePattern, + attributes: ["lineno", "col_offset", "end_lineno", "end_col_offset"] +); impl_node!( #[pyclass(module = "_ast", name = "match_case", base = NodeAst)] @@ -967,7 +983,10 @@ impl_node!( #[repr(transparent)] pub(crate) struct NodeTypeParam(NodeAst); -impl_base_node!(NodeTypeParam); +impl_base_node!( + NodeTypeParam, + attributes: ["lineno", "col_offset", "end_lineno", "end_col_offset"] +); impl_node!( #[pyclass(module = "_ast", name = "TypeIgnore", base = NodeTypeIgnore)] @@ -1681,7 +1700,6 @@ fn populate_field_types(vm: &VirtualMachine, module: &Py) { let field_types_attr = vm.ctx.intern_str("_field_types"); let annotations_attr = vm.ctx.intern_str("__annotations__"); - let empty_dict: PyObjectRef = vm.ctx.new_dict().into(); for &(class_name, fields) in FIELD_TYPES { if fields.is_empty() { @@ -1752,36 +1770,6 @@ fn populate_field_types(vm: &VirtualMachine, module: &Py) { type_obj.set_attr(annotations_attr, field_types); } } - - // Base AST classes (e.g., expr, stmt) should still expose __annotations__. - const BASE_AST_TYPES: &[&str] = &[ - "mod", - "stmt", - "expr", - "expr_context", - "boolop", - "operator", - "unaryop", - "cmpop", - "excepthandler", - "pattern", - "type_ignore", - "type_param", - ]; - for &class_name in BASE_AST_TYPES { - let class = module - .get_attr(class_name, vm) - .unwrap_or_else(|_| panic!("AST class '{class_name}' not found in module")); - let Some(type_obj) = class.downcast_ref::() else { - continue; - }; - if type_obj.get_attr(field_types_attr).is_none() { - type_obj.set_attr(field_types_attr, empty_dict.clone()); - } - if type_obj.get_attr(annotations_attr).is_none() { - type_obj.set_attr(annotations_attr, empty_dict.clone()); - } - } } fn populate_singletons(vm: &VirtualMachine, module: &Py) { diff --git a/crates/vm/src/stdlib/_ast/python.rs b/crates/vm/src/stdlib/_ast/python.rs index ee5588acb87..e7697e4cf4b 100644 --- a/crates/vm/src/stdlib/_ast/python.rs +++ b/crates/vm/src/stdlib/_ast/python.rs @@ -7,13 +7,10 @@ use super::{ #[pymodule] pub(crate) mod _ast { use crate::{ - AsObject, Context, Py, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, - builtins::{ - PyDictRef, PyStr, PyStrRef, PyTupleRef, PyType, PyTypeRef, PyUtf8Str, PyUtf8StrRef, - }, + AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, + builtins::{PyDictRef, PySet, PyStr, PyTupleRef, PyType, PyTypeRef, PyUtf8Str}, class::{PyClassImpl, StaticType}, - common::wtf8::Wtf8, - function::{FuncArgs, KwArgs, PyMethodDef, PyMethodFlags}, + function::{ArgIterable, FuncArgs, KwArgs, PyMethodDef, PyMethodFlags}, stdlib::_ast::repr, types::{Constructor, Initializer}, warn, @@ -117,10 +114,12 @@ pub(crate) mod _ast { let fields = cls.get_attr(vm.ctx.intern_str("_fields")); if let Some(fields) = fields { - let fields: Vec = fields.try_to_value(vm)?; + let fields = fields.sequence_unchecked(); + let numfields = fields.length(vm)?; let mut positional: Vec = Vec::new(); - for field in fields { - if dict.get_item_opt::(field.as_wtf8(), vm)?.is_some() { + for i in 0..numfields { + let field = fields.get_item(i as isize, vm)?; + if dict.get_item_opt(&*field, vm)?.is_some() { positional.push(vm.ctx.none()); } else { break; @@ -136,6 +135,85 @@ pub(crate) mod _ast { .new_tuple(vec![type_obj, vm.ctx.new_tuple(vec![]).into(), dict.into()])) } + fn ast_replace_update_payload( + payload: &PyDictRef, + keys: Option<&PyObjectRef>, + dict: &PyDictRef, + vm: &VirtualMachine, + ) -> PyResult<()> { + let Some(keys) = keys else { + return Ok(()); + }; + let keys = keys.sequence_unchecked(); + let num_keys = keys.length(vm)?; + for i in 0..num_keys { + let key = keys.get_item(i as isize, vm)?; + if let Some(value) = dict.get_item_opt(&*key, vm)? { + payload.set_item(&*key, value, vm)?; + } + } + Ok(()) + } + + fn ast_replace_set_update( + expecting: &PyRef, + iterable: Option<&PyObjectRef>, + vm: &VirtualMachine, + ) -> PyResult<()> { + let Some(iterable) = iterable else { + return Ok(()); + }; + let iterable = iterable.clone().try_into_value::(vm)?; + for item in iterable.iter(vm)? { + expecting.add(item?, vm)?; + } + Ok(()) + } + + fn ast_replace_set_discard( + expecting: &PyRef, + key: &PyObject, + vm: &VirtualMachine, + ) -> PyResult { + let contained = expecting + .as_object() + .sequence_unchecked() + .contains(key, vm)?; + if contained { + vm.call_method(expecting.as_object(), "discard", (key.to_owned(),))?; + } + Ok(contained) + } + + fn ast_replace_set_difference_update( + expecting: &PyRef, + iterable: Option<&PyObjectRef>, + vm: &VirtualMachine, + ) -> PyResult<()> { + let Some(iterable) = iterable else { + return Ok(()); + }; + let iterable = iterable.clone().try_into_value::(vm)?; + for item in iterable.iter(vm)? { + let item = item?; + ast_replace_set_discard(expecting, &item, vm)?; + } + Ok(()) + } + + fn ast_set_attr( + obj: &PyObject, + name: &PyObject, + value: impl Into, + vm: &VirtualMachine, + ) -> PyResult<()> { + let name = name + .to_owned() + .downcast::() + .map_err(|_| vm.new_type_error("attribute name must be string"))?; + obj.set_attr(&name, value, vm) + } + pub(crate) fn ast_replace(zelf: PyObjectRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { if !args.args.is_empty() { return Err(vm.new_type_error("__replace__() takes no positional arguments")); @@ -146,22 +224,13 @@ pub(crate) mod _ast { let attributes = cls.get_attr(vm.ctx.intern_str("_attributes")); let dict = zelf.as_object().dict(); - let mut expecting: std::collections::HashSet = std::collections::HashSet::new(); - if let Some(fields) = fields.clone() { - let fields: Vec = fields.try_to_value(vm)?; - for field in fields { - expecting.insert(field.as_str().to_owned()); - } - } - if let Some(attributes) = attributes.clone() { - let attributes: Vec = attributes.try_to_value(vm)?; - for attr in attributes { - expecting.insert(attr.as_str().to_owned()); - } - } + let expecting = PySet::default().into_ref(&vm.ctx); + ast_replace_set_update(&expecting, fields.as_ref(), vm)?; + ast_replace_set_update(&expecting, attributes.as_ref(), vm)?; for (key, _value) in &args.kwargs { - if !expecting.remove(key) { + let key_obj: PyObjectRef = vm.ctx.new_str(key.as_str()).into(); + if !ast_replace_set_discard(&expecting, &key_obj, vm)? { return Err(vm.new_type_error(format!( "{}.__replace__ got an unexpected keyword argument '{}'.", cls.name(), @@ -172,16 +241,9 @@ pub(crate) mod _ast { if let Some(dict) = dict.as_ref() { for (key, _value) in dict.items_vec() { - if let Ok(key) = key.downcast::() { - expecting.remove(key.as_str()); - } - } - if let Some(attributes) = attributes.clone() { - let attributes: Vec = attributes.try_to_value(vm)?; - for attr in attributes { - expecting.remove(attr.as_str()); - } + ast_replace_set_discard(&expecting, &key, vm)?; } + ast_replace_set_difference_update(&expecting, attributes.as_ref(), vm)?; } // Discard optional fields (T | None). @@ -189,20 +251,18 @@ pub(crate) mod _ast { && let Ok(field_types) = field_types.downcast::() { for (key, value) in field_types.items_vec() { - let Ok(key) = key.downcast::() else { - continue; - }; if value.fast_isinstance(vm.ctx.types.union_type) { - expecting.remove(key.as_str()); + ast_replace_set_discard(&expecting, &key, vm)?; } } } - if !expecting.is_empty() { - let mut names: Vec = expecting - .into_iter() - .map(|name| format!("{name:?}")) - .collect(); + let remaining = expecting.elements(); + if !remaining.is_empty() { + let mut names = Vec::with_capacity(remaining.len()); + for name in &remaining { + names.push(name.repr(vm)?.to_string()); + } names.sort(); let missing = names.join(", "); let count = names.len(); @@ -217,22 +277,8 @@ pub(crate) mod _ast { let payload = vm.ctx.new_dict(); if let Some(dict) = dict { - if let Some(fields) = fields { - let fields: Vec = fields.try_to_value(vm)?; - for field in fields { - if let Some(value) = dict.get_item_opt::(field.as_wtf8(), vm)? { - payload.set_item(field.as_object(), value, vm)?; - } - } - } - if let Some(attributes) = attributes { - let attributes: Vec = attributes.try_to_value(vm)?; - for attr in attributes { - if let Some(value) = dict.get_item_opt::(attr.as_wtf8(), vm)? { - payload.set_item(attr.as_object(), value, vm)?; - } - } - } + ast_replace_update_payload(&payload, fields.as_ref(), &dict, vm)?; + ast_replace_update_payload(&payload, attributes.as_ref(), &dict, vm)?; } for (key, value) in args.kwargs { payload.set_item(vm.ctx.intern_str(key), value, vm)?; @@ -327,7 +373,7 @@ pub(crate) mod _ast { } fn py_new(_cls: &Py, _args: Self::Args, _vm: &VirtualMachine) -> PyResult { - unimplemented!("use slot_new") + unreachable!("NodeAst construction is handled by slot_new") } } @@ -350,52 +396,55 @@ pub(crate) mod _ast { zelf.class().name() )) })?; - let fields: Vec = fields.try_to_value(vm)?; + let fields_seq = fields.sequence_unchecked(); + let numfields = fields_seq.length(vm)?; + let remaining_fields = PySet::default().into_ref(&vm.ctx); + ast_replace_set_update(&remaining_fields, Some(&fields), vm)?; let n_args = args.args.len(); - if n_args > fields.len() { + if n_args > numfields { return Err(vm.new_type_error(format!( "{} constructor takes at most {} positional argument{}", zelf.class().name(), - fields.len(), - if fields.len() == 1 { "" } else { "s" }, + numfields, + if numfields == 1 { "" } else { "s" }, ))); } - // Track which fields were set - let mut set_fields = std::collections::HashSet::new(); - let mut attributes: Option> = None; + let mut attributes: Option = None; - for (name, arg) in fields.iter().zip(args.args) { - zelf.set_attr(name, arg, vm)?; - set_fields.insert(name.as_str().to_owned()); + for (i, arg) in args.args.into_iter().enumerate() { + let name = fields_seq.get_item(i as isize, vm)?; + ast_set_attr(&zelf, &name, arg, vm)?; + ast_replace_set_discard(&remaining_fields, &name, vm)?; } for (key, value) in args.kwargs { - if let Some(pos) = fields.iter().position(|f| f.as_bytes() == key.as_bytes()) - && pos < n_args - { - return Err(vm.new_type_error(format!( - "{} got multiple values for argument '{}'", - zelf.class().name(), - key - ))); - } - - if fields - .iter() - .all(|field| field.as_bytes() != key.as_bytes()) - { - let attrs = if let Some(attrs) = &attributes { - attrs + let key_obj: PyObjectRef = vm.ctx.new_str(key.as_str()).into(); + let contains = fields_seq.contains(&key_obj, vm)?; + if contains { + if !ast_replace_set_discard(&remaining_fields, &key_obj, vm)? { + return Err(vm.new_type_error(format!( + "{} got multiple values for argument '{}'", + zelf.class().name(), + key + ))); + } + } else { + let attrs = if let Some(attributes) = &attributes { + attributes } else { let attrs = zelf .class() .get_attr(vm.ctx.intern_str("_attributes")) - .and_then(|attr| attr.try_to_value::>(vm).ok()) - .unwrap_or_default(); + .ok_or_else(|| { + vm.new_attribute_error(format!( + "type object '{}' has no attribute '_attributes'", + zelf.class().name() + )) + })?; attributes = Some(attrs); attributes.as_ref().unwrap() }; - if attrs.iter().all(|attr| attr.as_bytes() != key.as_bytes()) { + if !attrs.sequence_unchecked().contains(&key_obj, vm)? { let message = vm.ctx.new_str(format!( "{}.__init__ got an unexpected keyword argument '{}'. \ Support for arbitrary keyword arguments is deprecated and will be removed in Python 3.15.", @@ -412,7 +461,6 @@ Support for arbitrary keyword arguments is deprecated and will be removed in Pyt } } - set_fields.insert(key.clone()); zelf.set_attr(vm.ctx.intern_str(key), value, vm)?; } @@ -425,17 +473,14 @@ Support for arbitrary keyword arguments is deprecated and will be removed in Pyt let expr_ctx_type: PyObjectRef = super::super::pyast::NodeExprContext::make_static_type().into(); - for field in &fields { - if set_fields.contains(field.as_str()) { - continue; - } - if let Some(ftype) = ft_dict.get_item_opt::(field.as_wtf8(), vm)? { + for field in remaining_fields.elements() { + if let Some(ftype) = ft_dict.get_item_opt(&*field, vm)? { if ftype.fast_isinstance(vm.ctx.types.union_type) { // Optional field (T | None) — no default } else if ftype.fast_isinstance(vm.ctx.types.generic_alias_type) { // List field (list[T]) — default to [] let empty_list: PyObjectRef = vm.ctx.new_list(vec![]).into(); - zelf.set_attr(vm.ctx.intern_str(field.as_wtf8()), empty_list, vm)?; + ast_set_attr(&zelf, &field, empty_list, vm)?; } else if ftype.is(&expr_ctx_type) { // expr_context — default to Load() let load_type = @@ -445,13 +490,15 @@ Support for arbitrary keyword arguments is deprecated and will be removed in Pyt .unwrap_or_else(|| { vm.ctx.new_base_object(load_type, Some(vm.ctx.new_dict())) }); - zelf.set_attr(vm.ctx.intern_str(field.as_wtf8()), load_instance, vm)?; + ast_set_attr(&zelf, &field, load_instance, vm)?; } else { // Required field missing: emit DeprecationWarning. + let field_repr = field.repr(vm)?; let message = vm.ctx.new_str(format!( - "{}.__init__ missing 1 required positional argument: '{}'", + "{}.__init__ missing 1 required positional argument: {}. \ +This will become an error in Python 3.15.", zelf.class().name(), - field.as_wtf8() + field_repr )); warn::warn( message.into(), @@ -461,6 +508,21 @@ Support for arbitrary keyword arguments is deprecated and will be removed in Pyt vm, )?; } + } else { + let field_repr = field.repr(vm)?; + let message = vm.ctx.new_str(format!( + "Field {} is missing from {}._field_types. \ +This will become an error in Python 3.15.", + field_repr, + zelf.class().name() + )); + warn::warn( + message.into(), + Some(vm.ctx.exceptions.deprecation_warning.to_owned()), + 1, + None, + vm, + )?; } } } @@ -510,9 +572,29 @@ Support for arbitrary keyword arguments is deprecated and will be removed in Pyt .map_err(|_| vm.new_type_error("AST is not a type"))?; let ctx = &vm.ctx; let empty_tuple = ctx.empty_tuple.clone(); + let set_empty_annotations = |typ: &Py| { + typ.set_str_attr("__annotations__", ctx.new_dict(), ctx); + }; + set_empty_annotations(&ast_type); ast_type.set_str_attr("_fields", empty_tuple.clone(), ctx); ast_type.set_str_attr("_attributes", empty_tuple.clone(), ctx); ast_type.set_str_attr("__match_args__", empty_tuple, ctx); + for typ in [ + super::super::pyast::NodeMod::static_type(), + super::super::pyast::NodeStmt::static_type(), + super::super::pyast::NodeExpr::static_type(), + super::super::pyast::NodeExprContext::static_type(), + super::super::pyast::NodeBoolOp::static_type(), + super::super::pyast::NodeOperator::static_type(), + super::super::pyast::NodeUnaryOp::static_type(), + super::super::pyast::NodeCmpOp::static_type(), + super::super::pyast::NodeExceptHandler::static_type(), + super::super::pyast::NodePattern::static_type(), + super::super::pyast::NodeTypeIgnore::static_type(), + super::super::pyast::NodeTypeParam::static_type(), + ] { + set_empty_annotations(typ); + } const AST_REDUCE: PyMethodDef = PyMethodDef::new_const( "__reduce__", diff --git a/crates/vm/src/stdlib/_ast/repr.rs b/crates/vm/src/stdlib/_ast/repr.rs index 2897447fbec..57f00c095e0 100644 --- a/crates/vm/src/stdlib/_ast/repr.rs +++ b/crates/vm/src/stdlib/_ast/repr.rs @@ -1,7 +1,8 @@ use crate::{ AsObject, PyObjectRef, PyResult, VirtualMachine, - builtins::{PyList, PyTuple}, + builtins::{PyList, PyStr, PyTuple}, class::PyClassImpl, + recursion::ReprGuard, stdlib::_ast::NodeAst, }; use rustpython_common::wtf8::Wtf8Buf; @@ -33,9 +34,7 @@ fn repr_ast_list(vm: &VirtualMachine, items: Vec, depth: usize) -> rendered.push_wtf8(&parts[0]); } if items.len() > 2 { - if !parts[0].is_empty() { - rendered.push_wtf8(", ...".as_ref()); - } + rendered.push_wtf8(", ...".as_ref()); if parts.len() > 1 { rendered.push_wtf8(", ".as_ref()); rendered.push_wtf8(&parts[1]); @@ -75,9 +74,7 @@ fn repr_ast_tuple(vm: &VirtualMachine, items: Vec, depth: usize) -> rendered.push_wtf8(&parts[0]); } if items.len() > 2 { - if !parts[0].is_empty() { - rendered.push_wtf8(", ...".as_ref()); - } + rendered.push_wtf8(", ...".as_ref()); if parts.len() > 1 { rendered.push_wtf8(", ".as_ref()); rendered.push_wtf8(&parts[1]); @@ -86,9 +83,6 @@ fn repr_ast_tuple(vm: &VirtualMachine, items: Vec, depth: usize) -> rendered.push_wtf8(", ".as_ref()); rendered.push_wtf8(&parts[1]); } - if items.len() == 1 { - rendered.push_wtf8(",".as_ref()); - } rendered.push_wtf8(")".as_ref()); Ok(rendered) } @@ -104,18 +98,24 @@ pub(crate) fn repr_ast_node( s.push_wtf8("(...)".as_ref()); return Ok(s); } + let Some(_guard) = ReprGuard::enter(vm, obj.as_object()) else { + let mut s = Wtf8Buf::from(&*cls.name()); + s.push_wtf8("(...)".as_ref()); + return Ok(s); + }; - let fields = cls.get_attr(vm.ctx.intern_str("_fields")); - let fields = match fields { - Some(fields) => fields.try_to_value::>(vm)?, + let fields = match cls.get_attr(vm.ctx.intern_str("_fields")) { + Some(fields) => fields, None => { let mut s = Wtf8Buf::from(&*cls.name()); s.push_wtf8("(...)".as_ref()); return Ok(s); } }; + let fields = fields.sequence_unchecked(); + let numfields = fields.length(vm)?; - if fields.is_empty() { + if numfields == 0 { let mut s = Wtf8Buf::from(&*cls.name()); s.push_wtf8("()".as_ref()); return Ok(s); @@ -124,8 +124,12 @@ pub(crate) fn repr_ast_node( let mut rendered = Wtf8Buf::from(&*cls.name()); rendered.push_wtf8("(".as_ref()); - for (idx, field) in fields.iter().enumerate() { - let value = obj.get_attr(field, vm)?; + for idx in 0..numfields { + let field = fields.get_item(idx as isize, vm)?; + let field = field + .downcast::() + .map_err(|_| vm.new_type_error("attribute name must be string"))?; + let value = obj.get_attr(&field, vm)?; let value_repr = if value.fast_isinstance(vm.ctx.types.list_type) { let list = value .downcast::() diff --git a/crates/vm/src/stdlib/_ast/statement.rs b/crates/vm/src/stdlib/_ast/statement.rs index 43d1162a402..ad3306fce50 100644 --- a/crates/vm/src/stdlib/_ast/statement.rs +++ b/crates/vm/src/stdlib/_ast/statement.rs @@ -1,7 +1,67 @@ use super::*; -use crate::stdlib::_ast::argument::{merge_class_def_args, split_class_def_args}; +use crate::stdlib::_ast::argument::{ + KeywordArguments, PositionalArguments, merge_class_def_args, split_class_def_args, +}; +use crate::stdlib::_ast::exception::except_handler_from_object_unvalidated_range; +use crate::stdlib::_ast::type_parameters::type_params_from_field; use rustpython_compiler_core::SourceFile; +fn runtime_decorator_expr_list(values: &[Option]) -> Vec> { + values + .iter() + .map(|value| value.as_ref().map(|decorator| decorator.expression.clone())) + .collect() +} + +fn lower_runtime_decorator_list(values: Vec>) -> Vec { + values + .into_iter() + .map(|value| { + value.unwrap_or_else(|| ast::Decorator { + range: Default::default(), + node_index: Default::default(), + expression: runtime_null_expr_placeholder(), + }) + }) + .collect() +} + +fn definition_range_from_name( + source_file: &SourceFile, + name_start: TextSize, + end: TextSize, + keyword: &str, +) -> TextRange { + let source_code = source_file.to_source_code(); + let line = source_code.line_index(name_start); + let line_start = source_code.line_start(line); + let keyword_start = source_code + .slice(TextRange::new(line_start, name_start)) + .rfind(keyword) + .map_or(line_start, |offset| { + line_start + TextSize::new(offset as u32) + }); + TextRange::new(keyword_start, end) +} + +fn runtime_stmt_type_comment( + vm: &VirtualMachine, + type_comment: Option, +) -> (Option>, Option>) { + type_comment.map_or((None, None), |type_comment| { + super::constant::runtime_string_from_pyobject(vm, type_comment) + }) +} + +fn runtime_stmt_type_comment_object( + vm: &VirtualMachine, + value: Option>, + bytes: Option>, +) -> PyObjectRef { + super::constant::runtime_stmt_type_comment_object(vm, value, bytes) + .unwrap_or_else(|| vm.ctx.none()) +} + // sum impl Node for ast::Stmt { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { @@ -31,110 +91,292 @@ impl Node for ast::Stmt { Self::Break(cons) => cons.ast_to_object(vm, source_file), Self::Continue(cons) => cons.ast_to_object(vm, source_file), Self::IpyEscapeCommand(_) => { - unimplemented!("IPython escape command is not allowed in Python AST") + unreachable!("IPython escape command is not part of Python AST") } } } - #[expect(clippy::if_same_then_else, reason = "Looks better here")] fn ast_from_object( vm: &VirtualMachine, source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let cls = object.class(); - Ok(if cls.is(pyast::NodeStmtFunctionDef::static_type()) { - Self::FunctionDef(ast::StmtFunctionDef::ast_from_object( + if vm.is_none(&object) { + return Err(vm.new_value_error("None disallowed in statement list")); + } + enum StmtKind { + FunctionDef { is_async: bool }, + ClassDef, + Return, + Delete, + Assign, + TypeAlias, + AugAssign, + AnnAssign, + For { is_async: bool }, + While, + If, + With { is_async: bool }, + Match, + Raise, + Try { is_star: bool }, + Assert, + Import, + ImportFrom, + Global, + Nonlocal, + Expr, + Pass, + Break, + Continue, + } + let kind = if is_node_instance(vm, &object, pyast::NodeStmtFunctionDef::static_type())? { + StmtKind::FunctionDef { is_async: false } + } else if is_node_instance(vm, &object, pyast::NodeStmtAsyncFunctionDef::static_type())? { + StmtKind::FunctionDef { is_async: true } + } else if is_node_instance(vm, &object, pyast::NodeStmtClassDef::static_type())? { + StmtKind::ClassDef + } else if is_node_instance(vm, &object, pyast::NodeStmtReturn::static_type())? { + StmtKind::Return + } else if is_node_instance(vm, &object, pyast::NodeStmtDelete::static_type())? { + StmtKind::Delete + } else if is_node_instance(vm, &object, pyast::NodeStmtAssign::static_type())? { + StmtKind::Assign + } else if is_node_instance(vm, &object, pyast::NodeStmtTypeAlias::static_type())? { + StmtKind::TypeAlias + } else if is_node_instance(vm, &object, pyast::NodeStmtAugAssign::static_type())? { + StmtKind::AugAssign + } else if is_node_instance(vm, &object, pyast::NodeStmtAnnAssign::static_type())? { + StmtKind::AnnAssign + } else if is_node_instance(vm, &object, pyast::NodeStmtFor::static_type())? { + StmtKind::For { is_async: false } + } else if is_node_instance(vm, &object, pyast::NodeStmtAsyncFor::static_type())? { + StmtKind::For { is_async: true } + } else if is_node_instance(vm, &object, pyast::NodeStmtWhile::static_type())? { + StmtKind::While + } else if is_node_instance(vm, &object, pyast::NodeStmtIf::static_type())? { + StmtKind::If + } else if is_node_instance(vm, &object, pyast::NodeStmtWith::static_type())? { + StmtKind::With { is_async: false } + } else if is_node_instance(vm, &object, pyast::NodeStmtAsyncWith::static_type())? { + StmtKind::With { is_async: true } + } else if is_node_instance(vm, &object, pyast::NodeStmtMatch::static_type())? { + StmtKind::Match + } else if is_node_instance(vm, &object, pyast::NodeStmtRaise::static_type())? { + StmtKind::Raise + } else if is_node_instance(vm, &object, pyast::NodeStmtTry::static_type())? { + StmtKind::Try { is_star: false } + } else if is_node_instance(vm, &object, pyast::NodeStmtTryStar::static_type())? { + StmtKind::Try { is_star: true } + } else if is_node_instance(vm, &object, pyast::NodeStmtAssert::static_type())? { + StmtKind::Assert + } else if is_node_instance(vm, &object, pyast::NodeStmtImport::static_type())? { + StmtKind::Import + } else if is_node_instance(vm, &object, pyast::NodeStmtImportFrom::static_type())? { + StmtKind::ImportFrom + } else if is_node_instance(vm, &object, pyast::NodeStmtGlobal::static_type())? { + StmtKind::Global + } else if is_node_instance(vm, &object, pyast::NodeStmtNonlocal::static_type())? { + StmtKind::Nonlocal + } else if is_node_instance(vm, &object, pyast::NodeStmtExpr::static_type())? { + StmtKind::Expr + } else if is_node_instance(vm, &object, pyast::NodeStmtPass::static_type())? { + StmtKind::Pass + } else if is_node_instance(vm, &object, pyast::NodeStmtBreak::static_type())? { + StmtKind::Break + } else if is_node_instance(vm, &object, pyast::NodeStmtContinue::static_type())? { + StmtKind::Continue + } else { + return Err(vm.new_type_error(format!( + "expected some sort of stmt, but got {}", + object.repr(vm)? + ))); + }; + let range = stmt_range_from_object(vm, source_file, object.clone())?; + Ok(match kind { + StmtKind::FunctionDef { is_async } => Self::FunctionDef( + stmt_function_def_from_object_with_range(vm, source_file, object, range, is_async)?, + ), + StmtKind::ClassDef => Self::ClassDef(stmt_class_def_from_object_with_range( vm, source_file, object, - )?) - } else if cls.is(pyast::NodeStmtAsyncFunctionDef::static_type()) { - Self::FunctionDef(ast::StmtFunctionDef::ast_from_object( + range, + )?), + StmtKind::Return => Self::Return(stmt_return_from_object_with_range( vm, source_file, object, - )?) - } else if cls.is(pyast::NodeStmtClassDef::static_type()) { - Self::ClassDef(ast::StmtClassDef::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtReturn::static_type()) { - Self::Return(ast::StmtReturn::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtDelete::static_type()) { - Self::Delete(ast::StmtDelete::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtAssign::static_type()) { - Self::Assign(ast::StmtAssign::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtTypeAlias::static_type()) { - Self::TypeAlias(ast::StmtTypeAlias::ast_from_object( + range, + )?), + StmtKind::Delete => Self::Delete(stmt_delete_from_object_with_range( vm, source_file, object, - )?) - } else if cls.is(pyast::NodeStmtAugAssign::static_type()) { - Self::AugAssign(ast::StmtAugAssign::ast_from_object( + range, + )?), + StmtKind::Assign => Self::Assign(stmt_assign_from_object_with_range( vm, source_file, object, - )?) - } else if cls.is(pyast::NodeStmtAnnAssign::static_type()) { - Self::AnnAssign(ast::StmtAnnAssign::ast_from_object( + range, + )?), + StmtKind::TypeAlias => Self::TypeAlias(stmt_type_alias_from_object_with_range( vm, source_file, object, - )?) - } else if cls.is(pyast::NodeStmtFor::static_type()) { - Self::For(ast::StmtFor::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtAsyncFor::static_type()) { - Self::For(ast::StmtFor::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtWhile::static_type()) { - Self::While(ast::StmtWhile::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtIf::static_type()) { - Self::If(ast::StmtIf::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtWith::static_type()) { - Self::With(ast::StmtWith::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtAsyncWith::static_type()) { - Self::With(ast::StmtWith::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtMatch::static_type()) { - Self::Match(ast::StmtMatch::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtRaise::static_type()) { - Self::Raise(ast::StmtRaise::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtTry::static_type()) { - Self::Try(ast::StmtTry::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtTryStar::static_type()) { - Self::Try(ast::StmtTry::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtAssert::static_type()) { - Self::Assert(ast::StmtAssert::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtImport::static_type()) { - Self::Import(ast::StmtImport::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtImportFrom::static_type()) { - Self::ImportFrom(ast::StmtImportFrom::ast_from_object( + range, + )?), + StmtKind::AugAssign => Self::AugAssign(stmt_aug_assign_from_object_with_range( vm, source_file, object, - )?) - } else if cls.is(pyast::NodeStmtGlobal::static_type()) { - Self::Global(ast::StmtGlobal::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtNonlocal::static_type()) { - Self::Nonlocal(ast::StmtNonlocal::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtExpr::static_type()) { - Self::Expr(ast::StmtExpr::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtPass::static_type()) { - Self::Pass(ast::StmtPass::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtBreak::static_type()) { - Self::Break(ast::StmtBreak::ast_from_object(vm, source_file, object)?) - } else if cls.is(pyast::NodeStmtContinue::static_type()) { - Self::Continue(ast::StmtContinue::ast_from_object(vm, source_file, object)?) - } else if vm.is_none(&object) { - return Err(vm.new_value_error("None disallowed in statement list")); - } else { - return Err(vm.new_type_error(format!( - "expected some sort of stmt, but got {}", - object.repr(vm)? - ))); + range, + )?), + StmtKind::AnnAssign => Self::AnnAssign(stmt_ann_assign_from_object_with_range( + vm, + source_file, + object, + range, + )?), + StmtKind::For { is_async } => Self::For(stmt_for_from_object_with_range( + vm, + source_file, + object, + range, + is_async, + )?), + StmtKind::While => Self::While(stmt_while_from_object_with_range( + vm, + source_file, + object, + range, + )?), + StmtKind::If => Self::If(elif_else_clause::ast_from_object_with_range( + vm, + source_file, + object, + range, + )?), + StmtKind::With { is_async } => Self::With(stmt_with_from_object_with_range( + vm, + source_file, + object, + range, + is_async, + )?), + StmtKind::Match => Self::Match(stmt_match_from_object_with_range( + vm, + source_file, + object, + range, + )?), + StmtKind::Raise => Self::Raise(stmt_raise_from_object_with_range( + vm, + source_file, + object, + range, + )?), + StmtKind::Try { is_star } => Self::Try(stmt_try_from_object_with_range( + vm, + source_file, + object, + range, + is_star, + )?), + StmtKind::Assert => Self::Assert(stmt_assert_from_object_with_range( + vm, + source_file, + object, + range, + )?), + StmtKind::Import => Self::Import(stmt_import_from_object_with_range( + vm, + source_file, + object, + range, + )?), + StmtKind::ImportFrom => Self::ImportFrom(stmt_import_from_from_object_with_range( + vm, + source_file, + object, + range, + )?), + StmtKind::Global => Self::Global(stmt_global_from_object_with_range( + vm, + source_file, + object, + range, + )?), + StmtKind::Nonlocal => Self::Nonlocal(stmt_nonlocal_from_object_with_range( + vm, + source_file, + object, + range, + )?), + StmtKind::Expr => Self::Expr(stmt_expr_from_object_with_range( + vm, + source_file, + object, + range, + )?), + StmtKind::Pass => Self::Pass(stmt_pass_from_object_with_range(range)), + StmtKind::Break => Self::Break(stmt_break_from_object_with_range(range)), + StmtKind::Continue => Self::Continue(stmt_continue_from_object_with_range(range)), }) } } // constructor +fn stmt_function_def_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, + is_async: bool, +) -> PyResult { + let typ = if is_async { + "AsyncFunctionDef" + } else { + "FunctionDef" + }; + let name = get_required_identifier_field(vm, source_file, &object, "name", typ)?; + let parameters = Node::ast_from_object( + vm, + source_file, + get_node_field_required(vm, &object, "args", typ)?, + )?; + let body: Vec> = get_node_list_field(vm, source_file, &object, "body", typ)?; + let decorator_list: Vec> = + get_node_list_field(vm, source_file, &object, "decorator_list", typ)?; + let runtime_decorator_exprs = runtime_decorator_expr_list(&decorator_list); + let runtime_body = runtime_stmt_list_metadata(&body); + let runtime_decorator_list = runtime_expr_list_metadata(&runtime_decorator_exprs); + let body = lower_runtime_stmt_list(body); + let decorator_list = lower_runtime_decorator_list(decorator_list); + let returns = get_node_field_opt(vm, &object, "returns")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?; + let (runtime_type_comment, runtime_type_comment_bytes) = + runtime_stmt_type_comment(vm, get_ast_string_field_opt(vm, &object, "type_comment")?); + let type_params = type_params_from_field(vm, source_file, &object, "type_params", typ)?; + Ok(ast::StmtFunctionDef { + node_index: Default::default(), + name, + parameters, + body, + decorator_list, + returns, + type_params, + range, + is_async, + runtime_decorator_list, + runtime_type_comment, + runtime_type_comment_bytes, + runtime_body, + }) +} + impl Node for ast::StmtFunctionDef { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -144,14 +386,20 @@ impl Node for ast::StmtFunctionDef { body, decorator_list, returns, - // type_comment, type_params, is_async, - range: _range, + range, + runtime_decorator_list, + runtime_type_comment, + runtime_type_comment_bytes, + runtime_body, } = self; - let source_code = source_file.to_source_code(); - let def_line = source_code.line_index(name.range.start()); - let range = TextRange::new(source_code.line_start(def_line), _range.end()); + let range = definition_range_from_name( + source_file, + name.range.start(), + range.end(), + if is_async { "async" } else { "def" }, + ); let cls = if !is_async { pyast::NodeStmtFunctionDef::static_type().to_owned() @@ -165,18 +413,28 @@ impl Node for ast::StmtFunctionDef { .unwrap(); dict.set_item("args", parameters.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("body", body.ast_to_object(vm, source_file), vm) - .unwrap(); + let body = runtime_body.map_or_else( + || body.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("body", body, vm).unwrap(); dict.set_item( "decorator_list", - decorator_list.ast_to_object(vm, source_file), + runtime_decorator_list.map_or_else( + || decorator_list.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ), vm, ) .unwrap(); dict.set_item("returns", returns.ast_to_object(vm, source_file), vm) .unwrap(); - // Ruff AST doesn't track type_comment, so always set to None - dict.set_item("type_comment", vm.ctx.none(), vm).unwrap(); + dict.set_item( + "type_comment", + runtime_stmt_type_comment_object(vm, runtime_type_comment, runtime_type_comment_bytes), + vm, + ) + .unwrap(); dict.set_item( "type_params", type_params.map_or_else( @@ -191,57 +449,58 @@ impl Node for ast::StmtFunctionDef { } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - let _cls = _object.class(); - let is_async = _cls.is(pyast::NodeStmtAsyncFunctionDef::static_type()); - let range = range_from_object(_vm, source_file, _object.clone(), "FunctionDef")?; - Ok(Self { - node_index: Default::default(), - name: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "name", "FunctionDef")?, - )?, - parameters: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "args", "FunctionDef")?, - )?, - body: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "body", "FunctionDef")?, - )?, - decorator_list: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "decorator_list", "FunctionDef")?, - )?, - returns: get_node_field_opt(_vm, &_object, "returns")? - .map(|obj| Node::ast_from_object(_vm, source_file, obj)) - .transpose()?, - // TODO: Ruff ignores type_comment during parsing - // type_comment: get_node_field_opt(_vm, &_object, "type_comment")? - // .map(|obj| Node::ast_from_object(_vm, obj)) - // .transpose()?, - type_params: Node::ast_from_object( - _vm, - source_file, - get_node_field_opt(_vm, &_object, "type_params")? - .unwrap_or_else(|| _vm.ctx.new_list(Vec::new()).into()), - )?, - range, - is_async, - }) + let is_async = + is_node_instance(vm, &_object, pyast::NodeStmtAsyncFunctionDef::static_type())?; + let typ = if is_async { + "AsyncFunctionDef" + } else { + "FunctionDef" + }; + let range = range_from_object(vm, source_file, _object.clone(), typ)?; + stmt_function_def_from_object_with_range(vm, source_file, _object, range, is_async) } } // constructor +fn stmt_class_def_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let name = get_required_identifier_field(vm, source_file, &object, "name", "ClassDef")?; + let bases = PositionalArguments::ast_from_field(vm, source_file, &object, "bases", "ClassDef")?; + let keywords = + KeywordArguments::ast_from_field(vm, source_file, &object, "keywords", "ClassDef")?; + let body: Vec> = + get_node_list_field(vm, source_file, &object, "body", "ClassDef")?; + let decorator_list: Vec> = + get_node_list_field(vm, source_file, &object, "decorator_list", "ClassDef")?; + let runtime_decorator_exprs = runtime_decorator_expr_list(&decorator_list); + let runtime_body = runtime_stmt_list_metadata(&body); + let runtime_decorator_list = runtime_expr_list_metadata(&runtime_decorator_exprs); + let body = lower_runtime_stmt_list(body); + let decorator_list = lower_runtime_decorator_list(decorator_list); + let type_params = type_params_from_field(vm, source_file, &object, "type_params", "ClassDef")?; + Ok(ast::StmtClassDef { + node_index: Default::default(), + name, + arguments: merge_class_def_args(Some(bases), Some(keywords)), + body, + decorator_list, + type_params, + range, + runtime_decorator_list, + runtime_body, + }) +} + impl Node for ast::StmtClassDef { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, name, @@ -249,184 +508,213 @@ impl Node for ast::StmtClassDef { body, decorator_list, type_params, - range: _range, + range, + runtime_decorator_list, + runtime_body, } = self; let (bases, keywords) = split_class_def_args(arguments); - let source_code = source_file.to_source_code(); - let class_line = source_code.line_index(name.range.start()); - let range = TextRange::new(source_code.line_start(class_line), _range.end()); + let range = + definition_range_from_name(source_file, name.range.start(), range.end(), "class"); let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeStmtClassDef::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeStmtClassDef::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("name", name.ast_to_object(_vm, source_file), _vm) + dict.set_item("name", name.ast_to_object(vm, source_file), vm) .unwrap(); dict.set_item( "bases", bases.map_or_else( - || _vm.ctx.new_list(vec![]).into(), - |b| b.ast_to_object(_vm, source_file), + || vm.ctx.new_list(vec![]).into(), + |b| b.ast_to_object(vm, source_file), ), - _vm, + vm, ) .unwrap(); dict.set_item( "keywords", keywords.map_or_else( - || _vm.ctx.new_list(vec![]).into(), - |k| k.ast_to_object(_vm, source_file), + || vm.ctx.new_list(vec![]).into(), + |k| k.ast_to_object(vm, source_file), ), - _vm, + vm, ) .unwrap(); - dict.set_item("body", body.ast_to_object(_vm, source_file), _vm) - .unwrap(); + let body = runtime_body.map_or_else( + || body.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("body", body, vm).unwrap(); dict.set_item( "decorator_list", - decorator_list.ast_to_object(_vm, source_file), - _vm, + runtime_decorator_list.map_or_else( + || decorator_list.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ), + vm, ) .unwrap(); dict.set_item( "type_params", type_params.map_or_else( - || _vm.ctx.new_list(vec![]).into(), - |tp| tp.ast_to_object(_vm, source_file), + || vm.ctx.new_list(vec![]).into(), + |tp| tp.ast_to_object(vm, source_file), ), - _vm, + vm, ) .unwrap(); - node_add_location(&dict, range, _vm, source_file); + node_add_location(&dict, range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - let bases = Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "bases", "ClassDef")?, - )?; - let keywords = Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "keywords", "ClassDef")?, - )?; - Ok(Self { - node_index: Default::default(), - name: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "name", "ClassDef")?, - )?, - arguments: merge_class_def_args(bases, keywords), - body: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "body", "ClassDef")?, - )?, - decorator_list: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "decorator_list", "ClassDef")?, - )?, - type_params: Node::ast_from_object( - _vm, - source_file, - get_node_field_opt(_vm, &_object, "type_params")? - .unwrap_or_else(|| _vm.ctx.new_list(Vec::new()).into()), - )?, - range: range_from_object(_vm, source_file, _object, "ClassDef")?, - }) + let range = range_from_object(vm, source_file, _object.clone(), "ClassDef")?; + stmt_class_def_from_object_with_range(vm, source_file, _object, range) } } // constructor +fn stmt_return_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::StmtReturn { + node_index: Default::default(), + value: get_node_field_opt(vm, &object, "value")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + range, + }) +} + impl Node for ast::StmtReturn { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, value, range: _range, } = self; let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeStmtReturn::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeStmtReturn::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("value", value.ast_to_object(_vm, source_file), _vm) + dict.set_item("value", value.ast_to_object(vm, source_file), vm) .unwrap(); - node_add_location(&dict, _range, _vm, source_file); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - value: get_node_field_opt(_vm, &_object, "value")? - .map(|obj| Node::ast_from_object(_vm, source_file, obj)) - .transpose()?, - range: range_from_object(_vm, source_file, _object, "Return")?, - }) + let range = range_from_object(vm, source_file, _object.clone(), "Return")?; + stmt_return_from_object_with_range(vm, source_file, _object, range) } } // constructor +fn stmt_delete_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let targets: Vec> = + get_node_list_field(vm, source_file, &object, "targets", "Delete")?; + let (runtime_targets, targets) = runtime_expr_list_from_values(targets); + Ok(ast::StmtDelete { + node_index: Default::default(), + targets, + range, + runtime_targets, + }) +} + impl Node for ast::StmtDelete { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, targets, range: _range, + runtime_targets, } = self; let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeStmtDelete::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeStmtDelete::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("targets", targets.ast_to_object(_vm, source_file), _vm) - .unwrap(); - node_add_location(&dict, _range, _vm, source_file); + let targets = runtime_targets.map_or_else( + || targets.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("targets", targets, vm).unwrap(); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - targets: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "targets", "Delete")?, - )?, - range: range_from_object(_vm, source_file, _object, "Delete")?, - }) + let range = range_from_object(vm, source_file, _object.clone(), "Delete")?; + stmt_delete_from_object_with_range(vm, source_file, _object, range) } } // constructor +fn stmt_assign_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let targets: Vec> = + get_node_list_field(vm, source_file, &object, "targets", "Assign")?; + let (runtime_targets, targets) = runtime_expr_list_from_values(targets); + let value = get_required_node_field(vm, source_file, &object, "value", "Assign")?; + let (runtime_type_comment, runtime_type_comment_bytes) = + runtime_stmt_type_comment(vm, get_ast_string_field_opt(vm, &object, "type_comment")?); + Ok(ast::StmtAssign { + node_index: Default::default(), + targets, + value, + range, + runtime_targets, + runtime_type_comment, + runtime_type_comment_bytes, + }) +} + impl Node for ast::StmtAssign { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, targets, value, - // type_comment, range, + runtime_targets, + runtime_type_comment, + runtime_type_comment_bytes, } = self; let node = NodeAst .into_ref_with_type(vm, pyast::NodeStmtAssign::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("targets", targets.ast_to_object(vm, source_file), vm) - .unwrap(); + let targets = runtime_targets.map_or_else( + || targets.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("targets", targets, vm).unwrap(); dict.set_item("value", value.ast_to_object(vm, source_file), vm) .unwrap(); - // TODO - dict.set_item("type_comment", vm.ctx.none(), vm).unwrap(); + dict.set_item( + "type_comment", + runtime_stmt_type_comment_object(vm, runtime_type_comment, runtime_type_comment_bytes), + vm, + ) + .unwrap(); node_add_location(&dict, range, vm, source_file); node.into() } @@ -435,29 +723,29 @@ impl Node for ast::StmtAssign { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - targets: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "targets", "Assign")?, - )?, - value: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "value", "Assign")?, - )?, - // type_comment: get_node_field_opt(_vm, &_object, "type_comment")? - // .map(|obj| Node::ast_from_object(_vm, obj)) - // .transpose()?, - range: range_from_object(vm, source_file, object, "Assign")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "Assign")?; + stmt_assign_from_object_with_range(vm, source_file, object, range) } } // constructor +fn stmt_type_alias_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::StmtTypeAlias { + node_index: Default::default(), + name: get_required_node_field(vm, source_file, &object, "name", "TypeAlias")?, + type_params: type_params_from_field(vm, source_file, &object, "type_params", "TypeAlias")?, + value: get_required_node_field(vm, source_file, &object, "value", "TypeAlias")?, + range, + }) +} + impl Node for ast::StmtTypeAlias { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, name, @@ -466,56 +754,58 @@ impl Node for ast::StmtTypeAlias { range: _range, } = self; let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeStmtTypeAlias::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeStmtTypeAlias::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("name", name.ast_to_object(_vm, source_file), _vm) + dict.set_item("name", name.ast_to_object(vm, source_file), vm) .unwrap(); dict.set_item( "type_params", type_params.map_or_else( - || _vm.ctx.new_list(Vec::new()).into(), - |tp| tp.ast_to_object(_vm, source_file), + || vm.ctx.new_list(Vec::new()).into(), + |tp| tp.ast_to_object(vm, source_file), ), - _vm, + vm, ) .unwrap(); - dict.set_item("value", value.ast_to_object(_vm, source_file), _vm) + dict.set_item("value", value.ast_to_object(vm, source_file), vm) .unwrap(); - node_add_location(&dict, _range, _vm, source_file); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - name: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "name", "TypeAlias")?, - )?, - type_params: Node::ast_from_object( - _vm, - source_file, - get_node_field_opt(_vm, &_object, "type_params")?.unwrap_or_else(|| _vm.ctx.none()), - )?, - value: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "value", "TypeAlias")?, - )?, - range: range_from_object(_vm, source_file, _object, "TypeAlias")?, - }) + let range = range_from_object(vm, source_file, _object.clone(), "TypeAlias")?; + stmt_type_alias_from_object_with_range(vm, source_file, _object, range) } } // constructor +fn stmt_aug_assign_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::StmtAugAssign { + node_index: Default::default(), + target: get_required_node_field(vm, source_file, &object, "target", "AugAssign")?, + op: Node::ast_from_object( + vm, + source_file, + get_node_field_required(vm, &object, "op", "AugAssign")?, + )?, + value: get_required_node_field(vm, source_file, &object, "value", "AugAssign")?, + range, + }) +} + impl Node for ast::StmtAugAssign { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, target, @@ -524,48 +814,56 @@ impl Node for ast::StmtAugAssign { range: _range, } = self; let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeStmtAugAssign::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeStmtAugAssign::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("target", target.ast_to_object(_vm, source_file), _vm) + dict.set_item("target", target.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("op", op.ast_to_object(_vm, source_file), _vm) + dict.set_item("op", op.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("value", value.ast_to_object(_vm, source_file), _vm) + dict.set_item("value", value.ast_to_object(vm, source_file), vm) .unwrap(); - node_add_location(&dict, _range, _vm, source_file); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - target: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "target", "AugAssign")?, - )?, - op: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "op", "AugAssign")?, - )?, - value: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "value", "AugAssign")?, - )?, - range: range_from_object(_vm, source_file, _object, "AugAssign")?, - }) + let range = range_from_object(vm, source_file, _object.clone(), "AugAssign")?; + stmt_aug_assign_from_object_with_range(vm, source_file, _object, range) } } // constructor +fn stmt_ann_assign_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let simple = node_object_to_i32(vm, get_node_field(vm, &object, "simple", "AnnAssign")?)?; + let runtime_simple = if simple != 0 && simple != 1 { + Some(simple) + } else { + None + }; + Ok(ast::StmtAnnAssign { + node_index: Default::default(), + target: get_required_node_field(vm, source_file, &object, "target", "AnnAssign")?, + annotation: get_required_node_field(vm, source_file, &object, "annotation", "AnnAssign")?, + value: get_node_field_opt(vm, &object, "value")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + simple: simple != 0, + range, + runtime_simple, + }) +} + impl Node for ast::StmtAnnAssign { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, target, @@ -573,59 +871,73 @@ impl Node for ast::StmtAnnAssign { value, simple, range: _range, + runtime_simple, } = self; let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeStmtAnnAssign::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeStmtAnnAssign::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("target", target.ast_to_object(_vm, source_file), _vm) + dict.set_item("target", target.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item( - "annotation", - annotation.ast_to_object(_vm, source_file), - _vm, - ) - .unwrap(); - dict.set_item("value", value.ast_to_object(_vm, source_file), _vm) + dict.set_item("annotation", annotation.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("simple", simple.ast_to_object(_vm, source_file), _vm) + dict.set_item("value", value.ast_to_object(vm, source_file), vm) .unwrap(); - node_add_location(&dict, _range, _vm, source_file); + let simple = runtime_simple.map_or_else( + || simple.ast_to_object(vm, source_file), + |simple| vm.ctx.new_int(simple).into(), + ); + dict.set_item("simple", simple, vm).unwrap(); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - target: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "target", "AnnAssign")?, - )?, - annotation: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "annotation", "AnnAssign")?, - )?, - value: get_node_field_opt(_vm, &_object, "value")? - .map(|obj| Node::ast_from_object(_vm, source_file, obj)) - .transpose()?, - simple: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "simple", "AnnAssign")?, - )?, - range: range_from_object(_vm, source_file, _object, "AnnAssign")?, - }) + let range = range_from_object(vm, source_file, _object.clone(), "AnnAssign")?; + stmt_ann_assign_from_object_with_range(vm, source_file, _object, range) } } // constructor +fn stmt_for_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, + is_async: bool, +) -> PyResult { + let typ = if is_async { "AsyncFor" } else { "For" }; + let target = get_required_node_field(vm, source_file, &object, "target", typ)?; + let iter = get_required_node_field(vm, source_file, &object, "iter", typ)?; + let body: Vec> = get_node_list_field(vm, source_file, &object, "body", typ)?; + let orelse: Vec> = + get_node_list_field(vm, source_file, &object, "orelse", typ)?; + let runtime_body = runtime_stmt_list_metadata(&body); + let runtime_orelse = runtime_stmt_list_metadata(&orelse); + let body = lower_runtime_stmt_list(body); + let orelse = lower_runtime_stmt_list(orelse); + let (runtime_type_comment, runtime_type_comment_bytes) = + runtime_stmt_type_comment(vm, get_ast_string_field_opt(vm, &object, "type_comment")?); + Ok(ast::StmtFor { + node_index: Default::default(), + target, + iter, + body, + orelse, + range, + is_async, + runtime_type_comment, + runtime_type_comment_bytes, + runtime_body, + runtime_orelse, + }) +} + impl Node for ast::StmtFor { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, is_async, @@ -633,8 +945,11 @@ impl Node for ast::StmtFor { iter, body, orelse, - // type_comment, range: _range, + runtime_type_comment, + runtime_type_comment_bytes, + runtime_body, + runtime_orelse, } = self; let cls = if !is_async { @@ -643,133 +958,134 @@ impl Node for ast::StmtFor { pyast::NodeStmtAsyncFor::static_type().to_owned() }; - let node = NodeAst.into_ref_with_type(_vm, cls).unwrap(); + let node = NodeAst.into_ref_with_type(vm, cls).unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("target", target.ast_to_object(_vm, source_file), _vm) + dict.set_item("target", target.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("iter", iter.ast_to_object(_vm, source_file), _vm) + dict.set_item("iter", iter.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("body", body.ast_to_object(_vm, source_file), _vm) - .unwrap(); - dict.set_item("orelse", orelse.ast_to_object(_vm, source_file), _vm) - .unwrap(); - // Ruff AST doesn't track type_comment, so always set to None - dict.set_item("type_comment", _vm.ctx.none(), _vm).unwrap(); - node_add_location(&dict, _range, _vm, source_file); + let body = runtime_body.map_or_else( + || body.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("body", body, vm).unwrap(); + let orelse = runtime_orelse.map_or_else( + || orelse.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("orelse", orelse, vm).unwrap(); + dict.set_item( + "type_comment", + runtime_stmt_type_comment_object(vm, runtime_type_comment, runtime_type_comment_bytes), + vm, + ) + .unwrap(); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - let _cls = _object.class(); debug_assert!( - _cls.is(pyast::NodeStmtFor::static_type()) - || _cls.is(pyast::NodeStmtAsyncFor::static_type()) + is_node_instance(vm, &_object, pyast::NodeStmtFor::static_type())? + || is_node_instance(vm, &_object, pyast::NodeStmtAsyncFor::static_type())? ); - let is_async = _cls.is(pyast::NodeStmtAsyncFor::static_type()); - Ok(Self { - node_index: Default::default(), - target: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "target", "For")?, - )?, - iter: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "iter", "For")?, - )?, - body: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "body", "For")?, - )?, - orelse: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "orelse", "For")?, - )?, - // type_comment: get_node_field_opt(_vm, &_object, "type_comment")? - // .map(|obj| Node::ast_from_object(_vm, obj)) - // .transpose()?, - range: range_from_object(_vm, source_file, _object, "For")?, - is_async, - }) + let is_async = is_node_instance(vm, &_object, pyast::NodeStmtAsyncFor::static_type())?; + let typ = if is_async { "AsyncFor" } else { "For" }; + let range = range_from_object(vm, source_file, _object.clone(), typ)?; + stmt_for_from_object_with_range(vm, source_file, _object, range, is_async) } } // constructor +fn stmt_while_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let body: Vec> = + get_node_list_field(vm, source_file, &object, "body", "While")?; + let orelse: Vec> = + get_node_list_field(vm, source_file, &object, "orelse", "While")?; + let runtime_body = runtime_stmt_list_metadata(&body); + let runtime_orelse = runtime_stmt_list_metadata(&orelse); + Ok(ast::StmtWhile { + node_index: Default::default(), + test: get_required_node_field(vm, source_file, &object, "test", "While")?, + body: lower_runtime_stmt_list(body), + orelse: lower_runtime_stmt_list(orelse), + range, + runtime_body, + runtime_orelse, + }) +} + impl Node for ast::StmtWhile { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, test, body, orelse, range: _range, + runtime_body, + runtime_orelse, } = self; let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeStmtWhile::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeStmtWhile::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("test", test.ast_to_object(_vm, source_file), _vm) - .unwrap(); - dict.set_item("body", body.ast_to_object(_vm, source_file), _vm) + dict.set_item("test", test.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("orelse", orelse.ast_to_object(_vm, source_file), _vm) - .unwrap(); - node_add_location(&dict, _range, _vm, source_file); + let body = runtime_body.map_or_else( + || body.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("body", body, vm).unwrap(); + let orelse = runtime_orelse.map_or_else( + || orelse.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("orelse", orelse, vm).unwrap(); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - test: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "test", "While")?, - )?, - body: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "body", "While")?, - )?, - orelse: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "orelse", "While")?, - )?, - range: range_from_object(_vm, source_file, _object, "While")?, - }) + let range = range_from_object(vm, source_file, _object.clone(), "While")?; + stmt_while_from_object_with_range(vm, source_file, _object, range) } } // constructor impl Node for ast::StmtIf { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { - node_index: _, + node_index, test, body, range, elif_else_clauses, + runtime_body, } = self; elif_else_clause::ast_to_object( ast::ElifElseClause { - node_index: Default::default(), + node_index, range, test: Some(*test), body, + runtime_body, + runtime_orelse: None, }, elif_else_clauses.into_iter(), - _vm, + vm, source_file, ) } @@ -778,19 +1094,47 @@ impl Node for ast::StmtIf { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - elif_else_clause::ast_from_object(vm, source_file, object) + let range = range_from_object(vm, source_file, object.clone(), "If")?; + elif_else_clause::ast_from_object_with_range(vm, source_file, object, range) } } // constructor +fn stmt_with_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, + is_async: bool, +) -> PyResult { + let typ = if is_async { "AsyncWith" } else { "With" }; + let items = get_node_list_field(vm, source_file, &object, "items", typ)?; + let body: Vec> = get_node_list_field(vm, source_file, &object, "body", typ)?; + let (runtime_body, body) = runtime_stmt_list_from_values(body); + let (runtime_type_comment, runtime_type_comment_bytes) = + runtime_stmt_type_comment(vm, get_ast_string_field_opt(vm, &object, "type_comment")?); + Ok(ast::StmtWith { + node_index: Default::default(), + items, + body, + range, + is_async, + runtime_type_comment, + runtime_type_comment_bytes, + runtime_body, + }) +} + impl Node for ast::StmtWith { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, is_async, items, body, - // type_comment, range: _range, + runtime_type_comment, + runtime_type_comment_bytes, + runtime_body, } = self; let cls = if !is_async { @@ -799,51 +1143,56 @@ impl Node for ast::StmtWith { pyast::NodeStmtAsyncWith::static_type().to_owned() }; - let node = NodeAst.into_ref_with_type(_vm, cls).unwrap(); + let node = NodeAst.into_ref_with_type(vm, cls).unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("items", items.ast_to_object(_vm, source_file), _vm) - .unwrap(); - dict.set_item("body", body.ast_to_object(_vm, source_file), _vm) + dict.set_item("items", items.ast_to_object(vm, source_file), vm) .unwrap(); - // Ruff AST doesn't track type_comment, so always set to None - dict.set_item("type_comment", _vm.ctx.none(), _vm).unwrap(); - node_add_location(&dict, _range, _vm, source_file); + let body = runtime_body.map_or_else( + || body.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("body", body, vm).unwrap(); + dict.set_item( + "type_comment", + runtime_stmt_type_comment_object(vm, runtime_type_comment, runtime_type_comment_bytes), + vm, + ) + .unwrap(); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - let _cls = _object.class(); debug_assert!( - _cls.is(pyast::NodeStmtWith::static_type()) - || _cls.is(pyast::NodeStmtAsyncWith::static_type()) + is_node_instance(vm, &_object, pyast::NodeStmtWith::static_type())? + || is_node_instance(vm, &_object, pyast::NodeStmtAsyncWith::static_type())? ); - let is_async = _cls.is(pyast::NodeStmtAsyncWith::static_type()); - Ok(Self { - node_index: Default::default(), - items: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "items", "With")?, - )?, - body: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "body", "With")?, - )?, - // type_comment: get_node_field_opt(_vm, &_object, "type_comment")? - // .map(|obj| Node::ast_from_object(_vm, obj)) - // .transpose()?, - range: range_from_object(_vm, source_file, _object, "With")?, - is_async, - }) + let is_async = is_node_instance(vm, &_object, pyast::NodeStmtAsyncWith::static_type())?; + let typ = if is_async { "AsyncWith" } else { "With" }; + let range = range_from_object(vm, source_file, _object.clone(), typ)?; + stmt_with_from_object_with_range(vm, source_file, _object, range, is_async) } } // constructor +fn stmt_match_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::StmtMatch { + node_index: Default::default(), + subject: get_required_node_field(vm, source_file, &object, "subject", "Match")?, + cases: get_node_list_field(vm, source_file, &object, "cases", "Match")?, + range, + }) +} + impl Node for ast::StmtMatch { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, subject, @@ -851,40 +1200,46 @@ impl Node for ast::StmtMatch { range: _range, } = self; let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeStmtMatch::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeStmtMatch::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("subject", subject.ast_to_object(_vm, source_file), _vm) + dict.set_item("subject", subject.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("cases", cases.ast_to_object(_vm, source_file), _vm) + dict.set_item("cases", cases.ast_to_object(vm, source_file), vm) .unwrap(); - node_add_location(&dict, _range, _vm, source_file); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - subject: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "subject", "Match")?, - )?, - cases: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "cases", "Match")?, - )?, - range: range_from_object(_vm, source_file, _object, "Match")?, - }) + let range = range_from_object(vm, source_file, _object.clone(), "Match")?; + stmt_match_from_object_with_range(vm, source_file, _object, range) } } // constructor +fn stmt_raise_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::StmtRaise { + node_index: Default::default(), + exc: get_node_field_opt(vm, &object, "exc")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + cause: get_node_field_opt(vm, &object, "cause")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + range, + }) +} + impl Node for ast::StmtRaise { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, exc, @@ -892,36 +1247,118 @@ impl Node for ast::StmtRaise { range: _range, } = self; let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeStmtRaise::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeStmtRaise::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("exc", exc.ast_to_object(_vm, source_file), _vm) + dict.set_item("exc", exc.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("cause", cause.ast_to_object(_vm, source_file), _vm) + dict.set_item("cause", cause.ast_to_object(vm, source_file), vm) .unwrap(); - node_add_location(&dict, _range, _vm, source_file); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - exc: get_node_field_opt(_vm, &_object, "exc")? - .map(|obj| Node::ast_from_object(_vm, source_file, obj)) - .transpose()?, - cause: get_node_field_opt(_vm, &_object, "cause")? - .map(|obj| Node::ast_from_object(_vm, source_file, obj)) - .transpose()?, - range: range_from_object(_vm, source_file, _object, "Raise")?, - }) + let range = range_from_object(vm, source_file, _object.clone(), "Raise")?; + stmt_raise_from_object_with_range(vm, source_file, _object, range) } } // constructor +fn stmt_try_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, + is_star: bool, +) -> PyResult { + let typ = if is_star { "TryStar" } else { "Try" }; + let body: Vec> = get_node_list_field(vm, source_file, &object, "body", typ)?; + let orelse: Vec> = + get_node_list_field(vm, source_file, &object, "orelse", typ)?; + let finalbody: Vec> = + get_node_list_field(vm, source_file, &object, "finalbody", typ)?; + let (runtime_handler_values, handlers) = + except_handler_list_from_field(vm, source_file, &object, typ, is_star, range)?; + let runtime_body = runtime_stmt_list_metadata(&body); + let runtime_orelse = runtime_stmt_list_metadata(&orelse); + let runtime_finalbody = runtime_stmt_list_metadata(&finalbody); + let runtime_handlers = runtime_except_handler_list_metadata(&runtime_handler_values); + Ok(ast::StmtTry { + node_index: Default::default(), + body: lower_runtime_stmt_list(body), + handlers, + orelse: lower_runtime_stmt_list(orelse), + finalbody: lower_runtime_stmt_list(finalbody), + range, + is_star, + runtime_body, + runtime_handlers, + runtime_orelse, + runtime_finalbody, + }) +} + +fn except_handler_list_from_field( + vm: &VirtualMachine, + source_file: &SourceFile, + object: &PyObject, + typ: &str, + is_try_star: bool, + range: TextRange, +) -> PyResult<(Vec>, Vec)> { + let value = get_node_list_field_object(vm, object, "handlers", typ)?; + let list = value.downcast_ref::().unwrap(); + let len = list.borrow_vec().len(); + let mut result = Vec::with_capacity(len); + let mut runtime_values = Vec::with_capacity(len); + let recursion_context = format!(" while traversing '{typ}' node"); + for i in 0..len { + let item = { + let items = list.borrow_vec(); + if items.len() != len { + return Err(vm.new_runtime_error(format!( + r#"{typ} field "handlers" changed size during iteration"# + ))); + } + items[i].clone() + }; + let runtime_handler = if vm.is_none(&item) { + None + } else { + Some(vm.with_recursion(&recursion_context, || { + if is_try_star { + except_handler_from_object_unvalidated_range(vm, source_file, item) + } else { + Node::ast_from_object(vm, source_file, item) + } + })?) + }; + let handler = runtime_handler.clone().unwrap_or_else(|| { + ast::ExceptHandler::ExceptHandler(ast::ExceptHandlerExceptHandler { + node_index: Default::default(), + range, + type_: None, + name: None, + body: Vec::new(), + runtime_body: None, + }) + }); + runtime_values.push(runtime_handler); + result.push(handler); + if list.borrow_vec().len() != len { + return Err(vm.new_runtime_error(format!( + r#"{typ} field "handlers" changed size during iteration"# + ))); + } + } + Ok((runtime_values, result)) +} + impl Node for ast::StmtTry { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, body, @@ -930,9 +1367,12 @@ impl Node for ast::StmtTry { finalbody, range: _range, is_star, + runtime_body, + runtime_handlers, + runtime_orelse, + runtime_finalbody, } = self; - // let cls = gen::NodeStmtTry::static_type().to_owned(); let cls = if is_star { pyast::NodeStmtTryStar::static_type() } else { @@ -940,62 +1380,66 @@ impl Node for ast::StmtTry { } .to_owned(); - let node = NodeAst.into_ref_with_type(_vm, cls).unwrap(); + let node = NodeAst.into_ref_with_type(vm, cls).unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("body", body.ast_to_object(_vm, source_file), _vm) - .unwrap(); - dict.set_item("handlers", handlers.ast_to_object(_vm, source_file), _vm) - .unwrap(); - dict.set_item("orelse", orelse.ast_to_object(_vm, source_file), _vm) - .unwrap(); - dict.set_item("finalbody", finalbody.ast_to_object(_vm, source_file), _vm) - .unwrap(); - node_add_location(&dict, _range, _vm, source_file); + let body = runtime_body.map_or_else( + || body.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("body", body, vm).unwrap(); + let handlers = runtime_handlers.map_or_else( + || handlers.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("handlers", handlers, vm).unwrap(); + let orelse = runtime_orelse.map_or_else( + || orelse.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("orelse", orelse, vm).unwrap(); + let finalbody = runtime_finalbody.map_or_else( + || finalbody.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ); + dict.set_item("finalbody", finalbody, vm).unwrap(); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - let _cls = _object.class(); - let is_star = _cls.is(pyast::NodeStmtTryStar::static_type()); - let _cls = _object.class(); + let is_star = is_node_instance(vm, &_object, pyast::NodeStmtTryStar::static_type())?; debug_assert!( - _cls.is(pyast::NodeStmtTry::static_type()) - || _cls.is(pyast::NodeStmtTryStar::static_type()) + is_node_instance(vm, &_object, pyast::NodeStmtTry::static_type())? + || is_node_instance(vm, &_object, pyast::NodeStmtTryStar::static_type())? ); - - Ok(Self { - node_index: Default::default(), - body: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "body", "Try")?, - )?, - handlers: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "handlers", "Try")?, - )?, - orelse: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "orelse", "Try")?, - )?, - finalbody: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "finalbody", "Try")?, - )?, - range: range_from_object(_vm, source_file, _object, "Try")?, - is_star, - }) + let typ = if is_star { "TryStar" } else { "Try" }; + let range = range_from_object(vm, source_file, _object.clone(), typ)?; + stmt_try_from_object_with_range(vm, source_file, _object, range, is_star) } } + // constructor +fn stmt_assert_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::StmtAssert { + node_index: Default::default(), + test: get_required_node_field(vm, source_file, &object, "test", "Assert")?, + msg: get_node_field_opt(vm, &object, "msg")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + range, + }) +} + impl Node for ast::StmtAssert { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, test, @@ -1003,38 +1447,42 @@ impl Node for ast::StmtAssert { range: _range, } = self; let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeStmtAssert::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeStmtAssert::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("test", test.ast_to_object(_vm, source_file), _vm) + dict.set_item("test", test.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("msg", msg.ast_to_object(_vm, source_file), _vm) + dict.set_item("msg", msg.ast_to_object(vm, source_file), vm) .unwrap(); - node_add_location(&dict, _range, _vm, source_file); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - test: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "test", "Assert")?, - )?, - msg: get_node_field_opt(_vm, &_object, "msg")? - .map(|obj| Node::ast_from_object(_vm, source_file, obj)) - .transpose()?, - range: range_from_object(_vm, source_file, _object, "Assert")?, - }) + let range = range_from_object(vm, source_file, _object.clone(), "Assert")?; + stmt_assert_from_object_with_range(vm, source_file, _object, range) } } // constructor +fn stmt_import_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::StmtImport { + node_index: Default::default(), + names: get_node_list_field(vm, source_file, &object, "names", "Import")?, + range, + is_lazy: false, + }) +} + impl Node for ast::StmtImport { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, names, @@ -1042,33 +1490,62 @@ impl Node for ast::StmtImport { is_lazy: _, } = self; let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeStmtImport::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeStmtImport::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("names", names.ast_to_object(_vm, source_file), _vm) + dict.set_item("names", names.ast_to_object(vm, source_file), vm) .unwrap(); - node_add_location(&dict, _range, _vm, source_file); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - names: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "names", "Import")?, - )?, - range: range_from_object(_vm, source_file, _object, "Import")?, - is_lazy: false, // Placeholder - }) + let range = range_from_object(vm, source_file, _object.clone(), "Import")?; + stmt_import_from_object_with_range(vm, source_file, _object, range) } } // constructor +fn stmt_import_from_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let (level, raw_level) = import_from_level_from_field(vm, &object)?; + let runtime_level = raw_level.filter(|level| *level < 0); + Ok(ast::StmtImportFrom { + node_index: Default::default(), + module: get_node_field_opt(vm, &object, "module")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + names: get_node_list_field(vm, source_file, &object, "names", "ImportFrom")?, + level, + range, + is_lazy: false, + runtime_level, + }) +} + +fn import_from_level_from_field( + vm: &VirtualMachine, + object: &PyObjectRef, +) -> PyResult<(u32, Option)> { + let Some(value) = get_node_field_opt(vm, object, "level")? else { + return Ok((0, None)); + }; + let level = vm.with_recursion(" while traversing 'ImportFrom' node", || { + node_object_to_i32(vm, value) + })?; + if level < 0 { + return Ok((0, Some(level))); + } + Ok((level as u32, None)) +} + impl Node for ast::StmtImportFrom { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -1078,6 +1555,7 @@ impl Node for ast::StmtImportFrom { level, range, is_lazy: _, + runtime_level, } = self; let node = NodeAst .into_ref_with_type(vm, pyast::NodeStmtImportFrom::static_type().to_owned()) @@ -1087,8 +1565,11 @@ impl Node for ast::StmtImportFrom { .unwrap(); dict.set_item("names", names.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("level", vm.ctx.new_int(level).to_pyobject(vm), vm) - .unwrap(); + let level = runtime_level.map_or_else( + || vm.ctx.new_int(level).to_pyobject(vm), + |level| vm.ctx.new_int(level).to_pyobject(vm), + ); + dict.set_item("level", level, vm).unwrap(); node_add_location(&dict, range, vm, source_file); node.into() } @@ -1098,141 +1579,143 @@ impl Node for ast::StmtImportFrom { source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - module: get_node_field_opt(vm, &_object, "module")? - .map(|obj| Node::ast_from_object(vm, source_file, obj)) - .transpose()?, - names: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &_object, "names", "ImportFrom")?, - )?, - level: get_node_field_opt(vm, &_object, "level")? - .map(|obj| -> PyResult { - let int: PyRef = obj.try_into_value(vm)?; - let value: i64 = int.try_to_primitive(vm)?; - if value < 0 { - return Err(vm.new_value_error("Negative ImportFrom level")); - } - u32::try_from(value) - .map_err(|_| vm.new_overflow_error("ImportFrom level out of range")) - }) - .transpose()? - .unwrap_or(0), - range: range_from_object(vm, source_file, _object, "ImportFrom")?, - is_lazy: false, // Placeholder - }) + let range = range_from_object(vm, source_file, _object.clone(), "ImportFrom")?; + stmt_import_from_from_object_with_range(vm, source_file, _object, range) } } // constructor +fn stmt_global_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::StmtGlobal { + node_index: Default::default(), + names: get_node_list_field(vm, source_file, &object, "names", "Global")?, + range, + }) +} + impl Node for ast::StmtGlobal { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, names, range: _range, } = self; let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeStmtGlobal::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeStmtGlobal::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("names", names.ast_to_object(_vm, source_file), _vm) + dict.set_item("names", names.ast_to_object(vm, source_file), vm) .unwrap(); - node_add_location(&dict, _range, _vm, source_file); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - names: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "names", "Global")?, - )?, - range: range_from_object(_vm, source_file, _object, "Global")?, - }) + let range = range_from_object(vm, source_file, _object.clone(), "Global")?; + stmt_global_from_object_with_range(vm, source_file, _object, range) } } // constructor +fn stmt_nonlocal_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::StmtNonlocal { + node_index: Default::default(), + names: get_node_list_field(vm, source_file, &object, "names", "Nonlocal")?, + range, + }) +} + impl Node for ast::StmtNonlocal { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, names, range: _range, } = self; let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeStmtNonlocal::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeStmtNonlocal::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("names", names.ast_to_object(_vm, source_file), _vm) + dict.set_item("names", names.ast_to_object(vm, source_file), vm) .unwrap(); - node_add_location(&dict, _range, _vm, source_file); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - names: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "names", "Nonlocal")?, - )?, - range: range_from_object(_vm, source_file, _object, "Nonlocal")?, - }) + let range = range_from_object(vm, source_file, _object.clone(), "Nonlocal")?; + stmt_nonlocal_from_object_with_range(vm, source_file, _object, range) } } // constructor +fn stmt_expr_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::StmtExpr { + node_index: Default::default(), + value: get_required_node_field(vm, source_file, &object, "value", "Expr")?, + range, + }) +} + impl Node for ast::StmtExpr { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, value, range: _range, } = self; let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeStmtExpr::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeStmtExpr::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("value", value.ast_to_object(_vm, source_file), _vm) + dict.set_item("value", value.ast_to_object(vm, source_file), vm) .unwrap(); - node_add_location(&dict, _range, _vm, source_file); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - value: Node::ast_from_object( - _vm, - source_file, - get_node_field(_vm, &_object, "value", "Expr")?, - )?, - range: range_from_object(_vm, source_file, _object, "Expr")?, - }) + let range = range_from_object(vm, source_file, _object.clone(), "Expr")?; + stmt_expr_from_object_with_range(vm, source_file, _object, range) } } // constructor +fn stmt_pass_from_object_with_range(range: TextRange) -> ast::StmtPass { + ast::StmtPass { + node_index: Default::default(), + range, + } +} + impl Node for ast::StmtPass { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, range: _range, } = self; let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeStmtPass::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeStmtPass::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); let location = super::text_range_to_source_range(source_file, _range); @@ -1250,76 +1733,84 @@ impl Node for ast::StmtPass { location.end.column.get() }; - dict.set_item("lineno", _vm.ctx.new_int(start_row).into(), _vm) + dict.set_item("lineno", vm.ctx.new_int(start_row).into(), vm) .unwrap(); - dict.set_item("col_offset", _vm.ctx.new_int(start_col).into(), _vm) + dict.set_item("col_offset", vm.ctx.new_int(start_col).into(), vm) .unwrap(); - dict.set_item("end_lineno", _vm.ctx.new_int(end_row).into(), _vm) + dict.set_item("end_lineno", vm.ctx.new_int(end_row).into(), vm) .unwrap(); - dict.set_item("end_col_offset", _vm.ctx.new_int(end_col).into(), _vm) + dict.set_item("end_col_offset", vm.ctx.new_int(end_col).into(), vm) .unwrap(); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - range: range_from_object(_vm, source_file, _object, "Pass")?, - }) + let range = range_from_object(vm, source_file, _object, "Pass")?; + Ok(stmt_pass_from_object_with_range(range)) } } // constructor +fn stmt_break_from_object_with_range(range: TextRange) -> ast::StmtBreak { + ast::StmtBreak { + node_index: Default::default(), + range, + } +} + impl Node for ast::StmtBreak { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, range: _range, } = self; let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeStmtBreak::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeStmtBreak::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - node_add_location(&dict, _range, _vm, source_file); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - range: range_from_object(_vm, source_file, _object, "Break")?, - }) + let range = range_from_object(vm, source_file, _object, "Break")?; + Ok(stmt_break_from_object_with_range(range)) } } // constructor +fn stmt_continue_from_object_with_range(range: TextRange) -> ast::StmtContinue { + ast::StmtContinue { + node_index: Default::default(), + range, + } +} + impl Node for ast::StmtContinue { - fn ast_to_object(self, _vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { + fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { node_index: _, range: _range, } = self; let node = NodeAst - .into_ref_with_type(_vm, pyast::NodeStmtContinue::static_type().to_owned()) + .into_ref_with_type(vm, pyast::NodeStmtContinue::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - node_add_location(&dict, _range, _vm, source_file); + node_add_location(&dict, _range, vm, source_file); node.into() } fn ast_from_object( - _vm: &VirtualMachine, + vm: &VirtualMachine, source_file: &SourceFile, _object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - range: range_from_object(_vm, source_file, _object, "Continue")?, - }) + let range = range_from_object(vm, source_file, _object, "Continue")?; + Ok(stmt_continue_from_object_with_range(range)) } } diff --git a/crates/vm/src/stdlib/_ast/string.rs b/crates/vm/src/stdlib/_ast/string.rs index 24cae476694..7a3bb8799b9 100644 --- a/crates/vm/src/stdlib/_ast/string.rs +++ b/crates/vm/src/stdlib/_ast/string.rs @@ -19,6 +19,7 @@ fn ruff_fstring_element_into_iter( } fn ruff_fstring_element_to_joined_str_part( + vm: &VirtualMachine, element: ast::InterpolatedStringElement, ) -> JoinedStrPart { match element { @@ -38,12 +39,21 @@ fn ruff_fstring_element_to_joined_str_part( conversion, format_spec, node_index: _, - }) => JoinedStrPart::FormattedValue(FormattedValue { - value: expression, - conversion, - format_spec: ruff_format_spec_to_joined_str(format_spec), - range, - }), + runtime_str: _, + runtime_interpolation_format_spec: _, + runtime_formatted_value_format_spec, + }) => { + let runtime_format_spec = runtime_formatted_value_format_spec.or_else(|| { + ruff_format_spec_to_joined_str(vm, format_spec) + .map(|joined_str| Box::new(joined_str.into_expr(false))) + }); + JoinedStrPart::FormattedValue(FormattedValue { + value: expression, + conversion, + format_spec: runtime_format_spec, + range, + }) + } } } @@ -235,6 +245,7 @@ fn warn_invalid_escape_sequences_in_format_spec( } fn ruff_format_spec_to_joined_str( + vm: &VirtualMachine, format_spec: Option>, ) -> Option> { match format_spec { @@ -254,10 +265,14 @@ fn ruff_format_spec_to_joined_str( range }; let values: Vec<_> = ruff_fstring_element_into_iter(elements) - .map(ruff_fstring_element_to_joined_str_part) + .map(|element| ruff_fstring_element_to_joined_str_part(vm, element)) .collect(); let values = normalize_joined_str_parts(values).into_boxed_slice(); - Some(Box::new(JoinedStr { range, values })) + Some(Box::new(JoinedStr { + range, + values, + runtime_values: None, + })) } } } @@ -290,40 +305,98 @@ fn ruff_fstring_element_to_ruff_fstring_part( } } -fn joined_str_to_ruff_format_spec( - joined_str: Option>, +fn format_spec_expr_to_ruff_format_spec( + format_spec: Option>, ) -> Option> { - match joined_str { - None => None, - Some(joined_str) => { - let JoinedStr { range, values } = *joined_str; - let elements: Vec<_> = Box::into_iter(values) - .map(joined_str_part_to_ruff_fstring_element) - .collect(); - let format_spec = ast::InterpolatedStringFormatSpec { - node_index: Default::default(), + let format_spec = format_spec?; + let ast::Expr::FString(mut fstring) = *format_spec else { + return None; + }; + let ast::ExprFString { + range, + ref mut value, + node_index: _, + runtime_joined_str: _, + runtime_values: _, + } = fstring; + let default_part = ast::FStringPart::FString(ast::FString { + node_index: Default::default(), + range: Default::default(), + elements: Default::default(), + flags: ast::FStringFlags::empty(), + }); + let mut elements = Vec::new(); + for i in 0..value.as_slice().len() { + let part = core::mem::replace(value.iter_mut().nth(i).unwrap(), default_part.clone()); + match part { + ast::FStringPart::Literal(ast::StringLiteral { range, - elements: elements.into(), - }; - Some(Box::new(format_spec)) + value, + node_index: _, + flags: _, + }) => elements.push(ast::InterpolatedStringElement::Literal( + ast::InterpolatedStringLiteralElement { + node_index: Default::default(), + range, + value, + }, + )), + ast::FStringPart::FString(ast::FString { + elements: fstring_elements, + .. + }) => { + elements.extend(ruff_fstring_element_into_iter(fstring_elements)); + } } } + Some(Box::new(ast::InterpolatedStringFormatSpec { + node_index: Default::default(), + range, + elements: elements.into(), + })) } #[derive(Debug)] pub(super) struct JoinedStr { pub(super) range: TextRange, pub(super) values: Box<[JoinedStrPart]>, + pub(super) runtime_values: Option>>, } impl JoinedStr { - pub(super) fn into_expr(self) -> ast::Expr { - let Self { range, values } = self; + pub(super) fn into_expr(self, from_ast_object: bool) -> ast::Expr { + let Self { + range, + values, + runtime_values: mut raw_runtime_values, + } = self; + let values = if values.iter().any(joined_str_part_requires_runtime_values) { + if raw_runtime_values.is_none() { + raw_runtime_values = Some( + values + .into_vec() + .into_iter() + .map(|part| joined_str_part_to_expr(from_ast_object, part)) + .map(Some) + .collect(), + ); + } + Vec::new().into_boxed_slice() + } else { + values + }; + let (runtime_joined_str, runtime_values) = + raw_runtime_values.take().map_or((None, None), |values| { + if values.iter().any(Option::is_none) { + (None, Some(values)) + } else { + (Some(values.into_iter().flatten().collect()), None) + } + }); ast::Expr::FString(ast::ExprFString { node_index: Default::default(), - range: Default::default(), + range, value: match values.len() { - // ruff represents an empty fstring like this: 0 => ast::FStringValue::single(ast::FString { node_index: Default::default(), range, @@ -332,7 +405,8 @@ impl JoinedStr { }), 1 => ast::FStringValue::single( Box::<[_]>::into_iter(values) - .map(joined_str_part_to_ruff_fstring_element) + .map(|part| joined_str_part_to_ruff_fstring_element(from_ast_object, part)) + .map(Option::unwrap) .map(|element| ast::FString { node_index: Default::default(), range, @@ -344,54 +418,108 @@ impl JoinedStr { ), _ => ast::FStringValue::concatenated( Box::<[_]>::into_iter(values) - .map(joined_str_part_to_ruff_fstring_element) + .map(|part| joined_str_part_to_ruff_fstring_element(from_ast_object, part)) + .map(Option::unwrap) .map(ruff_fstring_element_to_ruff_fstring_part) .collect(), ), }, + runtime_joined_str, + runtime_values, }) } } -fn joined_str_part_to_ruff_fstring_element(part: JoinedStrPart) -> ast::InterpolatedStringElement { +fn joined_str_part_requires_runtime_values(part: &JoinedStrPart) -> bool { + matches!( + part, + JoinedStrPart::Constant(Constant { + value, + .. + }) if !matches!(value, ConstantLiteral::Str { .. }) + ) +} + +fn joined_str_part_to_expr(from_ast_object: bool, part: JoinedStrPart) -> ast::Expr { + match part { + JoinedStrPart::FormattedValue(value) => formatted_value_to_expr(from_ast_object, value), + JoinedStrPart::Constant(value) => value.into_expr(), + } +} + +fn joined_str_part_to_ruff_fstring_element( + from_ast_object: bool, + part: JoinedStrPart, +) -> Option { match part { JoinedStrPart::FormattedValue(value) => { - ast::InterpolatedStringElement::Interpolation(ast::InterpolatedElement { - node_index: Default::default(), - range: value.range, - expression: value.value.clone(), - debug_text: None, // TODO: What is this? - conversion: value.conversion, - format_spec: joined_str_to_ruff_format_spec(value.format_spec), - }) + let format_spec = value.format_spec.clone(); + let runtime_formatted_value_format_spec = (from_ast_object && format_spec.is_some()) + .then_some(format_spec.clone()) + .flatten(); + Some(ast::InterpolatedStringElement::Interpolation( + ast::InterpolatedElement { + node_index: Default::default(), + range: value.range, + expression: value.value.clone(), + debug_text: None, + conversion: value.conversion, + format_spec: format_spec_expr_to_ruff_format_spec(format_spec), + runtime_str: None, + runtime_interpolation_format_spec: None, + runtime_formatted_value_format_spec, + }, + )) } JoinedStrPart::Constant(value) => { - ast::InterpolatedStringElement::Literal(ast::InterpolatedStringLiteralElement { - node_index: Default::default(), - range: value.range, - value: match value.value { - ConstantLiteral::Str { value, .. } => value, - _ => todo!(), + let Constant { range, value, .. } = value; + let ConstantLiteral::Str { value, .. } = value else { + return None; + }; + Some(ast::InterpolatedStringElement::Literal( + ast::InterpolatedStringLiteralElement { + node_index: Default::default(), + range, + value, }, - }) + )) } } } // constructor +pub(super) fn joined_str_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let values: Vec> = + get_node_list_field(vm, source_file, &object, "values", "JoinedStr")?; + Ok(JoinedStr { + values: Vec::new().into_boxed_slice(), + runtime_values: Some(values), + range, + }) +} + impl Node for JoinedStr { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { - let Self { values, range } = self; + let Self { + values, + runtime_values, + range, + } = self; let node = NodeAst .into_ref_with_type(vm, pyast::NodeExprJoinedStr::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item( - "values", - BoxedSlice(values).ast_to_object(vm, source_file), - vm, - ) - .unwrap(); + let values = if let Some(runtime_values) = runtime_values { + BoxedSlice(runtime_values.into_boxed_slice()).ast_to_object(vm, source_file) + } else { + BoxedSlice(values).ast_to_object(vm, source_file) + }; + dict.set_item("values", values, vm).unwrap(); node_add_location(&dict, range, vm, source_file); node.into() } @@ -400,15 +528,8 @@ impl Node for JoinedStr { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let values: BoxedSlice<_> = Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "values", "JoinedStr")?, - )?; - Ok(Self { - values: values.0, - range: range_from_object(vm, source_file, object, "JoinedStr")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "JoinedStr")?; + joined_str_from_object_with_range(vm, source_file, object, range) } } @@ -431,8 +552,7 @@ impl Node for JoinedStrPart { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let cls = object.class(); - if cls.is(pyast::NodeExprFormattedValue::static_type()) { + if is_node_instance(vm, &object, pyast::NodeExprFormattedValue::static_type())? { Ok(Self::FormattedValue(Node::ast_from_object( vm, source_file, @@ -452,11 +572,31 @@ impl Node for JoinedStrPart { pub(super) struct FormattedValue { value: Box, conversion: ast::ConversionFlag, - format_spec: Option>, + format_spec: Option>, range: TextRange, } // constructor +pub(super) fn formatted_value_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(FormattedValue { + value: get_required_node_field(vm, source_file, &object, "value", "FormattedValue")?, + conversion: Node::ast_from_object( + vm, + source_file, + get_node_field(vm, &object, "conversion", "FormattedValue")?, + )?, + format_spec: get_node_field_opt(vm, &object, "format_spec")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + range, + }) +} + impl Node for FormattedValue { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -487,23 +627,22 @@ impl Node for FormattedValue { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - value: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "value", "FormattedValue")?, - )?, - conversion: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "conversion", "FormattedValue")?, - )?, - format_spec: get_node_field_opt(vm, &object, "format_spec")? - .map(|obj| Node::ast_from_object(vm, source_file, obj)) - .transpose()?, - range: range_from_object(vm, source_file, object, "FormattedValue")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "FormattedValue")?; + formatted_value_from_object_with_range(vm, source_file, object, range) + } +} + +pub(super) fn formatted_value_to_expr( + from_ast_object: bool, + formatted: FormattedValue, +) -> ast::Expr { + let range = formatted.range; + JoinedStr { + range, + values: vec![JoinedStrPart::FormattedValue(formatted)].into_boxed_slice(), + runtime_values: None, } + .into_expr(from_ast_object) } pub(super) fn fstring_to_object( @@ -515,7 +654,27 @@ pub(super) fn fstring_to_object( range, mut value, node_index: _, + runtime_joined_str, + runtime_values, } = expression; + if let Some(joined_str) = runtime_joined_str { + return JoinedStr { + range, + values: Vec::new().into_boxed_slice(), + runtime_values: Some(joined_str.into_iter().map(Some).collect()), + } + .ast_to_object(vm, source_file); + } + + if let Some(values) = runtime_values { + return JoinedStr { + range, + values: Vec::new().into_boxed_slice(), + runtime_values: Some(values), + } + .ast_to_object(vm, source_file); + } + let default_part = ast::FStringPart::FString(ast::FString { node_index: Default::default(), range: Default::default(), @@ -545,7 +704,7 @@ pub(super) fn fstring_to_object( node_index: _, }) => { for element in ruff_fstring_element_into_iter(elements) { - values.push(ruff_fstring_element_to_joined_str_part(element)); + values.push(ruff_fstring_element_to_joined_str_part(vm, element)); } } } @@ -555,12 +714,13 @@ pub(super) fn fstring_to_object( if let JoinedStrPart::FormattedValue(value) = part && let Some(format_spec) = &value.format_spec { - warn_invalid_escape_sequences_in_format_spec(vm, source_file, format_spec.range); + warn_invalid_escape_sequences_in_format_spec(vm, source_file, format_spec.range()); } } let c = JoinedStr { range, values: values.into_boxed_slice(), + runtime_values: None, }; c.ast_to_object(vm, source_file) } @@ -568,8 +728,9 @@ pub(super) fn fstring_to_object( // ===== TString (Template String) Support ===== fn ruff_tstring_element_to_template_str_part( - element: ast::InterpolatedStringElement, + vm: &VirtualMachine, source_file: &SourceFile, + element: ast::InterpolatedStringElement, ) -> TemplateStrPart { match element { ast::InterpolatedStringElement::Literal(ast::InterpolatedStringLiteralElement { @@ -588,6 +749,9 @@ fn ruff_tstring_element_to_template_str_part( conversion, format_spec, node_index: _, + runtime_str, + runtime_interpolation_format_spec, + runtime_formatted_value_format_spec: _, }) => { let expr_range = extend_expr_range_with_wrapping_parens(source_file, range, expression.range()) @@ -604,11 +768,23 @@ fn ruff_tstring_element_to_template_str_part( } else { tstring_interpolation_expr_str(source_file, range, expr_range) }; + let runtime_interpolation = super::constant::runtime_interpolation_object( + vm, + runtime_str, + runtime_interpolation_format_spec, + ); TemplateStrPart::Interpolation(TStringInterpolation { value: expression, - str: expr_str, + str: runtime_interpolation + .as_ref() + .map_or_else(|| vm.ctx.new_str(expr_str).into(), |(str, _)| str.clone()), conversion, - format_spec: ruff_format_spec_to_joined_str(format_spec), + format_spec: runtime_interpolation + .and_then(|(_, format_spec)| format_spec) + .or_else(|| { + ruff_format_spec_to_joined_str(vm, format_spec) + .map(|joined_str| Box::new(joined_str.into_expr(false))) + }), range, }) } @@ -695,34 +871,51 @@ fn strip_interpolation_expr(expr_source: &str) -> String { pub(super) struct TemplateStr { pub(super) range: TextRange, pub(super) values: Box<[TemplateStrPart]>, + pub(super) runtime_values: Option>>, } pub(super) fn template_str_to_expr( vm: &VirtualMachine, + source_file: &SourceFile, template: TemplateStr, ) -> PyResult { - let TemplateStr { range, values } = template; - let elements = template_parts_to_elements(vm, values)?; + let TemplateStr { + range, + values, + runtime_values: raw_runtime_values, + } = template; + let elements = template_parts_to_elements(vm, source_file, values)?; let tstring = ast::TString { range, node_index: Default::default(), elements, flags: ast::TStringFlags::empty(), }; + let (runtime_template_str, runtime_values) = + raw_runtime_values.map_or((None, None), |values| { + if values.iter().any(Option::is_none) { + (None, Some(values)) + } else { + (Some(values.into_iter().flatten().collect()), None) + } + }); Ok(ast::Expr::TString(ast::ExprTString { node_index: Default::default(), range, value: ast::TStringValue::single(tstring), + runtime_template_str, + runtime_values, })) } pub(super) fn interpolation_to_expr( vm: &VirtualMachine, + source_file: &SourceFile, interpolation: TStringInterpolation, ) -> PyResult { + let range = interpolation.range; let part = TemplateStrPart::Interpolation(interpolation); - let elements = template_parts_to_elements(vm, vec![part].into_boxed_slice())?; - let range = TextRange::default(); + let elements = template_parts_to_elements(vm, source_file, vec![part].into_boxed_slice())?; let tstring = ast::TString { range, node_index: Default::default(), @@ -733,22 +926,26 @@ pub(super) fn interpolation_to_expr( node_index: Default::default(), range, value: ast::TStringValue::single(tstring), + runtime_template_str: None, + runtime_values: None, })) } fn template_parts_to_elements( vm: &VirtualMachine, + source_file: &SourceFile, values: Box<[TemplateStrPart]>, ) -> PyResult { let mut elements = Vec::with_capacity(values.len()); for value in values.into_vec() { - elements.push(template_part_to_element(vm, value)?); + elements.push(template_part_to_element(vm, source_file, value)?); } Ok(ast::InterpolatedStringElements::from(elements)) } fn template_part_to_element( vm: &VirtualMachine, + source_file: &SourceFile, part: TemplateStrPart, ) -> PyResult { match part { @@ -767,12 +964,18 @@ fn template_part_to_element( TemplateStrPart::Interpolation(interpolation) => { let TStringInterpolation { value, + str, conversion, format_spec, range, - .. } = interpolation; - let format_spec = joined_str_to_ruff_format_spec(format_spec); + let str_constant = + super::constant::constant_object_to_constant_data(vm, source_file, str)?; + let runtime_str = Some(super::constant::constant_data_to_ast_constant_value( + str_constant, + )); + let runtime_interpolation_format_spec = format_spec.clone(); + let format_spec = format_spec_expr_to_ruff_format_spec(format_spec); Ok(ast::InterpolatedStringElement::Interpolation( ast::InterpolatedElement { range, @@ -781,6 +984,9 @@ fn template_part_to_element( debug_text: None, conversion, format_spec, + runtime_str, + runtime_interpolation_format_spec, + runtime_formatted_value_format_spec: None, }, )) } @@ -788,19 +994,38 @@ fn template_part_to_element( } // constructor +pub(super) fn template_str_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let values: Vec> = + get_node_list_field(vm, source_file, &object, "values", "TemplateStr")?; + Ok(TemplateStr { + values: Vec::new().into_boxed_slice(), + runtime_values: Some(values), + range, + }) +} + impl Node for TemplateStr { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { - let Self { values, range } = self; + let Self { + values, + runtime_values, + range, + } = self; let node = NodeAst .into_ref_with_type(vm, pyast::NodeExprTemplateStr::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item( - "values", - BoxedSlice(values).ast_to_object(vm, source_file), - vm, - ) - .unwrap(); + let values = if let Some(runtime_values) = runtime_values { + BoxedSlice(runtime_values.into_boxed_slice()).ast_to_object(vm, source_file) + } else { + BoxedSlice(values).ast_to_object(vm, source_file) + }; + dict.set_item("values", values, vm).unwrap(); node_add_location(&dict, range, vm, source_file); node.into() } @@ -809,15 +1034,8 @@ impl Node for TemplateStr { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let values: BoxedSlice<_> = Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "values", "TemplateStr")?, - )?; - Ok(Self { - values: values.0, - range: range_from_object(vm, source_file, object, "TemplateStr")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "TemplateStr")?; + template_str_from_object_with_range(vm, source_file, object, range) } } @@ -840,8 +1058,7 @@ impl Node for TemplateStrPart { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let cls = object.class(); - if cls.is(pyast::NodeExprInterpolation::static_type()) { + if is_node_instance(vm, &object, pyast::NodeExprInterpolation::static_type())? { Ok(Self::Interpolation(Node::ast_from_object( vm, source_file, @@ -860,13 +1077,38 @@ impl Node for TemplateStrPart { #[derive(Debug)] pub(super) struct TStringInterpolation { value: Box, - str: String, + str: PyObjectRef, conversion: ast::ConversionFlag, - format_spec: Option>, + format_spec: Option>, range: TextRange, } // constructor +pub(super) fn tstring_interpolation_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + let value = get_required_node_field(vm, source_file, &object, "value", "Interpolation")?; + let str = get_node_field(vm, &object, "str", "Interpolation")?; + let conversion = Node::ast_from_object( + vm, + source_file, + get_node_field(vm, &object, "conversion", "Interpolation")?, + )?; + let format_spec: Option> = get_node_field_opt(vm, &object, "format_spec")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?; + Ok(TStringInterpolation { + value, + str, + conversion, + format_spec, + range, + }) +} + impl Node for TStringInterpolation { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -882,8 +1124,7 @@ impl Node for TStringInterpolation { let dict = node.as_object().dict().unwrap(); dict.set_item("value", value.ast_to_object(vm, source_file), vm) .unwrap(); - dict.set_item("str", vm.ctx.new_str(str).into(), vm) - .unwrap(); + dict.set_item("str", str, vm).unwrap(); dict.set_item("conversion", conversion.ast_to_object(vm, source_file), vm) .unwrap(); dict.set_item( @@ -900,25 +1141,8 @@ impl Node for TStringInterpolation { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let str_obj = get_node_field(vm, &object, "str", "Interpolation")?; - let str_val: String = str_obj.try_into_value(vm)?; - Ok(Self { - value: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "value", "Interpolation")?, - )?, - str: str_val, - conversion: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "conversion", "Interpolation")?, - )?, - format_spec: get_node_field_opt(vm, &object, "format_spec")? - .map(|obj| Node::ast_from_object(vm, source_file, obj)) - .transpose()?, - range: range_from_object(vm, source_file, object, "Interpolation")?, - }) + let range = range_from_object(vm, source_file, object.clone(), "Interpolation")?; + tstring_interpolation_from_object_with_range(vm, source_file, object, range) } } @@ -931,7 +1155,42 @@ pub(super) fn tstring_to_object( range, mut value, node_index: _, + runtime_template_str, + runtime_values, } = expression; + if let Some(template_str) = runtime_template_str { + return TemplateStr { + range, + values: Vec::new().into_boxed_slice(), + runtime_values: Some(template_str.into_iter().map(Some).collect()), + } + .ast_to_object(vm, source_file); + } + + if let Some(values) = runtime_values { + return TemplateStr { + range, + values: Vec::new().into_boxed_slice(), + runtime_values: Some(values), + } + .ast_to_object(vm, source_file); + } + + if let [tstring] = value.as_slice() + && let Some(ast::InterpolatedStringElement::Interpolation(interp)) = + tstring.elements.iter().next() + && tstring.elements.get(1).is_none() + && let Some((str, format_spec)) = super::constant::runtime_interpolation_object( + vm, + interp.runtime_str.clone(), + interp.runtime_interpolation_format_spec.clone(), + ) + && let Some(interpolation) = + standalone_tstring_interpolation_to_object(vm, source_file, &value, str, format_spec) + { + return interpolation; + } + let default_tstring = ast::TString { node_index: Default::default(), range: Default::default(), @@ -943,8 +1202,9 @@ pub(super) fn tstring_to_object( let tstring = core::mem::replace(value.iter_mut().nth(i).unwrap(), default_tstring.clone()); for element in ruff_fstring_element_into_iter(tstring.elements) { values.push(ruff_tstring_element_to_template_str_part( - element, + vm, source_file, + element, )); } } @@ -952,6 +1212,37 @@ pub(super) fn tstring_to_object( let c = TemplateStr { range, values: values.into_boxed_slice(), + runtime_values: None, }; c.ast_to_object(vm, source_file) } + +fn standalone_tstring_interpolation_to_object( + vm: &VirtualMachine, + source_file: &SourceFile, + value: &ast::TStringValue, + str: PyObjectRef, + format_spec: Option>, +) -> Option { + let [tstring] = value.as_slice() else { + return None; + }; + let mut elements = tstring.elements.iter(); + let ast::InterpolatedStringElement::Interpolation(interp) = elements.next()? else { + return None; + }; + if elements.next().is_some() { + return None; + } + let interpolation = TStringInterpolation { + value: interp.expression.clone(), + str, + conversion: interp.conversion, + format_spec: format_spec.or_else(|| { + ruff_format_spec_to_joined_str(vm, interp.format_spec.clone()) + .map(|joined_str| Box::new(joined_str.into_expr(false))) + }), + range: interp.range, + }; + Some(interpolation.ast_to_object(vm, source_file)) +} diff --git a/crates/vm/src/stdlib/_ast/type_ignore.rs b/crates/vm/src/stdlib/_ast/type_ignore.rs index 6e90ba9b80e..d51e54f1c5d 100644 --- a/crates/vm/src/stdlib/_ast/type_ignore.rs +++ b/crates/vm/src/stdlib/_ast/type_ignore.rs @@ -2,6 +2,7 @@ use super::*; use rustpython_compiler_core::SourceFile; pub(super) enum TypeIgnore { + None, TypeIgnore(TypeIgnoreTypeIgnore), } @@ -9,6 +10,7 @@ pub(super) enum TypeIgnore { impl Node for TypeIgnore { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { match self { + Self::None => vm.ctx.none(), Self::TypeIgnore(cons) => cons.ast_to_object(vm, source_file), } } @@ -17,8 +19,9 @@ impl Node for TypeIgnore { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let cls = object.class(); - Ok(if cls.is(pyast::NodeTypeIgnoreTypeIgnore::static_type()) { + Ok(if vm.is_none(&object) { + Self::None + } else if is_node_instance(vm, &object, pyast::NodeTypeIgnoreTypeIgnore::static_type())? { Self::TypeIgnore(TypeIgnoreTypeIgnore::ast_from_object( vm, source_file, @@ -34,15 +37,14 @@ impl Node for TypeIgnore { } pub(super) struct TypeIgnoreTypeIgnore { - range: TextRange, - lineno: PyRefExact, - tag: PyRefExact, + lineno: i32, + tag: PyObjectRef, } // constructor impl Node for TypeIgnoreTypeIgnore { - fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { - let Self { lineno, tag, range } = self; + fn ast_to_object(self, vm: &VirtualMachine, _source_file: &SourceFile) -> PyObjectRef { + let Self { lineno, tag } = self; let node = NodeAst .into_ref_with_type( vm, @@ -50,25 +52,20 @@ impl Node for TypeIgnoreTypeIgnore { ) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("lineno", lineno.to_pyobject(vm), vm).unwrap(); - dict.set_item("tag", tag.to_pyobject(vm), vm).unwrap(); - node_add_location(&dict, range, vm, source_file); + dict.set_item("lineno", vm.ctx.new_int(lineno).into(), vm) + .unwrap(); + dict.set_item("tag", tag, vm).unwrap(); node.into() } fn ast_from_object( vm: &VirtualMachine, - source_file: &SourceFile, + _source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { Ok(Self { - lineno: get_node_field(vm, &object, "lineno", "TypeIgnore")? - .downcast_exact(vm) - .unwrap(), - tag: get_node_field(vm, &object, "tag", "TypeIgnore")? - .downcast_exact(vm) - .unwrap(), - range: range_from_object(vm, source_file, object, "TypeIgnore")?, + lineno: get_int_field(vm, &object, "lineno", "TypeIgnore")?, + tag: node_object_to_ast_string(vm, get_node_field(vm, &object, "tag", "TypeIgnore")?)?, }) } } diff --git a/crates/vm/src/stdlib/_ast/type_parameters.rs b/crates/vm/src/stdlib/_ast/type_parameters.rs index 0424ffbd768..8f2296ea76a 100644 --- a/crates/vm/src/stdlib/_ast/type_parameters.rs +++ b/crates/vm/src/stdlib/_ast/type_parameters.rs @@ -3,7 +3,10 @@ use rustpython_compiler_core::SourceFile; impl Node for ast::TypeParams { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { - self.type_params.ast_to_object(vm, source_file) + self.runtime_type_params.map_or_else( + || self.type_params.ast_to_object(vm, source_file), + |values| values.ast_to_object(vm, source_file), + ) } fn ast_from_object( @@ -11,22 +14,51 @@ impl Node for ast::TypeParams { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let type_params: Vec = Node::ast_from_object(vm, source_file, object)?; - let range = Option::zip(type_params.first(), type_params.last()) - .map(|(first, last)| first.range().cover(last.range())) - .unwrap_or_default(); - Ok(Self { - node_index: Default::default(), - type_params, - range, - }) + Ok(type_params_from_values( + vm, + Node::ast_from_object(vm, source_file, object)?, + )) } fn is_none(&self) -> bool { - self.type_params.is_empty() + self.type_params.is_empty() && self.runtime_type_params.is_none() + } +} + +pub(super) fn type_params_from_field( + vm: &VirtualMachine, + source_file: &SourceFile, + object: &PyObject, + field: &'static str, + typ: &str, +) -> PyResult>> { + let type_params: Vec> = + get_node_list_field(vm, source_file, object, field, typ)?; + let type_params = type_params_from_values(vm, type_params); + Ok((!type_params.is_none()).then_some(Box::new(type_params))) +} + +fn type_params_from_values( + _vm: &VirtualMachine, + values: Vec>, +) -> ast::TypeParams { + let runtime_type_params = values.iter().any(Option::is_none).then(|| values.clone()); + let type_params = lower_nullable_type_params(&values); + let range = Option::zip(type_params.first(), type_params.last()) + .map(|(first, last)| first.range().cover(last.range())) + .unwrap_or_default(); + ast::TypeParams { + node_index: Default::default(), + type_params, + range, + runtime_type_params, } } +fn lower_nullable_type_params(values: &[Option]) -> Vec { + values.iter().filter_map(Clone::clone).collect() +} + // sum impl Node for ast::TypeParam { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { @@ -42,35 +74,70 @@ impl Node for ast::TypeParam { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - let cls = object.class(); - Ok(if cls.is(pyast::NodeTypeParamTypeVar::static_type()) { - Self::TypeVar(ast::TypeParamTypeVar::ast_from_object( - vm, - source_file, - object, - )?) - } else if cls.is(pyast::NodeTypeParamParamSpec::static_type()) { - Self::ParamSpec(ast::TypeParamParamSpec::ast_from_object( + if vm.is_none(&object) { + return Err(vm.new_type_error(format!( + "expected some sort of type_param, but got {}", + object.repr(vm)? + ))); + } + enum TypeParamKind { + TypeVar, + ParamSpec, + TypeVarTuple, + } + let kind = if is_node_instance(vm, &object, pyast::NodeTypeParamTypeVar::static_type())? { + TypeParamKind::TypeVar + } else if is_node_instance(vm, &object, pyast::NodeTypeParamParamSpec::static_type())? { + TypeParamKind::ParamSpec + } else if is_node_instance(vm, &object, pyast::NodeTypeParamTypeVarTuple::static_type())? { + TypeParamKind::TypeVarTuple + } else { + return Err(vm.new_type_error(format!( + "expected some sort of type_param, but got {}", + object.repr(vm)? + ))); + }; + let range = type_param_range_from_object(vm, source_file, object.clone())?; + Ok(match kind { + TypeParamKind::TypeVar => Self::TypeVar(type_var_from_object_with_range( vm, source_file, object, - )?) - } else if cls.is(pyast::NodeTypeParamTypeVarTuple::static_type()) { - Self::TypeVarTuple(ast::TypeParamTypeVarTuple::ast_from_object( + range, + )?), + TypeParamKind::ParamSpec => Self::ParamSpec(param_spec_from_object_with_range( vm, source_file, object, - )?) - } else { - return Err(vm.new_type_error(format!( - "expected some sort of type_param, but got {}", - object.repr(vm)? - ))); + range, + )?), + TypeParamKind::TypeVarTuple => Self::TypeVarTuple( + type_var_tuple_from_object_with_range(vm, source_file, object, range)?, + ), }) } } // constructor +fn type_var_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::TypeParamTypeVar { + node_index: Default::default(), + name: get_required_identifier_field(vm, source_file, &object, "name", "TypeVar")?, + bound: get_node_field_opt(vm, &object, "bound")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + default: get_node_field_opt(vm, &object, "default_value")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + range, + }) +} + impl Node for ast::TypeParamTypeVar { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -99,27 +166,28 @@ impl Node for ast::TypeParamTypeVar { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - name: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "name", "TypeVar")?, - )?, - bound: get_node_field_opt(vm, &object, "bound")? - .map(|obj| Node::ast_from_object(vm, source_file, obj)) - .transpose()?, - default: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "default_value", "TypeVar")?, - )?, - range: range_from_object(vm, source_file, object, "TypeVar")?, - }) + let range = type_param_range_from_object(vm, source_file, object.clone())?; + type_var_from_object_with_range(vm, source_file, object, range) } } // constructor +fn param_spec_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::TypeParamParamSpec { + node_index: Default::default(), + name: get_required_identifier_field(vm, source_file, &object, "name", "ParamSpec")?, + default: get_node_field_opt(vm, &object, "default_value")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + range, + }) +} + impl Node for ast::TypeParamParamSpec { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -145,24 +213,28 @@ impl Node for ast::TypeParamParamSpec { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - name: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "name", "ParamSpec")?, - )?, - default: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "default_value", "ParamSpec")?, - )?, - range: range_from_object(vm, source_file, object, "ParamSpec")?, - }) + let range = type_param_range_from_object(vm, source_file, object.clone())?; + param_spec_from_object_with_range(vm, source_file, object, range) } } // constructor +fn type_var_tuple_from_object_with_range( + vm: &VirtualMachine, + source_file: &SourceFile, + object: PyObjectRef, + range: TextRange, +) -> PyResult { + Ok(ast::TypeParamTypeVarTuple { + node_index: Default::default(), + name: get_required_identifier_field(vm, source_file, &object, "name", "TypeVarTuple")?, + default: get_node_field_opt(vm, &object, "default_value")? + .map(|obj| Node::ast_from_object(vm, source_file, obj)) + .transpose()?, + range, + }) +} + impl Node for ast::TypeParamTypeVarTuple { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { let Self { @@ -191,19 +263,7 @@ impl Node for ast::TypeParamTypeVarTuple { source_file: &SourceFile, object: PyObjectRef, ) -> PyResult { - Ok(Self { - node_index: Default::default(), - name: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "name", "TypeVarTuple")?, - )?, - default: Node::ast_from_object( - vm, - source_file, - get_node_field(vm, &object, "default_value", "TypeVarTuple")?, - )?, - range: range_from_object(vm, source_file, object, "TypeVarTuple")?, - }) + let range = type_param_range_from_object(vm, source_file, object.clone())?; + type_var_tuple_from_object_with_range(vm, source_file, object, range) } } diff --git a/crates/vm/src/stdlib/_ast/validate.rs b/crates/vm/src/stdlib/_ast/validate.rs index cad37d38610..081197d6b1e 100644 --- a/crates/vm/src/stdlib/_ast/validate.rs +++ b/crates/vm/src/stdlib/_ast/validate.rs @@ -1,8 +1,10 @@ // spell-checker: ignore assignlist ifexp use super::module::Mod; -use crate::{PyResult, VirtualMachine}; +use crate::{PyResult, VirtualMachine, compiler::CompileError}; use ruff_python_ast as ast; +use rustpython_codegen::error::{CodegenError, CodegenErrorType}; +use rustpython_compiler_core::bytecode::ConstantData; fn expr_context_name(ctx: ast::ExprContext) -> &'static str { match ctx { @@ -13,6 +15,17 @@ fn expr_context_name(ctx: ast::ExprContext) -> &'static str { } } +fn invalid_syntax_error(vm: &VirtualMachine) -> crate::builtins::PyBaseExceptionRef { + vm.new_syntax_error( + &CompileError::Codegen(CodegenError { + location: None, + error: CodegenErrorType::SyntaxError("invalid syntax".to_owned()), + source_path: "".to_owned(), + }), + None, + ) +} + fn validate_name(vm: &VirtualMachine, name: &ast::name::Name) -> PyResult<()> { match name.as_str() { "None" | "True" | "False" => Err(vm.new_value_error(format!( @@ -30,6 +43,7 @@ fn validate_comprehension(vm: &VirtualMachine, gens: &[ast::Comprehension]) -> P for comp in gens { validate_expr(vm, &comp.target, ast::ExprContext::Store)?; validate_expr(vm, &comp.iter, ast::ExprContext::Load)?; + validate_runtime_expr_list_slots(vm, comp.runtime_ifs.as_ref(), ast::ExprContext::Load)?; validate_exprs(vm, &comp.ifs, ast::ExprContext::Load, false)?; } Ok(()) @@ -42,30 +56,49 @@ fn validate_keywords(vm: &VirtualMachine, keywords: &[ast::Keyword]) -> PyResult Ok(()) } +fn validate_parameter_annotation(vm: &VirtualMachine, parameter: &ast::Parameter) -> PyResult<()> { + if let Some(annotation) = ¶meter.annotation { + validate_expr(vm, annotation, ast::ExprContext::Load)?; + } + Ok(()) +} + fn validate_parameters(vm: &VirtualMachine, params: &ast::Parameters) -> PyResult<()> { - for param in params - .posonlyargs - .iter() - .chain(¶ms.args) - .chain(¶ms.kwonlyargs) - { - if let Some(annotation) = ¶m.parameter.annotation { - validate_expr(vm, annotation, ast::ExprContext::Load)?; - } - if let Some(default) = ¶m.default { - validate_expr(vm, default, ast::ExprContext::Load)?; - } + for param in params.posonlyargs.iter().chain(¶ms.args) { + validate_parameter_annotation(vm, ¶m.parameter)?; } if let Some(vararg) = ¶ms.vararg && let Some(annotation) = &vararg.annotation { validate_expr(vm, annotation, ast::ExprContext::Load)?; } + for param in ¶ms.kwonlyargs { + validate_parameter_annotation(vm, ¶m.parameter)?; + } if let Some(kwarg) = ¶ms.kwarg && let Some(annotation) = &kwarg.annotation { validate_expr(vm, annotation, ast::ExprContext::Load)?; } + if let Some(defaults) = params.runtime_defaults.as_ref() { + for default in defaults { + let Some(default) = default else { + return Err(vm.new_value_error("None disallowed in expression list")); + }; + validate_expr(vm, default, ast::ExprContext::Load)?; + } + } else { + for param in params.posonlyargs.iter().chain(¶ms.args) { + if let Some(default) = ¶m.default { + validate_expr(vm, default, ast::ExprContext::Load)?; + } + } + } + for param in ¶ms.kwonlyargs { + if let Some(default) = ¶m.default { + validate_expr(vm, default, ast::ExprContext::Load)?; + } + } Ok(()) } @@ -99,8 +132,14 @@ fn validate_assignlist( validate_exprs(vm, targets, ctx, false) } -fn validate_body(vm: &VirtualMachine, body: &[ast::Stmt], owner: &'static str) -> PyResult<()> { +fn validate_body( + vm: &VirtualMachine, + body: &[ast::Stmt], + metadata: Option<&Vec>>, + owner: &'static str, +) -> PyResult<()> { validate_nonempty_seq(vm, body.len(), "body", owner)?; + validate_runtime_stmt_list_slots(vm, metadata)?; validate_stmts(vm, body) } @@ -111,34 +150,83 @@ fn validate_interpolated_elements<'a>( for element in elements { if let ast::InterpolatedStringElementRef::Interpolation(interpolation) = element { validate_expr(vm, &interpolation.expression, ast::ExprContext::Load)?; - if let Some(format_spec) = &interpolation.format_spec { - for spec_element in &format_spec.elements { - if let ast::InterpolatedStringElement::Interpolation(spec_interp) = spec_element - { - validate_expr(vm, &spec_interp.expression, ast::ExprContext::Load)?; - } - } + if let Some(format_spec) = interpolation.runtime_formatted_value_format_spec.as_deref() + { + validate_expr(vm, format_spec, ast::ExprContext::Load)?; + } else if let Some(format_spec) = + interpolation.runtime_interpolation_format_spec.as_deref() + { + validate_expr(vm, format_spec, ast::ExprContext::Load)?; + } else if let Some(format_spec) = &interpolation.format_spec { + validate_interpolated_elements( + vm, + format_spec + .elements + .iter() + .map(ast::InterpolatedStringElementRef::from), + )?; } } } Ok(()) } +fn ensure_literal_number(expr: &ast::Expr, allow_real: bool, allow_imaginary: bool) -> bool { + let ast::Expr::NumberLiteral(number) = expr else { + return false; + }; + match number.value { + ast::Number::Int(_) | ast::Number::Float(_) => allow_real, + ast::Number::Complex { .. } => allow_imaginary, + } +} + +fn ensure_literal_negative(expr: &ast::Expr, allow_real: bool, allow_imaginary: bool) -> bool { + let ast::Expr::UnaryOp(unary) = expr else { + return false; + }; + if unary.op != ast::UnaryOp::USub { + return false; + } + ensure_literal_number(&unary.operand, allow_real, allow_imaginary) +} + +fn ensure_literal_complex(expr: &ast::Expr) -> bool { + let ast::Expr::BinOp(bin) = expr else { + return false; + }; + if !matches!(bin.op, ast::Operator::Add | ast::Operator::Sub) { + return false; + } + let real_left = ensure_literal_number(&bin.left, true, false) + || ensure_literal_negative(&bin.left, true, false); + real_left && ensure_literal_number(&bin.right, false, true) +} + +fn ast_constant_value(expr: &ast::Expr) -> Option { + expr.as_constant_expr() + .map(|expr| super::constant::ast_constant_value_to_constant_data(expr.value.clone())) +} + fn validate_pattern_match_value(vm: &VirtualMachine, expr: &ast::Expr) -> PyResult<()> { validate_expr(vm, expr, ast::ExprContext::Load)?; + if let Some(constant) = ast_constant_value(expr) { + return match &constant { + ConstantData::Integer { .. } + | ConstantData::Float { .. } + | ConstantData::Bytes { .. } + | ConstantData::Complex { .. } + | ConstantData::Str { .. } => Ok(()), + _ => Err(vm.new_value_error("unexpected constant inside of a literal pattern")), + }; + } match expr { ast::Expr::NumberLiteral(_) | ast::Expr::StringLiteral(_) | ast::Expr::BytesLiteral(_) => { Ok(()) } ast::Expr::Attribute(_) => Ok(()), - ast::Expr::UnaryOp(op) => match &*op.operand { - ast::Expr::NumberLiteral(_) => Ok(()), - _ => Err(vm.new_value_error("patterns may only match literals and attribute lookups")), - }, - ast::Expr::BinOp(bin) => match (&*bin.left, &*bin.right) { - (ast::Expr::NumberLiteral(_), ast::Expr::NumberLiteral(_)) => Ok(()), - _ => Err(vm.new_value_error("patterns may only match literals and attribute lookups")), - }, + ast::Expr::UnaryOp(_) if ensure_literal_negative(expr, true, true) => Ok(()), + ast::Expr::BinOp(_) if ensure_literal_complex(expr) => Ok(()), ast::Expr::FString(_) | ast::Expr::TString(_) => Ok(()), ast::Expr::BooleanLiteral(_) | ast::Expr::NoneLiteral(_) @@ -162,7 +250,10 @@ fn validate_pattern(vm: &VirtualMachine, pattern: &ast::Pattern, star_ok: bool) ast::Pattern::MatchSingleton(singleton) => match singleton.value { ast::Singleton::None | ast::Singleton::True | ast::Singleton::False => Ok(()), }, - ast::Pattern::MatchSequence(seq) => validate_patterns(vm, &seq.patterns, true), + ast::Pattern::MatchSequence(seq) => { + validate_runtime_pattern_list_slots(vm, seq.runtime_patterns.as_ref())?; + validate_patterns(vm, &seq.patterns, true) + } ast::Pattern::MatchMapping(mapping) => { if mapping.keys.len() != mapping.patterns.len() { return Err(vm.new_value_error( @@ -172,15 +263,34 @@ fn validate_pattern(vm: &VirtualMachine, pattern: &ast::Pattern, star_ok: bool) if let Some(rest) = &mapping.rest { validate_capture(vm, rest)?; } + validate_runtime_expr_option_list_slots(vm, mapping.runtime_keys.as_ref())?; for key in &mapping.keys { - if let ast::Expr::BooleanLiteral(_) | ast::Expr::NoneLiteral(_) = key { + if matches!( + key, + ast::Expr::BooleanLiteral(_) + | ast::Expr::NoneLiteral(_) + | ast::Expr::Constant(ast::ExprConstant { + value: ast::ConstantValue::Boolean(_) | ast::ConstantValue::None, + .. + }) + ) { continue; } validate_pattern_match_value(vm, key)?; } + validate_runtime_pattern_list_slots(vm, mapping.runtime_patterns.as_ref())?; validate_patterns(vm, &mapping.patterns, false) } ast::Pattern::MatchClass(match_class) => { + if let (Some(kwd_attrs), Some(kwd_patterns)) = ( + match_class.runtime_kwd_attrs.as_ref(), + match_class.runtime_kwd_patterns.as_ref(), + ) && kwd_attrs.len() != kwd_patterns.len() + { + return Err(vm.new_value_error( + "MatchClass doesn't have the same number of keyword attributes as patterns", + )); + } validate_expr(vm, &match_class.cls, ast::ExprContext::Load)?; let mut cls = match_class.cls.as_ref(); loop { @@ -199,7 +309,13 @@ fn validate_pattern(vm: &VirtualMachine, pattern: &ast::Pattern, star_ok: bool) for keyword in &match_class.arguments.keywords { validate_name(vm, keyword.attr.id())?; } + if let Some(patterns) = &match_class.runtime_patterns { + validate_runtime_nullable_patterns(vm, patterns)?; + } validate_patterns(vm, &match_class.arguments.patterns, false)?; + if let Some(kwd_patterns) = &match_class.runtime_kwd_patterns { + validate_runtime_nullable_patterns(vm, kwd_patterns)?; + } for keyword in &match_class.arguments.keywords { validate_pattern(vm, &keyword.pattern, false)?; } @@ -234,11 +350,80 @@ fn validate_pattern(vm: &VirtualMachine, pattern: &ast::Pattern, star_ok: bool) if match_or.patterns.len() < 2 { return Err(vm.new_value_error("MatchOr requires at least 2 patterns")); } + validate_runtime_pattern_list_slots(vm, match_or.runtime_patterns.as_ref())?; validate_patterns(vm, &match_or.patterns, false) } } } +fn validate_runtime_pattern_list_slots( + vm: &VirtualMachine, + values: Option<&Vec>>, +) -> PyResult<()> { + if values.is_some_and(|values| values.iter().any(Option::is_none)) { + return Err(vm.new_value_error("unexpected pattern")); + } + Ok(()) +} + +fn validate_runtime_expr_option_list_slots( + vm: &VirtualMachine, + values: Option<&Vec>>, +) -> PyResult<()> { + if values.is_some_and(|values| values.iter().any(Option::is_none)) { + return Err(vm.new_value_error("None disallowed in expression list")); + } + Ok(()) +} + +fn validate_runtime_expr_list_slots( + vm: &VirtualMachine, + values: Option<&Vec>>, + ctx: ast::ExprContext, +) -> PyResult<()> { + if let Some(values) = values { + for value in values { + let Some(value) = value else { + return Err(vm.new_value_error("None disallowed in expression list")); + }; + validate_expr(vm, value, ctx)?; + } + } + Ok(()) +} + +fn validate_runtime_stmt_list_slots( + vm: &VirtualMachine, + values: Option<&Vec>>, +) -> PyResult<()> { + if let Some(values) = values + && values.iter().any(Option::is_none) + { + return Err(vm.new_value_error("None disallowed in statement list")); + } + Ok(()) +} + +fn validate_runtime_except_handler_list_slots( + vm: &VirtualMachine, + values: Option<&Vec>>, +) -> PyResult<()> { + if values.is_some_and(|values| values.iter().any(Option::is_none)) { + return Err(vm.new_value_error("unexpected excepthandler")); + } + Ok(()) +} + +fn validate_runtime_nullable_patterns( + vm: &VirtualMachine, + patterns: &[Option], +) -> PyResult<()> { + if patterns.iter().any(Option::is_none) { + return Err(vm.new_value_error("unexpected pattern")); + } + Ok(()) +} + fn validate_patterns( vm: &VirtualMachine, patterns: &[ast::Pattern], @@ -282,6 +467,12 @@ fn validate_type_params( type_params: Option<&ast::TypeParams>, ) -> PyResult<()> { if let Some(type_params) = type_params { + if let Some(values) = type_params.runtime_type_params.as_ref() { + for tp in values.iter().flatten() { + validate_typeparam(vm, tp)?; + } + return Ok(()); + } for tp in &type_params.type_params { validate_typeparam(vm, tp)?; } @@ -337,6 +528,11 @@ fn validate_expr(vm: &VirtualMachine, expr: &ast::Expr, ctx: ast::ExprContext) - if op.values.len() < 2 { return Err(vm.new_value_error("BoolOp with less than 2 values")); } + validate_runtime_expr_list_slots( + vm, + op.runtime_values.as_ref(), + ast::ExprContext::Load, + )?; validate_exprs(vm, &op.values, ast::ExprContext::Load, false) } ast::Expr::Named(named) => { @@ -362,6 +558,11 @@ fn validate_expr(vm: &VirtualMachine, expr: &ast::Expr, ctx: ast::ExprContext) - validate_expr(vm, &ifexp.orelse, ast::ExprContext::Load) } ast::Expr::Dict(dict) => { + validate_runtime_expr_list_slots( + vm, + dict.runtime_values.as_ref(), + ast::ExprContext::Load, + )?; for item in &dict.items { if let Some(key) = &item.key { validate_expr(vm, key, ast::ExprContext::Load)?; @@ -370,7 +571,14 @@ fn validate_expr(vm: &VirtualMachine, expr: &ast::Expr, ctx: ast::ExprContext) - } Ok(()) } - ast::Expr::Set(set) => validate_exprs(vm, &set.elts, ast::ExprContext::Load, false), + ast::Expr::Set(set) => { + validate_runtime_expr_list_slots( + vm, + set.runtime_elts.as_ref(), + ast::ExprContext::Load, + )?; + validate_exprs(vm, &set.elts, ast::ExprContext::Load, false) + } ast::Expr::ListComp(list) => { validate_comprehension(vm, &list.generators)?; validate_expr(vm, &list.elt, ast::ExprContext::Load) @@ -409,34 +617,73 @@ fn validate_expr(vm: &VirtualMachine, expr: &ast::Expr, ctx: ast::ExprContext) - "Compare has a different number of comparators and operands", )); } + validate_runtime_expr_list_slots( + vm, + compare.runtime_comparators.as_ref(), + ast::ExprContext::Load, + )?; validate_exprs(vm, &compare.comparators, ast::ExprContext::Load, false)?; validate_expr(vm, &compare.left, ast::ExprContext::Load) } ast::Expr::Call(call) => { validate_expr(vm, &call.func, ast::ExprContext::Load)?; + validate_runtime_expr_list_slots( + vm, + call.arguments.runtime_args.as_ref(), + ast::ExprContext::Load, + )?; validate_exprs(vm, &call.arguments.args, ast::ExprContext::Load, false)?; validate_keywords(vm, &call.arguments.keywords) } - ast::Expr::FString(fstring) => validate_interpolated_elements( - vm, - fstring - .value - .elements() - .map(ast::InterpolatedStringElementRef::from), - ), - ast::Expr::TString(tstring) => validate_interpolated_elements( - vm, - tstring - .value - .elements() - .map(ast::InterpolatedStringElementRef::from), - ), + ast::Expr::FString(fstring) => { + validate_runtime_expr_list_slots( + vm, + fstring.runtime_values.as_ref(), + ast::ExprContext::Load, + )?; + if let Some(joined_str) = fstring.runtime_joined_str.as_ref() { + validate_exprs(vm, joined_str, ast::ExprContext::Load, false) + } else { + validate_interpolated_elements( + vm, + fstring + .value + .elements() + .map(ast::InterpolatedStringElementRef::from), + ) + } + } + ast::Expr::TString(tstring) => { + validate_runtime_expr_list_slots( + vm, + tstring.runtime_values.as_ref(), + ast::ExprContext::Load, + )?; + if let Some(template_str) = tstring.runtime_template_str.as_ref() { + validate_exprs(vm, template_str, ast::ExprContext::Load, false) + } else { + validate_interpolated_elements( + vm, + tstring + .value + .elements() + .map(ast::InterpolatedStringElementRef::from), + ) + } + } ast::Expr::StringLiteral(_) | ast::Expr::BytesLiteral(_) | ast::Expr::NumberLiteral(_) + | ast::Expr::Constant(_) | ast::Expr::BooleanLiteral(_) | ast::Expr::NoneLiteral(_) - | ast::Expr::EllipsisLiteral(_) => Ok(()), + | ast::Expr::EllipsisLiteral(_) => { + if let Some(invalid_type) = super::constant::invalid_constant_type(expr) { + Err(vm.new_type_error(format!("got an invalid type in Constant: {invalid_type}"))) + } else { + Ok(()) + } + } ast::Expr::Attribute(attr) => validate_expr(vm, &attr.value, ast::ExprContext::Load), ast::Expr::Subscript(sub) => { validate_expr(vm, &sub.slice, ast::ExprContext::Load)?; @@ -444,8 +691,14 @@ fn validate_expr(vm: &VirtualMachine, expr: &ast::Expr, ctx: ast::ExprContext) - } ast::Expr::Starred(star) => validate_expr(vm, &star.value, ctx), ast::Expr::Name(_) => Ok(()), - ast::Expr::List(list) => validate_exprs(vm, &list.elts, ctx, false), - ast::Expr::Tuple(tuple) => validate_exprs(vm, &tuple.elts, ctx, false), + ast::Expr::List(list) => { + validate_runtime_expr_list_slots(vm, list.runtime_elts.as_ref(), ctx)?; + validate_exprs(vm, &list.elts, ctx, false) + } + ast::Expr::Tuple(tuple) => { + validate_runtime_expr_list_slots(vm, tuple.runtime_elts.as_ref(), ctx)?; + validate_exprs(vm, &tuple.elts, ctx, false) + } ast::Expr::Slice(slice) => { if let Some(lower) = &slice.lower { validate_expr(vm, lower, ast::ExprContext::Load)?; @@ -458,7 +711,7 @@ fn validate_expr(vm: &VirtualMachine, expr: &ast::Expr, ctx: ast::ExprContext) - } Ok(()) } - ast::Expr::IpyEscapeCommand(_) => Ok(()), + ast::Expr::IpyEscapeCommand(_) => Err(invalid_syntax_error(vm)), } } @@ -477,9 +730,14 @@ fn validate_stmt(vm: &VirtualMachine, stmt: &ast::Stmt) -> PyResult<()> { } else { "FunctionDef" }; - validate_body(vm, &func.body, owner)?; + validate_body(vm, &func.body, func.runtime_body.as_ref(), owner)?; validate_type_params(vm, func.type_params.as_deref())?; validate_parameters(vm, &func.parameters)?; + validate_runtime_expr_list_slots( + vm, + func.runtime_decorator_list.as_ref(), + ast::ExprContext::Load, + )?; validate_decorators(vm, &func.decorator_list)?; if let Some(returns) = &func.returns { validate_expr(vm, returns, ast::ExprContext::Load)?; @@ -487,12 +745,27 @@ fn validate_stmt(vm: &VirtualMachine, stmt: &ast::Stmt) -> PyResult<()> { Ok(()) } ast::Stmt::ClassDef(class_def) => { - validate_body(vm, &class_def.body, "ClassDef")?; + validate_body( + vm, + &class_def.body, + class_def.runtime_body.as_ref(), + "ClassDef", + )?; validate_type_params(vm, class_def.type_params.as_deref())?; if let Some(arguments) = &class_def.arguments { + validate_runtime_expr_list_slots( + vm, + arguments.runtime_bases.as_ref(), + ast::ExprContext::Load, + )?; validate_exprs(vm, &arguments.args, ast::ExprContext::Load, false)?; validate_keywords(vm, &arguments.keywords)?; } + validate_runtime_expr_list_slots( + vm, + class_def.runtime_decorator_list.as_ref(), + ast::ExprContext::Load, + )?; validate_decorators(vm, &class_def.decorator_list) } ast::Stmt::Return(ret) => { @@ -501,8 +774,20 @@ fn validate_stmt(vm: &VirtualMachine, stmt: &ast::Stmt) -> PyResult<()> { } Ok(()) } - ast::Stmt::Delete(del) => validate_assignlist(vm, &del.targets, ast::ExprContext::Del), + ast::Stmt::Delete(del) => { + validate_runtime_expr_list_slots( + vm, + del.runtime_targets.as_ref(), + ast::ExprContext::Del, + )?; + validate_assignlist(vm, &del.targets, ast::ExprContext::Del) + } ast::Stmt::Assign(assign) => { + validate_runtime_expr_list_slots( + vm, + assign.runtime_targets.as_ref(), + ast::ExprContext::Store, + )?; validate_assignlist(vm, &assign.targets, ast::ExprContext::Store)?; validate_expr(vm, &assign.value, ast::ExprContext::Load) } @@ -532,22 +817,30 @@ fn validate_stmt(vm: &VirtualMachine, stmt: &ast::Stmt) -> PyResult<()> { let owner = if for_stmt.is_async { "AsyncFor" } else { "For" }; validate_expr(vm, &for_stmt.target, ast::ExprContext::Store)?; validate_expr(vm, &for_stmt.iter, ast::ExprContext::Load)?; - validate_body(vm, &for_stmt.body, owner)?; + validate_body(vm, &for_stmt.body, for_stmt.runtime_body.as_ref(), owner)?; + validate_runtime_stmt_list_slots(vm, for_stmt.runtime_orelse.as_ref())?; validate_stmts(vm, &for_stmt.orelse) } ast::Stmt::While(while_stmt) => { validate_expr(vm, &while_stmt.test, ast::ExprContext::Load)?; - validate_body(vm, &while_stmt.body, "While")?; + validate_body( + vm, + &while_stmt.body, + while_stmt.runtime_body.as_ref(), + "While", + )?; + validate_runtime_stmt_list_slots(vm, while_stmt.runtime_orelse.as_ref())?; validate_stmts(vm, &while_stmt.orelse) } ast::Stmt::If(if_stmt) => { validate_expr(vm, &if_stmt.test, ast::ExprContext::Load)?; - validate_body(vm, &if_stmt.body, "If")?; + validate_body(vm, &if_stmt.body, if_stmt.runtime_body.as_ref(), "If")?; for clause in &if_stmt.elif_else_clauses { if let Some(test) = &clause.test { validate_expr(vm, test, ast::ExprContext::Load)?; } - validate_body(vm, &clause.body, "If")?; + validate_body(vm, &clause.body, clause.runtime_body.as_ref(), "If")?; + validate_runtime_stmt_list_slots(vm, clause.runtime_orelse.as_ref())?; } Ok(()) } @@ -564,7 +857,7 @@ fn validate_stmt(vm: &VirtualMachine, stmt: &ast::Stmt) -> PyResult<()> { validate_expr(vm, optional_vars, ast::ExprContext::Store)?; } } - validate_body(vm, &with_stmt.body, owner) + validate_body(vm, &with_stmt.body, with_stmt.runtime_body.as_ref(), owner) } ast::Stmt::Match(match_stmt) => { validate_expr(vm, &match_stmt.subject, ast::ExprContext::Load)?; @@ -574,7 +867,7 @@ fn validate_stmt(vm: &VirtualMachine, stmt: &ast::Stmt) -> PyResult<()> { if let Some(guard) = &case.guard { validate_expr(vm, guard, ast::ExprContext::Load)?; } - validate_body(vm, &case.body, "match_case")?; + validate_body(vm, &case.body, case.runtime_body.as_ref(), "match_case")?; } Ok(()) } @@ -591,7 +884,7 @@ fn validate_stmt(vm: &VirtualMachine, stmt: &ast::Stmt) -> PyResult<()> { } ast::Stmt::Try(try_stmt) => { let owner = if try_stmt.is_star { "TryStar" } else { "Try" }; - validate_body(vm, &try_stmt.body, owner)?; + validate_body(vm, &try_stmt.body, try_stmt.runtime_body.as_ref(), owner)?; if try_stmt.handlers.is_empty() && try_stmt.finalbody.is_empty() { return Err(vm.new_value_error(format!( "{owner} has neither except handlers nor finalbody" @@ -602,14 +895,22 @@ fn validate_stmt(vm: &VirtualMachine, stmt: &ast::Stmt) -> PyResult<()> { vm.new_value_error(format!("{owner} has orelse but no except handlers")) ); } + validate_runtime_except_handler_list_slots(vm, try_stmt.runtime_handlers.as_ref())?; for handler in &try_stmt.handlers { let ast::ExceptHandler::ExceptHandler(handler) = handler; if let Some(type_expr) = &handler.type_ { validate_expr(vm, type_expr, ast::ExprContext::Load)?; } - validate_body(vm, &handler.body, "ExceptHandler")?; - } + validate_body( + vm, + &handler.body, + handler.runtime_body.as_ref(), + "ExceptHandler", + )?; + } + validate_runtime_stmt_list_slots(vm, try_stmt.runtime_finalbody.as_ref())?; validate_stmts(vm, &try_stmt.finalbody)?; + validate_runtime_stmt_list_slots(vm, try_stmt.runtime_orelse.as_ref())?; validate_stmts(vm, &try_stmt.orelse) } ast::Stmt::Assert(assert_stmt) => { @@ -624,6 +925,11 @@ fn validate_stmt(vm: &VirtualMachine, stmt: &ast::Stmt) -> PyResult<()> { Ok(()) } ast::Stmt::ImportFrom(import) => { + if let Some(level) = import.runtime_level + && level < 0 + { + return Err(vm.new_value_error("Negative ImportFrom level")); + } validate_nonempty_seq(vm, import.names.len(), "names", "ImportFrom")?; Ok(()) } @@ -636,10 +942,8 @@ fn validate_stmt(vm: &VirtualMachine, stmt: &ast::Stmt) -> PyResult<()> { Ok(()) } ast::Stmt::Expr(expr) => validate_expr(vm, &expr.value, ast::ExprContext::Load), - ast::Stmt::Pass(_) - | ast::Stmt::Break(_) - | ast::Stmt::Continue(_) - | ast::Stmt::IpyEscapeCommand(_) => Ok(()), + ast::Stmt::Pass(_) | ast::Stmt::Break(_) | ast::Stmt::Continue(_) => Ok(()), + ast::Stmt::IpyEscapeCommand(_) => Err(invalid_syntax_error(vm)), } } @@ -652,10 +956,17 @@ fn validate_stmts(vm: &VirtualMachine, stmts: &[ast::Stmt]) -> PyResult<()> { pub(super) fn validate_mod(vm: &VirtualMachine, module: &Mod) -> PyResult<()> { match module { - Mod::Module(module) => validate_stmts(vm, &module.body), - Mod::Interactive(module) => validate_stmts(vm, &module.body), + Mod::Module(module) => { + validate_runtime_stmt_list_slots(vm, module.module.runtime_body.as_ref())?; + validate_stmts(vm, &module.module.body) + } + Mod::Interactive(module) => { + validate_runtime_stmt_list_slots(vm, module.runtime_body.as_ref())?; + validate_stmts(vm, &module.body) + } Mod::Expression(expr) => validate_expr(vm, &expr.body, ast::ExprContext::Load), Mod::FunctionType(func_type) => { + validate_runtime_expr_option_list_slots(vm, func_type.runtime_argtypes.as_ref())?; validate_exprs(vm, &func_type.argtypes, ast::ExprContext::Load, false)?; validate_expr(vm, &func_type.returns, ast::ExprContext::Load) } diff --git a/crates/vm/src/stdlib/_symtable.rs b/crates/vm/src/stdlib/_symtable.rs index 4c6ec75f5ac..adcddeacc1f 100644 --- a/crates/vm/src/stdlib/_symtable.rs +++ b/crates/vm/src/stdlib/_symtable.rs @@ -153,7 +153,9 @@ mod _symtable { CompilerScope::Class => TYPE_CLASS, CompilerScope::Module => TYPE_MODULE, CompilerScope::Annotation => TYPE_ANNOTATION, + CompilerScope::TypeAlias => TYPE_TYPE_ALIAS, CompilerScope::TypeParams => TYPE_TYPE_PARAMETERS, + CompilerScope::TypeVariable => TYPE_TYPE_VARIABLE, } } diff --git a/crates/vm/src/stdlib/builtins.rs b/crates/vm/src/stdlib/builtins.rs index d0ed32b22d6..8ea849ab05b 100644 --- a/crates/vm/src/stdlib/builtins.rs +++ b/crates/vm/src/stdlib/builtins.rs @@ -13,11 +13,12 @@ mod builtins { PyByteArray, PyBytes, PyDictRef, PyStr, PyStrRef, PyTuple, PyTupleRef, PyType, PyUtf8StrRef, enumerate::PyReverseSequenceIterator, - function::{PyCellRef, PyFunction}, + function::{PyCell, PyCellRef, PyFunction}, int::PyIntRef, iter::PyCallableIterator, list::{PyList, SortOptions}, }, + bytecode, common::hash::PyHash, function::{ ArgBytesLike, ArgCallable, ArgIndex, ArgIntoBool, ArgIterable, ArgMapping, @@ -29,9 +30,13 @@ mod builtins { readline::{Readline, ReadlineResult}, stdlib::sys, types::PyComparisonOp, + vm::compile_mode::{ + CompilerFlags, PY_EVAL_INPUT, PY_FILE_INPUT, PY_FUNC_TYPE_INPUT, PY_SINGLE_INPUT, + compile_future_feature_mask, compile_future_features_from_flags, + }, }; use itertools::Itertools; - use num_traits::{Signed, ToPrimitive, Zero}; + use num_traits::{Signed, ToPrimitive}; use rustpython_common::wtf8::CodePoint; #[cfg(not(feature = "rustpython-compiler"))] @@ -103,8 +108,8 @@ mod builtins { filename: PyObjectRef, mode: PyUtf8StrRef, // CPython parity: flags / optimize accept any object with __index__, - // not just exact int. Matches the behavior of `int(x)` arg conversion - // used by Python/Python-ast.c::compile. + // not just exact int. Matches the argument conversion used by + // builtin_compile_impl. #[pyarg(any, optional)] flags: OptionalArg>, // CPython parity: dont_inherit goes through PyObject_IsTrue, so @@ -114,174 +119,67 @@ mod builtins { dont_inherit: OptionalArg, #[pyarg(any, optional)] optimize: OptionalArg>, - #[pyarg(any, optional)] + #[pyarg(named, optional)] _feature_version: OptionalArg, } - /// Detect PEP 263 encoding cookie from source bytes. - /// Checks first two lines for `# coding[:=] ` pattern. - /// Returns the encoding name if found, or None for default (UTF-8). - #[cfg(feature = "parser")] - fn detect_source_encoding(source: &[u8]) -> Option { - fn find_encoding_in_line(line: &[u8]) -> Option { - // PEP 263: '#' must be preceded only by whitespace/formfeed - let hash_pos = line.iter().position(|&b| b == b'#')?; - if !line[..hash_pos] - .iter() - .all(|&b| matches!(b, b' ' | b'\t' | b'\x0c' | b'\r')) - { - return None; - } - let after_hash = &line[hash_pos..]; - - // Find "coding" after the # - let coding_pos = after_hash.windows(6).position(|w| w == b"coding")?; - let after_coding = &after_hash[coding_pos + 6..]; - - // Next char must be ':' or '=' - let rest = if matches!(after_coding.first(), Some(b':' | b'=')) { - &after_coding[1..] - } else { - return None; - }; - - // Skip whitespace - let rest = rest - .iter() - .copied() - .skip_while(|&b| matches!(b, b' ' | b'\t')) - .collect::>(); - - // Read encoding name: [-\w.]+ - let name = rest - .iter() - .take_while(|&&b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) - .map(|&b| b as char) - .collect::(); - - if name.is_empty() { - None - } else { - Some(normalize_source_encoding(&name)) - } - } - - // Split into lines (first two only) - let mut lines = source.splitn(3, |&b| b == b'\n'); - - if let Some(first) = lines.next() { - // Strip BOM if present - let first = first.strip_prefix(b"\xef\xbb\xbf").unwrap_or(first); - if let Some(enc) = find_encoding_in_line(first) { - return Some(enc); - } - // Only check second line if first line is blank or a comment - let trimmed = first - .iter() - .find(|&&b| !matches!(b, b' ' | b'\t' | b'\x0c' | b'\r')) - .copied(); - - if trimmed.is_some_and(|b| b != b'#') { - return None; - } + fn merge_compile_future_features( + flags: i32, + dont_inherit: bool, + vm: &VirtualMachine, + ) -> bytecode::CodeFlags { + let mut future_features = compile_future_features_from_flags(flags); + if !dont_inherit && let Some(frame) = vm.current_frame() { + future_features |= bytecode::CodeFlags::from_bits_truncate( + frame.code.flags.bits() & compile_future_feature_mask().bits(), + ); } - - lines.next().and_then(find_encoding_in_line) + future_features } - /// Match CPython's Parser/tokenizer/helpers.c:get_normal_name(). - #[cfg(feature = "parser")] - fn normalize_source_encoding(name: &str) -> String { - let mut normalized = String::with_capacity(name.len().min(12)); - for ch in name.chars().take(12) { - if ch == '_' { - normalized.push('-'); - } else { - normalized.push(ch.to_ascii_lowercase()); - } - } + fn audit_compile_source(vm: &VirtualMachine, source: &[u8], filename: &str) -> PyResult<()> { + vm.sys_module.get_attr("audit", vm)?.call( + ( + vm.ctx.new_str("compile"), + vm.ctx.new_bytes(source.to_vec()), + vm.ctx.new_str(filename), + ), + vm, + )?; + Ok(()) + } - if normalized == "utf-8" || normalized.starts_with("utf-8-") { - "utf-8".to_owned() - } else if normalized == "latin-1" - || normalized == "iso-8859-1" - || normalized == "iso-latin-1" - || normalized.starts_with("latin-1-") - || normalized.starts_with("iso-8859-1-") - || normalized.starts_with("iso-latin-1-") + fn trim_eval_source_bytes(mut source: &[u8]) -> &[u8] { + while let Some((&first, rest)) = source.split_first() + && matches!(first, b' ' | b'\t') { - "iso-8859-1".to_owned() - } else { - name.to_owned() + source = rest; } + source } - /// Decode source bytes to a string, handling PEP 263 encoding declarations - /// and BOM. Raises SyntaxError for invalid UTF-8 without an encoding - /// declaration. - #[cfg(feature = "parser")] - fn is_utf8_encoding(name: &str) -> bool { - name == "utf-8" - } - - #[cfg(feature = "parser")] - fn decode_source_bytes(source: &[u8], filename: &str, vm: &VirtualMachine) -> PyResult { - let has_bom = source.starts_with(b"\xef\xbb\xbf"); - let encoding = detect_source_encoding(source); - - let is_utf8 = encoding.as_deref().is_none_or(is_utf8_encoding); - - // Validate BOM + encoding combination - if has_bom && !is_utf8 { - let enc = encoding.as_deref().unwrap_or("utf-8"); - return Err(vm.new_exception_msg( - vm.ctx.exceptions.syntax_error.to_owned(), - format!("encoding problem: {enc} with BOM").into(), - )); + fn decode_eval_exec_source_bytes( + vm: &VirtualMachine, + source: &[u8], + filename: &str, + ) -> PyResult { + #[cfg(feature = "parser")] + { + vm.decode_source_bytes(source, filename, false) } - - if is_utf8 { - let src = if has_bom { &source[3..] } else { source }; - match core::str::from_utf8(src) { - Ok(s) => Ok(s.to_owned()), - Err(e) => { - let bad_byte = src[e.valid_up_to()]; - let line = src[..e.valid_up_to()] - .iter() - .filter(|&&b| b == b'\n') - .count() - + 1; - Err(vm.new_exception_msg( - vm.ctx.exceptions.syntax_error.to_owned(), - format!( - "Non-UTF-8 code starting with '\\x{bad_byte:02x}' \ - on line {line}, but no encoding declared; \ - see https://peps.python.org/pep-0263/ for details \ - ({filename}, line {line})" - ) - .into(), - )) - } - } - } else { - // Use codec registry for non-UTF-8 encodings - let enc = encoding.as_deref().unwrap(); - let bytes_obj = vm.ctx.new_bytes(source.to_vec()); - let decoded = vm - .state - .codec_registry - .decode_text(bytes_obj.into(), enc, None, vm) - .map_err(|exc| { - if exc.fast_isinstance(vm.ctx.exceptions.lookup_error) { - vm.new_exception_msg( - vm.ctx.exceptions.syntax_error.to_owned(), - format!("unknown encoding for '{filename}': {enc}").into(), - ) - } else { - exc - } - })?; - Ok(decoded.to_string_lossy().into_owned()) + #[cfg(not(feature = "parser"))] + { + _ = filename; + core::str::from_utf8(source) + .map(str::to_owned) + .map_err(|err| { + let msg = format!( + "(unicode error) 'utf-8' codec can't decode byte 0x{:x?} in position {}: invalid start byte", + source[err.valid_up_to()], + err.valid_up_to() + ); + vm.new_exception_msg(vm.ctx.exceptions.syntax_error.to_owned(), msg.into()) + }) } } @@ -303,30 +201,60 @@ mod builtins { use crate::{class::PyClassImpl, stdlib::_ast}; - let feature_version = feature_version_from_arg(args._feature_version, vm)?; + let feature_version = args._feature_version.into_option().unwrap_or(-1); let mode_str = args.mode.as_str(); + let flags: i32 = args.flags.map_or(0, |v| v.value); + let cf = CompilerFlags::from_bits_retain(flags); + + if (flags & !CompilerFlags::ALLOWED_FLAGS.bits()) != 0 { + return Err(vm.new_value_error("compile(): unrecognised flags")); + } let optimize: i32 = args.optimize.map_or(-1, |v| v.value); let optimize: u8 = match optimize { - -1 => vm.state.config.settings.optimize, + -1 => vm.state.config.settings.optimize.min(2), 0..=2 => optimize as u8, _ => return Err(vm.new_value_error("compile(): invalid optimize value")), }; - - if args - .source - .fast_isinstance(&_ast::NodeAst::make_static_type()) - { - let flags: i32 = args.flags.map_or(0, |v| v.value); - let is_ast_only = !(flags & _ast::PY_CF_ONLY_AST).is_zero(); - - // func_type mode requires PyCF_ONLY_AST - if mode_str == "func_type" && !is_ast_only { + let dont_inherit = args.dont_inherit.map_or(false, ArgIntoBool::into_bool); + let is_ast_only = cf.contains(CompilerFlags::ONLY_AST); + let future_features = merge_compile_future_features(flags, dont_inherit, vm); + + let start = if mode_str == "exec" { + PY_FILE_INPUT + } else if mode_str == "eval" { + PY_EVAL_INPUT + } else if mode_str == "single" { + PY_SINGLE_INPUT + } else if mode_str == "func_type" { + if !is_ast_only { return Err(vm.new_value_error( "compile() mode 'func_type' requires flag PyCF_ONLY_AST", )); } + PY_FUNC_TYPE_INPUT + } else { + let msg = if is_ast_only { + "compile() mode must be 'exec', 'eval', 'single' or 'func_type'" + } else { + "compile() mode must be 'exec', 'eval' or 'single'" + }; + return Err(vm.new_value_error(msg)); + }; + + let ast_type = _ast::NodeAst::make_static_type().as_object().to_owned(); + if args.source.is_instance(&ast_type, vm)? { + let explicit_future_annotations = + future_features.contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS); + vm.sys_module.get_attr("audit", vm)?.call( + ( + vm.ctx.new_str("compile"), + args.source.clone(), + vm.ctx.none(), + ), + vm, + )?; // compile(ast_node, ..., PyCF_ONLY_AST) returns the AST after validation if is_ast_only { @@ -336,15 +264,29 @@ mod builtins { "compile() mode must be 'exec', 'eval', 'single' or 'func_type'", ) })?; - if !args.source.fast_isinstance(&expected_type) { + if !args.source.is_instance(expected_type.as_object(), vm)? { return Err(vm.new_type_error(format!( "expected {} node, got {}", expected_name, args.source.class().name() ))); } - _ast::validate_ast_object(vm, args.source.clone())?; - return Ok(args.source); + #[cfg(not(feature = "rustpython-codegen"))] + { + _ast::validate_ast_object(vm, args.source.clone())?; + return Ok(args.source); + } + #[cfg(feature = "rustpython-codegen")] + { + return _ast::preprocess_ast_object( + vm, + args.source, + &filename.to_string_lossy(), + optimize, + cf.contains(CompilerFlags::OPTIMIZED_AST), + explicit_future_annotations, + ); + } } #[cfg(not(feature = "rustpython-codegen"))] @@ -353,133 +295,104 @@ mod builtins { } #[cfg(feature = "rustpython-codegen")] { + let (expected_type, expected_name) = _ast::mode_type_and_name(mode_str) + .ok_or_else(|| { + vm.new_value_error("compile() mode must be 'exec', 'eval' or 'single'") + })?; + if !args.source.is_instance(expected_type.as_object(), vm)? { + return Err(vm.new_type_error(format!( + "expected {} node, got {}", + expected_name, + args.source.class().name() + ))); + } let mode = mode_str .parse::() .map_err(|err| vm.new_value_error(err.to_string()))?; - return _ast::compile( - vm, - args.source, - &filename.to_string_lossy(), - mode, - Some(optimize), - ); + let mut opts = vm.compile_opts(); + opts.optimize = optimize; + opts.allow_top_level_await = cf.contains(CompilerFlags::ALLOW_TOP_LEVEL_AWAIT); + opts.future_features = future_features; + return _ast::compile(vm, args.source, &filename.to_string_lossy(), mode, opts); } } #[cfg(not(feature = "parser"))] - return Err(vm.new_type_error( - "can't compile() source code when the `parser` feature of rustpython is disabled", - )); - + { + Err(vm.new_type_error( + "can't compile() source code when the `parser` feature of rustpython is disabled", + )) + } #[cfg(feature = "parser")] { - use crate::convert::ToPyException; - - use ruff_python_parser as parser; - let source = ArgStrOrBytesLike::try_from_object(vm, args.source)?; - let source = source.borrow_bytes(); - - let source = decode_source_bytes(&source, &filename.to_string_lossy(), vm)?; - let source = source.as_str(); - - let flags: i32 = args.flags.map_or(0, |v| v.value); - - if !(flags & !_ast::PY_COMPILE_FLAGS_MASK).is_zero() { - return Err(vm.new_value_error("compile(): unrecognised flags")); - } - - let allow_incomplete = !(flags & _ast::PY_CF_ALLOW_INCOMPLETE_INPUT).is_zero(); - let type_comments = !(flags & _ast::PY_CF_TYPE_COMMENTS).is_zero(); - - let optimize_level = optimize; - if (flags & _ast::PY_CF_ONLY_AST).is_zero() { - #[cfg(not(feature = "compiler"))] - { - Err(vm.new_value_error(CODEGEN_NOT_SUPPORTED)) - } - #[cfg(feature = "compiler")] - { - if let Some(feature_version) = feature_version { - let mode = mode_str - .parse::() - .map_err(|err| vm.new_value_error(err.to_string()))?; - let _ = _ast::parse( - vm, - source, - mode, - optimize_level, - Some(feature_version), - type_comments, - ) - .map_err(|e| (e, Some(source), allow_incomplete).to_pyexception(vm))?; - } - - let mode = mode_str - .parse::() - .map_err(|err| vm.new_value_error(err.to_string()))?; - - let mut opts = vm.compile_opts(); - opts.optimize = optimize; - - let code = vm - .compile_with_opts(source, mode, &filename.to_string_lossy(), opts) - .map_err(|err| { - (err, Some(source), allow_incomplete).to_pyexception(vm) - })?; - Ok(code.into()) - } - } else { - if mode_str == "func_type" { - return _ast::parse_func_type(vm, source, optimize_level, feature_version) - .map_err(|e| (e, Some(source), allow_incomplete).to_pyexception(vm)); - } - - let mode = mode_str - .parse::() - .map_err(|err| vm.new_value_error(err.to_string()))?; - let parsed = _ast::parse( - vm, + let mut compile_flags = flags | future_features.bits() as i32; + #[cfg(feature = "rustpython-compiler")] + let compile_source = |source: &[u8], compile_flags: i32| { + vm.compile_string_object_with_flags( source, - mode, - optimize_level, + &filename.to_string_lossy(), + start, + compile_flags, feature_version, - type_comments, + optimize as i32, ) - .map_err(|e| (e, Some(source), allow_incomplete).to_pyexception(vm))?; - - if mode_str == "single" { - return _ast::wrap_interactive(vm, parsed); + }; + match &source { + ArgStrOrBytesLike::Str(source) => { + if source.as_bytes().contains(&0) { + return Err(vm.new_exception_msg( + vm.ctx.exceptions.syntax_error.to_owned(), + "source code string cannot contain null bytes".into(), + )); + } + audit_compile_source( + vm, + source.as_bytes(), + filename.to_string_lossy().as_ref(), + )?; + compile_flags |= CompilerFlags::IGNORE_COOKIE.bits(); + #[cfg(feature = "rustpython-compiler")] + { + compile_source(source.as_bytes(), compile_flags) + } + #[cfg(not(feature = "rustpython-compiler"))] + { + Err(vm.new_value_error(CODEGEN_NOT_SUPPORTED)) + } + } + ArgStrOrBytesLike::Buf(source) => { + let source_bytes = source.borrow_buf(); + let source_bytes: &[u8] = &source_bytes; + if source_bytes.contains(&0) { + return Err(vm.new_exception_msg( + vm.ctx.exceptions.syntax_error.to_owned(), + "source code string cannot contain null bytes".into(), + )); + } + audit_compile_source( + vm, + source_bytes, + filename.to_string_lossy().as_ref(), + )?; + #[cfg(feature = "rustpython-compiler")] + { + compile_source(source_bytes, compile_flags) + } + #[cfg(not(feature = "rustpython-compiler"))] + { + Err(vm.new_value_error(CODEGEN_NOT_SUPPORTED)) + } } - - Ok(parsed) } } } } - #[cfg(feature = "ast")] - fn feature_version_from_arg( - feature_version: OptionalArg, - vm: &VirtualMachine, - ) -> PyResult> { - let Some(minor) = feature_version.into_option() else { - return Ok(None); - }; - - if minor < 0 { - return Ok(None); - } - - u8::try_from(minor) - .map(|v| Some(ruff_python_ast::PythonVersion { major: 3, minor: v })) - .map_err(|_| vm.new_value_error("compile() _feature_version out of range")) - } - #[pyfunction] fn delattr(obj: PyObjectRef, attr: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { - let attr = attr.try_to_ref::(vm).map_err(|_| { + let attr = attr.try_to_ref::(vm).map_err(|_e| { vm.new_type_error(format!( "attribute name must be string, not '{}'", attr.class().name() @@ -507,42 +420,39 @@ mod builtins { } impl ScopeArgs { - fn validate_globals_dict( - globals: &PyObject, - vm: &VirtualMachine, - func_name: &'static str, - ) -> PyResult<()> { - if globals.fast_isinstance(vm.ctx.types.dict_type) { - return Ok(()); - } - - let msg = match func_name { - "eval" => { - let is_mapping = globals.mapping_unchecked().check(); - if is_mapping { - "globals must be a real dict; try eval(expr, {}, mapping)".into() - } else { - "globals must be a dict".into() - } - } - "exec" => format!( - "exec() globals must be a dict, not {}", - globals.class().name() - ), - _ => "globals must be a dict".into(), - }; - - Err(vm.new_type_error(msg)) - } - fn make_scope( self, vm: &VirtualMachine, func_name: &'static str, ) -> PyResult { + fn validate_globals_dict( + globals: &PyObject, + vm: &VirtualMachine, + func_name: &'static str, + ) -> PyResult<()> { + if !globals.fast_isinstance(vm.ctx.types.dict_type) { + return Err(match func_name { + "eval" => { + let is_mapping = globals.mapping_unchecked().check(); + vm.new_type_error(if is_mapping { + "globals must be a real dict; try eval(expr, {}, mapping)" + } else { + "globals must be a dict" + }) + } + "exec" => vm.new_type_error(format!( + "exec() globals must be a dict, not {}", + globals.class().name() + )), + _ => vm.new_type_error("globals must be a dict"), + }); + } + Ok(()) + } + let (globals, locals) = match self.globals { Some(globals) => { - Self::validate_globals_dict(&globals, vm, func_name)?; + validate_globals_dict(&globals, vm, func_name)?; let globals = PyDictRef::try_from_object(vm, globals)?; if !globals.contains_key(identifier!(vm, __builtins__), vm) { @@ -570,6 +480,61 @@ mod builtins { } } + #[derive(FromArgs)] + struct ExecArgs { + #[pyarg(positional)] + source: Either>, + #[pyarg(any, default)] + globals: Option, + #[pyarg(any, default)] + locals: Option, + #[pyarg(named, optional)] + closure: OptionalOption, + } + + fn exec_closure( + code_obj: &PyRef, + closure: Option, + vm: &VirtualMachine, + ) -> PyResult>>> { + let num_free = code_obj.freevars.len(); + let Some(closure) = closure else { + if num_free == 0 { + return Ok(None); + } + return Err(vm.new_type_error(format!( + "code object requires a closure of exactly length {num_free}" + ))); + }; + + if num_free == 0 { + return Err(vm.new_type_error("cannot use a closure with this code object")); + } + + let closure_tuple = closure + .downcast_exact::(vm) + .map_err(|_| { + vm.new_type_error(format!( + "code object requires a closure of exactly length {num_free}" + )) + })? + .into_pyref(); + if closure_tuple.len() != num_free { + return Err(vm.new_type_error(format!( + "code object requires a closure of exactly length {num_free}" + ))); + } + + closure_tuple + .try_into_typed::(vm) + .map(Some) + .map_err(|_| { + vm.new_type_error(format!( + "code object requires a closure of exactly length {num_free}" + )) + }) + } + #[pyfunction] fn eval( source: Either>, @@ -581,38 +546,93 @@ mod builtins { // source as string let code = match source { Either::A(either) => { - let source: &[u8] = &either.borrow_bytes(); - if source.contains(&0) { - return Err(vm.new_exception_msg( - vm.ctx.exceptions.syntax_error.to_owned(), - "source code string cannot contain null bytes".into(), - )); - } - - let source = core::str::from_utf8(source).map_err(|err| { - let msg = format!( - "(unicode error) 'utf-8' codec can't decode byte 0x{:x?} in position {}: invalid start byte", - source[err.valid_up_to()], - err.valid_up_to() - ); - - vm.new_exception_msg(vm.ctx.exceptions.syntax_error.to_owned(), msg.into()) - })?; - Ok(Either::A(vm.ctx.new_utf8_str(source.trim_start()))) + let source = match &either { + ArgStrOrBytesLike::Str(source) => { + if source.as_bytes().contains(&0) { + return Err(vm.new_exception_msg( + vm.ctx.exceptions.syntax_error.to_owned(), + "source code string cannot contain null bytes".into(), + )); + } + let source = source.expect_str().trim_start_matches([' ', '\t']); + audit_compile_source(vm, source.as_bytes(), "")?; + source.to_owned() + } + ArgStrOrBytesLike::Buf(source) => { + let source: &[u8] = &source.borrow_buf(); + if source.contains(&0) { + return Err(vm.new_exception_msg( + vm.ctx.exceptions.syntax_error.to_owned(), + "source code string cannot contain null bytes".into(), + )); + } + let source = trim_eval_source_bytes(source); + audit_compile_source(vm, source, "")?; + decode_eval_exec_source_bytes(vm, source, "eval")? + } + }; + Ok(Either::A(vm.ctx.new_utf8_str(source))) } Either::B(code) => Ok(Either::B(code)), }?; - run_code(vm, code, scope, crate::compiler::Mode::Eval, "eval") + run_code(vm, code, scope, crate::compiler::Mode::Eval, "eval", None) } #[pyfunction] - fn exec( - source: Either>, - scope: ScopeArgs, - vm: &VirtualMachine, - ) -> PyResult { - let scope = scope.make_scope(vm, "exec")?; - run_code(vm, source, scope, crate::compiler::Mode::Exec, "exec") + fn exec(args: ExecArgs, vm: &VirtualMachine) -> PyResult { + let ExecArgs { + source, + globals, + locals, + closure, + } = args; + let scope = ScopeArgs { globals, locals }.make_scope(vm, "exec")?; + let closure = closure.flatten(); + let (source, closure) = match source { + Either::A(either) => { + if closure.is_some() { + return Err( + vm.new_type_error("closure can only be used when source is a code object") + ); + } + let source = match &either { + ArgStrOrBytesLike::Str(source) => { + if source.as_bytes().contains(&0) { + return Err(vm.new_exception_msg( + vm.ctx.exceptions.syntax_error.to_owned(), + "source code string cannot contain null bytes".into(), + )); + } + audit_compile_source(vm, source.as_bytes(), "")?; + source.expect_str().to_owned() + } + ArgStrOrBytesLike::Buf(source) => { + let source: &[u8] = &source.borrow_buf(); + if source.contains(&0) { + return Err(vm.new_exception_msg( + vm.ctx.exceptions.syntax_error.to_owned(), + "source code string cannot contain null bytes".into(), + )); + } + audit_compile_source(vm, source, "")?; + decode_eval_exec_source_bytes(vm, source, "exec")? + } + }; + (Either::A(vm.ctx.new_utf8_str(source)), None) + } + Either::B(code) => { + let closure = exec_closure(&code, closure, vm)?; + (Either::B(code), closure) + } + }; + run_code( + vm, + source, + scope, + crate::compiler::Mode::Exec, + "exec", + closure, + ) } fn run_code( @@ -621,28 +641,39 @@ mod builtins { scope: crate::scope::Scope, #[allow(unused_variables)] mode: crate::compiler::Mode, func: &str, + closure: Option>>, ) -> PyResult { // Determine code object: let code_obj = match source { #[cfg(feature = "rustpython-compiler")] Either::A(string) => { let source = string.as_str(); - vm.compile(source, mode, "") - .map_err(|err| vm.new_syntax_error(&err, Some(source)))? + let mut opts = vm.compile_opts(); + if let Some(frame) = vm.current_frame() { + opts.future_features = bytecode::CodeFlags::from_bits_truncate( + frame.code.flags.bits() & compile_future_feature_mask().bits(), + ); + } + vm.compile_with_opts(source, mode, "", opts) + .map_err(|err| err.into_pyexception(vm, Some(source)))? } #[cfg(not(feature = "rustpython-compiler"))] Either::A(_) => return Err(vm.new_type_error(CODEGEN_NOT_SUPPORTED)), Either::B(code_obj) => code_obj, }; - if !code_obj.freevars.is_empty() { + vm.sys_module + .get_attr("audit", vm)? + .call((vm.ctx.new_str("exec"), code_obj.clone()), vm)?; + + if closure.is_none() && !code_obj.freevars.is_empty() { return Err(vm.new_type_error(format!( "code object passed to {func}() may not contain free variables" ))); } // Run the code: - vm.run_code_obj(code_obj, scope) + vm.run_code_obj_with_closure(code_obj, scope, closure) } #[pyfunction] @@ -1002,8 +1033,8 @@ mod builtins { modulus, } = args; let modulus = modulus - .as_ref() - .map_or_else(|| vm.ctx.none.as_object(), |m| m); + .as_deref() + .unwrap_or_else(|| vm.ctx.none.as_object()); vm._pow(&x, &y, modulus) } diff --git a/crates/vm/src/stdlib/sys.rs b/crates/vm/src/stdlib/sys.rs index 58324a3c071..b8fe578f238 100644 --- a/crates/vm/src/stdlib/sys.rs +++ b/crates/vm/src/stdlib/sys.rs @@ -751,7 +751,7 @@ pub mod sys { .read_to_string(&mut source) .map_err(|e| vm.new_os_error(format!("Error reading from stdin: {e}")))?; vm.compile(&source, crate::compiler::Mode::Single, "") - .map_err(|e| vm.new_os_error(format!("Error running stdin: {e}")))?; + .map_err(|e| e.into_pyexception(vm, Some(&source)))?; Ok(()) } @@ -1228,7 +1228,7 @@ pub mod sys { vm.state.int_max_str_digits.store(maxdigits); Ok(()) } else { - let error = format!("maxdigits must be 0 or larger than {threshold:?}"); + let error = format!("maxdigits must be 0 or larger than {threshold}"); Err(vm.new_value_error(error)) } } @@ -1744,12 +1744,54 @@ pub mod sys { } for hook in hooks { - hook.call((event.clone(), args.clone()), vm)?; + call_audit_hook(&hook, event.clone().into(), args, vm)?; } Ok(()) } + fn audit_hook_can_trace(hook: &PyObjectRef, vm: &VirtualMachine) -> PyResult { + match hook.get_attr("__cantrace__", vm) { + Ok(can_trace) => can_trace.try_to_bool(vm), + Err(exc) + if exc + .class() + .fast_issubclass(vm.ctx.exceptions.attribute_error) => + { + Ok(false) + } + Err(exc) => Err(exc), + } + } + + fn call_audit_hook( + hook: &PyObjectRef, + event: PyObjectRef, + args: &PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult<()> { + // Tracing is suppressed while dispatching Python audit hooks, + // except for hooks that explicitly opt in with __cantrace__. + vm.enter_tracing(); + let can_trace = audit_hook_can_trace(hook, vm); + let result = match can_trace { + Ok(can_trace) => { + if can_trace { + vm.leave_tracing(); + } + let result = hook.call((event, args.clone()), vm).map(|_| ()); + if can_trace { + vm.enter_tracing(); + } + result + } + Err(exc) => Err(exc), + }; + + vm.leave_tracing(); + result + } + #[pyfunction] fn audit(event: PyStrRef, args: PosArgs, vm: &VirtualMachine) -> PyResult<()> { if vm.audit_hooks.borrow().is_empty() { @@ -1773,10 +1815,13 @@ pub mod sys { let event: PyObjectRef = vm.ctx.new_str("sys.addaudithook").into(); for existing_hook in hooks { - let Err(exc) = existing_hook.call((event.clone(), args.clone()), vm) else { + let Err(exc) = call_audit_hook(&existing_hook, event.clone(), &args, vm) else { continue; }; - if exc.class().fast_issubclass(vm.ctx.exceptions.runtime_error) { + if exc + .class() + .fast_issubclass(vm.ctx.exceptions.exception_type) + { return Ok(()); } return Err(exc); diff --git a/crates/vm/src/stdlib/sys/monitoring.rs b/crates/vm/src/stdlib/sys/monitoring.rs index 6e61692507d..accf2001675 100644 --- a/crates/vm/src/stdlib/sys/monitoring.rs +++ b/crates/vm/src/stdlib/sys/monitoring.rs @@ -747,7 +747,7 @@ fn fire( cb_extra: &[PyObjectRef], ) -> PyResult<()> { // Prevent recursive event firing - if FIRING.with(|f| f.get()) { + if vm.tracing_is_suppressed() || FIRING.with(|f| f.get()) { return Ok(()); } @@ -795,6 +795,7 @@ fn fire( let args = FuncArgs::from(args_vec); FIRING.with(|f| f.set(true)); + vm.enter_tracing(); let result = (|| { for (tool, cb) in callbacks { let result = cb.call(args.clone(), vm)?; @@ -817,6 +818,7 @@ fn fire( } Ok(()) })(); + vm.leave_tracing(); FIRING.with(|f| f.set(false)); result } diff --git a/crates/vm/src/vm/compile.rs b/crates/vm/src/vm/compile.rs index 2dbdb17ff4a..2beaf24f07c 100644 --- a/crates/vm/src/vm/compile.rs +++ b/crates/vm/src/vm/compile.rs @@ -2,19 +2,382 @@ //! //! For code execution functions, see python_run.rs +use core::fmt; + use crate::{ - PyRef, VirtualMachine, - builtins::PyCode, + AsObject, PyObjectRef, PyRef, PyResult, VirtualMachine, + builtins::{PyBaseExceptionRef, PyCode}, compiler::{self, CompileError, CompileOpts}, + vm::compile_mode::{ + CompilerFlags, PY_EVAL_INPUT, PY_FILE_INPUT, PY_FUNC_TYPE_INPUT, PY_SINGLE_INPUT, + compile_future_features_from_flags, + }, }; +#[derive(Debug)] +pub enum VmCompileError { + Compile(CompileError), + Warning(CompileWarningError), +} + +#[derive(Debug)] +pub struct CompileWarningError { + exception: PyBaseExceptionRef, + filename: String, + lineno: usize, + offset: usize, +} + +impl From for VmCompileError { + fn from(err: CompileError) -> Self { + Self::Compile(err) + } +} + +impl fmt::Display for VmCompileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Compile(err) => err.fmt(f), + Self::Warning(_) => f.write_str("compiler warning raised as an exception"), + } + } +} + +impl VmCompileError { + pub fn into_pyexception(self, vm: &VirtualMachine, source: Option<&str>) -> PyBaseExceptionRef { + self.into_pyexception_maybe_incomplete(vm, source, false) + } + + pub fn into_pyexception_maybe_incomplete( + self, + vm: &VirtualMachine, + source: Option<&str>, + allow_incomplete: bool, + ) -> PyBaseExceptionRef { + match self { + Self::Compile(err) => { + vm.new_syntax_error_maybe_incomplete(&err, source, allow_incomplete) + } + Self::Warning(err) => err.into_pyexception(vm, source), + } + } +} + +impl CompileWarningError { + fn into_pyexception(self, vm: &VirtualMachine, source: Option<&str>) -> PyBaseExceptionRef { + if !self + .exception + .fast_isinstance(vm.ctx.exceptions.syntax_warning) + { + return self.exception; + } + let Ok(message) = self.exception.as_object().str(vm) else { + return self.exception; + }; + let syntax_error = vm.new_exception_msg( + vm.ctx.exceptions.syntax_error.to_owned(), + message.as_wtf8().to_owned(), + ); + syntax_error + .as_object() + .set_attr("lineno", vm.ctx.new_int(self.lineno), vm) + .unwrap(); + syntax_error + .as_object() + .set_attr("offset", vm.ctx.new_int(self.offset), vm) + .unwrap(); + syntax_error + .as_object() + .set_attr("filename", vm.ctx.new_str(self.filename), vm) + .unwrap(); + let text = source + .and_then(|source| source.split('\n').nth(self.lineno.saturating_sub(1))) + .map_or_else( + || vm.ctx.none(), + |line| { + vm.ctx + .new_str(format!("{}\n", line.trim_end_matches('\r'))) + .into() + }, + ); + syntax_error.as_object().set_attr("text", text, vm).unwrap(); + syntax_error + } +} + impl VirtualMachine { + #[cfg(feature = "parser")] + fn detect_source_encoding(source: &[u8]) -> Option { + fn find_encoding_in_line(line: &[u8]) -> Option { + let hash_pos = line.iter().position(|&b| b == b'#')?; + if !line[..hash_pos] + .iter() + .all(|&b| b == b' ' || b == b'\t' || b == b'\x0c' || b == b'\r') + { + return None; + } + let after_hash = &line[hash_pos..]; + let coding_pos = after_hash.windows(6).position(|w| w == b"coding")?; + let after_coding = &after_hash[coding_pos + 6..]; + let rest = if after_coding.first() == Some(&b':') || after_coding.first() == Some(&b'=') + { + &after_coding[1..] + } else { + return None; + }; + let name: String = rest + .iter() + .copied() + .skip_while(|&b| b == b' ' || b == b'\t') + .take_while(|&b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') + .map(|b| b as char) + .collect(); + (!name.is_empty()).then(|| VirtualMachine::normalize_source_encoding(&name)) + } + + let mut lines = source.splitn(3, |&b| b == b'\n'); + if let Some(first) = lines.next() { + let first = first.strip_prefix(b"\xef\xbb\xbf").unwrap_or(first); + if let Some(enc) = find_encoding_in_line(first) { + return Some(enc); + } + let trimmed = first + .iter() + .skip_while(|&&b| b == b' ' || b == b'\t' || b == b'\x0c' || b == b'\r') + .copied() + .collect::>(); + if !trimmed.is_empty() && trimmed[0] != b'#' { + return None; + } + } + lines.next().and_then(find_encoding_in_line) + } + + #[cfg(feature = "parser")] + fn normalize_source_encoding(name: &str) -> String { + let mut normalized = String::with_capacity(name.len().min(12)); + for ch in name.chars().take(12) { + if ch == '_' { + normalized.push('-'); + } else { + normalized.push(ch.to_ascii_lowercase()); + } + } + + if normalized == "utf-8" || normalized.starts_with("utf-8-") { + "utf-8".to_owned() + } else if normalized == "latin-1" + || normalized == "iso-8859-1" + || normalized == "iso-latin-1" + || normalized.starts_with("latin-1-") + || normalized.starts_with("iso-8859-1-") + || normalized.starts_with("iso-latin-1-") + { + "iso-8859-1".to_owned() + } else { + name.to_owned() + } + } + + #[cfg(feature = "parser")] + fn is_utf8_encoding(name: &str) -> bool { + name == "utf-8" + } + + #[cfg(feature = "parser")] + pub(crate) fn decode_source_bytes( + &self, + source: &[u8], + filename: &str, + ignore_cookie: bool, + ) -> PyResult { + let has_bom = source.starts_with(b"\xef\xbb\xbf"); + let encoding = if ignore_cookie { + None + } else { + Self::detect_source_encoding(source) + }; + let is_utf8 = encoding.as_deref().is_none_or(Self::is_utf8_encoding); + if has_bom && !is_utf8 { + let enc = encoding.as_deref().unwrap_or("utf-8"); + return Err(self.new_exception_msg( + self.ctx.exceptions.syntax_error.to_owned(), + format!("encoding problem: {enc} with BOM").into(), + )); + } + + if is_utf8 { + let src = if has_bom { &source[3..] } else { source }; + match core::str::from_utf8(src) { + Ok(s) => Ok(s.to_owned()), + Err(e) => { + let bad_byte = src[e.valid_up_to()]; + let line = src[..e.valid_up_to()] + .iter() + .filter(|&&b| b == b'\n') + .count() + + 1; + Err(self.new_exception_msg( + self.ctx.exceptions.syntax_error.to_owned(), + format!( + "Non-UTF-8 code starting with '\\x{bad_byte:02x}' \ + on line {line}, but no encoding declared; \ + see https://peps.python.org/pep-0263/ for details \ + ({filename}, line {line})" + ) + .into(), + )) + } + } + } else { + let encoding = encoding.as_deref().unwrap(); + let bytes = self.ctx.new_bytes(source.to_vec()); + let decoded = self + .state + .codec_registry + .decode_text(bytes.into(), encoding, None, self) + .map_err(|exc| { + if exc.fast_isinstance(self.ctx.exceptions.lookup_error) { + self.new_exception_msg( + self.ctx.exceptions.syntax_error.to_owned(), + format!("unknown encoding for '{filename}': {encoding}").into(), + ) + } else { + exc + } + })?; + Ok(decoded.to_string_lossy().into_owned()) + } + } + + #[cfg(feature = "parser")] + pub fn compile_string_object_with_flags( + &self, + source: &[u8], + filename: &str, + start: i32, + flags: i32, + feature_version: i32, + optimize: i32, + ) -> PyResult { + use crate::convert::ToPyException; + use crate::stdlib::_ast; + + let cf = CompilerFlags::from_bits_retain(flags); + let source = + self.decode_source_bytes(source, filename, cf.contains(CompilerFlags::IGNORE_COOKIE))?; + let source = source.as_str(); + let optimize = match optimize { + -1 => self.state.config.settings.optimize.min(2), + 0..=2 => optimize as u8, + _ => return Err(self.new_value_error("compile(): invalid optimize value")), + }; + let allow_incomplete = cf.contains(CompilerFlags::ALLOW_INCOMPLETE_INPUT); + let type_comments = cf.contains(CompilerFlags::TYPE_COMMENTS); + let dont_imply_dedent = cf.contains(CompilerFlags::DONT_IMPLY_DEDENT); + let is_ast_only = cf.contains(CompilerFlags::ONLY_AST); + let optimized_ast = cf.contains(CompilerFlags::OPTIMIZED_AST); + let future_features = compile_future_features_from_flags(flags); + let explicit_future_annotations = + future_features.contains(crate::bytecode::CodeFlags::FUTURE_ANNOTATIONS); + let target_version = if is_ast_only { + Some(ruff_python_ast::PythonVersion { + major: 3, + minor: u8::try_from(feature_version).unwrap_or(crate::version::MINOR as u8), + }) + } else { + None + }; + + if is_ast_only { + if start == PY_FUNC_TYPE_INPUT { + return _ast::parse_func_type(self, source, optimize, target_version) + .map_err(|e| (e, Some(source), allow_incomplete).to_pyexception(self)); + } + let (parser_mode, interactive) = match start { + PY_SINGLE_INPUT => (ruff_python_parser::Mode::Module, true), + PY_FILE_INPUT => (ruff_python_parser::Mode::Module, false), + PY_EVAL_INPUT => (ruff_python_parser::Mode::Expression, false), + _ => { + return Err( + self.new_system_error("Invalid start argument passed to Py_CompileString") + ); + } + }; + let parsed = _ast::parse( + self, + source, + parser_mode, + optimize, + target_version, + type_comments, + optimized_ast, + interactive, + explicit_future_annotations, + dont_imply_dedent, + ) + .map_err(|e| (e, Some(source), allow_incomplete).to_pyexception(self))?; + if start == PY_SINGLE_INPUT { + return _ast::wrap_interactive(self, parsed); + } + return Ok(parsed); + } + + if type_comments { + let parser_mode = match start { + PY_SINGLE_INPUT | PY_FILE_INPUT => ruff_python_parser::Mode::Module, + PY_EVAL_INPUT => ruff_python_parser::Mode::Expression, + _ => { + return Err( + self.new_system_error("Invalid start argument passed to Py_CompileString") + ); + } + }; + _ast::parse( + self, + source, + parser_mode, + optimize, + None, + type_comments, + false, + start == PY_SINGLE_INPUT, + explicit_future_annotations, + dont_imply_dedent, + ) + .map_err(|e| (e, Some(source), allow_incomplete).to_pyexception(self))?; + } + + let mode = match start { + PY_SINGLE_INPUT => compiler::Mode::Single, + PY_FILE_INPUT => compiler::Mode::Exec, + PY_EVAL_INPUT => compiler::Mode::Eval, + PY_FUNC_TYPE_INPUT => compiler::Mode::BlockExpr, + _ => { + return Err( + self.new_system_error("Invalid start argument passed to Py_CompileString") + ); + } + }; + let mut opts = self.compile_opts(); + opts.optimize = optimize; + opts.allow_top_level_await = cf.contains(CompilerFlags::ALLOW_TOP_LEVEL_AWAIT); + opts.future_features = future_features; + opts.dont_imply_dedent = dont_imply_dedent; + let code = self + .compile_with_opts(source, mode, filename, opts) + .map_err(|err| { + err.into_pyexception_maybe_incomplete(self, Some(source), allow_incomplete) + })?; + Ok(code.into()) + } + pub fn compile( &self, source: &str, mode: compiler::Mode, - source_path: &str, - ) -> Result, CompileError> { + source_path: impl Into, + ) -> Result, VmCompileError> { self.compile_with_opts(source, mode, source_path, self.compile_opts()) } @@ -22,18 +385,55 @@ impl VirtualMachine { &self, source: &str, mode: compiler::Mode, - source_path: &str, + source_path: impl Into, opts: CompileOpts, - ) -> Result, CompileError> { - let code = compiler::compile(source, mode, source_path, opts) - .map(|code| PyCode::new_ref_from_bytecode(self, code)); - + ) -> Result, VmCompileError> { + let source_path = source_path.into(); #[cfg(feature = "parser")] - if code.is_ok() { - self.emit_string_escape_warnings(source, source_path); + { + self.emit_tokenizer_syntax_warnings(source, &source_path) + .map_err(VmCompileError::Warning)?; + self.emit_string_escape_warnings(source, &source_path) + .map_err(VmCompileError::Warning)?; } - - code + #[cfg(feature = "parser")] + let code = { + // A warning the filter escalates to an exception is stashed here so + // its precise category survives; codegen only sees an abort marker. + let escalated: core::cell::Cell> = + core::cell::Cell::new(None); + let mut syntax_warning_handler = |location, message| { + escape_warnings::warn_syntax_at_location(&source_path, location, message, self) + .map_err(|warning| { + escalated.set(Some(warning)); + // Recovered below via `escalated`, so this is never surfaced. + compiler::codegen::error::CodegenError { + location: Some(location), + error: compiler::codegen::error::CodegenErrorType::SyntaxError( + String::new(), + ), + source_path: source_path.clone(), + } + }) + }; + let result = compiler::compile_with_syntax_warning_handler( + source, + mode, + &source_path, + opts, + &mut syntax_warning_handler, + ); + match escalated.take() { + Some(warning) => return Err(VmCompileError::Warning(warning)), + None => result, + } + }; + #[cfg(not(feature = "parser"))] + let code = compiler::compile(source, mode, &source_path, opts); + let code = code + .map(|code| PyCode::new_ref_from_bytecode(self, code)) + .map_err(VmCompileError::Compile)?; + Ok(code) } } @@ -59,6 +459,30 @@ mod escape_warnings { + 1 } + fn line_offset_at(source: &str, offset: usize) -> (usize, usize) { + let offset = offset.min(source.len()); + let prefix = &source[..offset]; + let lineno = prefix.bytes().filter(|&b| b == b'\n').count() + 1; + let line_start = prefix.rfind('\n').map_or(0, |index| index + 1); + let column = source[line_start..offset].chars().count() + 1; + (lineno, column) + } + + fn compile_warning_error( + exception: PyBaseExceptionRef, + source: &str, + filename: &str, + offset: usize, + ) -> CompileWarningError { + let (lineno, offset) = line_offset_at(source, offset); + CompileWarningError { + exception, + filename: filename.to_owned(), + lineno, + offset, + } + } + /// Get content bounds (start, end byte offsets) of a quoted string literal, /// excluding prefix characters and quote delimiters. fn content_bounds(source: &str, range: TextRange) -> Option<(usize, usize)> { @@ -180,7 +604,7 @@ mod escape_warnings { offset: usize, filename: &str, vm: &VirtualMachine, - ) { + ) -> Result<(), CompileWarningError> { let lineno = line_number_at(source, offset); let message = vm.ctx.new_str(format!( "\"\\{ch}\" is an invalid escape sequence. \ @@ -188,7 +612,7 @@ mod escape_warnings { Did you mean \"\\\\{ch}\"? A raw string is also an option." )); let fname = vm.ctx.new_str(filename); - let _ = warn::warn_explicit( + warn::warn_explicit( Some(vm.ctx.exceptions.syntax_warning.to_owned()), message.into(), fname, @@ -198,23 +622,273 @@ mod escape_warnings { None, None, vm, - ); + ) + .map_err(|err| compile_warning_error(err, source, filename, offset)) + } + + fn warn_syntax_at_offset( + source: &str, + filename: &str, + offset: usize, + message: String, + vm: &VirtualMachine, + ) -> Result<(), CompileWarningError> { + let lineno = line_number_at(source, offset); + let fname = vm.ctx.new_str(filename); + let message = vm.ctx.new_str(message); + warn::warn_explicit( + Some(vm.ctx.exceptions.syntax_warning.to_owned()), + message.into(), + fname, + lineno, + None, + vm.ctx.none(), + None, + None, + vm, + ) + .map_err(|err| compile_warning_error(err, source, filename, offset)) + } + + pub(super) fn warn_syntax_at_location( + filename: &str, + location: compiler::core::SourceLocation, + message: String, + vm: &VirtualMachine, + ) -> Result<(), CompileWarningError> { + let fname = vm.ctx.new_str(filename); + let message = vm.ctx.new_str(message); + warn::warn_explicit( + Some(vm.ctx.exceptions.syntax_warning.to_owned()), + message.into(), + fname, + location.line.get(), + None, + vm.ctx.none(), + None, + None, + vm, + ) + .map_err(|exception| CompileWarningError { + exception, + filename: filename.to_owned(), + lineno: location.line.get(), + offset: location.character_offset.get(), + }) + } + + fn is_ascii_identifier_char(byte: u8) -> bool { + byte == b'_' || byte.is_ascii_alphanumeric() + } + + fn numeric_keyword_suffix(rest: &[u8]) -> bool { + rest.starts_with(b"and") + || rest.starts_with(b"else") + || rest.starts_with(b"for") + || rest.starts_with(b"if") + || rest.starts_with(b"in") + || rest.starts_with(b"is") + || rest.starts_with(b"or") + || rest.starts_with(b"not") + } + + fn consume_decimal_digits(bytes: &[u8], mut index: usize) -> usize { + while index < bytes.len() { + match bytes[index] { + b'0'..=b'9' => index += 1, + b'_' if bytes + .get(index + 1) + .is_some_and(|byte| byte.is_ascii_digit()) => + { + index += 2; + } + _ => break, + } + } + index + } + + fn consume_radix_digits( + bytes: &[u8], + mut index: usize, + is_digit: impl Fn(u8) -> bool, + ) -> usize { + while index < bytes.len() { + if is_digit(bytes[index]) { + index += 1; + } else if bytes.get(index) == Some(&b'_') + && bytes.get(index + 1).is_some_and(|&byte| is_digit(byte)) + { + index += 2; + } else { + break; + } + } + index + } + + fn number_literal_end(bytes: &[u8], start: usize) -> Option<(&'static str, usize)> { + if bytes.get(start) == Some(&b'.') { + if !bytes + .get(start + 1) + .is_some_and(|byte| byte.is_ascii_digit()) + { + return None; + } + let mut index = consume_decimal_digits(bytes, start + 1); + index = consume_exponent(bytes, index); + if matches!(bytes.get(index), Some(b'j' | b'J')) { + return Some(("imaginary", index + 1)); + } + return Some(("decimal", index)); + } + + if !bytes.get(start).is_some_and(|byte| byte.is_ascii_digit()) { + return None; + } + + if bytes.get(start) == Some(&b'0') { + match bytes.get(start + 1) { + Some(b'x' | b'X') => { + let end = + consume_radix_digits(bytes, start + 2, |byte| byte.is_ascii_hexdigit()); + return Some(("hexadecimal", end)); + } + Some(b'o' | b'O') => { + let end = + consume_radix_digits(bytes, start + 2, |byte| matches!(byte, b'0'..=b'7')); + return Some(("octal", end)); + } + Some(b'b' | b'B') => { + let end = + consume_radix_digits(bytes, start + 2, |byte| matches!(byte, b'0' | b'1')); + return Some(("binary", end)); + } + _ => {} + } + } + + let mut index = consume_decimal_digits(bytes, start); + if bytes.get(index) == Some(&b'.') { + index = consume_decimal_digits(bytes, index + 1); + } + index = consume_exponent(bytes, index); + if matches!(bytes.get(index), Some(b'j' | b'J')) { + return Some(("imaginary", index + 1)); + } + Some(("decimal", index)) + } + + fn consume_exponent(bytes: &[u8], index: usize) -> usize { + if !matches!(bytes.get(index), Some(b'e' | b'E')) { + return index; + } + let mut cursor = index + 1; + if matches!(bytes.get(cursor), Some(b'+' | b'-')) { + cursor += 1; + } + if bytes.get(cursor).is_some_and(|byte| byte.is_ascii_digit()) { + consume_decimal_digits(bytes, cursor) + } else { + index + } + } + + fn skip_quoted_string(bytes: &[u8], mut index: usize) -> usize { + let quote = bytes[index]; + let triple = bytes.get(index + 1) == Some("e) && bytes.get(index + 2) == Some("e); + let quote_len = if triple { 3 } else { 1 }; + index += quote_len; + while index < bytes.len() { + if bytes[index] == b'\\' { + index = (index + 2).min(bytes.len()); + } else if triple + && bytes.get(index) == Some("e) + && bytes.get(index + 1) == Some("e) + && bytes.get(index + 2) == Some("e) + { + return index + 3; + } else if !triple && bytes[index] == quote { + return index + 1; + } else { + index += 1; + } + } + index + } + + fn emit_numeric_literal_warnings( + source: &str, + filename: &str, + vm: &VirtualMachine, + ) -> Result<(), CompileWarningError> { + let bytes = source.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'\'' | b'"' => { + index = skip_quoted_string(bytes, index); + } + byte if byte >= 0x80 || byte == b'_' || byte.is_ascii_alphabetic() => { + index += 1; + while index < bytes.len() + && (bytes[index] >= 0x80 || is_ascii_identifier_char(bytes[index])) + { + index += 1; + } + } + b'.' | b'0'..=b'9' => { + let Some((kind, end)) = number_literal_end(bytes, index) else { + index += 1; + continue; + }; + if end > index && numeric_keyword_suffix(&bytes[end..]) { + warn_syntax_at_offset( + source, + filename, + index, + format!("invalid {kind} literal"), + vm, + )?; + } + index = end.max(index + 1); + } + _ => index += 1, + } + } + Ok(()) } struct EscapeWarningVisitor<'a> { source: &'a str, filename: &'a str, vm: &'a VirtualMachine, + error: Option, } impl<'a> EscapeWarningVisitor<'a> { + fn record_warning(&mut self, result: Result<(), CompileWarningError>) { + if self.error.is_none() + && let Err(err) = result + { + self.error = Some(err); + } + } + /// Check a quoted string/bytes literal for invalid escapes. /// The range must include the prefix and quote delimiters. - fn check_quoted_literal(&self, range: TextRange, is_bytes: bool) { + fn check_quoted_literal(&mut self, range: TextRange, is_bytes: bool) { if let Some((start, end)) = content_bounds(self.source, range) && let Some((ch, offset)) = first_invalid_escape(self.source, start, end, is_bytes) { - warn_invalid_escape_sequence(self.source, ch, offset, self.filename, self.vm); + let result = + warn_invalid_escape_sequence(self.source, ch, offset, self.filename, self.vm); + self.record_warning(result); } } @@ -224,14 +898,16 @@ mod escape_warnings { /// Also handles `\{` / `\}` at the literal–interpolation boundary, /// equivalent to `_PyTokenizer_warn_invalid_escape_sequence` handling /// `FSTRING_MIDDLE` / `FSTRING_END` tokens. - fn check_fstring_literal(&self, range: TextRange) { + fn check_fstring_literal(&mut self, range: TextRange) { let start = range.start().to_usize(); let end = range.end().to_usize(); if start >= end || end > self.source.len() { return; } if let Some((ch, offset)) = first_invalid_escape(self.source, start, end, false) { - warn_invalid_escape_sequence(self.source, ch, offset, self.filename, self.vm); + let result = + warn_invalid_escape_sequence(self.source, ch, offset, self.filename, self.vm); + self.record_warning(result); return; } // In CPython, _PyTokenizer_warn_invalid_escape_sequence handles @@ -249,13 +925,14 @@ mod escape_warnings { && let Some(&after) = self.source.as_bytes().get(end) && (after == b'{' || after == b'}') { - warn_invalid_escape_sequence( + let result = warn_invalid_escape_sequence( self.source, after as char, end - 1, self.filename, self.vm, ); + self.record_warning(result); } } @@ -263,6 +940,9 @@ mod escape_warnings { /// interpolation expressions and format specs. fn visit_fstring_elements(&mut self, elements: &'a ast::InterpolatedStringElements) { for element in elements { + if self.error.is_some() { + return; + } match element { ast::InterpolatedStringElement::Literal(lit) => { self.check_fstring_literal(lit.range); @@ -280,6 +960,9 @@ mod escape_warnings { impl<'a> Visitor<'a> for EscapeWarningVisitor<'a> { fn visit_expr(&mut self, expr: &'a ast::Expr) { + if self.error.is_some() { + return; + } match expr { // Regular string literals — decode_unicode_with_escapes path ast::Expr::StringLiteral(string) => { @@ -334,21 +1017,36 @@ mod escape_warnings { } impl VirtualMachine { + /// Emit tokenizer-level SyntaxWarnings raised before + /// code generation. + pub(super) fn emit_tokenizer_syntax_warnings( + &self, + source: &str, + filename: &str, + ) -> Result<(), CompileWarningError> { + emit_numeric_literal_warnings(source, filename, self) + } + /// Walk all string literals in `source` and emit `SyntaxWarning` for /// each that contains an invalid escape sequence. - pub(super) fn emit_string_escape_warnings(&self, source: &str, filename: &str) { + pub(super) fn emit_string_escape_warnings( + &self, + source: &str, + filename: &str, + ) -> Result<(), CompileWarningError> { let Ok(parsed) = ruff_python_parser::parse(source, ruff_python_parser::Mode::Module.into()) else { - return; + return Ok(()); }; let ast = parsed.into_syntax(); let mut visitor = EscapeWarningVisitor { source, filename, vm: self, + error: None, }; - match ast { + match &ast { ast::Mod::Module(module) => { for stmt in &module.body { visitor.visit_stmt(stmt); @@ -358,6 +1056,227 @@ mod escape_warnings { visitor.visit_expr(&expr.body); } } + visitor.error.map_or(Ok(()), Err) + } + } + + #[cfg(test)] + mod tests { + use super::*; + use crate::{Interpreter, builtins::PyTuple}; + + fn install_syntax_warning_error_filter(vm: &VirtualMachine) { + let error_filter = PyTuple::new_ref( + vec![ + vm.ctx.new_str("error").into(), + vm.ctx.none(), + vm.ctx.exceptions.syntax_warning.as_object().to_owned(), + vm.ctx.none(), + vm.ctx.new_int(0).into(), + ], + &vm.ctx, + ); + vm.state + .warnings + .filters + .borrow_vec_mut() + .insert(0, error_filter.into()); + vm.state.warnings.filters_mutated(); + } + + fn first_compiler_warning(source: &str) -> String { + Interpreter::without_stdlib(Default::default()).enter(|vm| { + install_syntax_warning_error_filter(vm); + let err = vm + .compile(source, compiler::Mode::Exec, "") + .expect_err("expected compiler SyntaxWarning"); + let exception = err.into_pyexception(vm, Some(source)); + exception + .as_object() + .str(vm) + .expect("warning message should stringify") + .as_wtf8() + .to_string() + }) + } + + fn compile_error_message(source: &str) -> String { + Interpreter::without_stdlib(Default::default()).enter(|vm| { + install_syntax_warning_error_filter(vm); + let err = match vm.compile(source, compiler::Mode::Exec, "") { + Ok(_) => panic!("expected compile error"), + Err(err) => err, + }; + err.into_pyexception(vm, Some(source)) + .as_object() + .str(vm) + .expect("compile error should stringify") + .as_wtf8() + .to_string() + }) + } + + #[test] + fn codegen_caller_warning_precedes_later_return_error() { + let message = compile_error_message("(1)()\nreturn\n"); + assert!( + message.contains("'int' object is not callable"), + "expected caller SyntaxWarning first, got {message:?}" + ); + } + + #[test] + fn symboltable_error_still_precedes_codegen_caller_warning() { + let message = compile_error_message("(1)()\ndef f():\n from x import *\n"); + assert!( + message.contains("import * only allowed at module level"), + "expected symboltable error first, got {message:?}" + ); + } + + #[test] + fn codegen_compare_warning_precedes_later_return_error() { + let message = compile_error_message("1 is 1\nreturn\n"); + assert!( + message.contains("\"is\" with 'int' literal"), + "expected compare SyntaxWarning first, got {message:?}" + ); + } + + #[test] + fn codegen_assert_warning_precedes_later_return_error() { + let message = compile_error_message("assert (1,)\nreturn\n"); + assert!( + message.contains("assertion is always true"), + "expected assert SyntaxWarning first, got {message:?}" + ); + } + + #[test] + fn codegen_subscript_warning_precedes_later_return_error() { + let message = compile_error_message("(1)[None]\nreturn\n"); + assert!( + message.contains("'int' object is not subscriptable"), + "expected subscript SyntaxWarning first, got {message:?}" + ); + } + + #[test] + fn codegen_index_warning_precedes_later_return_error() { + let message = compile_error_message("'x'[None]\nreturn\n"); + assert!( + message.contains("str indices must be integers or slices, not NoneType"), + "expected index SyntaxWarning first, got {message:?}" + ); + } + + #[test] + fn string_escape_warning_precedes_later_return_error() { + let message = compile_error_message("\"\\z\"\nreturn\n"); + assert!( + message.contains("\"\\z\" is an invalid escape sequence"), + "expected invalid escape SyntaxWarning first, got {message:?}" + ); + } + + #[test] + fn string_escape_warning_precedes_later_symboltable_error() { + let message = compile_error_message("\"\\z\"\ndef f():\n from x import *\n"); + assert!( + message.contains("\"\\z\" is an invalid escape sequence"), + "expected invalid escape SyntaxWarning first, got {message:?}" + ); + } + + #[test] + fn ast_preprocess_finally_warning_precedes_later_return_error() { + let message = compile_error_message("try:\n pass\nfinally:\n return\nreturn\n"); + assert!( + message.contains("'return' in a 'finally' block"), + "expected finally SyntaxWarning first, got {message:?}" + ); + } + + #[test] + fn ast_preprocess_finally_warning_precedes_symboltable_error() { + let message = compile_error_message( + "def f():\n from x import *\ntry:\n pass\nfinally:\n return\n", + ); + assert!( + message.contains("'return' in a 'finally' block"), + "expected finally SyntaxWarning first, got {message:?}" + ); + } + + #[test] + fn compiler_warning_visits_function_decorators_before_defaults_and_body() { + let message = first_compiler_warning( + r#" +@(b"decorator")() +def f(x=(1)()): + assert (1,) +"#, + ); + assert!( + message.contains("'bytes' object is not callable"), + "expected decorator warning first, got {message:?}" + ); + } + + #[test] + fn compiler_warning_visits_function_defaults_before_annotations() { + let message = first_compiler_warning( + r#" +def f(x: (1)() = ("default")()): + pass +"#, + ); + assert!( + message.contains("'str' object is not callable"), + "expected default warning before annotation warning, got {message:?}" + ); + } + + #[test] + fn compiler_warning_visits_class_decorators_before_body_and_bases() { + let message = first_compiler_warning( + r#" +@(b"decorator")() +class C((1)()): + assert (1,) +"#, + ); + assert!( + message.contains("'bytes' object is not callable"), + "expected class decorator warning first, got {message:?}" + ); + } + + #[test] + fn compiler_warning_visits_class_body_before_bases() { + let message = first_compiler_warning( + r#" +class C((1)()): + assert (1,) +"#, + ); + assert!( + message.contains("assertion is always true"), + "expected class body warning before base warning, got {message:?}" + ); + } + + #[test] + fn compiler_warning_visits_type_alias_type_params_before_value() { + let message = first_compiler_warning( + r#" +type Alias[T: (1)()] = ("value")() +"#, + ); + assert!( + message.contains("'int' object is not callable"), + "expected type parameter warning before alias value warning, got {message:?}" + ); } } } diff --git a/crates/vm/src/vm/compile_mode.rs b/crates/vm/src/vm/compile_mode.rs new file mode 100644 index 00000000000..9885ba2e1f7 --- /dev/null +++ b/crates/vm/src/vm/compile_mode.rs @@ -0,0 +1,83 @@ +use crate::bytecode; + +pub(crate) const PY_SINGLE_INPUT: i32 = 256; +pub(crate) const PY_FILE_INPUT: i32 = 257; +pub(crate) const PY_EVAL_INPUT: i32 = 258; +pub(crate) const PY_FUNC_TYPE_INPUT: i32 = 345; + +bitflags::bitflags! { + /// `PyCF_*` compiler flags together with the `__future__` `CO_FUTURE_*` + /// bits, mirroring `PyCompilerFlags.cf_flags`. + /// + /// Caveat emptor: these flags are undocumented on purpose and depending on + /// their effect outside the standard library is **unsupported**. + #[derive(Copy, Clone, Debug, PartialEq, Eq)] + pub(crate) struct CompilerFlags: i32 { + const SOURCE_IS_UTF8 = 0x0100; + const DONT_IMPLY_DEDENT = 0x0200; + const ONLY_AST = 0x0400; + const IGNORE_COOKIE = 0x0800; + const TYPE_COMMENTS = 0x1000; + const ALLOW_TOP_LEVEL_AWAIT = 0x2000; + const ALLOW_INCOMPLETE_INPUT = 0x4000; + const OPTIMIZED_AST = 0x8000 | Self::ONLY_AST.bits(); + + // __future__ flags - sync with Lib/__future__.py and Include/cpython/compile.h. + const NESTED = 0x0010; + const FUTURE_DIVISION = 0x20000; + const FUTURE_ABSOLUTE_IMPORT = 0x40000; + const FUTURE_WITH_STATEMENT = 0x80000; + const FUTURE_PRINT_FUNCTION = 0x100000; + const FUTURE_UNICODE_LITERALS = 0x200000; + const FUTURE_BARRY_AS_BDFL = 0x400000; + const FUTURE_GENERATOR_STOP = 0x800000; + const FUTURE_ANNOTATIONS = 0x1000000; + } +} + +impl CompilerFlags { + const FUTURE_MASK: Self = Self::FUTURE_DIVISION + .union(Self::FUTURE_ABSOLUTE_IMPORT) + .union(Self::FUTURE_WITH_STATEMENT) + .union(Self::FUTURE_PRINT_FUNCTION) + .union(Self::FUTURE_UNICODE_LITERALS) + .union(Self::FUTURE_BARRY_AS_BDFL) + .union(Self::FUTURE_GENERATOR_STOP) + .union(Self::FUTURE_ANNOTATIONS); + const MASK_OBSOLETE: Self = Self::NESTED; + const COMPILE_MASK: Self = Self::ONLY_AST + .union(Self::ALLOW_TOP_LEVEL_AWAIT) + .union(Self::TYPE_COMMENTS) + .union(Self::DONT_IMPLY_DEDENT) + .union(Self::ALLOW_INCOMPLETE_INPUT) + .union(Self::OPTIMIZED_AST); + pub(crate) const ALLOWED_FLAGS: Self = Self::FUTURE_MASK + .union(Self::MASK_OBSOLETE) + .union(Self::COMPILE_MASK); +} + +// Python-visible `ast.PyCF_*` attribute values. The flags cross the +// `compile()` boundary as a plain `int`, so the exposed surface stays `i32`. +pub(crate) const PY_CF_SOURCE_IS_UTF8: i32 = CompilerFlags::SOURCE_IS_UTF8.bits(); +pub(crate) const PY_CF_DONT_IMPLY_DEDENT: i32 = CompilerFlags::DONT_IMPLY_DEDENT.bits(); +pub(crate) const PY_CF_ONLY_AST: i32 = CompilerFlags::ONLY_AST.bits(); +pub(crate) const PY_CF_IGNORE_COOKIE: i32 = CompilerFlags::IGNORE_COOKIE.bits(); +pub(crate) const PY_CF_TYPE_COMMENTS: i32 = CompilerFlags::TYPE_COMMENTS.bits(); +pub(crate) const PY_CF_ALLOW_TOP_LEVEL_AWAIT: i32 = CompilerFlags::ALLOW_TOP_LEVEL_AWAIT.bits(); +pub(crate) const PY_CF_ALLOW_INCOMPLETE_INPUT: i32 = CompilerFlags::ALLOW_INCOMPLETE_INPUT.bits(); +pub(crate) const PY_CF_OPTIMIZED_AST: i32 = CompilerFlags::OPTIMIZED_AST.bits(); + +pub(crate) fn compile_future_feature_mask() -> bytecode::CodeFlags { + // RustPython accepts barry_as_FLUFL but leaves its parser mode disabled. + bytecode::CodeFlags::FUTURE_DIVISION + | bytecode::CodeFlags::FUTURE_ABSOLUTE_IMPORT + | bytecode::CodeFlags::FUTURE_WITH_STATEMENT + | bytecode::CodeFlags::FUTURE_PRINT_FUNCTION + | bytecode::CodeFlags::FUTURE_UNICODE_LITERALS + | bytecode::CodeFlags::FUTURE_GENERATOR_STOP + | bytecode::CodeFlags::FUTURE_ANNOTATIONS +} + +pub(crate) fn compile_future_features_from_flags(flags: i32) -> bytecode::CodeFlags { + bytecode::CodeFlags::from_bits_truncate(flags as u32 & compile_future_feature_mask().bits()) +} diff --git a/crates/vm/src/vm/interpreter.rs b/crates/vm/src/vm/interpreter.rs index 79e3e190a2f..56c9606f7de 100644 --- a/crates/vm/src/vm/interpreter.rs +++ b/crates/vm/src/vm/interpreter.rs @@ -300,8 +300,10 @@ impl Default for InterpreterBuilder { /// let scope = vm.new_scope_with_builtins(); /// let source = r#"print("Hello World!")"#; /// let code_obj = vm.compile( -/// source, Mode::Exec, "" -/// ).map_err(|err| vm.new_syntax_error(&err, Some(source))).unwrap(); +/// source, +/// Mode::Exec, +/// "", +/// ).map_err(|err| err.into_pyexception(vm, Some(source))).unwrap(); /// vm.run_code_obj(code_obj, scope).unwrap(); /// }); /// ``` diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index eb6546c02fe..32a2906fa84 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -5,6 +5,9 @@ #[cfg(feature = "rustpython-compiler")] mod compile; +pub(crate) mod compile_mode; +#[cfg(feature = "rustpython-compiler")] +pub use compile::VmCompileError; mod context; mod interpreter; mod method; @@ -82,6 +85,7 @@ pub struct VirtualMachine { pub profile_func: RefCell, pub trace_func: RefCell, pub use_tracing: Cell, + tracing_depth: Cell, pub recursion_limit: Cell, pub(crate) signal_handlers: OnceCell, pub(crate) signal_rx: Option, @@ -739,6 +743,7 @@ impl VirtualMachine { profile_func, trace_func, use_tracing: Cell::new(false), + tracing_depth: Cell::new(0), recursion_limit: Cell::new(if cfg!(debug_assertions) { 256 } else { 1000 }), signal_handlers, signal_rx: None, @@ -1095,30 +1100,22 @@ impl VirtualMachine { } pub fn run_code_obj(&self, code: PyRef, scope: Scope) -> PyResult { - use crate::builtins::{PyFunction, PyModule}; - - // Create a function object for module code, similar to CPython's PyEval_EvalCode - let func = PyFunction::new(code.clone(), scope.globals.clone(), self)?; - let func_obj = func.into_ref(&self.ctx).into(); + self.run_code_obj_with_closure(code, scope, None) + } - // Extract builtins from globals["__builtins__"], like PyEval_EvalCode - let builtins = match scope - .globals - .get_item_opt(identifier!(self, __builtins__), self)? - { - Some(b) => { - if let Some(module) = b.downcast_ref::() { - module.dict().into() - } else { - b - } - } - None => self.builtins.dict().into(), - }; + pub(crate) fn run_code_obj_with_closure( + &self, + code: PyRef, + scope: Scope, + closure: Option>>, + ) -> PyResult { + use crate::builtins::PyFunction; - let frame = - Frame::new(code, scope, builtins, &[], Some(func_obj), false, self).into_ref(&self.ctx); - self.run_frame(frame) + // Create a function object for module code, similar to PyEval_EvalCode + let mut func = PyFunction::new(code, scope.globals.clone(), self)?; + func.closure = closure; + let func = func.into_ref(&self.ctx); + func.invoke_with_locals(FuncArgs::default(), scope.locals, self) } #[cold] @@ -1434,9 +1431,11 @@ impl VirtualMachine { } /// Stack margin bytes (like _PyOS_STACK_MARGIN_BYTES). - /// 2048 * sizeof(void*) = 16KB for 64-bit. + /// The margin is doubled for debug/sanitized builds because frame + /// evaluation consumes more native stack in those configurations. #[cfg_attr(any(miri, target_env = "musl"), allow(dead_code))] - const STACK_MARGIN_BYTES: usize = 2048 * core::mem::size_of::(); + const STACK_MARGIN_BYTES: usize = + (if cfg!(debug_assertions) { 4096 } else { 2048 }) * core::mem::size_of::(); /// Get the stack boundaries using platform-specific APIs. /// Returns (base, top) where base is the lowest address and top is the highest. @@ -1709,11 +1708,33 @@ impl VirtualMachine { #[cfg(feature = "rustpython-codegen")] pub fn compile_opts(&self) -> crate::compiler::CompileOpts { crate::compiler::CompileOpts { - optimize: self.state.config.settings.optimize, + optimize: self.state.config.settings.optimize.min(2), debug_ranges: self.state.config.settings.code_debug_ranges, + int_max_str_digits: self.state.int_max_str_digits.load(), + allow_top_level_await: false, + future_features: crate::bytecode::CodeFlags::empty(), + dont_imply_dedent: false, + recursion_limit: self.recursion_limit.get(), } } + #[inline] + pub(crate) fn enter_tracing(&self) { + self.tracing_depth.set(self.tracing_depth.get() + 1); + } + + #[inline] + pub(crate) fn leave_tracing(&self) { + let depth = self.tracing_depth.get(); + debug_assert!(depth > 0); + self.tracing_depth.set(depth.saturating_sub(1)); + } + + #[inline] + pub(crate) fn tracing_is_suppressed(&self) -> bool { + self.tracing_depth.get() != 0 + } + // To be called right before raising the recursion depth. fn check_recursive_call(&self, _where: &str) -> PyResult<()> { if self.recursion_depth.get() >= self.recursion_limit.get() { @@ -2286,7 +2307,7 @@ mod tests { let source = "from dir_module.dir_module_inner import value2"; let code_obj = vm .compile(source, vm::compiler::Mode::Exec, "") - .map_err(|err| vm.new_syntax_error(&err, Some(source))) + .map_err(|err| err.into_pyexception(vm, Some(source))) .unwrap(); if let Err(e) = vm.run_code_obj(code_obj, scope) { diff --git a/crates/vm/src/vm/python_run.rs b/crates/vm/src/vm/python_run.rs index c21b437b575..91d5885e740 100644 --- a/crates/vm/src/vm/python_run.rs +++ b/crates/vm/src/vm/python_run.rs @@ -22,7 +22,7 @@ impl VirtualMachine { pub fn run_string(&self, scope: Scope, source: &str, source_path: &str) -> PyResult { let code_obj = self .compile(source, compiler::Mode::Exec, source_path) - .map_err(|err| self.new_syntax_error(&err, Some(source)))?; + .map_err(|err| err.into_pyexception(self, Some(source)))?; // linecache._register_code(code, source, filename) let _ = self.register_code_in_linecache(&code_obj, source); self.run_code_obj(code_obj, scope) @@ -47,7 +47,7 @@ impl VirtualMachine { pub fn run_block_expr(&self, scope: Scope, source: &str) -> PyResult { let code_obj = self .compile(source, compiler::Mode::BlockExpr, "") - .map_err(|err| self.new_syntax_error(&err, Some(source)))?; + .map_err(|err| err.into_pyexception(self, Some(source)))?; self.run_code_obj(code_obj, scope) } } @@ -105,11 +105,19 @@ mod file_run { if path != "" { set_main_loader(module_dict, path, "SourceFileLoader", self)?; } - match crate::host_env::fs::read_to_string(path) { - Ok(source) => { + match crate::host_env::fs::read(path) { + Ok(source_bytes) => { + if source_bytes.contains(&0) { + return Err(self.new_exception_msg( + self.ctx.exceptions.syntax_error.to_owned(), + "source code cannot contain null bytes".into(), + )); + } + let source = String::from_utf8(source_bytes) + .map_err(|err| self.new_os_error(err.to_string()))?; let code_obj = self .compile(&source, compiler::Mode::Exec, path) - .map_err(|err| self.new_syntax_error(&err, Some(&source)))?; + .map_err(|err| err.into_pyexception(self, Some(&source)))?; self.run_code_obj(code_obj, scope)?; } Err(err) => { diff --git a/crates/vm/src/vm/thread.rs b/crates/vm/src/vm/thread.rs index 26d8db9d764..5e73cc5f618 100644 --- a/crates/vm/src/vm/thread.rs +++ b/crates/vm/src/vm/thread.rs @@ -719,6 +719,7 @@ impl VirtualMachine { profile_func: RefCell::new(global_profile.unwrap_or_else(|| self.ctx.none())), trace_func: RefCell::new(global_trace.unwrap_or_else(|| self.ctx.none())), use_tracing: Cell::new(use_tracing), + tracing_depth: Cell::new(0), recursion_limit: self.recursion_limit.clone(), signal_handlers: core::cell::OnceCell::new(), signal_rx: None, diff --git a/crates/vm/src/vm/vm_new.rs b/crates/vm/src/vm/vm_new.rs index 4db965855df..2c053bdf838 100644 --- a/crates/vm/src/vm/vm_new.rs +++ b/crates/vm/src/vm/vm_new.rs @@ -54,15 +54,6 @@ impl SyntaxErrorInfo { Self { msg, narrow_caret } } - fn with_msg(&mut self, msg: &str) { - self.msg = msg.into(); - } - - #[cfg(feature = "parser")] - const fn with_narrow_caret(&mut self, narrow_caret: bool) { - self.narrow_caret = narrow_caret; - } - #[cfg(feature = "parser")] #[must_use] const fn handle_expected_token(expected: TokenKind, found: TokenKind) -> &'static str { @@ -114,7 +105,7 @@ impl SyntaxErrorInfo { } ParseErrorType::InvalidStarredExpressionUsage => { - self.with_narrow_caret(true); + self.narrow_caret = true; "invalid syntax".into() } @@ -134,7 +125,7 @@ impl SyntaxErrorInfo { ParseErrorType::EmptyTypeParams => "Type parameter list cannot be empty".into(), ParseErrorType::InvalidStarPatternUsage => { - self.with_narrow_caret(true); + self.narrow_caret = true; "cannot use starred expression here".into() } @@ -179,12 +170,22 @@ impl SyntaxErrorInfo { | ParseErrorType::SimpleAndCompoundStatementOnSameLine | ParseErrorType::ExpectedExpression => "invalid syntax".into(), + ParseErrorType::OtherError(s) if s.starts_with("Expected an identifier") => { + "invalid syntax".into() + } + ParseErrorType::OtherError(s) - if s.starts_with("Expected an identifier, but found a keyword") => + if s.eq_ignore_ascii_case( + "Expected a type parameter or the end of the type parameter list", + ) => { "invalid syntax".into() } + ParseErrorType::OtherError(s) if s.eq_ignore_ascii_case("Expected a statement") => { + "invalid syntax".into() + } + ParseErrorType::OtherError(s) if s.eq_ignore_ascii_case( "bytes literal cannot be mixed with non-bytes literals", @@ -262,7 +263,7 @@ impl SyntaxErrorInfo { _ => return, }; - self.with_msg(&msg); + self.msg = msg; } } @@ -576,6 +577,16 @@ impl VirtualMachine { source: Option<&str>, allow_incomplete: bool, ) -> PyBaseExceptionRef { + if matches!( + error, + crate::compiler::CompileError::Codegen(crate::compiler::codegen::error::CodegenError { + error: crate::compiler::codegen::error::CodegenErrorType::RecursionError, + .. + }) + ) { + return self.new_recursion_error(error.to_string()); + } + let incomplete_or_syntax = |allow| -> &'static Py { if allow { self.ctx.exceptions.incomplete_input_error @@ -687,7 +698,9 @@ impl VirtualMachine { raw_location, .. }) => { - if s.starts_with("Expected an indented block after") { + if s.starts_with("Expected an indented block after") + || s.starts_with("expected an indented block after") + { if allow_incomplete { // Check that all chars in the error are whitespace, if so, the source is // incomplete. Otherwise, we've found code that might violates @@ -717,6 +730,12 @@ impl VirtualMachine { } else { self.ctx.exceptions.indentation_error } + } else if allow_incomplete + && source.is_some_and(|source| { + raw_location.end().to_usize() >= source.len() && !source.ends_with('\n') + }) + { + self.ctx.exceptions.incomplete_input_error } else { self.ctx.exceptions.syntax_error } @@ -738,7 +757,29 @@ impl VirtualMachine { let statement = source.and_then(|src| get_statement(src, error.location())); let mut msg = error.to_string(); - if let Some(msg) = msg.get_mut(..1) { + if !msg.starts_with("Exceeds the limit ") + && !msg.starts_with("Did you mean ") + && !msg.starts_with("Invalid star expression") + && !msg.starts_with("Function parameters cannot be parenthesized") + && !msg.starts_with("Lambda expression parameters cannot be parenthesized") + && !msg.starts_with("Cannot have two type comments on def") + && !msg.starts_with("Variable annotation syntax is") + && !msg.starts_with("The '@' operator is") + && !msg.starts_with("Async functions are") + && !msg.starts_with("Async comprehensions are") + && !msg.starts_with("Async for loops are") + && !msg.starts_with("Async with statements are") + && !msg.starts_with("Exception groups are") + && !msg.starts_with("Positional-only parameters are") + && !msg.starts_with("Pattern matching is") + && !msg.starts_with("Type statement is") + && !msg.starts_with("Type parameter lists are") + && !msg.starts_with("Type parameter defaults are") + && !msg.starts_with("Assignment expressions are") + && !msg.starts_with("Await expressions are") + && !msg.starts_with("Underscores in numeric literals are") + && let Some(msg) = msg.get_mut(..1) + { msg.make_ascii_lowercase(); } @@ -753,17 +794,26 @@ impl VirtualMachine { }; if syntax_error_type.is(self.ctx.exceptions.tab_error) { - syntax_error_info.with_msg("inconsistent use of tabs and spaces in indentation"); + syntax_error_info.msg = "inconsistent use of tabs and spaces in indentation".to_owned(); + } + if syntax_error_type.is(self.ctx.exceptions.incomplete_input_error) { + syntax_error_info.msg = "incomplete input".to_owned(); } let SyntaxErrorInfo { msg, narrow_caret } = syntax_error_info; + let check_version_suite_error = msg.starts_with("Async functions are") + || msg.starts_with("Async for loops are") + || msg.starts_with("Async with statements are") + || msg.starts_with("Exception groups are") + || msg.starts_with("except expressions without parentheses are") + || msg.starts_with("Pattern matching is"); + let line_end_binary_operator_error = msg.starts_with("The '@' operator is"); let syntax_error = self.new_exception_msg(syntax_error_type, msg.into()); - let (lineno, offset) = error.python_location(); - let lineno = self.ctx.new_int(lineno); - let offset = self.ctx.new_int(offset); - + let (lineno_raw, offset_raw) = error.python_location(); + let lineno = self.ctx.new_int(lineno_raw); + let offset = self.ctx.new_int(offset_raw); set_attrs!( syntax_error.as_object(), self, unwrap, "lineno" => lineno, @@ -772,15 +822,23 @@ impl VirtualMachine { // Set end_lineno and end_offset if available if let Some((end_lineno, end_offset)) = error.python_end_location() { - let (end_lineno, end_offset) = if narrow_caret { + let (end_lineno, end_offset) = if check_version_suite_error + && statement + .as_deref() + .and_then(|line| line.chars().next()) + .is_some_and(|ch| ch.is_ascii_whitespace()) + { + (end_lineno, -1) + } else if line_end_binary_operator_error && end_offset == offset_raw { + (end_lineno, (end_offset + 1) as isize) + } else if narrow_caret { let (l, o) = error.python_location(); - (l, o + 1) + (l, (o + 1) as isize) } else { - (end_lineno, end_offset) + (end_lineno, end_offset as isize) }; let end_lineno = self.ctx.new_int(end_lineno); let end_offset = self.ctx.new_int(end_offset); - set_attrs!( syntax_error.as_object(), self, unwrap, "end_lineno" => end_lineno, diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml index 4150beaa81c..5a5e8d77fd6 100644 --- a/crates/wasm/Cargo.toml +++ b/crates/wasm/Cargo.toml @@ -20,6 +20,7 @@ no-start-func = [] rustpython-common = { workspace = true } rustpython-pylib = { workspace = true, optional = true } rustpython-stdlib = { workspace = true, default-features = false, optional = true } +ruff_text_size = { workspace = true } # make sure no threading! otherwise wasm build will fail rustpython-vm = { workspace = true, features = ["compiler", "encodings", "serde", "wasmbind"] } diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 99668df2855..ae4e52f21b0 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -70,7 +70,11 @@ pub mod eval { if let Some(js_vars) = js_vars { vm.add_to_scope("js_vars".into(), js_vars.into())?; } - vm.run(source, mode, None) + if matches!(mode, Mode::Single) { + vm.exec_single(source, None) + } else { + vm.run(source, mode, None) + } } /// Evaluate Python code diff --git a/crates/wasm/src/vm_class.rs b/crates/wasm/src/vm_class.rs index 08cb49ecfca..cd0af10f6df 100644 --- a/crates/wasm/src/vm_class.rs +++ b/crates/wasm/src/vm_class.rs @@ -6,9 +6,14 @@ use crate::{ use alloc::rc::{Rc, Weak}; use core::cell::RefCell; use js_sys::{Object, TypeError}; +use ruff_text_size::Ranged; use rustpython_vm::{ - Interpreter, PyObjectRef, PyRef, PyResult, Settings, VirtualMachine, builtins::PyWeak, - compiler::Mode, function::ArgMapping, scope::Scope, + Interpreter, PyObjectRef, PyRef, PyResult, Settings, VirtualMachine, + builtins::PyWeak, + compiler::{self, Mode}, + function::ArgMapping, + scope::Scope, + vm::VmCompileError, }; use std::collections::HashMap; use wasm_bindgen::prelude::*; @@ -21,6 +26,25 @@ pub(crate) struct StoredVirtualMachine { held_objects: RefCell>, } +fn compile_err_to_js(vm: &VirtualMachine, err: VmCompileError) -> JsValue { + match err { + VmCompileError::Compile(err) => convert::syntax_err(err).into(), + err => convert::py_err_to_js_err(vm, &err.into_pyexception(vm, None)), + } +} + +fn statement_chunks(source: &str) -> Option> { + let module = compiler::parser::parse_module(source).ok()?.into_syntax(); + module + .body + .iter() + .map(|stmt| { + let range = stmt.range(); + source.get(range.start().to_usize()..range.end().to_usize()) + }) + .collect() +} + #[pymodule] mod _window { use super::{js_module, wasm_builtins}; @@ -263,8 +287,8 @@ impl WASMVirtualMachine { ) -> Result<(), JsValue> { self.with_vm(|vm, _| { let code = vm - .compile(source, Mode::Exec, &name) - .map_err(convert::syntax_err)?; + .compile(source, Mode::Exec, name.as_str()) + .map_err(|err| compile_err_to_js(vm, err))?; let attrs = vm.ctx.new_dict(); attrs .set_item("__name__", vm.new_pyobj(name.as_str()), vm) @@ -327,13 +351,46 @@ impl WASMVirtualMachine { ) -> Result { self.with_vm(|vm, StoredVirtualMachine { scope, .. }| { let source_path = source_path.unwrap_or_else(|| "".to_owned()); - let code = vm.compile(source, mode, &source_path); - let code = code.map_err(convert::syntax_err)?; + let code = vm.compile(source, mode, source_path.as_str()); + let code = code.map_err(|err| compile_err_to_js(vm, err))?; let result = vm.run_code_obj(code, scope.clone()); convert::pyresult_to_js_result(vm, result) })? } + pub(crate) fn run_single( + &self, + source: &str, + source_path: Option, + ) -> Result { + self.with_vm(|vm, StoredVirtualMachine { scope, .. }| { + let source_path = source_path.unwrap_or_else(|| "".to_owned()); + let Some(chunks) = statement_chunks(source) else { + let code = vm.compile(source, Mode::Single, source_path.as_str()); + let code = code.map_err(|err| compile_err_to_js(vm, err))?; + let result = vm.run_code_obj(code, scope.clone()); + return convert::pyresult_to_js_result(vm, result); + }; + + if chunks.is_empty() { + return Ok(convert::py_to_js(vm, vm.ctx.none())); + } + + let displayhook = vm + .sys_module + .get_attr("displayhook", vm) + .map_err(|_| TypeError::new("lost sys.displayhook"))?; + let mut result = vm.ctx.none(); + for chunk in chunks { + let code = vm.compile(chunk, Mode::BlockExpr, source_path.as_str()); + let code = code.map_err(|err| compile_err_to_js(vm, err))?; + result = vm.run_code_obj(code, scope.clone()).into_js(vm)?; + displayhook.call((result.clone(),), vm).into_js(vm)?; + } + Ok(convert::py_to_js(vm, result)) + })? + } + pub fn exec(&self, source: &str, source_path: Option) -> Result { self.run(source, Mode::Exec, source_path) } @@ -348,6 +405,6 @@ impl WASMVirtualMachine { source: &str, source_path: Option, ) -> Result { - self.run(source, Mode::Single, source_path) + self.run_single(source, source_path) } } diff --git a/examples/hello_embed.rs b/examples/hello_embed.rs index 9e1cdb829d6..ae56ed21bf1 100644 --- a/examples/hello_embed.rs +++ b/examples/hello_embed.rs @@ -6,7 +6,7 @@ fn main() -> vm::PyResult<()> { let source = r#"print("Hello World!")"#; let code_obj = vm .compile(source, vm::compiler::Mode::Exec, "") - .map_err(|err| vm.new_syntax_error(&err, Some(source)))?; + .map_err(|err| err.into_pyexception(vm, Some(source)))?; vm.run_code_obj(code_obj, scope)?; diff --git a/examples/mini_repl.rs b/examples/mini_repl.rs index 40d111732ae..edbd6e1495c 100644 --- a/examples/mini_repl.rs +++ b/examples/mini_repl.rs @@ -66,7 +66,7 @@ def fib(n): // (note that this is only the case when compiler::Mode::Single is passed to vm.compile) match vm .compile(&input, vm::compiler::Mode::Single, "") - .map_err(|err| vm.new_syntax_error(&err, Some(&input))) + .map_err(|err| err.into_pyexception(vm, Some(&input))) .and_then(|code_obj| vm.run_code_obj(code_obj, scope.clone())) { Ok(output) => { diff --git a/examples/parse_folder.rs b/examples/parse_folder.rs index 440bcdb9b5f..7ece0c74065 100644 --- a/examples/parse_folder.rs +++ b/examples/parse_folder.rs @@ -131,4 +131,4 @@ struct ParsedFile { result: ParseResult, } -type ParseResult = Result, String>; +type ParseResult = Result; diff --git a/extra_tests/snippets/builtin_compile.py b/extra_tests/snippets/builtin_compile.py index 15095c0eede..49295bf26d2 100644 --- a/extra_tests/snippets/builtin_compile.py +++ b/extra_tests/snippets/builtin_compile.py @@ -1,3 +1,8 @@ +import __future__ + +import ast +import sys + from testutils import assert_raises # compile() basic mode acceptance @@ -43,4 +48,100 @@ def _check_flags_error(flags): _check_flags_error(99999) +_check_flags_error(0x100) +_check_flags_error(0x800) _check_flags_error(0x10000) + + +ns = {} +exec( + "from __future__ import annotations\n" + "inherited = compile('x: __debug__\\n', '', 'exec')\n" + "not_inherited = compile('x: __debug__\\n', '', 'exec', dont_inherit=True)\n", + ns, +) +assert ns["inherited"].co_flags & 0x1000000 +assert not (ns["not_inherited"].co_flags & 0x1000000) + +barry_flag = __future__.barry_as_FLUFL.compiler_flag +barry_code = compile("x = 1", "", "exec", flags=barry_flag) +compile("from __future__ import barry_as_FLUFL\nx = 1\n", "", "exec") +if sys.implementation.name == "rustpython": + assert not (barry_code.co_flags & barry_flag) + +n = ast.parse('x = "# type: int"\n', type_comments=True) +assert n.body[0].type_comment is None +n = ast.parse("x = '# type: int'\n", type_comments=True) +assert n.body[0].type_comment is None +n = ast.parse('x = "abc" # type: str\n', type_comments=True) +assert n.body[0].type_comment == "str" +n = ast.parse("x = 1 # type: ignore[excuse]\n", type_comments=True) +assert [(ti.lineno, ti.tag) for ti in n.type_ignores] == [(1, "[excuse]")] + + +compile("() -> int", "", "func_type", flags=ast.PyCF_ONLY_AST) +func_type_tree = compile( + '("a,b", str) -> int', "", "func_type", flags=ast.PyCF_ONLY_AST +) +assert len(func_type_tree.argtypes) == 2 +assert func_type_tree.argtypes[0].value == "a,b" +func_type_tree = compile( + "(int, *str, **Any) -> float", + "", + "func_type", + flags=ast.PyCF_ONLY_AST, +) +assert [arg.id for arg in func_type_tree.argtypes] == ["int", "str", "Any"] +assert_raises( + SyntaxError, + compile, + "int -> str", + "", + "func_type", + flags=ast.PyCF_ONLY_AST, +) +assert_raises( + SyntaxError, + compile, + "(x=1) -> str", + "", + "func_type", + flags=ast.PyCF_ONLY_AST, +) +assert_raises( + SyntaxError, + compile, + "(int,) -> str", + "", + "func_type", + flags=ast.PyCF_ONLY_AST, +) +PY_CF_DONT_IMPLY_DEDENT = 0x0200 +PY_CF_ALLOW_INCOMPLETE_INPUT = 0x4000 +compile(b"# coding: latin-1\nx = '\xe9'\n", "", "exec") +compile("if 1:\n pass", "", "single") +assert_raises( + SyntaxError, + compile, + "if 1:\n pass", + "", + "single", + flags=PY_CF_DONT_IMPLY_DEDENT, +) +compile( + "if 1:\n pass\n", + "", + "single", + flags=PY_CF_DONT_IMPLY_DEDENT | PY_CF_ALLOW_INCOMPLETE_INPUT, +) +try: + compile( + "if 1:\n pass", + "", + "single", + flags=PY_CF_DONT_IMPLY_DEDENT | PY_CF_ALLOW_INCOMPLETE_INPUT, + ) +except _IncompleteInputError as exc: + assert exc.args[0] == "incomplete input", repr(exc) +else: + raise AssertionError("expected _IncompleteInputError") diff --git a/ruff.toml b/ruff.toml index 2ed67851f0a..7cfaafdb08a 100644 --- a/ruff.toml +++ b/ruff.toml @@ -13,3 +13,6 @@ select = [ "F7", "F82", ] + +[lint.isort] +known-first-party = ["cpython", "opcodes", "utils"] diff --git a/src/shell.rs b/src/shell.rs index bc7ccec5c9d..7fb9336af4b 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -10,6 +10,7 @@ use rustpython_vm::{ compiler::{self}, readline::{Readline, ReadlineResult}, scope::Scope, + vm::VmCompileError, }; enum ShellExecResult { @@ -45,25 +46,25 @@ fn shell_exec( ShellExecResult::Ok } } - Err(CompileError::Parse(ParseError { + Err(VmCompileError::Compile(CompileError::Parse(ParseError { error: ParseErrorType::Lexical(LexicalErrorType::Eof), .. - })) => ShellExecResult::ContinueLine, - Err(CompileError::Parse(ParseError { + }))) => ShellExecResult::ContinueLine, + Err(VmCompileError::Compile(CompileError::Parse(ParseError { error: ParseErrorType::Lexical(LexicalErrorType::FStringError( InterpolatedStringErrorType::UnterminatedTripleQuotedString, )), .. - })) => ShellExecResult::ContinueLine, + }))) => ShellExecResult::ContinueLine, Err(err) => { // Check if the error is from an unclosed triple quoted string (which should always // continue) - if let CompileError::Parse(ParseError { + if let VmCompileError::Compile(CompileError::Parse(ParseError { error: ParseErrorType::Lexical(LexicalErrorType::UnclosedStringError), raw_location, .. - }) = err + })) = &err { let loc = raw_location.start().to_usize(); let mut iter = source.chars(); @@ -80,8 +81,8 @@ fn shell_exec( // since indentations errors on columns other than 0 should be ignored. // if its an unrecognized token for dedent, set to false - let bad_error = match err { - CompileError::Parse(ref p) => { + let bad_error = match &err { + VmCompileError::Compile(CompileError::Parse(p)) => { match &p.error { ParseErrorType::Lexical(LexicalErrorType::IndentationError) => { continuing_block @@ -97,7 +98,7 @@ fn shell_exec( // If we are handling an error on an empty line or an error worthy of throwing if empty_line_given || bad_error { - ShellExecResult::PyErr(vm.new_syntax_error(&err, Some(source))) + ShellExecResult::PyErr(err.into_pyexception(vm, Some(source))) } else { ShellExecResult::ContinueBlock } diff --git a/tools/opcode_metadata/generate_rs_opcode_metadata.py b/tools/opcode_metadata/generate_rs_opcode_metadata.py index df2476c5e08..fd13e026613 100644 --- a/tools/opcode_metadata/generate_rs_opcode_metadata.py +++ b/tools/opcode_metadata/generate_rs_opcode_metadata.py @@ -11,6 +11,7 @@ import typing import tomllib + from cpython import Analysis, get_analysis, get_stack_effect from opcodes import OpcodeInfo from utils import DEFAULT_INPUT, ROOT, get_conf, to_pascal_case From 2a344a5ab30687b729d4f001a0329d84a98b4fd0 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:23:54 +0300 Subject: [PATCH 039/351] Update `test_subprocess.py` to 3.14.6 (#8171) --- Lib/test/test_subprocess.py | 66 ++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/Lib/test/test_subprocess.py b/Lib/test/test_subprocess.py index 2ba98616ea6..f237508fbf4 100644 --- a/Lib/test/test_subprocess.py +++ b/Lib/test/test_subprocess.py @@ -22,6 +22,7 @@ import sysconfig import select import shutil +import socket import threading import gc import textwrap @@ -1044,19 +1045,49 @@ def test_communicate_timeout_large_input(self): # On Windows, stdin writing must also honor the timeout rather than # blocking indefinitely when the pipe buffer fills. - # Input larger than typical pipe buffer (4-64KB on Windows) - input_data = b"x" * (128 * 1024) + input_data = b"x" * (128 * 1024) # > typical pipe buffer + + # Cross-platform wake mechanism: the slow reader connects to a + # loopback TCP socket and blocks in select() on it (capped at 9s + # as a safety net we don't expect to hit). After phase 1 raises + # TimeoutExpired, the parent sends a byte to release the child so + # it drains stdin. A socket (rather than a raw pipe) is required + # because Windows select() only supports sockets, not arbitrary + # file descriptors. + server = socket.create_server(('127.0.0.1', 0), backlog=1) + server.settimeout(10) # bound the accept() if the child fails to start + port = server.getsockname()[1] + # The child sends one byte (low byte of its PID) first so the parent + # can detect the rare case of an unrelated process on the same host + # connecting to our ephemeral port before our child does. A single + # byte gives 1/256 collision odds, which is plenty for flake-prevention. + slow_reader = ( + "import os, socket, sys, select; " + f"s = socket.create_connection(('127.0.0.1', {port}), timeout=9); " + "s.sendall(bytes([os.getpid() & 0xff])); " + "select.select([s], [], [], 9); " + "sys.stdout.buffer.write(sys.stdin.buffer.read())" + ) p = subprocess.Popen( - [sys.executable, "-c", - "import sys, time; " - "time.sleep(30); " # Don't read stdin for a long time - "sys.stdout.buffer.write(sys.stdin.buffer.read())"], + [sys.executable, "-c", slow_reader], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + conn = None try: + conn, _ = server.accept() + server.close() + server = None + + conn.settimeout(5) + peer_byte = conn.recv(1) + conn.settimeout(None) + self.assertEqual(peer_byte, bytes([p.pid & 0xff]), + f"loopback handshake byte {peer_byte!r} != " + f"low byte of child PID {p.pid} ({p.pid & 0xff:#x})") + timeout = 0.2 start = time.monotonic() try: @@ -1065,7 +1096,7 @@ def test_communicate_timeout_large_input(self): elapsed = time.monotonic() - start self.fail( f"TimeoutExpired not raised. communicate() completed in " - f"{elapsed:.2f}s, but subprocess sleeps for 30s. " + f"{elapsed:.2f}s, but slow reader stalls for up to 9s. " "Stdin writing blocked without enforcing timeout.") except subprocess.TimeoutExpired: elapsed = time.monotonic() - start @@ -1073,11 +1104,16 @@ def test_communicate_timeout_large_input(self): # Timeout should occur close to the specified timeout value, # not after waiting for the subprocess to finish sleeping. # Allow generous margin for slow CI, but must be well under - # the subprocess sleep time. + # the slow-reader's stall cap. self.assertLess(elapsed, 5.0, f"TimeoutExpired raised after {elapsed:.2f}s; expected ~{timeout}s. " "Stdin writing blocked without checking timeout.") + # Release the slow reader so it stops blocking and drains stdin. + conn.sendall(b'go') + conn.close() + conn = None + # After timeout, continue communication. The remaining input # should be sent and we should receive all data back. stdout, stderr = p.communicate() @@ -1087,6 +1123,10 @@ def test_communicate_timeout_large_input(self): f"Expected {len(input_data)} bytes output but got {len(stdout)}") self.assertEqual(stdout, input_data) finally: + if conn is not None: + conn.close() + if server is not None: + server.close() p.kill() p.wait() @@ -3693,13 +3733,17 @@ def test_startupinfo_copy(self): self.assertEqual(startupinfo.wShowWindow, subprocess.SW_HIDE) self.assertEqual(startupinfo.lpAttributeList, {"handle_list": []}) + # CREATE_NEW_CONSOLE creates a "popup" window. + @support.requires_resource('gui') def test_creationflags(self): # creationflags argument CREATE_NEW_CONSOLE = 16 sys.stderr.write(" a DOS box should flash briefly ...\n") - subprocess.call(sys.executable + - ' -c "import time; time.sleep(0.25)"', - creationflags=CREATE_NEW_CONSOLE) + rc = subprocess.call(sys.executable + + ' -c "import time; time.sleep(0.25)"', + creationflags=CREATE_NEW_CONSOLE) + support.skip_on_low_desktop_heap_memory_subprocess(rc) + self.assertEqual(rc, 0) def test_invalid_args(self): # invalid arguments should raise ValueError From 83ce1a7cad790dcf95886f119c1585ee4089a41c Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:24:20 +0300 Subject: [PATCH 040/351] Update `test/signalinterproctester.py` to 3.14.6 (#8173) * Update `test/signalinterproctester.py` to 3.14.6 * Add `signalinterproctester.py` as a test dependency of `test_signal.py` --- Lib/test/signalinterproctester.py | 22 +++++++++++++++------- scripts/update_lib/deps.py | 6 ++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/Lib/test/signalinterproctester.py b/Lib/test/signalinterproctester.py index 168b5da0f2c..073c078f45f 100644 --- a/Lib/test/signalinterproctester.py +++ b/Lib/test/signalinterproctester.py @@ -1,9 +1,11 @@ +import gc import os import signal import subprocess import sys import time import unittest +from test import support class SIGUSR1Exception(Exception): @@ -27,16 +29,15 @@ def wait_signal(self, child, signame): # (if set) child.wait() - timeout = 10.0 - deadline = time.monotonic() + timeout - - while time.monotonic() < deadline: + start_time = time.monotonic() + for _ in support.busy_retry(support.SHORT_TIMEOUT, error=False): if self.got_signals[signame]: return signal.pause() - - self.fail('signal %s not received after %s seconds' - % (signame, timeout)) + else: + dt = time.monotonic() - start_time + self.fail('signal %s not received after %.1f seconds' + % (signame, dt)) def subprocess_send_signal(self, pid, signame): code = 'import os, signal; os.kill(%s, signal.%s)' % (pid, signame) @@ -59,6 +60,13 @@ def test_interprocess_signal(self): self.assertEqual(self.got_signals, {'SIGHUP': 1, 'SIGUSR1': 0, 'SIGALRM': 0}) + # gh-110033: Make sure that the subprocess.Popen is deleted before + # the next test which raises an exception. Otherwise, the exception + # may be raised when Popen.__del__() is executed and so be logged + # as "Exception ignored in: ". + child = None + gc.collect() + with self.assertRaises(SIGUSR1Exception): with self.subprocess_send_signal(pid, "SIGUSR1") as child: self.wait_signal(child, 'SIGUSR1') diff --git a/scripts/update_lib/deps.py b/scripts/update_lib/deps.py index 72374aa6be1..da49490c7ec 100644 --- a/scripts/update_lib/deps.py +++ b/scripts/update_lib/deps.py @@ -724,6 +724,12 @@ def clear_import_graph_caches() -> None: "curses_tests.py", ], }, + "signal": { + "test": [ + "signalinterproctester.py", + "test_signal.py", + ] + }, } From c4495649c8980867fbf0fdb0c7239ee3d4195aff Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:42:01 +0200 Subject: [PATCH 041/351] Add more object c-api's (#8170) --- crates/capi/src/object.rs | 276 +++++++++++++++++++++++++++++++++----- crates/capi/src/util.rs | 8 ++ 2 files changed, 254 insertions(+), 30 deletions(-) diff --git a/crates/capi/src/object.rs b/crates/capi/src/object.rs index 5e601d3817d..d92e9977f2c 100644 --- a/crates/capi/src/object.rs +++ b/crates/capi/src/object.rs @@ -5,7 +5,8 @@ use core::ptr::NonNull; use rustpython_vm::builtins::{PyStr, PyType, object_generic_set_dict, object_get_dict}; use rustpython_vm::bytecode::ComparisonOperator; use rustpython_vm::function::PySetterValue; -use rustpython_vm::{AsObject, Py, PyPayload}; +use rustpython_vm::types::{PyComparisonOp, hash_not_implemented}; +use rustpython_vm::{AsObject, Py, PyPayload, PyResult, VirtualMachine}; pub type PyTypeObject = Py; @@ -100,25 +101,27 @@ pub unsafe extern "C" fn PyType_GetFullyQualifiedName(ptr: *const PyTypeObject) }) } +#[inline] +fn get_constant(vm: &VirtualMachine, constant_id: c_uint) -> PyResult<&PyObject> { + let ctx = &vm.ctx; + match constant_id { + 0 => Ok(ctx.none.as_object()), + 1 => Ok(ctx.false_value.as_object()), + 2 => Ok(ctx.true_value.as_object()), + 3 => Ok(ctx.ellipsis.as_object()), + 4 => Ok(ctx.not_implemented.as_object()), + _ => Err(vm.new_system_error("Invalid constant ID passed to Py_GetConstantBorrowed")), + } +} + #[unsafe(no_mangle)] pub extern "C" fn Py_GetConstantBorrowed(constant_id: c_uint) -> *mut PyObject { - with_vm(|vm| { - let ctx = &vm.ctx; - let constant = match constant_id { - 0 => ctx.none.as_object(), - 1 => ctx.false_value.as_object(), - 2 => ctx.true_value.as_object(), - 3 => ctx.ellipsis.as_object(), - 4 => ctx.not_implemented.as_object(), - _ => { - return Err( - vm.new_system_error("Invalid constant ID passed to Py_GetConstantBorrowed") - ); - } - } - .as_raw(); - Ok(constant) - }) + with_vm(|vm| get_constant(vm, constant_id).map(PyObject::as_raw)) +} + +#[unsafe(no_mangle)] +pub extern "C" fn Py_GetConstant(constant_id: c_uint) -> *mut PyObject { + with_vm(|vm| get_constant(vm, constant_id).map(ToOwned::to_owned)) } #[unsafe(no_mangle)] @@ -143,12 +146,22 @@ pub unsafe extern "C" fn PyObject_GetAttrString( let name = unsafe { CStr::from_ptr(attr_name) .to_str() - .expect("attribute name must be valid UTF-8") + .map_err(|_| vm.new_value_error("attribute name must be valid UTF-8"))? }; obj.get_attr(name, vm) }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_ASCII(obj: *mut PyObject) -> *mut PyObject { + with_vm(|vm| unsafe { &*obj }.ascii(vm)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_Bytes(obj: *mut PyObject) -> *mut PyObject { + with_vm(|vm| unsafe { &*obj }.to_owned().bytes(vm)) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyObject_GetOptionalAttr( obj: *mut PyObject, @@ -172,6 +185,31 @@ pub unsafe extern "C" fn PyObject_GetOptionalAttr( }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_GetOptionalAttrString( + obj: *mut PyObject, + attr_name: *const c_char, + result: *mut *mut PyObject, +) -> c_int { + with_vm(|vm| { + unsafe { + *result = core::ptr::null_mut(); + } + let obj = unsafe { &*obj }; + let name = unsafe { CStr::from_ptr(attr_name) } + .to_str() + .map_err(|_| vm.new_value_error("attribute name must be valid UTF-8"))?; + if let Some(attr) = vm.get_attribute_opt(obj.to_owned(), name)? { + unsafe { + *result = attr.into_raw().as_ptr(); + } + Ok(true) + } else { + Ok(false) + } + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyObject_SetAttrString( obj: *mut PyObject, @@ -182,7 +220,7 @@ pub unsafe extern "C" fn PyObject_SetAttrString( let obj = unsafe { &*obj }; let name = unsafe { CStr::from_ptr(attr_name) } .to_str() - .expect("attribute name must be valid UTF-8"); + .map_err(|_| vm.new_value_error("attribute name must be valid UTF-8"))?; let value = unsafe { &*value }.to_owned(); obj.set_attr(name, value, vm) }) @@ -202,6 +240,46 @@ pub unsafe extern "C" fn PyObject_SetAttr( }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_DelAttr(obj: *mut PyObject, name: *mut PyObject) -> c_int { + with_vm(|vm| { + let obj = unsafe { &*obj }; + let name = unsafe { &*name }.try_downcast_ref::(vm)?; + obj.del_attr(name, vm) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_DelAttrString( + obj: *mut PyObject, + attr_name: *const c_char, +) -> c_int { + with_vm(|vm| { + let obj = unsafe { &*obj }; + let name = unsafe { CStr::from_ptr(attr_name) } + .to_str() + .map_err(|_| vm.new_value_error("attribute name must be valid UTF-8"))?; + obj.del_attr(name, vm) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_GenericSetAttr( + obj: *mut PyObject, + name: *mut PyObject, + value: *mut PyObject, +) -> c_int { + with_vm(|vm| { + let obj = unsafe { &*obj }; + let name = unsafe { &*name }.try_downcast_ref::(vm)?; + let value = match NonNull::new(value) { + Some(value) => PySetterValue::Assign(unsafe { value.as_ref() }.to_owned()), + None => PySetterValue::Delete, + }; + obj.generic_setattr(name, value, vm) + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyObject_HasAttrWithError( obj: *mut PyObject, @@ -214,6 +292,63 @@ pub unsafe extern "C" fn PyObject_HasAttrWithError( }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_HasAttr(obj: *mut PyObject, attr_name: *mut PyObject) -> c_int { + with_vm(|vm| { + let obj = unsafe { &*obj }; + let name = match unsafe { &*attr_name }.try_downcast_ref::(vm) { + Ok(name) => name, + Err(err) => { + vm.run_unraisable(err, None, obj.to_owned()); + return false; + } + }; + + match obj.has_attr(name, vm) { + Ok(has_attr) => has_attr, + Err(err) => { + vm.run_unraisable(err, None, obj.to_owned()); + false + } + } + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_HasAttrString( + obj: *mut PyObject, + attr_name: *const c_char, +) -> c_int { + with_vm(|vm| { + let obj = unsafe { &*obj }; + let Ok(name) = unsafe { CStr::from_ptr(attr_name) }.to_str() else { + return false; + }; + + match obj.has_attr(name, vm) { + Ok(has_attr) => has_attr, + Err(err) => { + vm.run_unraisable(err, None, obj.to_owned()); + false + } + } + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_HasAttrStringWithError( + obj: *mut PyObject, + attr_name: *const c_char, +) -> c_int { + with_vm(|vm| { + let obj = unsafe { &*obj }; + let name = unsafe { CStr::from_ptr(attr_name) } + .to_str() + .map_err(|_| vm.new_value_error("attribute name must be valid UTF-8"))?; + obj.has_attr(name, vm) + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyObject_GenericGetAttr( obj: *mut PyObject, @@ -248,6 +383,20 @@ pub extern "C" fn PyObject_Str(obj: *mut PyObject) -> *mut PyObject { }) } +#[inline] +fn parse_richcompare_op(vm: &VirtualMachine, op: c_int) -> PyResult { + match op { + 0 => Ok(ComparisonOperator::Less), + 1 => Ok(ComparisonOperator::LessOrEqual), + 2 => Ok(ComparisonOperator::Equal), + 3 => Ok(ComparisonOperator::NotEqual), + 4 => Ok(ComparisonOperator::Greater), + 5 => Ok(ComparisonOperator::GreaterOrEqual), + _ => Err(vm.new_system_error("invalid comparison operator")), + } + .map(Into::into) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyObject_RichCompare( left: *mut PyObject, @@ -255,19 +404,23 @@ pub unsafe extern "C" fn PyObject_RichCompare( op: c_int, ) -> *mut PyObject { with_vm(|vm| { - let op = match op { - 0 => ComparisonOperator::Less, - 1 => ComparisonOperator::LessOrEqual, - 2 => ComparisonOperator::Equal, - 3 => ComparisonOperator::NotEqual, - 4 => ComparisonOperator::Greater, - 5 => ComparisonOperator::GreaterOrEqual, - _ => return Err(vm.new_system_error("invalid comparison operator")), - }; let left = unsafe { &*left }; let right = unsafe { &*right }; left.to_owned() - .rich_compare(right.to_owned(), op.into(), vm) + .rich_compare(right.to_owned(), parse_richcompare_op(vm, op)?, vm) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_RichCompareBool( + left: *mut PyObject, + right: *mut PyObject, + op: c_int, +) -> c_int { + with_vm(|vm| { + let left = unsafe { &*left }; + let right = unsafe { &*right }; + left.rich_compare_bool(right, parse_richcompare_op(vm, op)?, vm) }) } @@ -298,6 +451,69 @@ pub unsafe extern "C" fn PyObject_IsTrue(obj: *mut PyObject) -> c_int { }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_Not(obj: *mut PyObject) -> c_int { + with_vm(|vm| { + let obj = unsafe { &*obj }; + obj.to_owned().not(vm) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_Hash(obj: *mut PyObject) -> isize { + with_vm(|vm| { + let obj = unsafe { &*obj }; + obj.hash(vm).map(|hash| hash as isize) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_HashNotImplemented(obj: *mut PyObject) -> isize { + with_vm(|vm| { + let obj = unsafe { &*obj }; + hash_not_implemented(obj, vm).map(|hash| hash as isize) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_SelfIter(obj: *mut PyObject) -> *mut PyObject { + with_vm(|_vm| unsafe { (&*obj).to_owned() }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Py_Is(x: *mut PyObject, y: *mut PyObject) -> c_int { + (x == y) as c_int +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Py_IsNone(x: *mut PyObject) -> c_int { + with_vm(|vm| vm.is_none(unsafe { &*x })) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Py_ReprEnter(obj: *mut PyObject) -> c_int { + with_vm(|vm| { + let obj = unsafe { &*obj }; + let id = obj.get_id(); + let mut guards = vm.repr_guards.borrow_mut(); + if guards.contains(&id) { + true + } else { + guards.insert(id); + false + } + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Py_ReprLeave(obj: *mut PyObject) { + with_vm(|vm| { + vm.repr_guards + .borrow_mut() + .remove(&unsafe { &*obj }.get_id()); + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyObject_GenericGetDict( obj: *mut PyObject, diff --git a/crates/capi/src/util.rs b/crates/capi/src/util.rs index 24d6d3d40e9..4061a8f370d 100644 --- a/crates/capi/src/util.rs +++ b/crates/capi/src/util.rs @@ -101,6 +101,14 @@ impl FfiResult for usize { } } +impl FfiResult for isize { + const ERR_VALUE: Self = -1; + + fn into_output(self, _vm: &VirtualMachine) -> Self { + self + } +} + impl FfiResult for c_long { const ERR_VALUE: Self = -1; From 0009dd6f07eecd4d949b50b4fc1de18410a1bbfa Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:14:08 +0900 Subject: [PATCH 042/351] Clean up compiler parity leftovers (#8174) --- crates/codegen/src/error.rs | 4 - crates/codegen/src/symboltable.rs | 295 +++++++++--------------------- crates/vm/src/stdlib/_ast.rs | 3 +- 3 files changed, 93 insertions(+), 209 deletions(-) diff --git a/crates/codegen/src/error.rs b/crates/codegen/src/error.rs index 9f11eba946f..668ceb605dc 100644 --- a/crates/codegen/src/error.rs +++ b/crates/codegen/src/error.rs @@ -88,7 +88,6 @@ pub enum CodegenErrorType { ConflictingNameBindPattern, /// break/continue/return inside except* block BreakContinueReturnInExceptStar, - NotImplementedYet, // RustPython marker for unimplemented features } impl core::error::Error for CodegenErrorType {} @@ -173,9 +172,6 @@ impl fmt::Display for CodegenErrorType { "'break', 'continue' and 'return' cannot appear in an except* block" ) } - Self::NotImplementedYet => { - write!(f, "RustPython does not implement this feature yet") - } } } } diff --git a/crates/codegen/src/symboltable.rs b/crates/codegen/src/symboltable.rs index b021a494c93..a78b49a6e90 100644 --- a/crates/codegen/src/symboltable.rs +++ b/crates/codegen/src/symboltable.rs @@ -325,6 +325,7 @@ pub struct Symbol { pub name: String, pub scope: SymbolScope, pub flags: SymbolFlags, + pub location: Option, } impl Symbol { @@ -334,6 +335,7 @@ impl Symbol { // table, scope: SymbolScope::Unknown, flags: SymbolFlags::empty(), + location: None, } } @@ -777,117 +779,98 @@ impl SymbolTableAnalyzer { sub_tables: &[SymbolTable], class_entry: Option<&SymbolMap>, ) -> SymbolTableResult { - if symbol - .flags - .contains(SymbolFlags::ASSIGNED_IN_COMPREHENSION) - && st_typ == CompilerScope::Comprehension - { - // propagate symbol to next higher level that can hold it, - // i.e., function or module. Comprehension is skipped and - // Class is not allowed and detected as error. - self.analyze_symbol_comprehension(symbol, 0)? - } else { - match symbol.scope { - SymbolScope::Free => { - if !self.tables.as_ref().is_empty() { - let scope_depth = self.tables.as_ref().len(); - // check if the name is already defined in any outer scope - if scope_depth < 2 - || self.found_in_outer_scope( - &symbol.name, - st_typ, - skip_enclosing_function_scope, - ) != Some(SymbolScope::Free) - { - return Err(SymbolTableError { - error: format!("no binding for nonlocal '{}' found", symbol.name), - // TODO: accurate location info, somehow - location: None, - }); - } - // Check if the nonlocal binding refers to a type parameter - if symbol.flags.contains(SymbolFlags::NONLOCAL) { - for (symbols, _typ, _skip) in self.tables.iter().rev() { - if let Some(sym) = symbols.get(&symbol.name) { - if sym.flags.contains(SymbolFlags::TYPE_PARAM) { - return Err(SymbolTableError { - error: format!( - "nonlocal binding not allowed for type parameter '{}'", - symbol.name - ), - location: None, - }); - } - if sym.is_bound() { - break; - } + match symbol.scope { + SymbolScope::Free => { + if !self.tables.as_ref().is_empty() { + let scope_depth = self.tables.as_ref().len(); + // check if the name is already defined in any outer scope + if scope_depth < 2 + || self.found_in_outer_scope( + &symbol.name, + st_typ, + skip_enclosing_function_scope, + ) != Some(SymbolScope::Free) + { + return Err(SymbolTableError { + error: format!("no binding for nonlocal '{}' found", symbol.name), + location: symbol.location, + }); + } + // Check if the nonlocal binding refers to a type parameter + if symbol.flags.contains(SymbolFlags::NONLOCAL) { + for (symbols, _typ, _skip) in self.tables.iter().rev() { + if let Some(sym) = symbols.get(&symbol.name) { + if sym.flags.contains(SymbolFlags::TYPE_PARAM) { + return Err(SymbolTableError { + error: format!( + "nonlocal binding not allowed for type parameter '{}'", + symbol.name + ), + location: symbol.location, + }); + } + if sym.is_bound() { + break; } } } - } else { - return Err(SymbolTableError { - error: format!( - "nonlocal {} defined at place without an enclosing scope", - symbol.name - ), - // TODO: accurate location info, somehow - location: None, - }); } + } else { + return Err(SymbolTableError { + error: format!( + "nonlocal {} defined at place without an enclosing scope", + symbol.name + ), + location: symbol.location, + }); } - SymbolScope::GlobalExplicit | SymbolScope::GlobalImplicit => { - // TODO: add more checks for globals? - } - SymbolScope::Local | SymbolScope::Cell => { - // all is well - } - SymbolScope::Unknown => { - // Try hard to figure out what the scope of this symbol is. - let scope = if symbol.is_bound() { - if symbol.flags.contains(SymbolFlags::COMP_CELL) - && matches!(st_typ, CompilerScope::Module | CompilerScope::Class) - { - // CPython keeps comprehension-only cells in - // module/class scopes as normal local/name - // bindings and uses DEF_COMP_CELL to allocate the - // synthetic cell slot. The spliced comp child - // should not force the outer name itself to CELL. - SymbolScope::Local - } else { - self.found_in_inner_scope(sub_tables, &symbol.name, st_typ) - .unwrap_or(SymbolScope::Local) - } - } else if let Some(scope) = class_entry - .and_then(|class_symbols| class_symbols.get(&symbol.name)) - .and_then(|class_sym| { - if class_sym.flags.contains(SymbolFlags::GLOBAL) { - Some(SymbolScope::GlobalExplicit) - } else if class_sym.is_bound() && class_sym.scope != SymbolScope::Free { - // If name is bound in enclosing class, use GlobalImplicit - // so it can be accessed via __classdict__ - Some(SymbolScope::GlobalImplicit) - } else { - None - } - }) + } + SymbolScope::GlobalExplicit | SymbolScope::GlobalImplicit => {} + SymbolScope::Local | SymbolScope::Cell => {} + SymbolScope::Unknown => { + // Try hard to figure out what the scope of this symbol is. + let scope = if symbol.is_bound() { + if symbol.flags.contains(SymbolFlags::COMP_CELL) + && matches!(st_typ, CompilerScope::Module | CompilerScope::Class) { - scope - } else if let Some(scope) = self.found_in_outer_scope( - &symbol.name, - st_typ, - skip_enclosing_function_scope, - ) { - // If found in enclosing scope (function/TypeParams), use that - scope - } else if self.tables.is_empty() { - // Don't make assumptions when we don't know. - SymbolScope::Unknown + // CPython keeps comprehension-only cells in + // module/class scopes as normal local/name + // bindings and uses DEF_COMP_CELL to allocate the + // synthetic cell slot. The spliced comp child + // should not force the outer name itself to CELL. + SymbolScope::Local } else { - // If there are scopes above we assume global. - SymbolScope::GlobalImplicit - }; - symbol.scope = scope; - } + self.found_in_inner_scope(sub_tables, &symbol.name, st_typ) + .unwrap_or(SymbolScope::Local) + } + } else if let Some(scope) = class_entry + .and_then(|class_symbols| class_symbols.get(&symbol.name)) + .and_then(|class_sym| { + if class_sym.flags.contains(SymbolFlags::GLOBAL) { + Some(SymbolScope::GlobalExplicit) + } else if class_sym.is_bound() && class_sym.scope != SymbolScope::Free { + // If name is bound in enclosing class, use GlobalImplicit + // so it can be accessed via __classdict__ + Some(SymbolScope::GlobalImplicit) + } else { + None + } + }) + { + scope + } else if let Some(scope) = + self.found_in_outer_scope(&symbol.name, st_typ, skip_enclosing_function_scope) + { + // If found in enclosing scope (function/TypeParams), use that + scope + } else if self.tables.is_empty() { + // Don't make assumptions when we don't know. + SymbolScope::Unknown + } else { + // If there are scopes above we assume global. + SymbolScope::GlobalImplicit + }; + symbol.scope = scope; } } Ok(()) @@ -1023,106 +1006,6 @@ impl SymbolTableAnalyzer { } }) } - - // Implements the symbol analysis and scope extension for names - // assigned by a named expression in a comprehension. See: - // https://github.com/python/cpython/blob/7b78e7f9fd77bb3280ee39fb74b86772a7d46a70/Python/symtable.c#L1435 - fn analyze_symbol_comprehension( - &mut self, - symbol: &mut Symbol, - parent_offset: usize, - ) -> SymbolTableResult { - // when this is called, we expect to be in the direct parent scope of the scope that contains 'symbol' - let last = self.tables.iter_mut().rev().nth(parent_offset).unwrap(); - let symbols = &mut last.0; - let table_type = last.1; - - // it is not allowed to use an iterator variable as assignee in a named expression - if symbol.flags.contains(SymbolFlags::ITER) { - return Err(SymbolTableError { - error: format!( - "assignment expression cannot rebind comprehension iteration variable {}", - symbol.name - ), - // TODO: accurate location info, somehow - location: None, - }); - } - - match table_type { - CompilerScope::Module => { - symbol.scope = SymbolScope::GlobalImplicit; - } - CompilerScope::Class => { - // named expressions are forbidden in comprehensions on class scope - return Err(SymbolTableError { - error: "assignment expression within a comprehension cannot be used in a class body".to_string(), - // TODO: accurate location info, somehow - location: None, - }); - } - CompilerScope::Function | CompilerScope::AsyncFunction | CompilerScope::Lambda => { - if let Some(parent_symbol) = symbols.get_mut(&symbol.name) { - if let SymbolScope::Unknown = parent_symbol.scope { - // this information is new, as the assignment is done in inner scope - parent_symbol.flags.insert(SymbolFlags::ASSIGNED); - } - - symbol.scope = if parent_symbol.is_global() { - parent_symbol.scope - } else { - SymbolScope::Free - }; - } else { - let mut cloned_sym = symbol.clone(); - cloned_sym.scope = SymbolScope::Cell; - last.0.insert(cloned_sym.name.to_owned(), cloned_sym); - } - } - CompilerScope::Comprehension => { - // TODO check for conflicts - requires more context information about variables - match symbols.get_mut(&symbol.name) { - Some(parent_symbol) => { - // check if assignee is an iterator in top scope - if parent_symbol.flags.contains(SymbolFlags::ITER) { - return Err(SymbolTableError { - error: format!( - "assignment expression cannot rebind comprehension iteration variable {}", - symbol.name - ), - location: None, - }); - } - - // we synthesize the assignment to the symbol from inner scope - parent_symbol.flags.insert(SymbolFlags::ASSIGNED); // more checks are required - } - None => { - // extend the scope of the inner symbol - // as we are in a nested comprehension, we expect that the symbol is needed - // outside, too, and set it therefore to non-local scope. I.e., we expect to - // find a definition on a higher level - let mut cloned_sym = symbol.clone(); - cloned_sym.scope = SymbolScope::Free; - last.0.insert(cloned_sym.name.to_owned(), cloned_sym); - } - } - - self.analyze_symbol_comprehension(symbol, parent_offset + 1)?; - } - CompilerScope::TypeParams => { - // Named expression in comprehension cannot be used in type params - return Err(SymbolTableError { - error: "assignment expression within a comprehension cannot be used within the definition of a generic".to_string(), - location: None, - }); - } - CompilerScope::Annotation | CompilerScope::TypeAlias | CompilerScope::TypeVariable => { - self.analyze_symbol_comprehension(symbol, parent_offset + 1)?; - } - } - Ok(()) - } } #[derive(Clone, Copy, Debug)] @@ -3475,6 +3358,10 @@ impl SymbolTableBuilder { table.symbols.entry(name.into_owned()).or_insert(symbol) }; + if matches!(role, SymbolUsage::Global | SymbolUsage::Nonlocal) { + symbol.location = location; + } + // Set proper scope and flags on symbol: let flags = &mut symbol.flags; match role { diff --git a/crates/vm/src/stdlib/_ast.rs b/crates/vm/src/stdlib/_ast.rs index f6ce6af86ae..a04def22524 100644 --- a/crates/vm/src/stdlib/_ast.rs +++ b/crates/vm/src/stdlib/_ast.rs @@ -2438,6 +2438,7 @@ pub(crate) fn compile( } }; opts.future_features |= codegen::preprocess::future_features(&ast); + let source = text.clone(); let source_file = SourceFileBuilder::new(filename, text).finish(); #[cfg(feature = "parser")] let code = { @@ -2491,7 +2492,7 @@ pub(crate) fn compile( }; #[cfg(not(feature = "parser"))] let code = codegen::compile::compile_top(ast, source_file, mode, opts); - let code = code.map_err(|err| vm.new_syntax_error(&err.into(), None))?; // FIXME source + let code = code.map_err(|err| vm.new_syntax_error(&err.into(), Some(source.as_str())))?; Ok(crate::builtins::PyCode::new_ref_from_bytecode(vm, code).into()) } From 19a17d1b36c87a66dde13ea778a80d34013914ce Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:28:23 +0300 Subject: [PATCH 043/351] Fix all clippy suggestions in `ir.rs` (#8162) * Fix all clippy suggestions in `ir.rs` * remove some `idx()` calls --- crates/codegen/src/ir.rs | 211 +++++++++++++++++---------------------- 1 file changed, 94 insertions(+), 117 deletions(-) diff --git a/crates/codegen/src/ir.rs b/crates/codegen/src/ir.rs index b369ec37df6..e24909f9c41 100644 --- a/crates/codegen/src/ir.rs +++ b/crates/codegen/src/ir.rs @@ -706,7 +706,6 @@ fn instruction_sequence_set_annotations_code( } /// instruction_sequence.c _PyInstructionSequence_UseLabel -#[allow(clippy::needless_range_loop)] fn instruction_sequence_use_label( seq: &mut InstructionSequence, label: InstructionSequenceLabel, @@ -740,9 +739,8 @@ fn instruction_sequence_use_label( if label_map.len() < seq.label_map_allocation { label_map.resize(seq.label_map_allocation, INSTRUCTION_SEQUENCE_UNSET_LABEL); } - for i in old_size..seq.label_map_allocation { - label_map[i] = INSTRUCTION_SEQUENCE_UNSET_LABEL; - } + + label_map[old_size..seq.label_map_allocation].fill(INSTRUCTION_SEQUENCE_UNSET_LABEL); label_map[label.idx()] = seq.instr_used as i32; Ok(()) } @@ -770,7 +768,6 @@ fn instruction_sequence_last_info_mut( } /// instruction_sequence.c _PyInstructionSequence_InsertInstruction -#[allow(clippy::needless_range_loop)] fn instruction_sequence_insert_instruction( seq: &mut InstructionSequence, pos: usize, @@ -781,27 +778,28 @@ fn instruction_sequence_insert_instruction( for i in (pos..last_idx).rev() { seq.instrs[i + 1] = seq.instrs[i]; } + seq.instrs[pos].info = info; if let Some(label_map) = &mut seq.label_map { let pos = pos as i32; - for lbl in 0..seq.label_map_allocation { - if label_map[lbl] >= pos { - label_map[lbl] += 1; + + for lbl in label_map.iter_mut().take(seq.label_map_allocation) { + if *lbl >= pos { + *lbl += 1; } } } + Ok(()) } /// instruction_sequence.c _PyInstructionSequence_ApplyLabelMap -#[allow(clippy::needless_range_loop, clippy::unnecessary_wraps)] -fn instruction_sequence_apply_label_map( - instrs: &mut InstructionSequence, -) -> crate::InternalResult<()> { +fn instruction_sequence_apply_label_map(instrs: &mut InstructionSequence) { { let Some(label_map) = instrs.label_map.as_ref() else { - return Ok(()); + return; }; + for i in 0..instrs.instr_used { let entry = &mut instrs.instrs[i]; if entry.info.instr.has_target() { @@ -819,9 +817,9 @@ fn instruction_sequence_apply_label_map( } } } + instrs.label_map = None; instrs.label_map_allocation = 0; - Ok(()) } /// assemble.c instr_size @@ -870,10 +868,7 @@ const fn is_pseudo_target(pseudo: PseudoOpcode, target: Opcode) -> bool { } } /// assemble.c resolve_unconditional_jumps -#[allow(clippy::unnecessary_wraps)] -fn resolve_unconditional_jumps( - instr_sequence: &mut InstructionSequence, -) -> crate::InternalResult<()> { +fn resolve_unconditional_jumps(instr_sequence: &mut InstructionSequence) { for i in 0..instr_sequence.instr_used { let instr = &mut instr_sequence.instrs[i].info; let is_forward = (u32::from(instr.arg) as i32) > i as i32; @@ -910,12 +905,10 @@ fn resolve_unconditional_jumps( } } } - Ok(()) } /// assemble.c resolve_jump_offsets -#[allow(clippy::needless_range_loop, clippy::unnecessary_wraps)] -fn resolve_jump_offsets(instr_sequence: &mut InstructionSequence) -> crate::InternalResult<()> { +fn resolve_jump_offsets(instr_sequence: &mut InstructionSequence) { // The offset (in code units) of END_SEND from SEND in the yield-from sequence. const END_SEND_OFFSET: i32 = 5; for i in 0..instr_sequence.instr_used { @@ -976,8 +969,6 @@ fn resolve_jump_offsets(instr_sequence: &mut InstructionSequence) -> crate::Inte break; } } - - Ok(()) } struct AssembledCode { @@ -1082,7 +1073,6 @@ fn assemble_emit_instr( } /// assemble.c assemble_location_info -#[allow(clippy::needless_range_loop)] fn assemble_location_info( instr_sequence: &mut InstructionSequence, first_line: i32, @@ -1798,7 +1788,7 @@ impl Blocks { block_idx = self[block_idx].next; } - instruction_sequence_apply_label_map(instr_sequence)?; + instruction_sequence_apply_label_map(instr_sequence); Ok(()) } @@ -2094,7 +2084,7 @@ impl Blocks { } /// flowgraph.c remove_redundant_nops_and_pairs - fn remove_redundant_nops_and_pairs(&mut self) -> crate::InternalResult<()> { + fn remove_redundant_nops_and_pairs(&mut self) { let mut done = false; while !done { @@ -2103,7 +2093,7 @@ impl Blocks { let mut block_idx = BlockIdx::new(0); while block_idx != BlockIdx::NULL { - self.basicblock_remove_redundant_nops(block_idx)?; + self.basicblock_remove_redundant_nops(block_idx); if is_label(self[block_idx].cpython_label) { instr = None; } @@ -2150,7 +2140,6 @@ impl Blocks { block_idx = block.next; } } - Ok(()) } /// flowgraph.c calculate_stackdepth @@ -2244,7 +2233,6 @@ impl Blocks { } /// flowgraph.c remove_unused_consts - #[allow(clippy::needless_range_loop)] fn remove_unused_consts(&mut self, consts: &mut ConstantPool) -> crate::InternalResult<()> { let nconsts = consts.len(); if nconsts == 0 { @@ -2256,9 +2244,9 @@ impl Blocks { .try_reserve_exact(nconsts) .map_err(|_| InternalError::MalformedControlFlowGraph)?; index_map.resize(nconsts, 0isize); - for i in 1..nconsts { - index_map[i] = -1; - } + + index_map[1..nconsts].fill(-1); + // The first constant may be docstring; keep it always. index_map[0] = 0; @@ -2294,8 +2282,8 @@ impl Blocks { // Move all used consts to the beginning of the consts list. debug_assert!(n_used_consts < nconsts); - for i in 0..n_used_consts { - let old_index = index_map[i] as usize; + for (i, item) in index_map.iter().enumerate().take(n_used_consts) { + let old_index = *item as usize; debug_assert!(i <= old_index && old_index < nconsts); if i != old_index { let value = consts.constants[old_index].clone(); @@ -2312,20 +2300,18 @@ impl Blocks { .try_reserve_exact(nconsts) .map_err(|_| InternalError::MalformedControlFlowGraph)?; reverse_index_map.resize(nconsts, 0isize); - for i in 0..nconsts { - reverse_index_map[i] = -1; - } - for i in 0..n_used_consts { - let old_index = index_map[i]; - debug_assert!(old_index != -1); - let old_index = old_index as usize; + + reverse_index_map[..nconsts].fill(-1); + for (i, old_index) in index_map.iter().enumerate().take(n_used_consts) { + debug_assert!(*old_index != -1); + let old_index = *old_index as usize; debug_assert_eq!(reverse_index_map[old_index], -1); reverse_index_map[old_index] = i as isize; } block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { - let next_block = self[block_idx.idx()].next; + let next_block = self[block_idx].next; let block = &mut self[block_idx]; for i in 0..block.instruction_used { let instr = &mut block.instructions[i]; @@ -2342,7 +2328,7 @@ impl Blocks { } /// flowgraph.c insert_superinstructions - fn insert_superinstructions(&mut self) -> crate::InternalResult { + fn insert_superinstructions(&mut self) -> usize { let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { let next_block = self[block_idx].next; @@ -2389,18 +2375,17 @@ impl Blocks { block_idx = next_block; } - let res = self.remove_redundant_nops()?; + let res = self.remove_redundant_nops(); #[cfg(debug_assertions)] assert!(self.no_redundant_nops()); - Ok(res) + res } /// Mark exception handler target blocks. /// flowgraph.c mark_except_handlers - #[allow(clippy::unnecessary_wraps)] - pub(crate) fn mark_except_handlers(&mut self) -> crate::InternalResult<()> { + pub(crate) fn mark_except_handlers(&mut self) { #[cfg(debug_assertions)] { let mut block_idx = BlockIdx(0); @@ -2423,7 +2408,6 @@ impl Blocks { } block_idx = next; } - Ok(()) } /// flowgraph.c mark_cold (two-pass). @@ -2900,11 +2884,7 @@ impl Blocks { } /// flowgraph.c basicblock_remove_redundant_nops - #[allow(clippy::unnecessary_wraps)] - fn basicblock_remove_redundant_nops( - &mut self, - block_idx: BlockIdx, - ) -> crate::InternalResult { + fn basicblock_remove_redundant_nops(&mut self, block_idx: BlockIdx) -> usize { let mut dest = 0; let mut prev_lineno = -1i32; let instr_count = self[block_idx].instruction_used; @@ -2967,27 +2947,26 @@ impl Blocks { debug_assert!(dest <= instr_count); let num_removed = instr_count - dest; self[block_idx].instruction_used = dest; - Ok(num_removed) + num_removed } /// flowgraph.c remove_redundant_nops - #[allow(clippy::unnecessary_wraps)] - fn remove_redundant_nops(&mut self) -> crate::InternalResult { + fn remove_redundant_nops(&mut self) -> usize { let mut changes = 0; let mut current = BlockIdx(0); while current != BlockIdx::NULL { let next = self[current].next; - let change = self.basicblock_remove_redundant_nops(current)?; + let change = self.basicblock_remove_redundant_nops(current); changes += change; current = next; } - Ok(changes) + changes } /// flowgraph.c no_redundant_nops #[cfg(debug_assertions)] fn no_redundant_nops(&mut self) -> bool { - matches!(self.remove_redundant_nops(), Ok(0)) + self.remove_redundant_nops() == 0 } /// flowgraph.c remove_redundant_jumps @@ -3050,7 +3029,7 @@ impl Blocks { loop { // Convergence is guaranteed because the number of redundant jumps and // nops only decreases. - let removed_nops = self.remove_redundant_nops()?; + let removed_nops = self.remove_redundant_nops(); let removed_jumps = self.remove_redundant_jumps()?; if removed_nops + removed_jumps == 0 { break; @@ -3415,11 +3394,11 @@ impl CodeInfo { &mut self, info: InstructionInfo, ) -> crate::InternalResult<()> { - basicblock_addop(&mut self.blocks[self.current_block.idx()], info) + basicblock_addop(&mut self.blocks[self.current_block], info) } pub(crate) fn last_current_block_instr_mut(&mut self) -> Option<&mut InstructionInfo> { - basicblock_last_instr_mut(&mut self.blocks[self.current_block.idx()]) + basicblock_last_instr_mut(&mut self.blocks[self.current_block]) } pub(crate) fn set_last_instr_sequence_lineno_override(&mut self, lineno_override: i32) { @@ -3457,7 +3436,7 @@ impl CodeInfo { &mut self.instr_sequence, block, )?; - self.blocks[block.idx()].cpython_label = label; + self.blocks[block].cpython_label = label; Ok(()) } @@ -3535,20 +3514,21 @@ impl CodeInfo { instruction_sequence_label_map_push_unlabeled_block(&mut self.instr_sequence_label_map) } - fn take_recorded_instr_sequence(&mut self) -> crate::InternalResult { + fn take_recorded_instr_sequence(&mut self) -> InstructionSequence { let mut instr_sequence = core::mem::replace(&mut self.instr_sequence, instruction_sequence_new()); if let Some(mut annotations_instr_sequence) = self.annotations_instr_sequence.take() { - instruction_sequence_apply_label_map(&mut annotations_instr_sequence)?; + instruction_sequence_apply_label_map(&mut annotations_instr_sequence); instruction_sequence_set_annotations_code( &mut instr_sequence, Some(Box::new(annotations_instr_sequence)), ); } - Ok(instr_sequence) + + instr_sequence } - fn prepare_cfg_from_codegen(&mut self) -> crate::InternalResult { + fn prepare_cfg_from_codegen(&mut self) -> InstructionSequence { // compile.c optimize_and_assemble_code_unit passes // u_instr_sequence directly into flowgraph.c _PyCfg_FromInstructionSequence(). self.take_recorded_instr_sequence() @@ -3565,7 +3545,7 @@ fn optimize_code_unit( // Phase 1: _PyCfg_OptimizeCodeUnit (flowgraph.c) *blocks = cfg_from_instruction_sequence(instr_sequence)?; translate_jump_labels_to_targets(blocks)?; - blocks.mark_except_handlers()?; + blocks.mark_except_handlers(); label_exception_targets(blocks)?; optimize_cfg(metadata, blocks, metadata.firstlineno)?; blocks.remove_unused_consts(&mut metadata.consts)?; @@ -3573,7 +3553,7 @@ fn optimize_code_unit( // Superinstructions are inserted in _PyCfg_OptimizeCodeUnit, before // later jump normalization / block reordering can create adjacencies // that never exist at this stage in flowgraph.c. - blocks.insert_superinstructions()?; + blocks.insert_superinstructions(); blocks.push_cold_blocks_to_end()?; // Line numbers are resolved again after cold-block extraction. blocks.resolve_line_numbers(metadata.firstlineno)?; @@ -3608,7 +3588,7 @@ fn optimize_cfg( blocks.optimize_basic_block(metadata, block_idx)?; block_idx = next_block; } - blocks.remove_redundant_nops_and_pairs()?; + blocks.remove_redundant_nops_and_pairs(); // optimize_cfg() removes newly-unreachable blocks and // redundant NOP/jump chains before _PyCfg_OptimizeCodeUnit() prunes // unused constants. @@ -3648,7 +3628,7 @@ impl CodeInfo { mut self, opts: &crate::compile::CompileOpts, ) -> crate::InternalResult { - let instr_sequence = self.prepare_cfg_from_codegen()?; + let instr_sequence = self.prepare_cfg_from_codegen(); let nlocals = self.metadata.varnames.len(); let nparams = self.nparams; optimize_code_unit( @@ -3701,8 +3681,8 @@ impl CodeInfo { .checked_add(arg_count) .ok_or(InternalError::MalformedControlFlowGraph)?; - resolve_unconditional_jumps(&mut instr_sequence)?; - resolve_jump_offsets(&mut instr_sequence)?; + resolve_unconditional_jumps(&mut instr_sequence); + resolve_jump_offsets(&mut instr_sequence); let assembled = assemble_emit( &mut instr_sequence, first_line_number.get() as i32, @@ -5371,7 +5351,7 @@ fn optimize_load_const( ) -> crate::InternalResult<()> { let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { - let next_block = blocks[block_idx.idx()].next; + let next_block = blocks[block_idx].next; let block = &mut blocks[block_idx]; basicblock_optimize_load_const(metadata, block)?; block_idx = next_block; @@ -5386,7 +5366,7 @@ impl CodeInfo { let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { use core::fmt::Write; - let block = &self.blocks[block_idx.idx()]; + let block = &self.blocks[block_idx]; let block_return = if basicblock_returns(block) { " return" } else { @@ -5441,14 +5421,14 @@ impl CodeInfo { let mut trace = Vec::new(); trace.push(("initial".to_owned(), self.debug_block_dump())); - let instr_sequence = self.prepare_cfg_from_codegen()?; + let instr_sequence = self.prepare_cfg_from_codegen(); self.blocks = cfg_from_instruction_sequence(instr_sequence)?; trace.push(( "after_cfg_from_instruction_sequence".to_owned(), self.debug_block_dump(), )); translate_jump_labels_to_targets(&mut self.blocks)?; - self.blocks.mark_except_handlers()?; + self.blocks.mark_except_handlers(); label_exception_targets(&mut self.blocks)?; self.blocks.check_cfg()?; self.blocks.inline_small_or_no_lineno_blocks()?; @@ -5475,7 +5455,7 @@ impl CodeInfo { "after_optimize_basic_block".to_owned(), self.debug_block_dump(), )); - self.blocks.remove_redundant_nops_and_pairs()?; + self.blocks.remove_redundant_nops_and_pairs(); self.blocks.remove_unreachable()?; self.blocks.remove_redundant_nops_and_jumps()?; @@ -5491,7 +5471,7 @@ impl CodeInfo { let nlocals = self.metadata.varnames.len(); let nparams = self.nparams; add_checks_for_loads_of_uninitialized_variables(&mut self.blocks, nlocals, nparams)?; - self.blocks.insert_superinstructions()?; + self.blocks.insert_superinstructions(); self.blocks.push_cold_blocks_to_end()?; trace.push(( "after_push_cold_before_chain_reorder".to_owned(), @@ -5711,8 +5691,7 @@ fn stackdepth_push( target: BlockIdx, depth: i32, ) -> crate::InternalResult<()> { - let idx = target.idx(); - let block_depth = &mut blocks[idx].start_depth; + let block_depth = &mut blocks[target].start_depth; if !(*block_depth < 0 || *block_depth == depth) { return Err(InternalError::InconsistentStackDepth); } @@ -5731,7 +5710,6 @@ struct StackEffects { } /// flowgraph.c get_stack_effects -#[allow(clippy::unnecessary_wraps)] fn get_stack_effects( instr: AnyInstruction, oparg: OpArg, @@ -6089,8 +6067,8 @@ struct CfgBuilder { /// flowgraph.c cfg_builder_new_block fn cfg_builder_new_block(g: &mut CfgBuilder) -> crate::InternalResult { let block = g.blocks.blocks_new_block()?; - g.blocks[block.idx()].allocation_next = g.block_list; - g.blocks[block.idx()].cpython_label = InstructionSequenceLabel::NO_LABEL; + g.blocks[block].allocation_next = g.block_list; + g.blocks[block].cpython_label = InstructionSequenceLabel::NO_LABEL; g.block_list = block; Ok(block) } @@ -6098,7 +6076,7 @@ fn cfg_builder_new_block(g: &mut CfgBuilder) -> crate::InternalResult /// flowgraph.c cfg_builder_use_next_block fn cfg_builder_use_next_block(g: &mut CfgBuilder, block: BlockIdx) -> BlockIdx { debug_assert!(block != BlockIdx::NULL); - g.blocks[g.current.idx()].next = block; + g.blocks[g.current].next = block; g.current = block; block } @@ -6128,7 +6106,7 @@ fn cfg_builder_new() -> crate::InternalResult { /// flowgraph.c cfg_builder_current_block_is_terminated fn cfg_builder_current_block_is_terminated(g: &mut CfgBuilder) -> bool { - let block = &mut g.blocks[g.current.idx()]; + let block = &mut g.blocks[g.current]; let last = basicblock_last_instr(block).copied(); if last.is_some_and(|last| last.instr.is_terminator()) { return true; @@ -6147,7 +6125,7 @@ fn cfg_builder_current_block_is_terminated(g: &mut CfgBuilder) -> bool { fn cfg_builder_maybe_start_new_block(g: &mut CfgBuilder) -> crate::InternalResult<()> { if cfg_builder_current_block_is_terminated(g) { let block = cfg_builder_new_block(g)?; - g.blocks[block.idx()].cpython_label = g.current_label; + g.blocks[block].cpython_label = g.current_label; g.current_label = InstructionSequenceLabel::NO_LABEL; cfg_builder_use_next_block(g, block); } @@ -6172,11 +6150,11 @@ fn cfg_builder_addop(g: &mut CfgBuilder, info: InstructionInfo) -> crate::Intern /// flowgraph.c cfg_builder_check fn cfg_builder_check(g: &CfgBuilder) -> bool { debug_assert!(g.entry != BlockIdx::NULL); - debug_assert!(g.blocks[g.entry.idx()].instruction_used != 0); + debug_assert!(g.blocks[g.entry].instruction_used != 0); let mut block = g.block_list; while block != BlockIdx::NULL { debug_assert!(block.idx() < g.blocks.len()); - let block_ref = &g.blocks[block.idx()]; + let block_ref = &g.blocks[block]; let has_instr_array = block_ref.instruction_allocation > 0; if has_instr_array { debug_assert!(block_ref.instruction_allocation > 0); @@ -6204,7 +6182,7 @@ fn cfg_builder_check_size(g: &CfgBuilder) -> crate::InternalResult<()> { while block != BlockIdx::NULL { debug_assert!(block.idx() < g.blocks.len()); nblocks += 1; - block = g.blocks[block.idx()].allocation_next; + block = g.blocks[block].allocation_next; } debug_assert_eq!(nblocks, g.blocks.len()); if nblocks > usize::MAX / core::mem::size_of::() { @@ -6259,7 +6237,7 @@ fn translate_jump_labels_to_targets(blocks: &mut Blocks) -> crate::InternalResul fn cfg_from_instruction_sequence( mut instr_sequence: InstructionSequence, ) -> crate::InternalResult { - instruction_sequence_apply_label_map(&mut instr_sequence)?; + instruction_sequence_apply_label_map(&mut instr_sequence); let mut builder = cfg_builder_new()?; for i in 0..instr_sequence.instr_used { @@ -6344,13 +6322,12 @@ fn maybe_push( ) { debug_assert!(block != BlockIdx::NULL); - let idx = block.idx(); - let both = blocks[idx].unsafe_locals_mask | unsafe_mask; - if blocks[idx].unsafe_locals_mask != both { - blocks[idx].unsafe_locals_mask = both; - if !blocks[idx].visited { + let both = blocks[block].unsafe_locals_mask | unsafe_mask; + if blocks[block].unsafe_locals_mask != both { + blocks[block].unsafe_locals_mask = both; + if !blocks[block].visited { worklist.push(block); - blocks[idx].visited = true; + blocks[block].visited = true; } } } @@ -6437,8 +6414,8 @@ fn fast_scan_many_locals(blocks: &mut Blocks, nlocals: usize) -> crate::Internal let mut current = BlockIdx(0); while current != BlockIdx::NULL { blocknum += 1; - for i in 0..blocks[current.idx()].instruction_used { - let info = &mut blocks[current.idx()].instructions[i]; + for i in 0..blocks[current].instruction_used { + let info = &mut blocks[current].instructions[i]; debug_assert!(!matches!(info.instr.real(), Some(Instruction::ExtendedArg))); let arg = u32::from(info.arg) as usize; if arg < LOCAL_UNSAFE_MASK_BITS { @@ -6467,7 +6444,7 @@ fn fast_scan_many_locals(blocks: &mut Blocks, nlocals: usize) -> crate::Internal _ => {} } } - current = blocks[current.idx()].next; + current = blocks[current].next; } Ok(()) } @@ -6497,11 +6474,11 @@ fn add_checks_for_loads_of_uninitialized_variables( let mut current = BlockIdx(0); while current != BlockIdx::NULL { scan_block_for_locals(blocks, current, &mut worklist); - current = blocks[current.idx()].next; + current = blocks[current].next; } while let Some(block_idx) = worklist.pop() { - blocks[block_idx.idx()].visited = false; + blocks[block_idx].visited = false; scan_block_for_locals(blocks, block_idx, &mut worklist); } Ok(()) @@ -6613,9 +6590,9 @@ fn get_max_label(blocks: &Blocks) -> i32 { let mut lbl = -1; let mut current = BlockIdx(0); while current != BlockIdx::NULL { - let cpython_label = blocks[current.idx()].cpython_label; + let cpython_label = blocks[current].cpython_label; lbl = lbl.max(cpython_label.0); - current = blocks[current.idx()].next; + current = blocks[current].next; } lbl } @@ -6794,8 +6771,8 @@ pub(crate) fn label_exception_targets(blocks: &mut Blocks) -> crate::InternalRes pub(crate) fn convert_pseudo_ops(blocks: &mut Blocks) -> crate::InternalResult<()> { let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { - let next = blocks[block_idx.idx()].next; - let block = &mut blocks[block_idx.idx()]; + let next = blocks[block_idx].next; + let block = &mut blocks[block_idx]; for i in 0..block.instruction_used { let info = &mut block.instructions[i]; if is_block_push(info) { @@ -6828,7 +6805,6 @@ pub(crate) fn convert_pseudo_ops(blocks: &mut Blocks) -> crate::InternalResult<( } /// flowgraph.c build_cellfixedoffsets -#[allow(clippy::needless_range_loop)] pub(crate) fn build_cellfixedoffsets( metadata: &CodeUnitMetadata, ) -> crate::InternalResult> { @@ -6839,24 +6815,25 @@ pub(crate) fn build_cellfixedoffsets( let mut fixed = Vec::new(); vec_try_reserve_exact(&mut fixed, noffsets)?; fixed.resize(noffsets, 0); - for i in 0..noffsets { - fixed[i] = (nlocals + i) as i32; + + for (i, item) in fixed.iter_mut().enumerate().take(noffsets) { + *item = (nlocals + i) as i32; } - for oldindex in 0..ncellvars { + + for (oldindex, cell) in fixed.iter_mut().enumerate().take(ncellvars) { let varname = metadata .cellvars .get_index(oldindex) .expect("cellvar index is in range"); if let Some(varindex) = metadata.varnames.get_index_of(varname) { let argoffset = varindex as i32; - fixed[oldindex] = argoffset; + *cell = argoffset; } } Ok(fixed) } /// flowgraph.c fix_cell_offsets -#[allow(clippy::needless_range_loop)] pub(crate) fn fix_cell_offsets( metadata: &CodeUnitMetadata, blocks: &mut Blocks, @@ -6869,9 +6846,9 @@ pub(crate) fn fix_cell_offsets( debug_assert_eq!(cellfixedoffsets.len(), noffsets); let mut numdropped = 0usize; - for i in 0..noffsets { - if cellfixedoffsets[i] == (i + nlocals) as i32 { - cellfixedoffsets[i] -= numdropped as i32; + for (i, cell) in cellfixedoffsets.iter_mut().enumerate().take(noffsets) { + if *cell == (i + nlocals) as i32 { + *cell -= numdropped as i32; } else { numdropped += 1; } @@ -6879,8 +6856,8 @@ pub(crate) fn fix_cell_offsets( let mut block_idx = BlockIdx(0); while block_idx != BlockIdx::NULL { - let next = blocks[block_idx.idx()].next; - let block = &mut blocks[block_idx.idx()]; + let next = blocks[block_idx].next; + let block = &mut blocks[block_idx]; for i in 0..block.instruction_used { let inst = &mut block.instructions[i]; debug_assert!( From 33b8c106cfe440fc8d2db61226159d447d061459 Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:31:15 +0200 Subject: [PATCH 044/351] Add more int functions to the c-api (#8159) * Add more int functions to the c-api * Simplify as_mask logic * Add `PyLong_FromNativeBytes` & `PyLong_FromUnsignedNativeBytes` --- .cspell.dict/cpython.txt | 1 + Cargo.lock | 1 + crates/capi/Cargo.toml | 1 + crates/capi/src/longobject.rs | 328 +++++++++++++++++++++++++++++++++- crates/capi/src/util.rs | 75 +++++++- crates/vm/src/builtins/int.rs | 30 ++-- 6 files changed, 417 insertions(+), 19 deletions(-) diff --git a/.cspell.dict/cpython.txt b/.cspell.dict/cpython.txt index e5b31b57f15..11440e30f8a 100644 --- a/.cspell.dict/cpython.txt +++ b/.cspell.dict/cpython.txt @@ -4,6 +4,7 @@ argdefs argtypes asdl asname +ASNATIVEBYTES atopen atext attro diff --git a/Cargo.lock b/Cargo.lock index e6a4194a056..740ce5f09fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3448,6 +3448,7 @@ dependencies = [ "bitflags 2.13.0", "itertools 0.14.0", "libc", + "malachite-bigint", "num-complex", "pyo3", "rustpython-pylib", diff --git a/crates/capi/Cargo.toml b/crates/capi/Cargo.toml index 878620ebe23..e408d7ab0fe 100644 --- a/crates/capi/Cargo.toml +++ b/crates/capi/Cargo.toml @@ -15,6 +15,7 @@ crate-type = ["cdylib", "rlib"] bitflags = { workspace = true } itertools = { workspace = true } libc = { workspace = true } +malachite-bigint = { workspace = true } num-complex = { workspace = true } rustpython-vm = { workspace = true, features = ["threading", "compiler", "importlib", "host_env"] } rustpython-stdlib = {workspace = true, features = ["threading"] } diff --git a/crates/capi/src/longobject.rs b/crates/capi/src/longobject.rs index 8c9fe5e1acb..0668f8df643 100644 --- a/crates/capi/src/longobject.rs +++ b/crates/capi/src/longobject.rs @@ -1,9 +1,13 @@ use crate::PyObject; use crate::object::define_py_check; use crate::pystate::with_vm; -use core::ffi::{c_long, c_longlong, c_ulong, c_ulonglong}; -use rustpython_vm::PyResult; -use rustpython_vm::builtins::PyInt; +use bitflags::bitflags; +use core::ffi::{CStr, c_char, c_double, c_int, c_long, c_longlong, c_ulong, c_ulonglong, c_void}; +use malachite_bigint::{BigInt, Sign}; +use rustpython_vm::builtins::{PyInt, try_bigint_to_f64, try_f64_to_bigint}; +use rustpython_vm::common::int::bytes_to_int; +use rustpython_vm::protocol::handle_bytes_to_int_err; +use rustpython_vm::{AsObject, PyResult, VirtualMachine}; define_py_check!(fn PyLong_Check, types.int_type); define_py_check!(exact fn PyLong_CheckExact, types.int_type); @@ -38,6 +42,150 @@ pub extern "C" fn PyLong_FromUnsignedLongLong(value: c_ulonglong) -> *mut PyObje with_vm(|vm| vm.ctx.new_int(value)) } +#[unsafe(no_mangle)] +pub extern "C" fn PyLong_FromDouble(value: c_double) -> *mut PyObject { + with_vm(|vm| Ok(vm.ctx.new_bigint(&try_f64_to_bigint(value, vm)?))) +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyLong_FromInt32(value: i32) -> *mut PyObject { + with_vm(|vm| vm.ctx.new_int(value)) +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyLong_FromInt64(value: i64) -> *mut PyObject { + with_vm(|vm| vm.ctx.new_int(value)) +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyLong_FromUInt32(value: u32) -> *mut PyObject { + with_vm(|vm| vm.ctx.new_int(value)) +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyLong_FromUInt64(value: u64) -> *mut PyObject { + with_vm(|vm| vm.ctx.new_int(value)) +} + +bitflags! { + #[derive(Clone, Copy)] + struct AsNativeBytesFlags: c_int { + const BIG_ENDIAN = 0; + const LITTLE_ENDIAN = 1; + const NATIVE_ENDIAN = 3; + const UNSIGNED_BUFFER = 4; + const REJECT_NEGATIVE = 8; + const ALLOW_INDEX = 16; + } +} + +impl AsNativeBytesFlags { + #[inline] + fn is_little_endian(self) -> bool { + if self.contains(Self::NATIVE_ENDIAN) { + cfg!(target_endian = "little") + } else { + self.contains(Self::LITTLE_ENDIAN) + } + } + + fn from_bits_or_default(vm: &VirtualMachine, raw_flags: c_int) -> PyResult { + const PY_ASNATIVEBYTES_DEFAULTS: c_int = -1; + if raw_flags == PY_ASNATIVEBYTES_DEFAULTS { + return Ok(Self::default()); + }; + + let flags = Self::from_bits(raw_flags) + .ok_or_else(|| vm.new_value_error("Invalid NativeBytes flags"))?; + if flags.contains(Self::LITTLE_ENDIAN) & flags.contains(Self::NATIVE_ENDIAN) { + Err(vm.new_value_error("Cannot specify both LITTLE_ENDIAN and NATIVE_ENDIAN")) + } else { + Ok(flags) + } + } +} + +impl Default for AsNativeBytesFlags { + fn default() -> Self { + Self::NATIVE_ENDIAN | Self::UNSIGNED_BUFFER + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyLong_FromNativeBytes( + buffer: *const c_void, + n_bytes: usize, + flags: c_int, +) -> *mut PyObject { + with_vm(|vm| { + let flags = AsNativeBytesFlags::from_bits_or_default(vm, flags)?; + let little_endian = flags.is_little_endian(); + let bytes = unsafe { core::slice::from_raw_parts(buffer.cast::(), n_bytes) }; + + let value = if flags.contains(AsNativeBytesFlags::UNSIGNED_BUFFER) { + if little_endian { + BigInt::from_bytes_le(Sign::Plus, bytes) + } else { + BigInt::from_bytes_be(Sign::Plus, bytes) + } + } else if little_endian { + BigInt::from_signed_bytes_le(bytes) + } else { + BigInt::from_signed_bytes_be(bytes) + }; + + Ok(vm.ctx.new_bigint(&value)) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyLong_FromUnsignedNativeBytes( + buffer: *const c_void, + n_bytes: usize, + flags: c_int, +) -> *mut PyObject { + with_vm(|vm| { + let flags = AsNativeBytesFlags::from_bits_or_default(vm, flags)?; + let bytes = unsafe { core::slice::from_raw_parts(buffer.cast::(), n_bytes) }; + + let value = if flags.is_little_endian() { + BigInt::from_bytes_le(Sign::Plus, bytes) + } else { + BigInt::from_bytes_be(Sign::Plus, bytes) + }; + + Ok(vm.ctx.new_bigint(&value)) + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyLong_FromVoidPtr(ptr: *mut c_void) -> *mut PyObject { + with_vm(|vm| vm.ctx.new_int(ptr as usize)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyLong_FromString( + str: *const c_char, + pend: *mut *mut c_char, + base: c_int, +) -> *mut PyObject { + with_vm(|vm| { + let bytes = unsafe { CStr::from_ptr(str) }.to_bytes(); + let parsed = bytes_to_int(bytes, base as u32, vm.state.int_max_str_digits.load()) + .map(|value| vm.ctx.new_bigint(&value)); + + if let Some(pend) = unsafe { pend.as_mut() } { + let end_offset = if parsed.is_ok() { bytes.len() } else { 0 }; + unsafe { *pend = bytes.as_ptr().add(end_offset).cast_mut().cast() }; + } + + parsed.map_err(|err| { + let obj = vm.ctx.new_bytes(bytes.to_vec()); + handle_bytes_to_int_err(err, obj.as_object(), vm) + }) + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyLong_AsLong(obj: *mut PyObject) -> c_long { with_vm::, _>(|vm| { @@ -50,12 +198,173 @@ pub unsafe extern "C" fn PyLong_AsLong(obj: *mut PyObject) -> c_long { }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyLong_AsDouble(obj: *mut PyObject) -> c_double { + with_vm::, _>(|vm| { + let int = unsafe { &*obj }.try_downcast_ref::(vm)?; + try_bigint_to_f64(int.as_bigint(), vm) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyLong_AsInt(obj: *mut PyObject) -> c_int { + with_vm::, _>(|vm| { + unsafe { &*obj } + .to_owned() + .try_index(vm)? + .as_bigint() + .try_into() + .map_err(|_| vm.new_overflow_error("Python int too large to convert to C int")) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyLong_AsInt32(obj: *mut PyObject, out: *mut i32) -> c_int { + with_vm(|vm| { + let value: i32 = unsafe { &*obj } + .to_owned() + .try_index(vm)? + .as_bigint() + .try_into() + .map_err(|_| vm.new_overflow_error("Python int too large to convert to int32_t"))?; + unsafe { *out = value }; + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyLong_AsInt64(obj: *mut PyObject, out: *mut i64) -> c_int { + with_vm(|vm| { + let value: i64 = unsafe { &*obj } + .to_owned() + .try_index(vm)? + .as_bigint() + .try_into() + .map_err(|_| vm.new_overflow_error("Python int too large to convert to int64_t"))?; + unsafe { *out = value }; + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyLong_AsLongLong(obj: *mut PyObject) -> c_longlong { + with_vm::, _>(|vm| { + unsafe { &*obj } + .to_owned() + .try_index(vm)? + .as_bigint() + .try_into() + .map_err(|_| vm.new_overflow_error("Python int too large to convert to C long long")) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyLong_AsSize_t(obj: *mut PyObject) -> usize { + with_vm::, _>(|vm| { + let value: usize = unsafe { &*obj } + .try_downcast_ref::(vm)? + .as_bigint() + .try_into() + .map_err(|_| vm.new_overflow_error("Python int too large to convert to C size_t"))?; + Ok(value) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyLong_AsSsize_t(obj: *mut PyObject) -> isize { + with_vm::, _>(|vm| { + unsafe { &*obj } + .try_downcast_ref::(vm)? + .as_bigint() + .try_into() + .map_err(|_| vm.new_overflow_error("Python int too large to convert to C ssize_t")) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyLong_AsUInt32(obj: *mut PyObject, out: *mut u32) -> c_int { + with_vm(|vm| { + let value: u32 = unsafe { &*obj } + .to_owned() + .try_index(vm)? + .as_bigint() + .try_into() + .map_err(|_| vm.new_overflow_error("Python int too large to convert to uint32_t"))?; + unsafe { *out = value }; + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyLong_AsUInt64(obj: *mut PyObject, out: *mut u64) -> c_int { + with_vm(|vm| { + let value: u64 = unsafe { &*obj } + .to_owned() + .try_index(vm)? + .as_bigint() + .try_into() + .map_err(|_| vm.new_overflow_error("Python int too large to convert to uint64_t"))?; + unsafe { *out = value }; + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyLong_AsUnsignedLong(obj: *mut PyObject) -> c_ulong { + with_vm::, _>(|vm| { + unsafe { &*obj } + .try_downcast_ref::(vm)? + .as_bigint() + .try_into() + .map_err(|_| { + vm.new_overflow_error("Python int too large to convert to C unsigned long") + }) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyLong_AsUnsignedLongMask(obj: *mut PyObject) -> c_ulong { + with_vm::, _>(|vm| { + let int = unsafe { &*obj }.to_owned().try_index(vm)?; + if const { c_ulong::BITS == 32 } { + Ok(c_ulong::from(int.as_u32_mask())) + } else { + Ok(int.as_u64_mask() as c_ulong) + } + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyLong_AsUnsignedLongLongMask(obj: *mut PyObject) -> c_ulonglong { + with_vm::, _>(|vm| { + let int = unsafe { &*obj }.to_owned().try_index(vm)?; + Ok(int.as_u64_mask()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyLong_AsVoidPtr(obj: *mut PyObject) -> *mut c_void { + with_vm(|vm| { + let value = unsafe { &*obj }.try_downcast_ref::(vm)?; + + let unsigned: Result = value.as_bigint().try_into(); + if let Ok(v) = unsigned { + return Ok(v as *mut c_void); + } + let signed: Result = value.as_bigint().try_into(); + if let Ok(v) = signed { + return Ok((v as usize) as *mut c_void); + } + + Err(vm.new_overflow_error("int too large to convert to pointer")) + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyLong_AsUnsignedLongLong(obj: *mut PyObject) -> c_ulonglong { with_vm::, _>(|vm| { unsafe { &*obj } - .to_owned() - .try_downcast::(vm)? + .try_downcast_ref::(vm)? .as_bigint() .try_into() .map_err(|_| { @@ -86,4 +395,13 @@ mod tests { assert_eq!(number.extract::().unwrap(), 123); }) } + + #[test] + fn py_int_u128() { + Python::attach(|py| { + let value = 1u128 << 100; + let number = PyInt::new(py, value); + assert_eq!(number.extract::().unwrap(), value); + }) + } } diff --git a/crates/capi/src/util.rs b/crates/capi/src/util.rs index 4061a8f370d..ccb8ee254af 100644 --- a/crates/capi/src/util.rs +++ b/crates/capi/src/util.rs @@ -1,6 +1,6 @@ use crate::PyObject; use core::convert::Infallible; -use core::ffi::{c_char, c_double, c_int, c_long, c_ulonglong, c_void}; +use core::ffi::{c_char, c_double, c_int, c_long, c_ulong, c_void}; use rustpython_vm::{PyObjectRef, PyRef, PyResult, VirtualMachine}; pub(crate) trait FfiResult { @@ -109,6 +109,23 @@ impl FfiResult for isize { } } +#[cfg(not(windows))] +impl FfiResult for c_int { + const ERR_VALUE: Self = -1; + + fn into_output(self, _vm: &VirtualMachine) -> Self { + self + } +} + +impl FfiResult for usize { + const ERR_VALUE: Self = Self::MAX; + + fn into_output(self, _vm: &VirtualMachine) -> Self { + self + } +} + impl FfiResult for c_long { const ERR_VALUE: Self = -1; @@ -117,7 +134,25 @@ impl FfiResult for c_long { } } -impl FfiResult for c_ulonglong { +impl FfiResult for c_ulong { + const ERR_VALUE: Self = Self::MAX; + + fn into_output(self, _vm: &VirtualMachine) -> Self { + self + } +} + +#[cfg(windows)] +impl FfiResult for core::ffi::c_longlong { + const ERR_VALUE: Self = -1; + + fn into_output(self, _vm: &VirtualMachine) -> Self { + self + } +} + +#[cfg(windows)] +impl FfiResult for core::ffi::c_ulonglong { const ERR_VALUE: Self = Self::MAX; fn into_output(self, _vm: &VirtualMachine) -> Self { @@ -167,3 +202,39 @@ where ) } } + +#[cfg(test)] +mod tests { + use super::*; + use core::any::type_name; + use core::ffi::{c_longlong, c_ulonglong}; + use core::fmt::Debug; + + #[test] + fn ffi_result_err_value() { + fn assert_error_value(value: Output) + where + T: FfiResult + 'static, + Output: PartialEq + Debug, + { + assert_eq!(value, T::ERR_VALUE, "{}", type_name::(),); + } + + assert_error_value::<(), _>(()); + assert_error_value::<(), c_int>(-1); + + assert_error_value::(-1); + assert_error_value::(usize::MAX); + assert_error_value::(-1); + assert_error_value::(-1); // i32 + assert_error_value::(-1); //Windows i32, unix i64 + assert_error_value::(c_ulong::MAX); // Windows u32, unix u64 + assert_error_value::(-1); // i64 + assert_error_value::(c_ulonglong::MAX); // u64 + assert_error_value::(-1.0); + assert_error_value::(-1); + + assert_error_value::, _>(-1); + assert_error_value::, _>(usize::MAX); + } +} diff --git a/crates/vm/src/builtins/int.rs b/crates/vm/src/builtins/int.rs index 198c2765cdc..b9246149731 100644 --- a/crates/vm/src/builtins/int.rs +++ b/crates/vm/src/builtins/int.rs @@ -319,18 +319,24 @@ impl PyInt { #[must_use] pub fn as_u32_mask(&self) -> u32 { let v = self.as_bigint(); - v.to_u32() - .or_else(|| v.to_i32().map(|i| i as u32)) - .unwrap_or_else(|| { - let mut out = 0u32; - for digit in v.iter_u32_digits() { - out = out.wrapping_shl(32) | digit; - } - match v.sign() { - Sign::Minus => out * -1i32 as u32, - _ => out, - } - }) + let out = v.iter_u32_digits().next().unwrap_or(0); + match v.sign() { + Sign::Minus => out.wrapping_neg(), + _ => out, + } + } + + // _PyLong_AsUnsignedLongLongMask + #[must_use] + pub fn as_u64_mask(&self) -> u64 { + let v = self.as_bigint(); + let mut digits = v.iter_u32_digits(); + let out = + u64::from(digits.next().unwrap_or(0)) | (u64::from(digits.next().unwrap_or(0)) << 32); + match v.sign() { + Sign::Minus => out.wrapping_neg(), + _ => out, + } } pub fn try_to_primitive<'a, I>(&'a self, vm: &VirtualMachine) -> PyResult From ba23db2d425ce427d04026b485f1e2b97bd41d1b Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:32:30 +0300 Subject: [PATCH 045/351] Uncomment codegen crashing tests (#8175) * Update `test_patma.py` to 3.14.6 * Update `test_listcomps.py` * Align patches of `test_super.py` * Align `test_grammar.py` * Align patches for `test_pep646_syntax.py` --- Lib/test/test_grammar.py | 3 +-- Lib/test/test_listcomps.py | 4 +--- Lib/test/test_patma.py | 26 +++++++++++++++++--------- Lib/test/test_pep646_syntax.py | 3 +-- Lib/test/test_super.py | 4 ++-- 5 files changed, 22 insertions(+), 18 deletions(-) diff --git a/Lib/test/test_grammar.py b/Lib/test/test_grammar.py index 19440b10115..cfb24a5c457 100644 --- a/Lib/test/test_grammar.py +++ b/Lib/test/test_grammar.py @@ -1723,8 +1723,7 @@ class G: pass class H: pass @d := class_decorator class I: pass - # TODO: RUSTPYTHON; SyntaxError: the symbol 'class_decorator' must be present in the symbol table - # @lambda c: class_decorator(c) + @lambda c: class_decorator(c) class J: pass @[..., class_decorator, ...][1] class K: pass diff --git a/Lib/test/test_listcomps.py b/Lib/test/test_listcomps.py index 5e09fad72d8..e76169c69df 100644 --- a/Lib/test/test_listcomps.py +++ b/Lib/test/test_listcomps.py @@ -206,11 +206,9 @@ class i: [__classdict__ for x in y] """ self._check_in_scopes(code, raises=NameError) - @unittest.expectedFailure # TODO: RUSTPYTHON; SyntaxError: compiler_make_closure: cannot find '__classdict__' in parent vars def test_references___classdict___nested(self): class _C: - # res = [(lambda: __classdict__)() for _ in [1]] # TODO: RUSTPYTHON - pass # TODO: RUSTPYTHON + res = [(lambda: __classdict__)() for _ in [1]] self.assertIn("res", _C.res[0]) def test_references___conditional_annotations__(self): diff --git a/Lib/test/test_patma.py b/Lib/test/test_patma.py index 5a06972fdde..5d0857b059e 100644 --- a/Lib/test/test_patma.py +++ b/Lib/test/test_patma.py @@ -6,6 +6,7 @@ import inspect import sys import unittest +from test import support @dataclasses.dataclass @@ -2563,15 +2564,14 @@ def test_patma_240(self): self.assertEqual(y, 0) self.assertEqual(z, {0: 1}) - # TODO: RUSTPYTHON - # def test_patma_241(self): - # x = [[{0: 0}]] - # match x: - # case list([({-0-0j: int(real=0+0j, imag=0-0j) | (1) as z},)]): - # y = 0 - # self.assertEqual(x, [[{0: 0}]]) - # self.assertEqual(y, 0) - # self.assertEqual(z, 0) + def test_patma_241(self): + x = [[{0: 0}]] + match x: + case list([({-0-0j: int(real=0+0j, imag=0-0j) | (1) as z},)]): + y = 0 + self.assertEqual(x, [[{0: 0}]]) + self.assertEqual(y, 0) + self.assertEqual(z, 0) def test_patma_242(self): x = range(3) @@ -3016,6 +3016,13 @@ def test_multiple_assignments_to_name_in_pattern_5(self): pass """) + def test_multiple_assignments_to_name_in_pattern_6(self): + self.assert_syntax_error(""" + match ...: + case a as a + 1: # NAME and expression with no () + pass + """) + def test_multiple_starred_names_in_sequence_pattern_0(self): self.assert_syntax_error(""" match ...: @@ -3492,6 +3499,7 @@ def f(command): # 0 self.assertListEqual(self._trace(f, 1), [1, 2, 3]) self.assertListEqual(self._trace(f, 0), [1, 2, 5, 6]) + @support.skip_wasi_stack_overflow() def test_parser_deeply_nested_patterns(self): # Deeply nested patterns can cause exponential backtracking when parsing. # See gh-93671 for more information. diff --git a/Lib/test/test_pep646_syntax.py b/Lib/test/test_pep646_syntax.py index d79196219fe..aac089b190b 100644 --- a/Lib/test/test_pep646_syntax.py +++ b/Lib/test/test_pep646_syntax.py @@ -321,8 +321,7 @@ __test__ = {'doctests' : doctests} def load_tests(loader, tests, pattern): - from test.support.rustpython import DocTestChecker # TODO: RUSTPYTHON - tests.addTest(doctest.DocTestSuite(checker=DocTestChecker())) # TODO: RUSTPYTHON + tests.addTest(doctest.DocTestSuite()) return tests diff --git a/Lib/test/test_super.py b/Lib/test/test_super.py index b53e38c77f4..4d338bbbc5a 100644 --- a/Lib/test/test_super.py +++ b/Lib/test/test_super.py @@ -112,7 +112,7 @@ def f(): __class__""", globals(), {}) self.assertIs(type(e.exception), NameError) # Not UnboundLocalError class X: - # global __class__ # TODO: RUSTPYTHON; SyntaxError: name '__class__' is assigned to before global declaration + global __class__ __class__ = 42 def f(): __class__ @@ -120,7 +120,7 @@ def f(): del globals()["__class__"] self.assertNotIn("__class__", X.__dict__) class X: - # nonlocal __class__ # TODO: RUSTPYTHON; SyntaxError: name '__class__' is assigned to before nonlocal declaration + nonlocal __class__ __class__ = 42 def f(): __class__ From e831e24865621c3295be90640739014575d518af Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:34:08 +0300 Subject: [PATCH 046/351] clippy `iter_over_hash_type` (#8182) --- Cargo.toml | 1 + crates/derive-impl/src/pyclass.rs | 12 ++++++++++++ crates/stdlib/src/faulthandler.rs | 5 +++++ crates/vm/src/builtins/frame.rs | 4 ++++ crates/vm/src/gc_state.rs | 14 ++++++++++++++ crates/vm/src/vm/mod.rs | 25 +++++++++++++++++++++++++ 6 files changed, 61 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 4781c95e101..756d9ec13ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -350,6 +350,7 @@ similar_names = "allow" # restriction lints alloc_instead_of_core = "warn" cfg_not_test = "warn" +iter_over_hash_type = "warn" redundant_test_prefix = "warn" std_instead_of_alloc = "warn" std_instead_of_core = "warn" diff --git a/crates/derive-impl/src/pyclass.rs b/crates/derive-impl/src/pyclass.rs index d5bae7eebed..aed2c5d8c5f 100644 --- a/crates/derive-impl/src/pyclass.rs +++ b/crates/derive-impl/src/pyclass.rs @@ -1433,6 +1433,11 @@ impl GetSetNursery { fn validate(&mut self) -> Result<()> { let mut errors = Vec::new(); + + #[expect( + clippy::iter_over_hash_type, + reason = "Iteration order doesn't matter here" + )] for ((name, _cfgs), (getter, setter)) in &self.map { if getter.is_none() { errors.push(err_span!( @@ -1442,6 +1447,7 @@ impl GetSetNursery { )); }; } + errors.into_result()?; self.validated = true; Ok(()) @@ -1525,6 +1531,11 @@ impl MemberNursery { fn validate(&mut self) -> Result<()> { let mut errors = Vec::new(); + + #[expect( + clippy::iter_over_hash_type, + reason = "Iteration order doesn't matter here" + )] for (name, entry) in &self.map { if entry.getter.is_none() { errors.push(err_span!( @@ -1534,6 +1545,7 @@ impl MemberNursery { )); }; } + errors.into_result()?; self.validated = true; Ok(()) diff --git a/crates/stdlib/src/faulthandler.rs b/crates/stdlib/src/faulthandler.rs index b3af501d83a..e890cecfac4 100644 --- a/crates/stdlib/src/faulthandler.rs +++ b/crates/stdlib/src/faulthandler.rs @@ -304,10 +304,15 @@ mod decl { let registry = vm.state.thread_frames.lock(); // First dump non-current threads, then current thread last + #[expect( + clippy::iter_over_hash_type, + reason = "Iteration order doesn't matter here" + )] for (&tid, slot) in registry.iter() { if tid == current_tid { continue; } + let frames_guard = slot.frames.lock(); dump_traceback_thread_frames(fd, tid, false, &frames_guard); puts(fd, "\n"); diff --git a/crates/vm/src/builtins/frame.rs b/crates/vm/src/builtins/frame.rs index 0cb16b2359f..ab45f68673c 100644 --- a/crates/vm/src/builtins/frame.rs +++ b/crates/vm/src/builtins/frame.rs @@ -738,6 +738,10 @@ impl Py { #[cfg(feature = "threading")] { let registry = vm.state.thread_frames.lock(); + #[expect( + clippy::iter_over_hash_type, + reason = "Iteration order doesn't matter here" + )] for slot in registry.values() { let frames = slot.frames.lock(); // SAFETY: the owning thread can't pop while we hold the Mutex, diff --git a/crates/vm/src/gc_state.rs b/crates/vm/src/gc_state.rs index 93596a9dad7..e20cbcb8ecf 100644 --- a/crates/vm/src/gc_state.rs +++ b/crates/vm/src/gc_state.rs @@ -455,6 +455,11 @@ impl GcState { // Step 2: Build gc_refs map (copy reference counts) let mut gc_refs: std::collections::HashMap = std::collections::HashMap::new(); + + #[expect( + clippy::iter_over_hash_type, + reason = "Iteration order doesn't matter here" + )] for &ptr in &collecting { let obj = unsafe { ptr.0.as_ref() }; gc_refs.insert(ptr, obj.strong_count()); @@ -468,6 +473,11 @@ impl GcState { // results, causing live objects to be incorrectly collected. let mut referents_map: std::collections::HashMap>> = std::collections::HashMap::new(); + + #[expect( + clippy::iter_over_hash_type, + reason = "Iteration order doesn't matter here" + )] for &ptr in &collecting { let obj = unsafe { ptr.0.as_ref() }; if obj.strong_count() == 0 { @@ -489,6 +499,10 @@ impl GcState { let mut reachable: HashSet = HashSet::new(); let mut worklist: Vec = Vec::new(); + #[expect( + clippy::iter_over_hash_type, + reason = "Iteration order doesn't matter here" + )] for (&ptr, &refs) in &gc_refs { if refs > 0 { reachable.insert(ptr); diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 32a2906fa84..5766c5b8ab1 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -273,10 +273,16 @@ impl StopTheWorldState { let registry = vm.state.thread_frames.lock(); let mut attached_seen = 0u64; let mut forced_parks = 0u64; + + #[expect( + clippy::iter_over_hash_type, + reason = "Iteration order doesn't matter here" + )] for (&id, slot) in registry.iter() { if id == requester { continue; } + let state = slot.state.load(Ordering::Relaxed); if state == THREAD_DETACHED { // CAS DETACHED → SUSPENDED (park without thread cooperation) @@ -412,10 +418,16 @@ impl StopTheWorldState { // thread-slot initialization. self.requested.store(false, Ordering::Release); self.world_stopped.store(false, Ordering::Release); + + #[expect( + clippy::iter_over_hash_type, + reason = "Iteration order doesn't matter here" + )] for (&id, slot) in registry.iter() { if id == requester { continue; } + slot.stop_requested.store(false, Ordering::Release); let state = slot.state.load(Ordering::Relaxed); debug_assert!( @@ -427,6 +439,7 @@ impl StopTheWorldState { slot.thread.unpark(); } } + drop(registry); self.thread_countdown.store(0, Ordering::Release); self.requester.store(0, Ordering::Relaxed); @@ -506,10 +519,16 @@ impl StopTheWorldState { use thread::THREAD_SUSPENDED; let requester = self.requester.load(Ordering::Relaxed); let registry = vm.state.thread_frames.lock(); + + #[expect( + clippy::iter_over_hash_type, + reason = "Iteration order doesn't matter here" + )] for (&id, slot) in registry.iter() { if id == requester { continue; } + let state = slot.state.load(Ordering::Relaxed); debug_assert!( state == THREAD_SUSPENDED, @@ -523,10 +542,16 @@ impl StopTheWorldState { use thread::THREAD_SUSPENDED; let requester = self.requester.load(Ordering::Relaxed); let registry = vm.state.thread_frames.lock(); + + #[expect( + clippy::iter_over_hash_type, + reason = "Iteration order doesn't matter here" + )] for (&id, slot) in registry.iter() { if id == requester { continue; } + let state = slot.state.load(Ordering::Relaxed); debug_assert!( state != THREAD_SUSPENDED, From eae567ff338a23f3f59060e3c646dd404be962f4 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:39:13 +0300 Subject: [PATCH 047/351] `FutureFeature` enum (#8185) --- crates/codegen/src/compile.rs | 127 ++++++++++++++++++++++++++----- crates/codegen/src/preprocess.rs | 50 +++++++----- 2 files changed, 141 insertions(+), 36 deletions(-) diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index 711ac392694..39500f1ad31 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -201,6 +201,90 @@ enum DoneWithFuture { Yes, } +/// A Python `__future__` feature flag imported via `from __future__ import `. +/// +/// # See Also +/// +/// - [Python documentation on `__future__`](https://docs.python.org/3.14/library/__future__.html) +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FutureFeature { + /// ```py + /// from __future__ import absolute_import + /// ``` + AbsoluteImport, + + /// ```py + /// from __future__ import annotations + /// ``` + Annotations, + + /// ```py + /// from __future__ import barry_as_FLUFL + /// ``` + BarryAsFLUFL, + + /// ```py + /// from __future__ import braces + /// ``` + Braces, + + /// ```py + /// from __future__ import division + /// ``` + Division, + + /// ```py + /// from __future__ import generator_stop + /// ``` + GeneratorStop, + + /// ```py + /// from __future__ import generators + /// ``` + Generators, + + /// ```py + /// from __future__ import nested_scopes + /// ``` + NestedScopes, + + /// ```py + /// from __future__ import print_function + /// ``` + PrintFunction, + + /// ```py + /// from __future__ import unicode_literals + /// ``` + UnicodeLiterals, + + /// ```py + /// from __future__ import with_statement + /// ``` + WithStatement, +} + +impl TryFrom<&str> for FutureFeature { + type Error = String; + + fn try_from(name: &str) -> Result { + Ok(match name { + "absolute_import" => Self::AbsoluteImport, + "annotations" => Self::Annotations, + "barry_as_FLUFL" => Self::BarryAsFLUFL, + "braces" => Self::Braces, + "division" => Self::Division, + "generator_stop" => Self::GeneratorStop, + "generators" => Self::Generators, + "nested_scopes" => Self::NestedScopes, + "print_function" => Self::PrintFunction, + "unicode_literals" => Self::UnicodeLiterals, + "with_statement" => Self::WithStatement, + _ => return Err(name.into()), + }) + } +} + #[derive(Clone, Copy)] enum ComprehensionSymbolSource { Child, @@ -11144,16 +11228,21 @@ impl<'warnings> Compiler<'warnings> { if let DoneWithFuture::Yes = self.done_with_future_stmts { return Err(self.error(CodegenErrorType::InvalidFuturePlacement)); } + self.done_with_future_stmts = DoneWithFuture::DoneWithDoc; + for feature in features { - match feature.name.as_str() { - // Python 3 features; we've already implemented them by default - "nested_scopes" | "generators" | "division" | "absolute_import" - | "with_statement" | "print_function" | "unicode_literals" | "generator_stop" => {} - // Accept the future feature name, but do not implement - // Barry-as-BDFL parser mode. - "barry_as_FLUFL" => {} - "annotations" => { + let future_feature = feature.name.as_str().try_into().map_err(|name| { + self.error_ranged(CodegenErrorType::InvalidFutureFeature(name), feature.range) + })?; + + match future_feature { + FutureFeature::Braces => { + return Err( + self.error_ranged(CodegenErrorType::InvalidFutureBraces, feature.range) + ); + } + FutureFeature::Annotations => { self.future_annotations = true; self.future_features .insert(bytecode::CodeFlags::FUTURE_ANNOTATIONS); @@ -11161,16 +11250,18 @@ impl<'warnings> Compiler<'warnings> { .flags .insert(bytecode::CodeFlags::FUTURE_ANNOTATIONS); } - "braces" => { - return Err( - self.error_ranged(CodegenErrorType::InvalidFutureBraces, feature.range) - ); - } - other => { - return Err(self.error_ranged( - CodegenErrorType::InvalidFutureFeature(other.to_owned()), - feature.range, - )); + FutureFeature::BarryAsFLUFL => { + // We do not support Barry-as-BDFL parser mode yet. This is a nop for now. + } + FutureFeature::AbsoluteImport + | FutureFeature::Division + | FutureFeature::GeneratorStop + | FutureFeature::Generators + | FutureFeature::NestedScopes + | FutureFeature::PrintFunction + | FutureFeature::UnicodeLiterals + | FutureFeature::WithStatement => { + // Python 3 features. They are already implemented by default. } } } diff --git a/crates/codegen/src/preprocess.rs b/crates/codegen/src/preprocess.rs index f6ca18b67ba..084b72c87f7 100644 --- a/crates/codegen/src/preprocess.rs +++ b/crates/codegen/src/preprocess.rs @@ -8,6 +8,8 @@ use ruff_python_ast::{ visitor::transformer::{self, Transformer}, }; use ruff_text_size::{Ranged, TextRange}; + +use crate::compile::FutureFeature; use rustpython_compiler_core::bytecode; const MAXDIGITS: usize = 3; @@ -232,30 +234,41 @@ pub fn checked_future_features_in_body( .. }) if *level == 0 && module.as_ref().map(|id| id.as_str()) == Some("__future__") => { for alias in names { - match alias.name.as_str() { - "nested_scopes" | "generators" | "division" | "absolute_import" - | "with_statement" | "print_function" | "unicode_literals" - | "generator_stop" => {} - "annotations" => { - future_features.insert(bytecode::CodeFlags::FUTURE_ANNOTATIONS); - } - // Accept the future feature name, but leave it - // as a RustPython no-op. - "barry_as_FLUFL" => {} - "braces" => { - return Err(FutureFeatureError { + let future_feature = + alias + .name + .as_str() + .try_into() + .map_err(|name| FutureFeatureError { features: future_features, range: alias.range, - kind: FutureFeatureErrorKind::InvalidBraces, - }); - } - other => { + kind: FutureFeatureErrorKind::InvalidFeature(name), + })?; + + match future_feature { + FutureFeature::Braces => { return Err(FutureFeatureError { features: future_features, range: alias.range, - kind: FutureFeatureErrorKind::InvalidFeature(other.to_owned()), + kind: FutureFeatureErrorKind::InvalidBraces, }); } + FutureFeature::Annotations => { + future_features.insert(bytecode::CodeFlags::FUTURE_ANNOTATIONS) + } + FutureFeature::BarryAsFLUFL => { + // We do not support Barry-as-BDFL parser mode yet. This is a nop for now. + } + FutureFeature::AbsoluteImport + | FutureFeature::Division + | FutureFeature::GeneratorStop + | FutureFeature::Generators + | FutureFeature::NestedScopes + | FutureFeature::PrintFunction + | FutureFeature::UnicodeLiterals + | FutureFeature::WithStatement => { + // Python 3 features. They are already implemented by default. + } } } } @@ -298,6 +311,7 @@ pub fn preprocess_mod( } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] struct AstPreprocessor { optimize: u8, future_annotations: bool, @@ -305,7 +319,7 @@ struct AstPreprocessor { } impl AstPreprocessor { - fn visit_astfold_body(&self, body: &mut ast::Suite) { + fn visit_astfold_body(self, body: &mut ast::Suite) { let mut docstring = body_starts_with_docstring(body); if docstring && self.optimize >= 2 { remove_docstring_from_body(body); From 12b8305249804c55e462b8dc345883b4ccff137e Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:40:29 +0300 Subject: [PATCH 048/351] Add more clippy rules (#8183) * clippy `assigning_clones` * string_lit_as_bytes * tuple_array_conversions * while_float * manual_assert * Revert "string_lit_as_bytes" This reverts commit f40f7564407c1378e0118a621eff782666536fc2. --- Cargo.toml | 4 ++++ crates/codegen/src/compile.rs | 2 +- crates/codegen/src/symboltable.rs | 3 ++- crates/common/src/float_ops.rs | 4 ++-- crates/common/src/hash.rs | 19 ++++++++++--------- crates/jit/src/instructions.rs | 5 +++++ crates/vm/src/function/builtin.rs | 7 ++++--- crates/vm/src/getpath.rs | 6 +++--- crates/vm/src/stdlib/os.rs | 7 +++---- crates/vm/src/types/slot.rs | 9 ++++----- crates/vm/src/vm/mod.rs | 4 +--- crates/vm/src/vm/vm_new.rs | 8 ++++---- 12 files changed, 43 insertions(+), 35 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 756d9ec13ff..89a07e1bd62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -371,14 +371,17 @@ search_is_some = "warn" significant_drop_in_scrutinee = "warn" single_option_map = "warn" trait_duplication_in_bounds = "warn" +tuple_array_conversions = "warn" type_repetition_in_bounds = "warn" unnecessary_struct_initialization = "warn" unused_peekable = "warn" unused_rounding = "warn" use_self = "warn" useless_let_if_seq = "warn" +while_float = "warn" # pedantic lints to enforce gradually +assigning_clones = "warn" bool_to_int_with_if = "warn" checked_conversions = "warn" cloned_instead_of_copied = "warn" @@ -404,6 +407,7 @@ iter_filter_is_ok = "warn" iter_filter_is_some = "warn" large_futures = "warn" large_types_passed_by_value = "warn" +manual_assert = "warn" manual_instant_elapsed = "warn" manual_is_variant_and = "warn" map_unwrap_or = "warn" diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index 39500f1ad31..6664ec34c8e 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -2157,7 +2157,7 @@ impl<'warnings> Compiler<'warnings> { fn expose_annotation_format_parameter(code: &mut CodeObject) { if let Some(first) = code.varnames.first_mut() { - *first = "format".to_owned(); + *first = String::from("format"); } } diff --git a/crates/codegen/src/symboltable.rs b/crates/codegen/src/symboltable.rs index a78b49a6e90..a72b839f400 100644 --- a/crates/codegen/src/symboltable.rs +++ b/crates/codegen/src/symboltable.rs @@ -1632,8 +1632,9 @@ impl SymbolTableBuilder { } if type_params.is_none() { - self.class_name = prev_class.clone(); + self.class_name.clone_from(&prev_class); } + if let Some(arguments) = arguments { self.scan_expressions(&arguments.args, ExpressionContext::Load)?; for keyword in &arguments.keywords { diff --git a/crates/common/src/float_ops.rs b/crates/common/src/float_ops.rs index fed080dcec8..f643961534a 100644 --- a/crates/common/src/float_ops.rs +++ b/crates/common/src/float_ops.rs @@ -4,8 +4,8 @@ use num_traits::{Signed, ToPrimitive}; #[must_use] pub const fn decompose_float(value: f64) -> (f64, i32) { - if 0.0 == value { - (0.0, 0i32) + if value == 0.0 { + (0.0, 0) } else { let bits = value.to_bits(); let exponent: i32 = ((bits >> 52) & 0x7ff) as i32 - 1022; diff --git a/crates/common/src/hash.rs b/crates/common/src/hash.rs index 5b16c89e7bc..91fd5e1cba0 100644 --- a/crates/common/src/hash.rs +++ b/crates/common/src/hash.rs @@ -49,9 +49,7 @@ impl HashSecret { let k1 = u64::from_le_bytes(right.try_into().unwrap()); Self { k0, k1 } } -} -impl HashSecret { pub fn hash_value(&self, data: &T) -> PyHash { fix_sentinel(mod_int(self.hash_one(data) as _)) } @@ -94,7 +92,7 @@ pub const fn hash_pointer(value: usize) -> PyHash { #[inline] #[must_use] -pub fn hash_float(value: f64) -> Option { +pub const fn hash_float(value: f64) -> Option { // cpython _Py_HashDouble if !value.is_finite() { return if value.is_infinite() { @@ -111,6 +109,8 @@ pub fn hash_float(value: f64) -> Option { let mut m = frexp.0; let mut e = frexp.1; let mut x: PyUHash = 0; + + #[expect(clippy::while_float, reason = "keep this loop like CPython does it")] while m != 0.0 { x = ((x << 28) & MODULUS) | (x >> (BITS - 28)); m *= 268_435_456.0; // 2**28 @@ -137,13 +137,14 @@ pub fn hash_float(value: f64) -> Option { #[must_use] pub fn hash_bigint(value: &BigInt) -> PyHash { - let ret = match value.to_i64() { - Some(i) => mod_int(i), - None => (value % MODULUS).to_i64().unwrap_or_else(|| unsafe { - // SAFETY: MODULUS < i64::MAX, so value % MODULUS is guaranteed to be in the range of i64 - core::hint::unreachable_unchecked() - }), + let ret = if let Some(v) = value.to_i64() { + mod_int(v) + } else { + // SAFETY: + // MODULUS < i64::MAX, so value % MODULUS is guaranteed to be in the range of i64 + unsafe { (value % MODULUS).to_i64().unwrap_unchecked() } }; + fix_sentinel(ret) } diff --git a/crates/jit/src/instructions.rs b/crates/jit/src/instructions.rs index 6aff9f093e5..9b4656da5ad 100644 --- a/crates/jit/src/instructions.rs +++ b/crates/jit/src/instructions.rs @@ -666,6 +666,11 @@ impl<'a, 'b> FunctionCompiler<'a, 'b> { | Instruction::LoadFastBorrowLoadFastBorrow { var_nums } => { let oparg = var_nums.get(arg); let (idx1, idx2) = oparg.indexes(); + + #[expect( + clippy::tuple_array_conversions, + reason = "Seems like a false positive" + )] for idx in [idx1, idx2] { let local = self.variables[idx] .as_ref() diff --git a/crates/vm/src/function/builtin.rs b/crates/vm/src/function/builtin.rs index 47cb8dfcb64..a2753bdf145 100644 --- a/crates/vm/src/function/builtin.rs +++ b/crates/vm/src/function/builtin.rs @@ -57,9 +57,10 @@ const fn zst_ref_out_of_thin_air(x: T) -> &'static T { // operation. if T isn't zero-sized, we don't have to worry about it because we'll fail to compile. core::mem::forget(x); const { - if core::mem::size_of::() != 0 { - panic!("can't use a non-zero-sized type here") - } + assert!( + core::mem::size_of::() == 0, + "can't use a non-zero-sized type here" + ); // SAFETY: we just confirmed that T is zero-sized, so we can // pull a value of it out of thin air. unsafe { core::ptr::NonNull::::dangling().as_ref() } diff --git a/crates/vm/src/getpath.rs b/crates/vm/src/getpath.rs index 38aa122db49..63454720178 100644 --- a/crates/vm/src/getpath.rs +++ b/crates/vm/src/getpath.rs @@ -121,7 +121,7 @@ pub fn init_path_config(settings: &Settings) -> Paths { // - sys.executable should be the launcher path (where user invoked Python) // - sys._base_executable should be the real Python executable let exe_dir = if let Ok(launcher) = crate::host_env::os::var("__PYVENV_LAUNCHER__") { - paths.executable = launcher.clone(); + paths.executable.clone_from(&launcher); paths.base_executable = real_executable; PathBuf::from(&launcher).parent().map(PathBuf::from) } else { @@ -152,7 +152,7 @@ pub fn init_path_config(settings: &Settings) -> Paths { paths.base_prefix = calculated_prefix; } else { // Not in venv: prefix == base_prefix - paths.prefix = calculated_prefix.clone(); + paths.prefix.clone_from(&calculated_prefix); paths.base_prefix = calculated_prefix; } @@ -163,7 +163,7 @@ pub fn init_path_config(settings: &Settings) -> Paths { } else { calculate_exec_prefix(search_dir.as_ref(), paths.prefix.as_ref()) }; - paths.base_exec_prefix = paths.base_prefix.clone(); + paths.base_exec_prefix.clone_from(&paths.base_prefix); // Step 7: Calculate base_executable (if not already set by __PYVENV_LAUNCHER__) if paths.base_executable.is_empty() { diff --git a/crates/vm/src/stdlib/os.rs b/crates/vm/src/stdlib/os.rs index 4a1cbe2aecd..3c216168f3f 100644 --- a/crates/vm/src/stdlib/os.rs +++ b/crates/vm/src/stdlib/os.rs @@ -1795,9 +1795,9 @@ pub(super) mod _os { #[cfg(all(unix, not(any(target_os = "redox", target_os = "android"))))] #[pyfunction] fn getloadavg(vm: &VirtualMachine) -> PyResult<(f64, f64, f64)> { - let loadavg = crate::host_env::time::getloadavg() - .map_err(|_| vm.new_os_error("Load averages are unobtainable"))?; - Ok((loadavg[0], loadavg[1], loadavg[2])) + crate::host_env::time::getloadavg() + .map(Into::into) + .map_err(|_| vm.new_os_error("Load averages are unobtainable")) } #[cfg(unix)] @@ -1918,7 +1918,6 @@ pub(super) mod _os { } } - /// Perform a statvfs system call on the given path. #[cfg(all(unix, not(target_os = "redox")))] #[pyfunction] #[pyfunction(name = "fstatvfs")] diff --git a/crates/vm/src/types/slot.rs b/crates/vm/src/types/slot.rs index 4197e1554aa..83d9706cb33 100644 --- a/crates/vm/src/types/slot.rs +++ b/crates/vm/src/types/slot.rs @@ -1667,11 +1667,10 @@ pub trait Initializer: PyPayload { .matches(&class_name_for_debug as &str) .count() == 2; - if double_appearance { - panic!( - "This type `{class_name_for_debug}` doesn't seem to support `init`. Override `slot_init` instead: {msg}" - ); - } + assert!( + !double_appearance, + "This type `{class_name_for_debug}` doesn't seem to support `init`. Override `slot_init` instead: {msg}" + ) } } return Err(err); diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 5766c5b8ab1..8ad8a0d0bca 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -908,9 +908,7 @@ impl VirtualMachine { fn initialize(&mut self) { flame_guard!("init VirtualMachine"); - if self.initialized { - panic!("Double Initialize Error"); - } + assert!(!self.initialized, "Double Initialize Error"); // Initialize main thread ident before any threading operations #[cfg(feature = "threading")] diff --git a/crates/vm/src/vm/vm_new.rs b/crates/vm/src/vm/vm_new.rs index 2c053bdf838..c71cf842520 100644 --- a/crates/vm/src/vm/vm_new.rs +++ b/crates/vm/src/vm/vm_new.rs @@ -794,10 +794,10 @@ impl VirtualMachine { }; if syntax_error_type.is(self.ctx.exceptions.tab_error) { - syntax_error_info.msg = "inconsistent use of tabs and spaces in indentation".to_owned(); - } - if syntax_error_type.is(self.ctx.exceptions.incomplete_input_error) { - syntax_error_info.msg = "incomplete input".to_owned(); + syntax_error_info.msg = + String::from("inconsistent use of tabs and spaces in indentation"); + } else if syntax_error_type.is(self.ctx.exceptions.incomplete_input_error) { + syntax_error_info.msg = String::from("incomplete input"); } let SyntaxErrorInfo { msg, narrow_caret } = syntax_error_info; From 103d6a26458af5eeca93edca87e51902c8eafc3a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:41:02 +0900 Subject: [PATCH 049/351] Bump zizmorcore/zizmor-action from 0.5.6 to 0.5.7 (#8189) Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.5.6 to 0.5.7. - [Release notes](https://github.com/zizmorcore/zizmor-action/releases) - [Commits](https://github.com/zizmorcore/zizmor-action/compare/5f14fd08f7cf1cb1609c1e344975f152c7ee938d...192e21d79ab29983730a13d1382995c2307fbcaa) --- updated-dependencies: - dependency-name: zizmorcore/zizmor-action dependency-version: 0.5.7 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 72599d28703..c2cb153536d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -533,7 +533,7 @@ jobs: uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1.72.0 - name: zizmor - uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 + uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 - name: restore prek cache uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 From 3f0064bde60150eb1334537a1457f1d8571f624b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:41:13 +0900 Subject: [PATCH 050/351] Bump libffi from 5.1.0 to 5.1.1 in the libffi group across 1 directory (#8190) Bumps the libffi group with 1 update in the / directory: [libffi](https://github.com/libffi-rs/libffi-rs). Updates `libffi` from 5.1.0 to 5.1.1 - [Commits](https://github.com/libffi-rs/libffi-rs/compare/libffi-v5.1.0...libffi-v5.1.1) --- updated-dependencies: - dependency-name: libffi dependency-version: 5.1.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: libffi ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 740ce5f09fa..0065f778715 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2080,9 +2080,9 @@ dependencies = [ [[package]] name = "libffi" -version = "5.1.0" +version = "5.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0498fe5655f857803e156523e644dcdcdc3b3c7edda42ea2afdae2e09b2db87b" +checksum = "ed185dbb87539a100c1b36c219e16e71572c6d4d4fed3ded898140f755adeaaf" dependencies = [ "libc", "libffi-sys", @@ -2090,9 +2090,9 @@ dependencies = [ [[package]] name = "libffi-sys" -version = "4.1.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71d4f1d4ce15091955144350b75db16a96d4a63728500122706fb4d29a26afbb" +checksum = "25831b230b6a90bdea9f28339c1d00d59773a1c492e8ca09b1ad80e56394c261" dependencies = [ "cc", ] @@ -4340,7 +4340,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.52.0", From cf4a3d190391ea802a92bfe8f74991f5cfb29884 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:41:25 +0900 Subject: [PATCH 051/351] Bump phf from 0.13.1 to 0.14.0 in the phf group across 1 directory (#8191) Bumps the phf group with 1 update in the / directory: [phf](https://github.com/rust-phf/rust-phf). Updates `phf` from 0.13.1 to 0.14.0 - [Release notes](https://github.com/rust-phf/rust-phf/releases) - [Changelog](https://github.com/rust-phf/rust-phf/blob/main/RELEASE_PROCESS.md) - [Commits](https://github.com/rust-phf/rust-phf/compare/v0.13.1...v0.14.0) --- updated-dependencies: - dependency-name: phf dependency-version: 0.14.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: phf ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 48 ++++++++++++++++++++++++------------------------ Cargo.toml | 2 +- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0065f778715..322a13572cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,7 +100,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -111,7 +111,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1145,7 +1145,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1237,7 +1237,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1946,7 +1946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "160f2eade097f30263b548aae5deb12ad349c909baa710fa24b92c9090b2e006" dependencies = [ "scopeguard", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2659,12 +2659,12 @@ dependencies = [ [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", - "phf_shared 0.13.1", + "phf_shared 0.14.0", "serde", ] @@ -2690,22 +2690,22 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", - "phf_shared 0.13.1", + "phf_shared 0.14.0", ] [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn", @@ -2722,9 +2722,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -3319,7 +3319,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3395,7 +3395,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3565,7 +3565,7 @@ dependencies = [ name = "rustpython-doc" version = "0.5.0" dependencies = [ - "phf 0.13.1", + "phf 0.14.0", ] [[package]] @@ -3756,7 +3756,7 @@ dependencies = [ "paste", "pbkdf2", "pem-rfc7468 1.0.0", - "phf 0.13.1", + "phf 0.14.0", "pkcs8", "pymath", "rand 0.10.1", @@ -4199,7 +4199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4343,7 +4343,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4846,7 +4846,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 89a07e1bd62..3b4cd480b2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -190,7 +190,7 @@ ruff_text_size = { package = "rustpython-ruff_text_size", git = "https://github. ruff_source_file = { package = "rustpython-ruff_source_file", git = "https://github.com/RustPython/ruff.git", tag = "0.15.19-rustpython" } der = { version = "0.8", features = ["alloc", "oid", "pem", "zeroize"] } -phf = { version = "0.13.1", default-features = false, features = ["macros"]} +phf = { version = "0.14.0", default-features = false, features = ["macros"]} adler32 = "1.2.0" approx = "0.5.1" ascii = "1.1" From c6702774e0ebc7555fbd1f1a7ac0cf39bc20a37b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:41:35 +0900 Subject: [PATCH 052/351] Bump rustyline in the unix group across 1 directory (#8192) Bumps the unix group with 1 update in the / directory: [rustyline](https://github.com/kkawakam/rustyline). Updates `rustyline` from 18.0.0 to 18.0.1 - [Release notes](https://github.com/kkawakam/rustyline/releases) - [Changelog](https://github.com/kkawakam/rustyline/blob/master/History.md) - [Commits](https://github.com/kkawakam/rustyline/compare/v18.0.0...v18.0.1) --- updated-dependencies: - dependency-name: rustyline dependency-version: 18.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: unix ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 322a13572cb..cb4ccbaff19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3896,9 +3896,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rustyline" -version = "18.0.0" +version = "18.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a990b25f351b25139ddc7f21ee3f6f56f86d6846b74ac8fad3a719a287cd4a0" +checksum = "53f6a737db68eb1a8ccff86b584b2fc13eca6a7bb6f78ebc7c529547e3ab9684" dependencies = [ "bitflags 2.13.0", "cfg-if", From 5f4af936c3ccc5e79fd3a29b62a9cf770bca8371 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:41:44 +0900 Subject: [PATCH 053/351] Bump libz-rs-sys from 0.6.3 to 0.6.4 (#8193) Bumps [libz-rs-sys](https://github.com/trifectatechfoundation/zlib-rs) from 0.6.3 to 0.6.4. - [Release notes](https://github.com/trifectatechfoundation/zlib-rs/releases) - [Changelog](https://github.com/trifectatechfoundation/zlib-rs/blob/main/docs/release.md) - [Commits](https://github.com/trifectatechfoundation/zlib-rs/compare/v0.6.3...v0.6.4) --- updated-dependencies: - dependency-name: libz-rs-sys dependency-version: 0.6.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cb4ccbaff19..90f5c318b8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2145,9 +2145,9 @@ dependencies = [ [[package]] name = "libz-rs-sys" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1116a951fd9d5110720bb2ae66f72ce5d7b10bd8aa8744924677157089ce13f" +checksum = "3deaad727e11899800b9a177af14682bfa5d5d96fa525a6c9bd13e650c019f40" dependencies = [ "zlib-rs", ] @@ -5292,9 +5292,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" [[package]] name = "zmij" From 3a663cfde82f37e0127011e509cee7707b3e602d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:41:56 +0900 Subject: [PATCH 054/351] Bump github/gh-aw/actions/setup from 0.79.9 to 0.80.9 (#8194) Bumps [github/gh-aw/actions/setup](https://github.com/github/gh-aw) from 0.79.9 to 0.80.9. - [Release notes](https://github.com/github/gh-aw/releases) - [Changelog](https://github.com/github/gh-aw/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/gh-aw/compare/54ad1f83a833db4de127cf278b00438e19a103a0...a3624368c4e7d877586ff2784b61de73405e2cdd) --- updated-dependencies: - dependency-name: github/gh-aw/actions/setup dependency-version: 0.80.9 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/upgrade-pylib.lock.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/upgrade-pylib.lock.yml b/.github/workflows/upgrade-pylib.lock.yml index 17361719a6d..bdddf025d7f 100644 --- a/.github/workflows/upgrade-pylib.lock.yml +++ b/.github/workflows/upgrade-pylib.lock.yml @@ -58,7 +58,7 @@ jobs: comment_repo: "" steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@54ad1f83a833db4de127cf278b00438e19a103a0 # v0.79.9 + uses: github/gh-aw/actions/setup@a3624368c4e7d877586ff2784b61de73405e2cdd # v0.80.9 with: destination: /opt/gh-aw/actions - name: Check workflow file timestamps @@ -99,7 +99,7 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@54ad1f83a833db4de127cf278b00438e19a103a0 # v0.79.9 + uses: github/gh-aw/actions/setup@a3624368c4e7d877586ff2784b61de73405e2cdd # v0.80.9 with: destination: /opt/gh-aw/actions - name: Checkout repository @@ -806,7 +806,7 @@ jobs: total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@54ad1f83a833db4de127cf278b00438e19a103a0 # v0.79.9 + uses: github/gh-aw/actions/setup@a3624368c4e7d877586ff2784b61de73405e2cdd # v0.80.9 with: destination: /opt/gh-aw/actions - name: Download agent output artifact @@ -927,7 +927,7 @@ jobs: success: ${{ steps.parse_results.outputs.success }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@54ad1f83a833db4de127cf278b00438e19a103a0 # v0.79.9 + uses: github/gh-aw/actions/setup@a3624368c4e7d877586ff2784b61de73405e2cdd # v0.80.9 with: destination: /opt/gh-aw/actions - name: Download agent artifacts @@ -1039,7 +1039,7 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@54ad1f83a833db4de127cf278b00438e19a103a0 # v0.79.9 + uses: github/gh-aw/actions/setup@a3624368c4e7d877586ff2784b61de73405e2cdd # v0.80.9 with: destination: /opt/gh-aw/actions - name: Download agent output artifact From 05f7f2d00f0c41f02a74a262eb31911934561d9d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:42:06 +0900 Subject: [PATCH 055/351] Bump reviewdog/action-suggester from 1.24.0 to 1.24.3 (#8195) Bumps [reviewdog/action-suggester](https://github.com/reviewdog/action-suggester) from 1.24.0 to 1.24.3. - [Release notes](https://github.com/reviewdog/action-suggester/releases) - [Commits](https://github.com/reviewdog/action-suggester/compare/aa38384ceb608d00f84b4690cacc83a5aba307ff...2558ba17e65a9039e73764a73009fc05fef28a46) --- updated-dependencies: - dependency-name: reviewdog/action-suggester dependency-version: 1.24.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c2cb153536d..373c7c519f6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -584,7 +584,7 @@ jobs: - name: reviewdog if: ${{ !cancelled() }} - uses: reviewdog/action-suggester@aa38384ceb608d00f84b4690cacc83a5aba307ff # v1.24.0 + uses: reviewdog/action-suggester@2558ba17e65a9039e73764a73009fc05fef28a46 # v1.24.3 with: level: warning fail_level: error From d88048c51b542b08e428da03c2bc27a155cb95b5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:42:18 +0900 Subject: [PATCH 056/351] Bump cargo-bins/cargo-binstall from 1.20.0 to 1.20.1 (#8196) Bumps [cargo-bins/cargo-binstall](https://github.com/cargo-bins/cargo-binstall) from 1.20.0 to 1.20.1. - [Release notes](https://github.com/cargo-bins/cargo-binstall/releases) - [Changelog](https://github.com/cargo-bins/cargo-binstall/blob/main/release-plz.toml) - [Commits](https://github.com/cargo-bins/cargo-binstall/compare/30b5ca8b54e1dcffd9548bc87ede1531310fdc67...732870f031d2fb36309d0deaf36abcc704a7be65) --- updated-dependencies: - dependency-name: cargo-bins/cargo-binstall dependency-version: 1.20.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 373c7c519f6..2a8dfec7371 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -502,7 +502,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - uses: cargo-bins/cargo-binstall@30b5ca8b54e1dcffd9548bc87ede1531310fdc67 # v1.20.0 + - uses: cargo-bins/cargo-binstall@732870f031d2fb36309d0deaf36abcc704a7be65 # v1.20.1 - name: cargo shear run: | From 31b605309eb10bb4a4ca7c5d147be06a0aa00fb1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:48:21 +0900 Subject: [PATCH 057/351] Bump actions/checkout from 6.0.3 to 7.0.0 (#8197) Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.3 to 7.0.0. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 24 +++++++++++------------ .github/workflows/cron-ci.yaml | 8 ++++---- .github/workflows/lib-deps-check.yaml | 4 ++-- .github/workflows/release.yml | 6 +++--- .github/workflows/update-caches.yml | 2 +- .github/workflows/update-doc-db.yml | 4 ++-- .github/workflows/update-libs-status.yaml | 4 ++-- .github/workflows/upgrade-pylib.lock.yml | 4 ++-- 8 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2a8dfec7371..9f76984bea9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -43,7 +43,7 @@ jobs: # Flag that is raised when any rust code is changed. rust_code: ${{ steps.check_rust_code.outputs.changed }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false @@ -87,7 +87,7 @@ jobs: os: [macos-latest, ubuntu-latest, windows-2025] fail-fast: false steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -203,7 +203,7 @@ jobs: target: x86_64-apple-darwin fail-fast: false steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -314,7 +314,7 @@ jobs: timeout: 50 fail-fast: false steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -458,7 +458,7 @@ jobs: - ubuntu-latest - windows-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -496,7 +496,7 @@ jobs: needs.determine_changes.outputs.rust_code == 'true' || github.ref == 'refs/heads/main' steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -519,7 +519,7 @@ jobs: pull-requests: write security-events: write # for zizmor steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -559,7 +559,7 @@ jobs: echo "version=${version}" >> "$GITHUB_OUTPUT" - name: Clone CPython - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: python/cpython path: cpython @@ -597,7 +597,7 @@ jobs: env: NIGHTLY_CHANNEL: nightly steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -632,7 +632,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -747,7 +747,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -799,7 +799,7 @@ jobs: name: cargo doc runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/cron-ci.yaml b/.github/workflows/cron-ci.yaml index 4e5ed04eaae..a3b91fde449 100644 --- a/.github/workflows/cron-ci.yaml +++ b/.github/workflows/cron-ci.yaml @@ -27,7 +27,7 @@ jobs: env: INSTA_WORKSPACE_ROOT: ${{ github.workspace }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -67,7 +67,7 @@ jobs: # Disable this scheduled job when running on a fork. if: ${{ github.repository == 'RustPython/RustPython' || github.event_name != 'schedule' }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: true @@ -105,7 +105,7 @@ jobs: # Disable this scheduled job when running on a fork. if: ${{ github.repository == 'RustPython/RustPython' || github.event_name != 'schedule' }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: true @@ -168,7 +168,7 @@ jobs: # Disable this scheduled job when running on a fork. if: ${{ github.repository == 'RustPython/RustPython' || github.event_name != 'schedule' }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: true diff --git a/.github/workflows/lib-deps-check.yaml b/.github/workflows/lib-deps-check.yaml index e74dd561bb5..eb4561daa63 100644 --- a/.github/workflows/lib-deps-check.yaml +++ b/.github/workflows/lib-deps-check.yaml @@ -18,7 +18,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout base branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Use base branch for scripts (security: don't run PR code with elevated permissions) ref: ${{ github.event.pull_request.base.ref }} @@ -41,7 +41,7 @@ jobs: echo "version=${version}" >> "$GITHUB_OUTPUT" - name: Checkout CPython - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: python/cpython path: cpython diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21a0068cbbf..cd66ae84572 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,7 @@ jobs: # target: aarch64-pc-windows-msvc fail-fast: false steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -91,7 +91,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -156,7 +156,7 @@ jobs: permissions: contents: write # for creating a release steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/update-caches.yml b/.github/workflows/update-caches.yml index 5e99abac694..3a48418a502 100644 --- a/.github/workflows/update-caches.yml +++ b/.github/workflows/update-caches.yml @@ -39,7 +39,7 @@ jobs: target: "" steps: - name: Checkout RustPython main branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: RustPython/RustPython ref: main diff --git a/.github/workflows/update-doc-db.yml b/.github/workflows/update-doc-db.yml index a543f428cb5..c7dad17d252 100644 --- a/.github/workflows/update-doc-db.yml +++ b/.github/workflows/update-doc-db.yml @@ -30,7 +30,7 @@ jobs: - windows-latest - macos-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false sparse-checkout: | @@ -58,7 +58,7 @@ jobs: contents: write pull-requests: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: true ref: ${{ inputs.base-ref }} diff --git a/.github/workflows/update-libs-status.yaml b/.github/workflows/update-libs-status.yaml index 837586913c8..846818e12be 100644 --- a/.github/workflows/update-libs-status.yaml +++ b/.github/workflows/update-libs-status.yaml @@ -21,7 +21,7 @@ jobs: if: ${{ github.repository == 'RustPython/RustPython' }} steps: - name: Clone RustPython - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: path: rustpython persist-credentials: false @@ -37,7 +37,7 @@ jobs: echo "version=${version}" >> "$GITHUB_OUTPUT" - name: Clone CPython - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: python/cpython path: cpython diff --git a/.github/workflows/upgrade-pylib.lock.yml b/.github/workflows/upgrade-pylib.lock.yml index bdddf025d7f..318657d52f5 100644 --- a/.github/workflows/upgrade-pylib.lock.yml +++ b/.github/workflows/upgrade-pylib.lock.yml @@ -103,7 +103,7 @@ jobs: with: destination: /opt/gh-aw/actions - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Setup Python @@ -1061,7 +1061,7 @@ jobs: path: /tmp/gh-aw/ - name: Checkout repository if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: token: ${{ github.token }} persist-credentials: false From 01899cb706076eaf0241ee341fb6f1f0ccc1fc5b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:48:39 +0900 Subject: [PATCH 058/351] Bump https://github.com/astral-sh/ruff-pre-commit (#8198) Bumps [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit) from v0.15.17 to 0.15.18. - [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases) - [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.17...v0.15.18) --- updated-dependencies: - dependency-name: https://github.com/astral-sh/ruff-pre-commit dependency-version: 0.15.18 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 07edb8a052f..3c743dc6c74 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: priority: 0 - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.17 + rev: v0.15.18 hooks: - id: ruff-format priority: 0 From ac01aceb099ec374b9aa06ed989cd7a7fe166477 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:48:55 +0900 Subject: [PATCH 059/351] Bump https://github.com/streetsidesoftware/cspell-cli (#8199) Bumps [https://github.com/streetsidesoftware/cspell-cli](https://github.com/streetsidesoftware/cspell-cli) from v10.0.0 to 10.0.1. - [Release notes](https://github.com/streetsidesoftware/cspell-cli/releases) - [Changelog](https://github.com/streetsidesoftware/cspell-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/streetsidesoftware/cspell-cli/compare/v10.0.0...v10.0.1) --- updated-dependencies: - dependency-name: https://github.com/streetsidesoftware/cspell-cli dependency-version: 10.0.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3c743dc6c74..52245bb3333 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -63,7 +63,7 @@ repos: - manual - repo: https://github.com/streetsidesoftware/cspell-cli - rev: v10.0.0 + rev: v10.0.1 hooks: - id: cspell types: [rust] From 33c0cf7ee36b8574e0a5afdf8c430c23d5920ff7 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:49:27 +0300 Subject: [PATCH 060/351] Update `test_sqlite3` to 3.14.6 (#8172) --- Lib/test/test_sqlite3/__init__.py | 11 +- Lib/test/test_sqlite3/test_backup.py | 2 +- Lib/test/test_sqlite3/test_dbapi.py | 57 +++++--- Lib/test/test_sqlite3/test_dump.py | 14 +- Lib/test/test_sqlite3/test_factory.py | 25 ++-- Lib/test/test_sqlite3/test_hooks.py | 152 ++++++++++++++++++-- Lib/test/test_sqlite3/test_regression.py | 10 +- Lib/test/test_sqlite3/test_transactions.py | 14 +- Lib/test/test_sqlite3/test_userfunctions.py | 48 +++---- 9 files changed, 250 insertions(+), 83 deletions(-) diff --git a/Lib/test/test_sqlite3/__init__.py b/Lib/test/test_sqlite3/__init__.py index 78a1e2078a5..145f3b80024 100644 --- a/Lib/test/test_sqlite3/__init__.py +++ b/Lib/test/test_sqlite3/__init__.py @@ -6,9 +6,14 @@ import os import sqlite3 +# make sure only print once +_printed_version = False + # Implement the unittest "load tests" protocol. -def load_tests(*args): - if verbose: +def load_tests(loader, tests, pattern): + global _printed_version + if verbose and not _printed_version: print(f"test_sqlite3: testing with SQLite version {sqlite3.sqlite_version}") + _printed_version = True pkg_dir = os.path.dirname(__file__) - return load_package_tests(pkg_dir, *args) + return load_package_tests(pkg_dir, loader, tests, pattern) diff --git a/Lib/test/test_sqlite3/test_backup.py b/Lib/test/test_sqlite3/test_backup.py index 9d31978b1ad..bc24831a0c7 100644 --- a/Lib/test/test_sqlite3/test_backup.py +++ b/Lib/test/test_sqlite3/test_backup.py @@ -103,7 +103,7 @@ def progress(status, remaining, total): self.assertEqual(len(journal), 1) self.assertEqual(journal[0], 0) - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON def test_non_callable_progress(self): with self.assertRaises(TypeError) as cm: with memory_database() as bck: diff --git a/Lib/test/test_sqlite3/test_dbapi.py b/Lib/test/test_sqlite3/test_dbapi.py index 2174e14e7cb..68faf0a2abb 100644 --- a/Lib/test/test_sqlite3/test_dbapi.py +++ b/Lib/test/test_sqlite3/test_dbapi.py @@ -364,7 +364,7 @@ def test_use_after_close(self): with self.cx: pass - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON def test_exceptions(self): # Optional DB-API extension. self.assertEqual(self.cx.Warning, sqlite.Warning) @@ -401,7 +401,7 @@ def test_in_transaction_ro(self): with self.assertRaises(AttributeError): self.cx.in_transaction = True - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON def test_connection_exceptions(self): exceptions = [ "DataError", @@ -527,7 +527,7 @@ def test_connection_bad_reinit(self): cx.executemany, "insert into t values(?)", ((v,) for v in range(3))) - @unittest.expectedFailure # TODO: RUSTPYTHON SQLITE_DBCONFIG constants not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; SQLITE_DBCONFIG constants not implemented def test_connection_config(self): op = sqlite.SQLITE_DBCONFIG_ENABLE_FKEY with memory_database() as cx: @@ -552,7 +552,7 @@ def test_connection_config(self): with self.assertRaisesRegex(sqlite.IntegrityError, "constraint"): cx.execute("insert into u values(0)") - @unittest.expectedFailure # TODO: RUSTPYTHON deprecation warning not emitted for positional args + @unittest.expectedFailure # TODO: RUSTPYTHON; deprecation warning not emitted for positional args def test_connect_positional_arguments(self): regex = ( r"Passing more than 1 positional argument to sqlite3.connect\(\)" @@ -566,14 +566,14 @@ def test_connect_positional_arguments(self): cx.close() self.assertEqual(cm.filename, __file__) - @unittest.expectedFailure # TODO: RUSTPYTHON ResourceWarning not emitted + @unittest.expectedFailure # TODO: RUSTPYTHON; ResourceWarning not emitted def test_connection_resource_warning(self): with self.assertWarns(ResourceWarning): cx = sqlite.connect(":memory:") del cx gc_collect() - @unittest.expectedFailure # TODO: RUSTPYTHON Connection signature inspection not working + @unittest.expectedFailure # TODO: RUSTPYTHON; Connection signature inspection not working def test_connection_signature(self): from inspect import signature sig = signature(self.cx) @@ -584,7 +584,7 @@ class UninitialisedConnectionTests(unittest.TestCase): def setUp(self): self.cx = sqlite.Connection.__new__(sqlite.Connection) - @unittest.skip('TODO: RUSTPYTHON') + @unittest.skip("TODO: RUSTPYTHON") def test_uninit_operations(self): funcs = ( lambda: self.cx.isolation_level, @@ -726,7 +726,7 @@ def test_open_undecodable_uri(self): self.assertTrue(os.path.exists(path)) cx.execute(self._sql) - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON def test_factory_database_arg(self): def factory(database, *args, **kwargs): nonlocal database_arg @@ -875,7 +875,7 @@ def __getitem__(slf, x): with self.assertRaises(ZeroDivisionError): self.cu.execute("select name from test where name=?", L()) - @unittest.expectedFailure # TODO: RUSTPYTHON mixed named and positional parameters not validated + @unittest.expectedFailure # TODO: RUSTPYTHON; mixed named and positional parameters not validated def test_execute_named_param_and_sequence(self): dataset = ( ("select :a", (1,)), @@ -1396,6 +1396,11 @@ def test_blob_get_slice(self): def test_blob_get_empty_slice(self): self.assertEqual(self.blob[5:5], b"") + def test_blob_get_empty_slice_oob_indices(self): + self.cx.execute("insert into test(b) values (?)", (b"abc",)) + with self.cx.blobopen("test", "b", 2) as blob: + self.assertEqual(blob[5:-5], b"") + def test_blob_get_slice_negative_index(self): self.assertEqual(self.blob[5:-5], self.data[5:-5]) @@ -1412,6 +1417,18 @@ def test_blob_set_empty_slice(self): self.blob[0:0] = b"" self.assertEqual(self.blob[:], self.data) + def test_blob_set_empty_slice_wrong_type(self): + with self.assertRaises(TypeError): + self.blob[5:5] = None + + def test_blob_set_empty_slice_wrong_size(self): + with self.assertRaisesRegex(IndexError, "wrong size"): + self.blob[5:5] = b"123" + + def test_blob_set_empty_slice_correct(self): + self.blob[5:5] = b"" + self.assertEqual(self.blob[:], self.data) + def test_blob_set_slice_with_skip(self): self.blob[0:10:2] = b"12345" actual = self.cx.execute("select b from test").fetchone()[0] @@ -1603,7 +1620,7 @@ def test_check_connection_thread(self): with self.subTest(fn=fn): self._run_test(fn) - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON def test_check_cursor_thread(self): fns = [ lambda: self.cur.execute("insert into test(name) values('a')"), @@ -1758,29 +1775,29 @@ def setUp(self): self.cur = self.con.cursor() self.con.close() - @unittest.expectedFailure # TODO: RUSTPYTHON error message differs for closed connection + @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for closed connection def test_closed_con_cursor(self): self.check(self.con.cursor) - @unittest.expectedFailure # TODO: RUSTPYTHON error message differs for closed connection + @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for closed connection def test_closed_con_commit(self): self.check(self.con.commit) - @unittest.expectedFailure # TODO: RUSTPYTHON error message differs for closed connection + @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for closed connection def test_closed_con_rollback(self): self.check(self.con.rollback) - @unittest.expectedFailure # TODO: RUSTPYTHON error message differs for closed connection + @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for closed connection def test_closed_cur_execute(self): self.check(self.cur.execute, "select 4") - @unittest.expectedFailure # TODO: RUSTPYTHON error message differs for closed connection + @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for closed connection def test_closed_create_function(self): def f(x): return 17 self.check(self.con.create_function, "foo", 1, f) - @unittest.expectedFailure # TODO: RUSTPYTHON error message differs for closed connection + @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for closed connection def test_closed_create_aggregate(self): class Agg: def __init__(self): @@ -1791,19 +1808,19 @@ def finalize(self): return 17 self.check(self.con.create_aggregate, "foo", 1, Agg) - @unittest.expectedFailure # TODO: RUSTPYTHON error message differs for closed connection + @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for closed connection def test_closed_set_authorizer(self): def authorizer(*args): return sqlite.DENY self.check(self.con.set_authorizer, authorizer) - @unittest.expectedFailure # TODO: RUSTPYTHON error message differs for closed connection + @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for closed connection def test_closed_set_progress_callback(self): def progress(): pass self.check(self.con.set_progress_handler, progress, 100) - @unittest.expectedFailure # TODO: RUSTPYTHON error message differs for closed connection + @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for closed connection def test_closed_call(self): self.check(self.con) @@ -2037,7 +2054,7 @@ def test_row_equality(self): self.assertNotEqual(r1, r3) - @unittest.expectedFailure # TODO: RUSTPYTHON Row with no description fails + @unittest.expectedFailure # TODO: RUSTPYTHON; Row with no description fails def test_row_no_description(self): cu = self.cx.cursor() self.assertIsNone(cu.description) diff --git a/Lib/test/test_sqlite3/test_dump.py b/Lib/test/test_sqlite3/test_dump.py index 74aacc05c2b..9ba71a49cfc 100644 --- a/Lib/test/test_sqlite3/test_dump.py +++ b/Lib/test/test_sqlite3/test_dump.py @@ -9,7 +9,7 @@ class DumpTests(MemoryDatabaseMixin, unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON def test_table_dump(self): expected_sqls = [ "PRAGMA foreign_keys=OFF;", @@ -57,7 +57,7 @@ def test_table_dump(self): [self.assertEqual(expected_sqls[i], actual_sqls[i]) for i in range(len(expected_sqls))] - @unittest.expectedFailure # TODO: RUSTPYTHON iterdump filter parameter not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; iterdump filter parameter not implemented def test_table_dump_filter(self): all_table_sqls = [ """CREATE TABLE "some_table_2" ("id_1" INTEGER);""", @@ -128,7 +128,7 @@ def test_table_dump_filter(self): ["BEGIN TRANSACTION;", *all_table_sqls, *all_views_sqls, "COMMIT;"], ) - @unittest.expectedFailure # TODO: RUSTPYTHON _iterdump not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; _iterdump not implemented def test_dump_autoincrement(self): expected = [ 'CREATE TABLE "t1" (id integer primary key autoincrement);', @@ -149,7 +149,7 @@ def test_dump_autoincrement(self): actual = [stmt for stmt in self.cx.iterdump()] self.assertEqual(expected, actual) - @unittest.expectedFailure # TODO: RUSTPYTHON _iterdump not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; _iterdump not implemented def test_dump_autoincrement_create_new_db(self): self.cu.execute("BEGIN TRANSACTION") self.cu.execute("CREATE TABLE t1 (id integer primary key autoincrement)") @@ -175,7 +175,7 @@ def test_dump_autoincrement_create_new_db(self): rows = res.fetchall() self.assertEqual(rows[0][0], seq) - @unittest.expectedFailure # TODO: RUSTPYTHON _iterdump not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; _iterdump not implemented def test_unorderable_row(self): # iterdump() should be able to cope with unorderable row types (issue #15545) class UnorderableRow: @@ -197,7 +197,7 @@ def __getitem__(self, index): got = list(self.cx.iterdump()) self.assertEqual(expected, got) - @unittest.expectedFailure # TODO: RUSTPYTHON _iterdump not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; _iterdump not implemented def test_dump_custom_row_factory(self): # gh-118221: iterdump should be able to cope with custom row factories. def dict_factory(cu, row): @@ -213,7 +213,7 @@ def dict_factory(cu, row): self.assertEqual(expected, actual) self.assertEqual(self.cx.row_factory, dict_factory) - @unittest.expectedFailure # TODO: RUSTPYTHON _iterdump not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; _iterdump not implemented @requires_virtual_table("fts4") def test_dump_virtual_tables(self): # gh-64662 diff --git a/Lib/test/test_sqlite3/test_factory.py b/Lib/test/test_sqlite3/test_factory.py index 2816bd91253..4345df7aef0 100644 --- a/Lib/test/test_sqlite3/test_factory.py +++ b/Lib/test/test_sqlite3/test_factory.py @@ -40,7 +40,7 @@ def __init__(self, *args, **kwargs): self.row_factory = dict_factory class ConnectionFactoryTests(unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON def test_connection_factories(self): class DefectFactory(sqlite.Connection): def __init__(self, *args, **kwargs): @@ -56,7 +56,7 @@ def __init__(self, *args, **kwargs): with memory_database(factory=DefectFactory) as con: self.assertIsInstance(con, DefectFactory) - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON def test_connection_factory_relayed_call(self): # gh-95132: keyword args must not be passed as positional args class Factory(sqlite.Connection): @@ -68,7 +68,7 @@ def __init__(self, *args, **kwargs): self.assertIsNone(con.isolation_level) self.assertIsInstance(con, Factory) - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON def test_connection_factory_as_positional_arg(self): class Factory(sqlite.Connection): def __init__(self, *args, **kwargs): @@ -90,9 +90,6 @@ def __init__(self, *args, **kwargs): class CursorFactoryTests(MemoryDatabaseMixin, unittest.TestCase): - def tearDown(self): - self.con.close() - def test_is_instance(self): cur = self.con.cursor() self.assertIsInstance(cur, sqlite.Cursor) @@ -131,7 +128,7 @@ def test_custom_factory(self): row = self.con.execute("select 1, 2").fetchone() self.assertIsInstance(row, list) - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON def test_sqlite_row_index(self): row = self.con.execute("select 1 as a_1, 2 as b").fetchone() self.assertIsInstance(row, sqlite.Row) @@ -162,7 +159,19 @@ def test_sqlite_row_index(self): with self.assertRaises(IndexError): row[complex()] # index must be int or string - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: can't delete attribute + def test_delete_connection_row_factory(self): + # gh-149738: deleting row_factory should raise an exception + with self.assertRaises(AttributeError): + del self.con.row_factory + + @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: can't delete attribute + def test_delete_connection_text_factory(self): + # gh-149738: deleting text_factory should raise an exception + with self.assertRaises(AttributeError): + del self.con.text_factory + + @unittest.expectedFailure # TODO: RUSTPYTHON def test_sqlite_row_index_unicode(self): row = self.con.execute("select 1 as \xff").fetchone() self.assertEqual(row["\xff"], 1) diff --git a/Lib/test/test_sqlite3/test_hooks.py b/Lib/test/test_sqlite3/test_hooks.py index c47cfab180d..e5b946bbaf2 100644 --- a/Lib/test/test_sqlite3/test_hooks.py +++ b/Lib/test/test_sqlite3/test_hooks.py @@ -24,11 +24,15 @@ import sqlite3 as sqlite import unittest +from test.support import import_helper from test.support.os_helper import TESTFN, unlink from .util import memory_database, cx_limit, with_tracebacks from .util import MemoryDatabaseMixin +# TODO(picnixz): increase test coverage for other callbacks +# such as 'func', 'step', 'finalize', and 'collation'. + class CollationTests(MemoryDatabaseMixin, unittest.TestCase): @@ -116,6 +120,21 @@ def test_collation_register_twice(self): self.assertEqual(result[0][0], 'b') self.assertEqual(result[1][0], 'a') + def test_collation_register_when_busy(self): + # See https://github.com/python/cpython/issues/146090. + con = self.con + con.create_collation("mycoll", lambda x, y: (x > y) - (x < y)) + con.execute("CREATE TABLE t(x TEXT)") + con.execute("INSERT INTO t VALUES (?)", ("a",)) + con.execute("INSERT INTO t VALUES (?)", ("b",)) + con.commit() + + cursor = self.con.execute("SELECT x FROM t ORDER BY x COLLATE mycoll") + next(cursor) + # Replace the collation while the statement is active -> SQLITE_BUSY. + with self.assertRaises(sqlite.OperationalError) as cm: + self.con.create_collation("mycoll", lambda a, b: 0) + def test_deregister_collation(self): """ Register a collation, then deregister it. Make sure an error is raised if we try @@ -129,8 +148,56 @@ def test_deregister_collation(self): self.assertEqual(str(cm.exception), 'no such collation sequence: mycoll') +class AuthorizerTests(MemoryDatabaseMixin, unittest.TestCase): + + def assert_not_authorized(self, func, /, *args, **kwargs): + with self.assertRaisesRegex(sqlite.DatabaseError, "not authorized"): + func(*args, **kwargs) + + # When a handler has an invalid signature, the exception raised is + # the same that would be raised if the handler "negatively" replied. + + def test_authorizer_invalid_signature(self): + self.cx.execute("create table if not exists test(a number)") + self.cx.set_authorizer(lambda: None) + self.assert_not_authorized(self.cx.execute, "select * from test") + + # Tests for checking that callback context mutations do not crash. + # Regression tests for https://github.com/python/cpython/issues/142830. + + @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'NoneType' object has no attribute 'exc_type' + @with_tracebacks(ZeroDivisionError, regex="hello world") + def test_authorizer_concurrent_mutation_in_call(self): + self.cx.execute("create table if not exists test(a number)") + + def handler(*a, **kw): + self.cx.set_authorizer(None) + raise ZeroDivisionError("hello world") + + self.cx.set_authorizer(handler) + self.assert_not_authorized(self.cx.execute, "select * from test") + + @with_tracebacks(OverflowError) + def test_authorizer_concurrent_mutation_with_overflown_value(self): + _testcapi = import_helper.import_module("_testcapi") + self.cx.execute("create table if not exists test(a number)") + + def handler(*a, **kw): + self.cx.set_authorizer(None) + # We expect 'int' at the C level, so this one will raise + # when converting via PyLong_Int(). + return _testcapi.INT_MAX + 1 + + self.cx.set_authorizer(handler) + self.assert_not_authorized(self.cx.execute, "select * from test") + + class ProgressTests(MemoryDatabaseMixin, unittest.TestCase): + def assert_interrupted(self, func, /, *args, **kwargs): + with self.assertRaisesRegex(sqlite.OperationalError, "interrupted"): + func(*args, **kwargs) + def test_progress_handler_used(self): """ Test that the progress handler is invoked once it is set. @@ -196,7 +263,7 @@ def progress(): con.execute("select 1 union select 2 union select 3").fetchall() self.assertEqual(action, 0, "progress handler was not cleared") - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON @with_tracebacks(ZeroDivisionError, msg_regex="bad_progress") def test_error_in_progress_handler(self): def bad_progress(): @@ -207,7 +274,7 @@ def bad_progress(): create table foo(a, b) """) - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks(ZeroDivisionError, msg_regex="bad_progress") def test_error_in_progress_handler_result(self): class BadBool: @@ -221,8 +288,8 @@ def bad_progress(): create table foo(a, b) """) - @unittest.expectedFailure # TODO: RUSTPYTHON keyword-only arguments not supported for set_progress_handler - def test_progress_handler_keyword_args(self): + @unittest.expectedFailure # TODO: RUSTPYTHON; keyword-only arguments not supported for set_progress_handler + def test_set_progress_handler_keyword_args(self): regex = ( r"Passing keyword argument 'progress_handler' to " r"_sqlite3.Connection.set_progress_handler\(\) is deprecated. " @@ -234,6 +301,44 @@ def test_progress_handler_keyword_args(self): self.con.set_progress_handler(progress_handler=lambda: None, n=1) self.assertEqual(cm.filename, __file__) + # When a handler has an invalid signature, the exception raised is + # the same that would be raised if the handler "negatively" replied. + + def test_progress_handler_invalid_signature(self): + self.cx.execute("create table if not exists test(a number)") + self.cx.set_progress_handler(lambda x: None, 1) + self.assert_interrupted(self.cx.execute, "select * from test") + + # Tests for checking that callback context mutations do not crash. + # Regression tests for https://github.com/python/cpython/issues/142830. + + @unittest.skip("TODO: RUSTPYTHON; Timeout after 10 minutes") + @with_tracebacks(ZeroDivisionError, regex="hello world") + def test_progress_handler_concurrent_mutation_in_call(self): + self.cx.execute("create table if not exists test(a number)") + + def handler(*a, **kw): + self.cx.set_progress_handler(None, 1) + raise ZeroDivisionError("hello world") + + self.cx.set_progress_handler(handler, 1) + self.assert_interrupted(self.cx.execute, "select * from test") + + def test_progress_handler_concurrent_mutation_in_conversion(self): + self.cx.execute("create table if not exists test(a number)") + + class Handler: + def __bool__(_): + # clear the progress handler + self.cx.set_progress_handler(None, 1) + raise ValueError # force PyObject_True() to fail + + self.cx.set_progress_handler(Handler.__init__, 1) + self.assert_interrupted(self.cx.execute, "select * from test") + + # Running with tracebacks makes the second execution of this + # function raise another exception because of a database change. + class TraceCallbackTests(MemoryDatabaseMixin, unittest.TestCase): @@ -325,7 +430,7 @@ def test_trace_expanded_sql(self): cx.execute("create table t(t)") cx.executemany("insert into t values(?)", ((v,) for v in range(3))) - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks( sqlite.DataError, regex="Expanded SQL string exceeds the maximum string length" @@ -350,15 +455,15 @@ def test_trace_too_much_expanded_sql(self): with self.check_stmt_trace(cx, [expanded_query]): cx.execute(unexpanded_query, (ok_param,)) - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks(ZeroDivisionError, regex="division by zero") def test_trace_bad_handler(self): with memory_database() as cx: cx.set_trace_callback(lambda stmt: 5/0) cx.execute("select 1") - @unittest.expectedFailure # TODO: RUSTPYTHON keyword-only arguments not supported for set_trace_callback - def test_trace_keyword_args(self): + @unittest.expectedFailure # TODO: RUSTPYTHON; keyword-only arguments not supported for set_trace_callback + def test_set_trace_callback_keyword_args(self): regex = ( r"Passing keyword argument 'trace_callback' to " r"_sqlite3.Connection.set_trace_callback\(\) is deprecated. " @@ -370,6 +475,37 @@ def test_trace_keyword_args(self): self.con.set_trace_callback(trace_callback=lambda: None) self.assertEqual(cm.filename, __file__) + # When a handler has an invalid signature, the exception raised is + # the same that would be raised if the handler "negatively" replied, + # but for the trace handler, exceptions are never re-raised (only + # printed when needed). + + @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'NoneType' object has no attribute 'exc_type' + @with_tracebacks( + TypeError, + regex=r".*\(\) missing 6 required positional arguments", + ) + def test_trace_handler_invalid_signature(self): + self.cx.execute("create table if not exists test(a number)") + self.cx.set_trace_callback(lambda x, y, z, t, a, b, c: None) + self.cx.execute("select * from test") + + # Tests for checking that callback context mutations do not crash. + # Regression tests for https://github.com/python/cpython/issues/142830. + + @unittest.skip("TODO: RUSTPYTHON; Timeout after 10 minutes") + @with_tracebacks(ZeroDivisionError, regex="hello world") + def test_trace_callback_concurrent_mutation_in_call(self): + self.cx.execute("create table if not exists test(a number)") + + def handler(statement): + # clear the progress handler + self.cx.set_trace_callback(None) + raise ZeroDivisionError("hello world") + + self.cx.set_trace_callback(handler) + self.cx.execute("select * from test") + if __name__ == "__main__": unittest.main() diff --git a/Lib/test/test_sqlite3/test_regression.py b/Lib/test/test_sqlite3/test_regression.py index 0ebd6d5e9da..2f59cf9ba4d 100644 --- a/Lib/test/test_sqlite3/test_regression.py +++ b/Lib/test/test_sqlite3/test_regression.py @@ -258,7 +258,7 @@ def collation_cb(a, b): # Lone surrogate cannot be encoded to the default encoding (utf8) "\uDC80", collation_cb) - @unittest.skip('TODO: RUSTPYTHON; recursive cursor use causes lock contention') + @unittest.skip("TODO: RUSTPYTHON; recursive cursor use causes lock contention") def test_recursive_cursor_use(self): """ http://bugs.python.org/issue10811 @@ -305,7 +305,7 @@ def test_convert_timestamp_microsecond_padding(self): datetime.datetime(2012, 4, 4, 15, 6, 0, 123456), ]) - @unittest.expectedFailure # TODO: RUSTPYTHON; error message mismatch + @unittest.expectedFailure # TODO: RUSTPYTHON; error message mismatch def test_invalid_isolation_level_type(self): # isolation level is a string, not an integer regex = "isolation_level must be str or None" @@ -396,7 +396,7 @@ def test_del_isolation_level_segfault(self): with self.assertRaises(AttributeError): del self.con.isolation_level - @unittest.expectedFailure # TODO: RUSTPYTHON + @unittest.expectedFailure # TODO: RUSTPYTHON def test_bpo37347(self): class Printer: def log(self, *args): @@ -440,7 +440,7 @@ def test_table_lock_cursor_dealloc(self): con.execute("drop table t") con.commit() - @unittest.skip('TODO: RUSTPYTHON; recursive cursor use causes lock contention') + @unittest.skip("TODO: RUSTPYTHON; recursive cursor use causes lock contention") def test_table_lock_cursor_non_readonly_select(self): with memory_database() as con: con.execute("create table t(t)") @@ -469,7 +469,7 @@ def test_executescript_step_through_select(self): self.assertEqual(steps, values) -@unittest.skip('TODO: RUSTPYTHON; recursive cursor use causes lock contention') +@unittest.skip("TODO: RUSTPYTHON; recursive cursor use causes lock contention") class RecursiveUseOfCursors(unittest.TestCase): # GH-80254: sqlite3 should not segfault for recursive use of cursors. msg = "Recursive use of cursors not allowed" diff --git a/Lib/test/test_sqlite3/test_transactions.py b/Lib/test/test_sqlite3/test_transactions.py index 3b57b7f6a08..db58938b9db 100644 --- a/Lib/test/test_sqlite3/test_transactions.py +++ b/Lib/test/test_sqlite3/test_transactions.py @@ -387,7 +387,7 @@ def test_autocommit_setget(self): cx.autocommit = mode self.assertEqual(cx.autocommit, mode) - @unittest.expectedFailure # TODO: RUSTPYTHON autocommit validation error messages differ + @unittest.expectedFailure # TODO: RUSTPYTHON; autocommit validation error messages differ def test_autocommit_setget_invalid(self): msg = "autocommit must be True, False, or.*LEGACY" for mode in "a", 12, (), None: @@ -395,7 +395,7 @@ def test_autocommit_setget_invalid(self): with self.assertRaisesRegex(ValueError, msg): sqlite.connect(":memory:", autocommit=mode) - @unittest.expectedFailure # TODO: RUSTPYTHON autocommit behavior differs + @unittest.expectedFailure # TODO: RUSTPYTHON; autocommit behavior differs def test_autocommit_disabled(self): expected = [ "SELECT 1", @@ -411,7 +411,7 @@ def test_autocommit_disabled(self): cx.commit() cx.rollback() - @unittest.expectedFailure # TODO: RUSTPYTHON autocommit behavior differs + @unittest.expectedFailure # TODO: RUSTPYTHON; autocommit behavior differs def test_autocommit_disabled_implicit_rollback(self): expected = ["ROLLBACK"] with memory_database(autocommit=False) as cx: @@ -438,7 +438,7 @@ def test_autocommit_enabled_txn_ctl(self): meth() # expect this to pass silently self.assertFalse(cx.in_transaction) - @unittest.expectedFailure # TODO: RUSTPYTHON autocommit behavior differs + @unittest.expectedFailure # TODO: RUSTPYTHON; autocommit behavior differs def test_autocommit_disabled_then_enabled(self): expected = ["COMMIT"] with memory_database(autocommit=False) as cx: @@ -472,7 +472,7 @@ def test_autocommit_enabled_ctx_mgr(self): self.assertFalse(cx.in_transaction) self.assertFalse(cx.in_transaction) - @unittest.expectedFailure # TODO: RUSTPYTHON autocommit behavior differs + @unittest.expectedFailure # TODO: RUSTPYTHON; autocommit behavior differs def test_autocommit_disabled_ctx_mgr(self): expected = ["COMMIT", "BEGIN"] with memory_database(autocommit=False) as cx: @@ -492,7 +492,7 @@ def test_autocommit_compat_ctx_mgr(self): self.assertTrue(cx.in_transaction) self.assertFalse(cx.in_transaction) - @unittest.expectedFailure # TODO: RUSTPYTHON autocommit behavior differs + @unittest.expectedFailure # TODO: RUSTPYTHON; autocommit behavior differs def test_autocommit_enabled_executescript(self): expected = ["BEGIN", "SELECT 1"] with memory_database(autocommit=True) as cx: @@ -502,7 +502,7 @@ def test_autocommit_enabled_executescript(self): cx.executescript("SELECT 1") self.assertTrue(cx.in_transaction) - @unittest.expectedFailure # TODO: RUSTPYTHON autocommit behavior differs + @unittest.expectedFailure # TODO: RUSTPYTHON; autocommit behavior differs def test_autocommit_disabled_executescript(self): expected = ["SELECT 1"] with memory_database(autocommit=False) as cx: diff --git a/Lib/test/test_sqlite3/test_userfunctions.py b/Lib/test/test_sqlite3/test_userfunctions.py index 3fdde4a26cd..e7cecb85213 100644 --- a/Lib/test/test_sqlite3/test_userfunctions.py +++ b/Lib/test/test_sqlite3/test_userfunctions.py @@ -170,7 +170,7 @@ def setUp(self): def tearDown(self): self.con.close() - @unittest.expectedFailure # TODO: RUSTPYTHON error message differs for invalid num args + @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for invalid num args def test_func_error_on_create(self): with self.assertRaisesRegex(sqlite.ProgrammingError, "not -100"): self.con.create_function("bla", -100, lambda x: 2*x) @@ -255,7 +255,7 @@ def test_func_return_nan(self): cur.execute("select returnnan()") self.assertIsNone(cur.fetchone()[0]) - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks(ZeroDivisionError, msg_regex="func_raiseexception") def test_func_exception(self): cur = self.con.cursor() @@ -264,7 +264,7 @@ def test_func_exception(self): cur.fetchone() self.assertEqual(str(cm.exception), 'user-defined function raised exception') - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks(MemoryError, msg_regex="func_memoryerror") def test_func_memory_error(self): cur = self.con.cursor() @@ -272,7 +272,7 @@ def test_func_memory_error(self): cur.execute("select memoryerror()") cur.fetchone() - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks(OverflowError, msg_regex="func_overflowerror") def test_func_overflow_error(self): cur = self.con.cursor() @@ -306,7 +306,7 @@ def test_non_contiguous_blob(self): self.con.execute, "select spam(?)", (memoryview(b"blob")[::2],)) - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks(BufferError, regex="buffer.*contiguous") def test_return_non_contiguous_blob(self): with self.assertRaises(sqlite.OperationalError): @@ -385,7 +385,7 @@ def md5sum(t): del x,y gc_collect() - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks(OverflowError) def test_func_return_too_large_int(self): cur = self.con.cursor() @@ -395,7 +395,7 @@ def test_func_return_too_large_int(self): with self.assertRaisesRegex(sqlite.DataError, msg): cur.execute("select largeint()") - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks(UnicodeEncodeError, "surrogates not allowed") def test_func_return_text_with_surrogates(self): cur = self.con.cursor() @@ -428,7 +428,7 @@ def test_func_return_illegal_value(self): self.assertRaisesRegex(sqlite.OperationalError, msg, self.con.execute, "select badreturn()") - @unittest.expectedFailure # TODO: RUSTPYTHON deprecation warning not emitted for keyword args + @unittest.expectedFailure # TODO: RUSTPYTHON; deprecation warning not emitted for keyword args def test_func_keyword_args(self): regex = ( r"Passing keyword arguments 'name', 'narg' and 'func' to " @@ -514,12 +514,12 @@ def test_win_sum_int(self): self.cur.execute(self.query % "sumint") self.assertEqual(self.cur.fetchall(), self.expected) - @unittest.expectedFailure # TODO: RUSTPYTHON error message differs for invalid num args + @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for invalid num args def test_win_error_on_create(self): with self.assertRaisesRegex(sqlite.ProgrammingError, "not -100"): self.con.create_window_function("shouldfail", -100, WindowSumInt) - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks(BadWindow) def test_win_exception_in_method(self): for meth in "__init__", "step", "value", "inverse": @@ -532,7 +532,7 @@ def test_win_exception_in_method(self): self.cur.execute(self.query % name) self.cur.fetchall() - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks(BadWindow) def test_win_exception_in_finalize(self): # Note: SQLite does not (as of version 3.38.0) propagate finalize @@ -544,7 +544,7 @@ def test_win_exception_in_finalize(self): self.cur.execute(self.query % name) self.cur.fetchall() - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks(AttributeError) def test_win_missing_method(self): class MissingValue: @@ -576,7 +576,7 @@ def finalize(self): return 42 self.cur.execute(self.query % name) self.cur.fetchall() - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks(AttributeError) def test_win_missing_finalize(self): # Note: SQLite does not (as of version 3.38.0) propagate finalize @@ -649,12 +649,12 @@ def setUp(self): def tearDown(self): self.con.close() - @unittest.expectedFailure # TODO: RUSTPYTHON error message differs for invalid num args + @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for invalid num args def test_aggr_error_on_create(self): with self.assertRaisesRegex(sqlite.ProgrammingError, "not -100"): self.con.create_function("bla", -100, AggrSum) - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks(AttributeError, msg_regex="AggrNoStep") def test_aggr_no_step(self): cur = self.con.cursor() @@ -670,7 +670,7 @@ def test_aggr_no_finalize(self): cur.execute("select nofinalize(t) from test") val = cur.fetchone()[0] - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks(ZeroDivisionError, msg_regex="AggrExceptionInInit") def test_aggr_exception_in_init(self): cur = self.con.cursor() @@ -679,7 +679,7 @@ def test_aggr_exception_in_init(self): val = cur.fetchone()[0] self.assertEqual(str(cm.exception), "user-defined aggregate's '__init__' method raised error") - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks(ZeroDivisionError, msg_regex="AggrExceptionInStep") def test_aggr_exception_in_step(self): cur = self.con.cursor() @@ -688,7 +688,7 @@ def test_aggr_exception_in_step(self): val = cur.fetchone()[0] self.assertEqual(str(cm.exception), "user-defined aggregate's 'step' method raised error") - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks(ZeroDivisionError, msg_regex="AggrExceptionInFinalize") def test_aggr_exception_in_finalize(self): cur = self.con.cursor() @@ -754,7 +754,7 @@ def test_aggr_text(self): val = cur.fetchone()[0] self.assertEqual(val, txt) - @unittest.expectedFailure # TODO: RUSTPYTHON keyword-only arguments not supported for create_aggregate + @unittest.expectedFailure # TODO: RUSTPYTHON; keyword-only arguments not supported for create_aggregate def test_agg_keyword_args(self): regex = ( r"Passing keyword arguments 'name', 'n_arg' and 'aggregate_class' to " @@ -803,13 +803,13 @@ def setUp(self): def tearDown(self): self.con.close() - @unittest.expectedFailure # TODO: RUSTPYTHON error message differs + @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs def test_table_access(self): with self.assertRaises(sqlite.DatabaseError) as cm: self.con.execute("select * from t2") self.assertIn('prohibited', str(cm.exception)) - @unittest.expectedFailure # TODO: RUSTPYTHON error message differs + @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs def test_column_access(self): with self.assertRaises(sqlite.DatabaseError) as cm: self.con.execute("select c2 from t1") @@ -820,7 +820,7 @@ def test_clear_authorizer(self): self.con.execute("select * from t2") self.con.execute("select c2 from t1") - @unittest.expectedFailure # TODO: RUSTPYTHON keyword-only arguments not supported for set_authorizer + @unittest.expectedFailure # TODO: RUSTPYTHON; keyword-only arguments not supported for set_authorizer def test_authorizer_keyword_args(self): regex = ( r"Passing keyword argument 'authorizer_callback' to " @@ -843,12 +843,12 @@ def authorizer_cb(action, arg1, arg2, dbname, source): raise ValueError return sqlite.SQLITE_OK - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks(ValueError, msg_regex="authorizer_cb") def test_table_access(self): super().test_table_access() - @unittest.expectedFailure # TODO: RUSTPYTHON unraisable exception handling not implemented + @unittest.expectedFailure # TODO: RUSTPYTHON; unraisable exception handling not implemented @with_tracebacks(ValueError, msg_regex="authorizer_cb") def test_column_access(self): super().test_table_access() From e071198ec4ab2834c4168bfb9825b7b28ed86ef5 Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:30:38 -0400 Subject: [PATCH 061/351] unicodedata: No alloc is_normalized (#8206) `icu4x` supports checking if a string is normalized without allocation. --- crates/stdlib/src/unicodedata.rs | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/crates/stdlib/src/unicodedata.rs b/crates/stdlib/src/unicodedata.rs index b373b083e23..0d6e7b97226 100644 --- a/crates/stdlib/src/unicodedata.rs +++ b/crates/stdlib/src/unicodedata.rs @@ -339,30 +339,20 @@ mod unicodedata { #[pymethod] fn is_normalized(&self, form: super::NormalizeForm, unistr: PyStrRef) -> bool { - let text = unistr.as_wtf8(); - let normalized: Wtf8Buf = match form { + match form { NormalizeForm::Nfc => { - let normalizer = ComposingNormalizerBorrowed::new_nfc(); - text.map_utf8(|s| normalizer.normalize_iter(s.chars())) - .collect() + ComposingNormalizerBorrowed::new_nfc().is_normalized_utf8(unistr.as_bytes()) } NormalizeForm::Nfkc => { - let normalizer = ComposingNormalizerBorrowed::new_nfkc(); - text.map_utf8(|s| normalizer.normalize_iter(s.chars())) - .collect() + ComposingNormalizerBorrowed::new_nfkc().is_normalized_utf8(unistr.as_bytes()) } NormalizeForm::Nfd => { - let normalizer = DecomposingNormalizerBorrowed::new_nfd(); - text.map_utf8(|s| normalizer.normalize_iter(s.chars())) - .collect() + DecomposingNormalizerBorrowed::new_nfd().is_normalized_utf8(unistr.as_bytes()) } NormalizeForm::Nfkd => { - let normalizer = DecomposingNormalizerBorrowed::new_nfkd(); - text.map_utf8(|s| normalizer.normalize_iter(s.chars())) - .collect() + DecomposingNormalizerBorrowed::new_nfkd().is_normalized_utf8(unistr.as_bytes()) } - }; - text == &*normalized + } } #[pymethod] From e7aeaeede4212b29fda5b911a5cb4fc7a1740b8c Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:31:34 +0300 Subject: [PATCH 062/351] Move free standing `ir::Block` functions to methods (#8205) --- crates/codegen/src/ir.rs | 998 +++++++++++++++++++-------------------- 1 file changed, 499 insertions(+), 499 deletions(-) diff --git a/crates/codegen/src/ir.rs b/crates/codegen/src/ir.rs index e24909f9c41..28554aa846f 100644 --- a/crates/codegen/src/ir.rs +++ b/crates/codegen/src/ir.rs @@ -440,103 +440,6 @@ fn c_array_ensure_capacity( } } -/// flowgraph.c basicblock_next_instr -fn basicblock_next_instr(block: &mut Block) -> crate::InternalResult { - let off = block.instruction_used; - let new_allocation = c_array_ensure_capacity::( - block.instruction_allocation, - off + 1, - DEFAULT_BLOCK_SIZE, - )?; - if new_allocation > block.instruction_allocation { - if new_allocation > block.instructions.len() { - block - .instructions - .try_reserve_exact(new_allocation - block.instructions.len()) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - block - .instructions - .resize_with(new_allocation, empty_instruction_info); - } - block.instruction_allocation = new_allocation; - } - debug_assert!(block.instruction_allocation > off); - block.instruction_used += 1; - Ok(off) -} - -/// flowgraph.c basicblock_last_instr -fn basicblock_last_instr(block: &Block) -> Option<&InstructionInfo> { - debug_assert!(block.instruction_allocation >= block.instruction_used); - if block.instruction_used > 0 { - debug_assert!(!block.instructions.is_empty()); - Some(&block.instructions[block.instruction_used - 1]) - } else { - None - } -} - -/// flowgraph.c basicblock_last_instr -fn basicblock_last_instr_mut(block: &mut Block) -> Option<&mut InstructionInfo> { - debug_assert!(block.instruction_allocation >= block.instruction_used); - if block.instruction_used > 0 { - debug_assert!(!block.instructions.is_empty()); - Some(&mut block.instructions[block.instruction_used - 1]) - } else { - None - } -} - -/// flowgraph.c basicblock_addop -fn basicblock_addop(block: &mut Block, mut info: InstructionInfo) -> crate::InternalResult<()> { - let opcode = AnyOpcode::from(info.instr); - debug_assert!(is_within_opcode_range(opcode)); - debug_assert!(!info.instr.is_assembler()); - debug_assert!( - info.instr.has_arg() || info.instr.has_target() || u32::from(info.arg) == 0, - "CPython basicblock_addop requires OPCODE_HAS_ARG, HAS_TARGET, or oparg == 0" - ); - debug_assert!( - u32::from(info.arg) < (1 << 30), - "CPython basicblock_addop requires 0 <= oparg < (1 << 30)" - ); - let off = basicblock_next_instr(block)?; - let except_handler = block.instructions[off].except_handler; - info.target = BlockIdx::NULL; - info.except_handler = except_handler; - block.instructions[off] = info; - Ok(()) -} - -/// flowgraph.c basicblock_insert_instruction -fn basicblock_insert_instruction( - block: &mut Block, - pos: usize, - info: InstructionInfo, -) -> crate::InternalResult<()> { - let old_len = block.instruction_used; - debug_assert!(pos <= old_len); - basicblock_next_instr(block)?; - for i in (pos + 1..=old_len).rev() { - block.instructions[i] = block.instructions[i - 1]; - } - block.instructions[pos] = info; - Ok(()) -} - -/// flowgraph.c direct `b_iused = 0` -fn basicblock_clear(block: &mut Block) { - block.instruction_used = 0; -} - -/// CPython direct `b_instr[0]` access. Some passes set `b_iused = 0` -/// without clearing the backing array, so an empty basic block can still have -/// a first raw instruction slot. -fn basicblock_raw_first_instr_mut(block: &mut Block) -> &mut InstructionInfo { - debug_assert!(block.instruction_allocation > 0); - &mut block.instructions[0] -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct InstructionSequenceLabel(i32); @@ -1309,6 +1212,384 @@ impl Block { pub(crate) const fn is_empty(&self) -> bool { self.instruction_used == 0 } + + /// flowgraph.c basicblock_next_instr + fn basicblock_next_instr(&mut self) -> crate::InternalResult { + let off = self.instruction_used; + let new_allocation = c_array_ensure_capacity::( + self.instruction_allocation, + off + 1, + DEFAULT_BLOCK_SIZE, + )?; + if new_allocation > self.instruction_allocation { + if new_allocation > self.instructions.len() { + self.instructions + .try_reserve_exact(new_allocation - self.instructions.len()) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + self.instructions + .resize_with(new_allocation, empty_instruction_info); + } + self.instruction_allocation = new_allocation; + } + debug_assert!(self.instruction_allocation > off); + self.instruction_used += 1; + Ok(off) + } + + /// flowgraph.c basicblock_last_instr + fn basicblock_last_instr(&self) -> Option<&InstructionInfo> { + debug_assert!(self.instruction_allocation >= self.instruction_used); + if self.instruction_used > 0 { + debug_assert!(!self.instructions.is_empty()); + Some(&self.instructions[self.instruction_used - 1]) + } else { + None + } + } + + /// flowgraph.c basicblock_last_instr + fn basicblock_last_instr_mut(&mut self) -> Option<&mut InstructionInfo> { + debug_assert!(self.instruction_allocation >= self.instruction_used); + if self.instruction_used > 0 { + debug_assert!(!self.instructions.is_empty()); + Some(&mut self.instructions[self.instruction_used - 1]) + } else { + None + } + } + + /// flowgraph.c basicblock_addop + fn basicblock_addop(&mut self, mut info: InstructionInfo) -> crate::InternalResult<()> { + let opcode = AnyOpcode::from(info.instr); + debug_assert!(is_within_opcode_range(opcode)); + debug_assert!(!info.instr.is_assembler()); + debug_assert!( + info.instr.has_arg() || info.instr.has_target() || u32::from(info.arg) == 0, + "CPython basicblock_addop requires OPCODE_HAS_ARG, HAS_TARGET, or oparg == 0" + ); + debug_assert!( + u32::from(info.arg) < (1 << 30), + "CPython basicblock_addop requires 0 <= oparg < (1 << 30)" + ); + let off = self.basicblock_next_instr()?; + let except_handler = self.instructions[off].except_handler; + info.target = BlockIdx::NULL; + info.except_handler = except_handler; + self.instructions[off] = info; + Ok(()) + } + + /// flowgraph.c basicblock_insert_instruction + fn basicblock_insert_instruction( + &mut self, + pos: usize, + info: InstructionInfo, + ) -> crate::InternalResult<()> { + let old_len = self.instruction_used; + debug_assert!(pos <= old_len); + self.basicblock_next_instr()?; + for i in (pos + 1..=old_len).rev() { + self.instructions[i] = self.instructions[i - 1]; + } + self.instructions[pos] = info; + Ok(()) + } + + /// flowgraph.c direct `b_iused = 0` + fn basicblock_clear(&mut self) { + self.instruction_used = 0; + } + + /// CPython direct `b_instr[0]` access. Some passes set `b_iused = 0` + /// without clearing the backing array, so an empty basic block can still have + /// a first raw instruction slot. + fn basicblock_raw_first_instr_mut(&mut self) -> &mut InstructionInfo { + debug_assert!(self.instruction_allocation > 0); + &mut self.instructions[0] + } + + /// flowgraph.c BB_NO_FALLTHROUGH + fn bb_no_fallthrough(&self) -> bool { + self.basicblock_nofallthrough() + } + + /// flowgraph.c BB_HAS_FALLTHROUGH + fn bb_has_fallthrough(&self) -> bool { + !self.bb_no_fallthrough() + } + + /// flowgraph.c basicblock_returns + #[cfg(test)] + fn basicblock_returns(&self) -> bool { + let last = self.basicblock_last_instr(); + if let Some(last) = last { + matches!(last.instr.real(), Some(Instruction::ReturnValue)) + } else { + false + } + } + + /// flowgraph.c basicblock_exits_scope + fn basicblock_exits_scope(&self) -> bool { + let last = self.basicblock_last_instr(); + last.is_some_and(|last| last.instr.is_scope_exit()) + } + + /// flowgraph.c is_exit_or_eval_check_without_lineno + fn is_exit_or_eval_check_without_lineno(&self) -> bool { + if self.basicblock_exits_scope() || self.basicblock_has_eval_break() { + self.basicblock_has_no_lineno() + } else { + false + } + } + + /// flowgraph.c basicblock_has_eval_break + fn basicblock_has_eval_break(&self) -> bool { + let mut i = 0; + while i < self.instruction_used { + if self.instructions[i].instr.has_eval_break() { + return true; + } + i += 1; + } + false + } + + /// flowgraph.c basicblock_has_no_lineno + fn basicblock_has_no_lineno(&self) -> bool { + let mut i = 0; + while i < self.instruction_used { + if instruction_lineno(&self.instructions[i]) >= 0 { + return false; + } + i += 1; + } + true + } + + /// flowgraph.c basicblock_nofallthrough + fn basicblock_nofallthrough(&self) -> bool { + let last = self.basicblock_last_instr(); + last.is_some_and(|last| last.instr.is_scope_exit() || last.instr.is_unconditional_jump()) + } + + /// flowgraph.c nop_out + fn nop_out(&mut self, instrs: &[usize]) { + for &i in instrs { + nop_out_no_location(&mut self.instructions[i]); + } + } + + /// flowgraph.c get_const_loading_instrs + fn get_const_loading_instrs( + &self, + mut start: usize, + size: usize, + ) -> crate::InternalResult>> { + let mut indices = Vec::new(); + indices + .try_reserve_exact(size) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + loop { + if start >= self.instruction_used { + return Ok(None); + } + let instr = &self.instructions[start]; + if !matches!(instr.instr.real(), Some(Instruction::Nop)) { + if !loads_const(instr) { + return Ok(None); + } + indices.push(start); + if indices.len() == size { + break; + } + } + let Some(prev) = start.checked_sub(1) else { + return Ok(None); + }; + start = prev; + } + indices.reverse(); + Ok(Some(indices)) + } + + /// flowgraph.c next_swappable_instruction + fn next_swappable_instruction(&self, mut i: usize, lineno: i32) -> Option { + loop { + i += 1; + if i >= self.instruction_used { + return None; + } + + let info = &self.instructions[i]; + let info_lineno = instruction_lineno(info); + + if lineno >= 0 && info_lineno != lineno { + return None; + } + + if matches!(info.instr, AnyInstruction::Real(Instruction::Nop)) { + continue; + } + + if is_swappable(info.instr) { + return Some(i); + } + + return None; + } + } + + /// flowgraph.c swaptimize + fn swaptimize(&mut self, ix: &mut usize) -> crate::InternalResult<()> { + debug_assert!(matches!( + self.instructions[*ix].instr.real_opcode(), + Some(Opcode::Swap) + )); + let mut depth = u32::from(self.instructions[*ix].arg) as usize; + let mut len = 1usize; + let mut more = false; + let limit = self.instruction_used - *ix; + while len < limit { + match self.instructions[*ix + len].instr.real_opcode() { + Some(Opcode::Swap) => { + depth = depth.max(u32::from(self.instructions[*ix + len].arg) as usize); + more = true; + len += 1; + } + Some(Opcode::Nop) => { + len += 1; + } + _ => break, + } + } + + if !more { + return Ok(()); + } + + let mut stack = Vec::new(); + stack + .try_reserve_exact(depth) + .map_err(|_| InternalError::MalformedControlFlowGraph)?; + stack.resize(depth, 0); + let mut i = 0; + while i < depth { + stack[i] = i as i32; + i += 1; + } + + i = 0; + while i < len { + let info = &self.instructions[*ix + i]; + if matches!(info.instr.real_opcode(), Some(Opcode::Swap)) { + let oparg = u32::from(info.arg) as usize; + stack.swap(0, oparg - 1); + } + i += 1; + } + + let mut current = len as isize - 1; + for i in 0..depth { + if stack[i] == VISITED || stack[i] == i as i32 { + continue; + } + let mut j = i; + loop { + if j != 0 { + debug_assert!(current >= 0); + let out = &mut self.instructions[*ix + current as usize]; + out.instr = Opcode::Swap.into(); + out.arg = OpArg::new((j + 1) as u32); + current -= 1; + } + if stack[j] == VISITED { + debug_assert_eq!(j, i); + break; + } + let next_j = stack[j] as usize; + stack[j] = VISITED; + j = next_j; + } + } + + while current >= 0 { + set_to_nop(&mut self.instructions[*ix + current as usize]); + current -= 1; + } + *ix += len - 1; + Ok(()) + } + + /// flowgraph.c apply_static_swaps + fn apply_static_swaps(&mut self, mut i: isize) { + while i >= 0 { + let idx = i as usize; + debug_assert!(idx < self.instruction_used); + let swap_arg = match self.instructions[idx].instr.real_opcode() { + Some(Opcode::Swap) => u32::from(self.instructions[idx].arg), + Some(Opcode::Nop | Opcode::PopTop | Opcode::StoreFast) => { + i -= 1; + continue; + } + _ if matches!( + self.instructions[idx].instr.pseudo_opcode(), + Some(PseudoOpcode::StoreFastMaybeNull) + ) => + { + i -= 1; + continue; + } + _ => return, + }; + + let Some(j) = self.next_swappable_instruction(idx, -1) else { + return; + }; + let lineno = instruction_lineno(&self.instructions[j]); + let mut k = j; + for _ in 1..swap_arg { + let Some(next) = self.next_swappable_instruction(k, lineno) else { + return; + }; + k = next; + } + + let store_j = stores_to(&self.instructions[j]); + let store_k = stores_to(&self.instructions[k]); + if store_j >= 0 || store_k >= 0 { + if store_j == store_k { + return; + } + let mut idx = j + 1; + while idx < k { + let store_idx = stores_to(&self.instructions[idx]); + if store_idx >= 0 && (store_idx == store_j || store_idx == store_k) { + return; + } + idx += 1; + } + } + + set_to_nop(&mut self.instructions[idx]); + self.instructions.swap(j, k); + i -= 1; + } + } + + /// flowgraph.c optimize_basic_block swap pass + fn apply_static_swaps_block(&mut self) -> crate::InternalResult<()> { + let mut i = 0; + while i < self.instruction_used { + if matches!(self.instructions[i].instr.real_opcode(), Some(Opcode::Swap)) { + self.swaptimize(&mut i)?; + self.apply_static_swaps(i as isize); + } + i += 1; + } + Ok(()) + } } #[derive(Clone, Debug, Default)] @@ -1347,7 +1628,7 @@ impl Blocks { while let Some(current) = stack.pop() { let idx = current.idx(); let next = self[idx].next; - if next != BlockIdx::NULL && bb_has_fallthrough(&self[idx]) { + if next != BlockIdx::NULL && self[idx].bb_has_fallthrough() { if !self[next].visited { debug_assert_eq!(self[next].predecessors, 0); stack.push(next); @@ -1377,7 +1658,7 @@ impl Blocks { let next = self[block_idx].next; if self[block_idx].predecessors == 0 { let block = &mut self[block_idx]; - basicblock_clear(block); + block.basicblock_clear(); block.except_handler = false; } block_idx = next; @@ -1396,7 +1677,7 @@ impl Blocks { let from_len = self[from].instruction_used; for i in 0..from_len { let info = self[from].instructions[i]; - let off = basicblock_next_instr(&mut self[to])?; + let off = self[to].basicblock_next_instr()?; self[to].instructions[off] = info; } @@ -1405,7 +1686,7 @@ impl Blocks { /// flowgraph.c copy_basicblock fn copy_basicblock(&mut self, block_idx: BlockIdx) -> crate::InternalResult { - debug_assert!(bb_no_fallthrough(&self[block_idx])); + debug_assert!(self[block_idx].bb_no_fallthrough()); let result = self.blocks_new_block()?; self.basicblock_append_block_instructions(result, block_idx)?; @@ -1418,7 +1699,7 @@ impl Blocks { let entryblock = BlockIdx(0); let mut b = entryblock; while b != BlockIdx::NULL { - let Some(last) = basicblock_last_instr(&self[b]).copied() else { + let Some(last) = self[b].basicblock_last_instr().copied() else { b = self[b].next; continue; }; @@ -1430,7 +1711,7 @@ impl Blocks { debug_assert!(target != BlockIdx::NULL); - if is_exit_or_eval_check_without_lineno(&self[target]) + if self[target].is_exit_or_eval_check_without_lineno() && self[target].predecessors > 1 { let new_target = self.copy_basicblock(target)?; @@ -1438,7 +1719,7 @@ impl Blocks { &mut self[new_target].instructions[0], instr_location(&last), ); - let last_mut = basicblock_last_instr_mut(&mut self[b]).unwrap(); + let last_mut = self[b].basicblock_last_instr_mut().unwrap(); last_mut.target = new_target; self[target].predecessors -= 1; self[new_target].predecessors = 1; @@ -1454,12 +1735,14 @@ impl Blocks { b = entryblock; while b != BlockIdx::NULL { let next = self[b].next; - if bb_has_fallthrough(&self[b]) + if self[b].bb_has_fallthrough() && next != BlockIdx::NULL && self[b].instruction_used != 0 - && is_exit_or_eval_check_without_lineno(&self[next]) + && self[next].is_exit_or_eval_check_without_lineno() { - let last = *basicblock_last_instr(&self[b]).expect("block has instructions"); + let last = *self[b] + .basicblock_last_instr() + .expect("block has instructions"); instr_set_location(&mut self[next].instructions[0], instr_location(&last)); } b = self[b].next; @@ -1734,7 +2017,7 @@ impl Blocks { i += 1; } - apply_static_swaps_block(&mut self[block_idx])?; + self[block_idx].apply_static_swaps_block()?; Ok(()) } @@ -1997,13 +2280,13 @@ impl Blocks { } let fallthrough = self[block_idx].next; - let term = basicblock_last_instr(&self[block_idx]).copied(); + let term = self[block_idx].basicblock_last_instr().copied(); if let Some(term) = term && fallthrough != BlockIdx::NULL && !term.instr.is_unconditional_jump() && !term.instr.is_scope_exit() { - debug_assert!(bb_has_fallthrough(&self[block_idx])); + debug_assert!(self[block_idx].bb_has_fallthrough()); load_fast_push_block(&mut worklist, self, fallthrough, refs.size); } @@ -2043,7 +2326,7 @@ impl Blocks { fn propagate_line_numbers(&mut self) { let mut current = BlockIdx(0); while current != BlockIdx::NULL { - let Some(last) = basicblock_last_instr(&self[current]).copied() else { + let Some(last) = self[current].basicblock_last_instr().copied() else { current = self[current].next; continue; }; @@ -2058,7 +2341,7 @@ impl Blocks { } let next = self[current].next; - if bb_has_fallthrough(&self[current]) { + if self[current].bb_has_fallthrough() { debug_assert!(next != BlockIdx::NULL); if next != BlockIdx::NULL && self[next].predecessors == 1 @@ -2073,7 +2356,7 @@ impl Blocks { let target = last.target; debug_assert!(target != BlockIdx::NULL); if self[target].predecessors == 1 { - let instr = basicblock_raw_first_instr_mut(&mut self[target]); + let instr = self[target].basicblock_raw_first_instr_mut(); if instruction_is_no_location(instr) { instr_set_location(instr, prev_location); } @@ -2134,7 +2417,7 @@ impl Blocks { }); let block = &self[block_idx]; - if instr_is_jump || !bb_has_fallthrough(block) { + if instr_is_jump || !block.bb_has_fallthrough() { instr = None; } block_idx = block.next; @@ -2183,7 +2466,7 @@ impl Blocks { } if next != BlockIdx::NULL { - debug_assert!(bb_has_fallthrough(&self[block_idx])); + debug_assert!(self[block_idx].bb_has_fallthrough()); stackdepth_push(&mut stack, self, next, depth)?; } } @@ -2436,7 +2719,7 @@ impl Blocks { self[block_idx].warm = true; let next = self[block_idx].next; - if next != BlockIdx::NULL && bb_has_fallthrough(&self[block_idx]) && !self[next].visited + if next != BlockIdx::NULL && self[block_idx].bb_has_fallthrough() && !self[next].visited { stack.push(next); self[next].visited = true; @@ -2486,7 +2769,7 @@ impl Blocks { self[block_idx].cold = true; let next = self[block_idx].next; if next != BlockIdx::NULL - && bb_has_fallthrough(&self[block_idx]) + && self[block_idx].bb_has_fallthrough() && !self[next].warm && !self[next].visited { @@ -2525,7 +2808,7 @@ impl Blocks { while block_idx != BlockIdx::NULL { let next = self[block_idx].next; if self[block_idx].cold - && bb_has_fallthrough(&self[block_idx]) + && self[block_idx].bb_has_fallthrough() && next != BlockIdx::NULL && self[next].warm { @@ -2536,24 +2819,22 @@ impl Blocks { } let jump_label = self[next].cpython_label; debug_assert!(is_label(jump_label)); - basicblock_addop( - &mut self[explicit_jump], - InstructionInfo { - instr: PseudoOpcode::JumpNoInterrupt.into(), - arg: instruction_sequence_label_oparg(jump_label), - target: BlockIdx::NULL, - location: SourceLocation::default(), - end_location: SourceLocation::default(), - except_handler: None, - lineno_override: Some(NO_LOCATION_OVERRIDE), - }, - )?; + self[explicit_jump].basicblock_addop(InstructionInfo { + instr: PseudoOpcode::JumpNoInterrupt.into(), + arg: instruction_sequence_label_oparg(jump_label), + target: BlockIdx::NULL, + location: SourceLocation::default(), + end_location: SourceLocation::default(), + except_handler: None, + lineno_override: Some(NO_LOCATION_OVERRIDE), + })?; self[explicit_jump].cold = true; self[explicit_jump].next = next; self[explicit_jump].predecessors = 1; self[block_idx].next = explicit_jump; let target = self[explicit_jump].next; - let last = basicblock_last_instr_mut(&mut self[explicit_jump]) + let last = self[explicit_jump] + .basicblock_last_instr_mut() .expect("missing explicit jump"); last.target = target; } @@ -2653,7 +2934,7 @@ impl Blocks { target: BlockIdx, loc_source: &InstructionInfo, ) -> crate::InternalResult<()> { - let last = basicblock_last_instr(&self[block_idx]); + let last = self[block_idx].basicblock_last_instr(); if last.is_some_and(is_jump) { return Err(InternalError::MalformedControlFlowGraph); } @@ -2662,19 +2943,16 @@ impl Blocks { debug_assert!(is_label(label)); let arg = instruction_sequence_label_oparg(label); let block = &mut self[block_idx]; - basicblock_addop( - block, - InstructionInfo { - instr, - arg, - target: BlockIdx::NULL, - location: loc_source.location, - end_location: loc_source.end_location, - except_handler: None, - lineno_override: loc_source.lineno_override, - }, - )?; - let last = basicblock_last_instr_mut(block).expect("missing jump"); + block.basicblock_addop(InstructionInfo { + instr, + arg, + target: BlockIdx::NULL, + location: loc_source.location, + end_location: loc_source.end_location, + except_handler: None, + lineno_override: loc_source.lineno_override, + })?; + let last = block.basicblock_last_instr_mut().expect("missing jump"); debug_assert!(match (last.instr, instr) { (AnyInstruction::Real(last), AnyInstruction::Real(opcode)) => last.as_opcode() == opcode.as_opcode(), @@ -2722,7 +3000,7 @@ impl Blocks { except_handler, lineno_override, }; - basicblock_insert_instruction(block, i, copy)?; + block.basicblock_insert_instruction(i, copy)?; i += 1; let to_bool = InstructionInfo { @@ -2734,7 +3012,7 @@ impl Blocks { except_handler, lineno_override, }; - basicblock_insert_instruction(block, i, to_bool)?; + block.basicblock_insert_instruction(i, to_bool)?; i += 1; } i += 1; @@ -2746,7 +3024,7 @@ impl Blocks { /// flowgraph.c normalize_jumps_in_block fn normalize_jumps_in_block(&mut self, block_idx: BlockIdx) -> crate::InternalResult<()> { - let Some(last_ins) = basicblock_last_instr(&self[block_idx]).copied() else { + let Some(last_ins) = self[block_idx].basicblock_last_instr().copied() else { return Ok(()); }; if !is_conditional_jump_opcode(last_ins.instr) { @@ -2768,7 +3046,8 @@ impl Blocks { except_handler: None, lineno_override: last_ins.lineno_override, }; - basicblock_addop(&mut self[block_idx], not_taken)?; + + self[block_idx].basicblock_addop(not_taken)?; return Ok(()); } @@ -2787,18 +3066,16 @@ impl Blocks { let target = last_ins.target; let backwards_jump_idx = self.blocks_new_block()?; - basicblock_addop( - &mut self[backwards_jump_idx], - InstructionInfo { - instr: Opcode::NotTaken.into(), - arg: OpArg::new(0), - target: BlockIdx::NULL, - location: loc, - end_location: end_loc, - except_handler: None, - lineno_override: last_ins.lineno_override, - }, - )?; + + self[backwards_jump_idx].basicblock_addop(InstructionInfo { + instr: Opcode::NotTaken.into(), + arg: OpArg::new(0), + target: BlockIdx::NULL, + location: loc, + end_location: end_loc, + except_handler: None, + lineno_override: last_ins.lineno_override, + })?; self.basicblock_add_jump( backwards_jump_idx, PseudoOpcode::Jump.into(), @@ -2810,7 +3087,7 @@ impl Blocks { let old_next = self[block_idx].next; debug_assert!(old_next != BlockIdx::NULL); - let last_mut = basicblock_last_instr_mut(&mut self[block_idx]).unwrap(); + let last_mut = self[block_idx].basicblock_last_instr_mut().unwrap(); last_mut.instr = reversed_opcode; last_mut.target = old_next; @@ -2825,7 +3102,7 @@ impl Blocks { &mut self, block_idx: BlockIdx, ) -> crate::InternalResult { - let Some(last) = basicblock_last_instr(&self[block_idx]).copied() else { + let Some(last) = self[block_idx].basicblock_last_instr().copied() else { return Ok(false); }; @@ -2836,18 +3113,19 @@ impl Blocks { let target = last.target; debug_assert!(target != BlockIdx::NULL); let small_exit_block = - basicblock_exits_scope(&self[target]) && self[target].instruction_used <= MAX_COPY_SIZE; + self[target].basicblock_exits_scope() && self[target].instruction_used <= MAX_COPY_SIZE; let no_lineno_no_fallthrough = - basicblock_has_no_lineno(&self[target]) && !bb_has_fallthrough(&self[target]); + self[target].basicblock_has_no_lineno() && !self[target].bb_has_fallthrough(); if small_exit_block || no_lineno_no_fallthrough { debug_assert!(is_jump(&last)); let removed_jump_opcode = last.instr; - let last = basicblock_last_instr_mut(&mut self[block_idx]) + let last = self[block_idx] + .basicblock_last_instr_mut() .expect("non-empty block has last instruction"); set_to_nop(last); self.basicblock_append_block_instructions(block_idx, target)?; if no_lineno_no_fallthrough { - let last = basicblock_last_instr_mut(&mut self[block_idx]).unwrap(); + let last = self[block_idx].basicblock_last_instr_mut().unwrap(); if last.instr.is_unconditional_jump() && matches!( removed_jump_opcode.into(), @@ -2974,7 +3252,7 @@ impl Blocks { let mut changes = 0; let mut current = BlockIdx(0); while current != BlockIdx::NULL { - let Some(last) = basicblock_last_instr(&self[current]).copied() else { + let Some(last) = self[current].basicblock_last_instr().copied() else { current = self[current].next; continue; }; @@ -2988,7 +3266,7 @@ impl Blocks { let next = next_nonempty_block(self, self[current].next); if jump_target == next { changes += 1; - let last = basicblock_last_instr_mut(&mut self[current]).unwrap(); + let last = self[current].basicblock_last_instr_mut().unwrap(); set_to_nop(last); } } @@ -3003,7 +3281,7 @@ impl Blocks { let mut current = BlockIdx(0); while current != BlockIdx::NULL { let block = &self[current]; - if let Some(last) = basicblock_last_instr(block) + if let Some(last) = block.basicblock_last_instr() && last.instr.is_unconditional_jump() { let next = next_nonempty_block(self, block.next); @@ -3394,11 +3672,11 @@ impl CodeInfo { &mut self, info: InstructionInfo, ) -> crate::InternalResult<()> { - basicblock_addop(&mut self.blocks[self.current_block], info) + self.blocks[self.current_block].basicblock_addop(info) } pub(crate) fn last_current_block_instr_mut(&mut self) -> Option<&mut InstructionInfo> { - basicblock_last_instr_mut(&mut self.blocks[self.current_block]) + self.blocks[self.current_block].basicblock_last_instr_mut() } pub(crate) fn set_last_instr_sequence_lineno_override(&mut self, lineno_override: i32) { @@ -3743,8 +4021,7 @@ fn insert_prefix_instructions( line: firstlineno, character_offset: OneIndexed::MIN, }; - basicblock_insert_instruction( - entry, + entry.basicblock_insert_instruction( 0, InstructionInfo { instr: Instruction::ReturnGenerator.into(), @@ -3756,8 +4033,7 @@ fn insert_prefix_instructions( lineno_override: Some(LINE_ONLY_LOCATION_OVERRIDE), }, )?; - basicblock_insert_instruction( - entry, + entry.basicblock_insert_instruction( 1, InstructionInfo { instr: Instruction::PopTop.into(), @@ -3787,8 +4063,7 @@ fn insert_prefix_instructions( if oldindex == -1 { continue; } - basicblock_insert_instruction( - entry, + entry.basicblock_insert_instruction( ncellsused, InstructionInfo { instr: Opcode::MakeCell.into(), @@ -3805,8 +4080,7 @@ fn insert_prefix_instructions( } if nfreevars > 0 { - basicblock_insert_instruction( - entry, + entry.basicblock_insert_instruction( 0, InstructionInfo { instr: Opcode::CopyFreeVars.into(), @@ -3967,7 +4241,7 @@ fn fold_const_unaryop( _ => return Ok(false), }; let Some(operand_index) = (if let Some(start) = i.checked_sub(1) { - get_const_loading_instrs(block, start, 1)? + block.get_const_loading_instrs(start, 1)? } else { None }) @@ -3981,51 +4255,11 @@ fn fold_const_unaryop( let Some(folded_const) = eval_const_unaryop(&operand, op, intrinsic) else { return Ok(false); }; - nop_out(block, &[operand_index]); + block.nop_out(&[operand_index]); instr_make_load_const(metadata, &mut block.instructions[i], folded_const)?; Ok(true) } -/// flowgraph.c get_const_loading_instrs -fn get_const_loading_instrs( - block: &Block, - mut start: usize, - size: usize, -) -> crate::InternalResult>> { - let mut indices = Vec::new(); - indices - .try_reserve_exact(size) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - loop { - if start >= block.instruction_used { - return Ok(None); - } - let instr = &block.instructions[start]; - if !matches!(instr.instr.real(), Some(Instruction::Nop)) { - if !loads_const(instr) { - return Ok(None); - } - indices.push(start); - if indices.len() == size { - break; - } - } - let Some(prev) = start.checked_sub(1) else { - return Ok(None); - }; - start = prev; - } - indices.reverse(); - Ok(Some(indices)) -} - -/// flowgraph.c nop_out -fn nop_out(block: &mut Block, instrs: &[usize]) { - for &i in instrs { - nop_out_no_location(&mut block.instructions[i]); - } -} - /// flowgraph.c fold_const_binop fn fold_const_binop( metadata: &mut CodeUnitMetadata, @@ -4039,7 +4273,7 @@ fn fold_const_binop( }; let Some(operand_indices) = (if let Some(start) = i.checked_sub(1) { - get_const_loading_instrs(block, start, 2)? + block.get_const_loading_instrs(start, 2)? } else { None }) else { @@ -4061,7 +4295,7 @@ fn fold_const_binop( return Ok(false); }; - nop_out(block, &operand_indices); + block.nop_out(&operand_indices); instr_make_load_const(metadata, &mut block.instructions[i], result_const)?; Ok(true) } @@ -4778,7 +5012,7 @@ fn fold_tuple_of_constants( let Some(operand_indices) = (if tuple_size == 0 { Some(Vec::new()) } else if let Some(start) = i.checked_sub(1) { - get_const_loading_instrs(block, start, tuple_size)? + block.get_const_loading_instrs(start, tuple_size)? } else { None }) else { @@ -4796,7 +5030,7 @@ fn fold_tuple_of_constants( elements.push(element); } - nop_out(block, &operand_indices); + block.nop_out(&operand_indices); instr_make_load_const( metadata, &mut block.instructions[i], @@ -4906,7 +5140,7 @@ fn optimize_lists_and_sets( let Some(operand_indices) = (if seq_size == 0 { Some(Vec::new()) } else if let Some(start) = i.checked_sub(1) { - get_const_loading_instrs(block, start, seq_size)? + block.get_const_loading_instrs(start, seq_size)? } else { None }) else { @@ -4940,7 +5174,7 @@ fn optimize_lists_and_sets( debug_assert!(i >= 2); let folded_loc = instr_location(&block.instructions[i]); - nop_out(block, &operand_indices); + block.nop_out(&operand_indices); let build_instr = if is_list { Opcode::BuildList @@ -4970,7 +5204,7 @@ fn optimize_lists_and_sets( return Ok(true); } - nop_out(block, &operand_indices); + block.nop_out(&operand_indices); instr_set_op1( &mut block.instructions[i], @@ -5001,186 +5235,6 @@ fn stores_to(info: &InstructionInfo) -> i32 { } } -/// flowgraph.c next_swappable_instruction -fn next_swappable_instruction(block: &Block, mut i: usize, lineno: i32) -> Option { - loop { - i += 1; - if i >= block.instruction_used { - return None; - } - - let info = &block.instructions[i]; - let info_lineno = instruction_lineno(info); - - if lineno >= 0 && info_lineno != lineno { - return None; - } - - if matches!(info.instr, AnyInstruction::Real(Instruction::Nop)) { - continue; - } - - if is_swappable(info.instr) { - return Some(i); - } - - return None; - } -} - -/// flowgraph.c swaptimize -fn swaptimize(block: &mut Block, ix: &mut usize) -> crate::InternalResult<()> { - debug_assert!(matches!( - block.instructions[*ix].instr.real_opcode(), - Some(Opcode::Swap) - )); - let mut depth = u32::from(block.instructions[*ix].arg) as usize; - let mut len = 1usize; - let mut more = false; - let limit = block.instruction_used - *ix; - while len < limit { - match block.instructions[*ix + len].instr.real_opcode() { - Some(Opcode::Swap) => { - depth = depth.max(u32::from(block.instructions[*ix + len].arg) as usize); - more = true; - len += 1; - } - Some(Opcode::Nop) => { - len += 1; - } - _ => break, - } - } - - if !more { - return Ok(()); - } - - let mut stack = Vec::new(); - stack - .try_reserve_exact(depth) - .map_err(|_| InternalError::MalformedControlFlowGraph)?; - stack.resize(depth, 0); - let mut i = 0; - while i < depth { - stack[i] = i as i32; - i += 1; - } - - i = 0; - while i < len { - let info = &block.instructions[*ix + i]; - if matches!(info.instr.real_opcode(), Some(Opcode::Swap)) { - let oparg = u32::from(info.arg) as usize; - stack.swap(0, oparg - 1); - } - i += 1; - } - - let mut current = len as isize - 1; - for i in 0..depth { - if stack[i] == VISITED || stack[i] == i as i32 { - continue; - } - let mut j = i; - loop { - if j != 0 { - debug_assert!(current >= 0); - let out = &mut block.instructions[*ix + current as usize]; - out.instr = Opcode::Swap.into(); - out.arg = OpArg::new((j + 1) as u32); - current -= 1; - } - if stack[j] == VISITED { - debug_assert_eq!(j, i); - break; - } - let next_j = stack[j] as usize; - stack[j] = VISITED; - j = next_j; - } - } - - while current >= 0 { - set_to_nop(&mut block.instructions[*ix + current as usize]); - current -= 1; - } - *ix += len - 1; - Ok(()) -} - -/// flowgraph.c apply_static_swaps -fn apply_static_swaps(block: &mut Block, mut i: isize) { - while i >= 0 { - let idx = i as usize; - debug_assert!(idx < block.instruction_used); - let swap_arg = match block.instructions[idx].instr.real_opcode() { - Some(Opcode::Swap) => u32::from(block.instructions[idx].arg), - Some(Opcode::Nop | Opcode::PopTop | Opcode::StoreFast) => { - i -= 1; - continue; - } - _ if matches!( - block.instructions[idx].instr.pseudo_opcode(), - Some(PseudoOpcode::StoreFastMaybeNull) - ) => - { - i -= 1; - continue; - } - _ => return, - }; - - let Some(j) = next_swappable_instruction(block, idx, -1) else { - return; - }; - let lineno = instruction_lineno(&block.instructions[j]); - let mut k = j; - for _ in 1..swap_arg { - let Some(next) = next_swappable_instruction(block, k, lineno) else { - return; - }; - k = next; - } - - let store_j = stores_to(&block.instructions[j]); - let store_k = stores_to(&block.instructions[k]); - if store_j >= 0 || store_k >= 0 { - if store_j == store_k { - return; - } - let mut idx = j + 1; - while idx < k { - let store_idx = stores_to(&block.instructions[idx]); - if store_idx >= 0 && (store_idx == store_j || store_idx == store_k) { - return; - } - idx += 1; - } - } - - set_to_nop(&mut block.instructions[idx]); - block.instructions.swap(j, k); - i -= 1; - } -} - -/// flowgraph.c optimize_basic_block swap pass -fn apply_static_swaps_block(block: &mut Block) -> crate::InternalResult<()> { - let mut i = 0; - while i < block.instruction_used { - if matches!( - block.instructions[i].instr.real_opcode(), - Some(Opcode::Swap) - ) { - swaptimize(block, &mut i)?; - apply_static_swaps(block, i as isize); - } - i += 1; - } - Ok(()) -} - /// flowgraph.c maybe_instr_make_load_smallint fn maybe_instr_make_load_smallint(instr: &mut InstructionInfo, constant: &ConstantData) -> bool { if let ConstantData::Integer { value } = constant @@ -5367,7 +5421,7 @@ impl CodeInfo { while block_idx != BlockIdx::NULL { use core::fmt::Write; let block = &self.blocks[block_idx]; - let block_return = if basicblock_returns(block) { + let block_return = if block.basicblock_returns() { " return" } else { "" @@ -6107,7 +6161,7 @@ fn cfg_builder_new() -> crate::InternalResult { /// flowgraph.c cfg_builder_current_block_is_terminated fn cfg_builder_current_block_is_terminated(g: &mut CfgBuilder) -> bool { let block = &mut g.blocks[g.current]; - let last = basicblock_last_instr(block).copied(); + let last = block.basicblock_last_instr().copied(); if last.is_some_and(|last| last.instr.is_terminator()) { return true; } @@ -6144,7 +6198,7 @@ fn cfg_builder_use_label( /// flowgraph.c _PyCfgBuilder_Addop fn cfg_builder_addop(g: &mut CfgBuilder, info: InstructionInfo) -> crate::InternalResult<()> { cfg_builder_maybe_start_new_block(g)?; - basicblock_addop(&mut g.blocks[g.current], info) + g.blocks[g.current].basicblock_addop(info) } /// flowgraph.c cfg_builder_check @@ -6388,11 +6442,11 @@ fn scan_block_for_locals( } let next = blocks[idx].next; - if next != BlockIdx::NULL && bb_has_fallthrough(&blocks[idx]) { + if next != BlockIdx::NULL && blocks[idx].bb_has_fallthrough() { maybe_push(blocks, worklist, next, unsafe_mask); } - let last = basicblock_last_instr(&blocks[idx]).copied(); + let last = blocks[idx].basicblock_last_instr().copied(); if let Some(last) = last && is_jump(&last) { @@ -6503,22 +6557,6 @@ fn instruction_is_no_location(instr: &InstructionInfo) -> bool { instruction_lineno(instr) == NO_LOCATION_OVERRIDE } -/// flowgraph.c basicblock_nofallthrough -fn basicblock_nofallthrough(block: &Block) -> bool { - let last = basicblock_last_instr(block); - last.is_some_and(|last| last.instr.is_scope_exit() || last.instr.is_unconditional_jump()) -} - -/// flowgraph.c BB_NO_FALLTHROUGH -fn bb_no_fallthrough(block: &Block) -> bool { - basicblock_nofallthrough(block) -} - -/// flowgraph.c BB_HAS_FALLTHROUGH -fn bb_has_fallthrough(block: &Block) -> bool { - !bb_no_fallthrough(block) -} - /// flowgraph.c add_checks_for_loads_of_uninitialized_variables uses uint64_t masks. const LOCAL_UNSAFE_MASK_BITS: usize = 64; @@ -6535,56 +6573,6 @@ fn is_block_push(instr: &InstructionInfo) -> bool { instr.instr.is_block_push() } -/// flowgraph.c basicblock_returns -#[cfg(test)] -fn basicblock_returns(block: &Block) -> bool { - let last = basicblock_last_instr(block); - if let Some(last) = last { - matches!(last.instr.real(), Some(Instruction::ReturnValue)) - } else { - false - } -} - -/// flowgraph.c basicblock_exits_scope -fn basicblock_exits_scope(block: &Block) -> bool { - let last = basicblock_last_instr(block); - last.is_some_and(|last| last.instr.is_scope_exit()) -} - -/// flowgraph.c is_exit_or_eval_check_without_lineno -fn is_exit_or_eval_check_without_lineno(block: &Block) -> bool { - if basicblock_exits_scope(block) || basicblock_has_eval_break(block) { - basicblock_has_no_lineno(block) - } else { - false - } -} - -/// flowgraph.c basicblock_has_eval_break -fn basicblock_has_eval_break(block: &Block) -> bool { - let mut i = 0; - while i < block.instruction_used { - if block.instructions[i].instr.has_eval_break() { - return true; - } - i += 1; - } - false -} - -/// flowgraph.c basicblock_has_no_lineno -fn basicblock_has_no_lineno(block: &Block) -> bool { - let mut i = 0; - while i < block.instruction_used { - if instruction_lineno(&block.instructions[i]) >= 0 { - return false; - } - i += 1; - } - true -} - /// flowgraph.c get_max_label fn get_max_label(blocks: &Blocks) -> i32 { let mut lbl = -1; @@ -6710,7 +6698,7 @@ pub(crate) fn label_exception_targets(blocks: &mut Blocks) -> crate::InternalRes // to the jump target. debug_assert!(target != BlockIdx::NULL); if !blocks[target].visited { - if bb_has_fallthrough(&blocks[bi]) { + if blocks[bi].bb_has_fallthrough() { blocks[target].except_stack = Some(copy_except_stack( stack.as_ref().expect("active exception stack"), )); @@ -6745,7 +6733,7 @@ pub(crate) fn label_exception_targets(blocks: &mut Blocks) -> crate::InternalRes } let next = blocks[bi].next; - if !stack_transferred && bb_has_fallthrough(&blocks[bi]) { + if !stack_transferred && blocks[bi].bb_has_fallthrough() { debug_assert!(next != BlockIdx::NULL); if next != BlockIdx::NULL && !blocks[next].visited { blocks[next].except_stack = stack.take(); @@ -6985,7 +6973,9 @@ mod tests { } fn test_block_push(block: &mut Block, info: InstructionInfo) { - let off = basicblock_next_instr(block).expect("test block instruction slot"); + let off = block + .basicblock_next_instr() + .expect("test block instruction slot"); block.instructions[off] = info; } @@ -7235,9 +7225,10 @@ mod tests { let mut stale = test_instr(Instruction::Nop, 11); stale.except_handler = Some(handler); test_block_push(&mut block, stale); - basicblock_clear(&mut block); + block.basicblock_clear(); - basicblock_addop(&mut block, test_instr(Instruction::PopTop, 12)) + block + .basicblock_addop(test_instr(Instruction::PopTop, 12)) .expect("basicblock_addop succeeds"); // CPython `basicblock_addop()` writes opcode/oparg/target/location into @@ -7251,14 +7242,16 @@ mod tests { fn basicblock_next_instr_tracks_cpython_c_array_allocation() { let mut block = Block::default(); for i in 0..15 { - basicblock_addop(&mut block, test_instr(Instruction::PopTop, 10 + i)) + block + .basicblock_addop(test_instr(Instruction::PopTop, 10 + i)) .expect("basicblock_addop succeeds"); } assert_eq!(block.instruction_allocation, DEFAULT_BLOCK_SIZE); // CPython calls `_Py_CArray_EnsureCapacity(b_iused + 1)`, so the 16th // instruction expands a 16-slot array to 32 before returning offset 15. - basicblock_addop(&mut block, test_instr(Instruction::PopTop, 25)) + block + .basicblock_addop(test_instr(Instruction::PopTop, 25)) .expect("basicblock_addop succeeds"); assert_eq!(block.instruction_allocation, DEFAULT_BLOCK_SIZE * 2); } @@ -7276,7 +7269,8 @@ mod tests { test_block_push(&mut block, stale); block.instruction_used = 1; - basicblock_insert_instruction(&mut block, 0, test_instr(Instruction::PopTop, 23)) + block + .basicblock_insert_instruction(0, test_instr(Instruction::PopTop, 23)) .expect("basicblock_insert_instruction succeeds"); // CPython `basicblock_insert_instruction()` also obtains a slot with @@ -7297,8 +7291,9 @@ mod tests { stale.except_handler = Some(handler); test_block_push(&mut block, stale); - basicblock_clear(&mut block); - basicblock_addop(&mut block, test_instr(Instruction::Nop, 32)) + block.basicblock_clear(); + block + .basicblock_addop(test_instr(Instruction::Nop, 32)) .expect("basicblock_addop succeeds"); // CPython `remove_unreachable()` sets `b_iused = 0` without clearing the @@ -7320,9 +7315,10 @@ mod tests { test_block_push(&mut block, stale); } - basicblock_clear(&mut block); + block.basicblock_clear(); for i in 0..3 { - basicblock_addop(&mut block, test_instr(Instruction::PopTop, 38 + i)) + block + .basicblock_addop(test_instr(Instruction::PopTop, 38 + i)) .expect("basicblock_addop succeeds"); } @@ -7352,7 +7348,7 @@ mod tests { let mut stale = test_instr(Instruction::Nop, 41); stale.except_handler = Some(handler); test_block_push(&mut blocks[0], stale); - basicblock_clear(&mut blocks[0]); + blocks[0].basicblock_clear(); test_block_push(&mut blocks[1], test_instr(Instruction::PopTop, 42)); blocks @@ -7408,7 +7404,9 @@ mod tests { test_block_push(&mut block, info); } - apply_static_swaps_block(&mut block).expect("apply_static_swaps_block succeeds"); + block + .apply_static_swaps_block() + .expect("apply_static_swaps_block succeeds"); // CPython `next_swappable_instruction()` compares `i_loc.lineno` // directly, so a following NO_LOCATION swaperand does not match the @@ -7437,7 +7435,9 @@ mod tests { test_block_push(&mut block, info); } - apply_static_swaps_block(&mut block).expect("apply_static_swaps_block succeeds"); + block + .apply_static_swaps_block() + .expect("apply_static_swaps_block succeeds"); // Conversely, when the first swaperand has NO_LOCATION, CPython passes // `-1` as the line filter and does not enforce a boundary. @@ -7720,7 +7720,7 @@ mod tests { test_block_push(&mut blocks[0], test_cond_jump(BlockIdx::new(1), 10)); test_block_push(&mut blocks[1], test_instr(Instruction::Nop, 20)); blocks[1].instructions[0].lineno_override = Some(NO_LOCATION_OVERRIDE); - basicblock_clear(&mut blocks[1]); + blocks[1].basicblock_clear(); test_block_push(&mut blocks[2], test_instr(Instruction::ReturnValue, 30)); blocks @@ -7743,10 +7743,10 @@ mod tests { // CPython `basicblock_has_no_lineno()` treats every negative lineno as // no line number, including `NEXT_LOCATION` (`lineno == -2`). - assert!(basicblock_has_no_lineno(&block)); + assert!(block.basicblock_has_no_lineno()); test_block_push(&mut block, test_instr(Instruction::PopTop, 11)); - assert!(!basicblock_has_no_lineno(&block)); + assert!(!block.basicblock_has_no_lineno()); } #[test] @@ -7775,7 +7775,7 @@ mod tests { // CPython `optimize_basic_block()` continues after `jump_thread()`, so // the appended jump is immediately checked against the next jump target. - let threaded = basicblock_last_instr(&blocks[0]).expect("threaded jump"); + let threaded = blocks[0].basicblock_last_instr().expect("threaded jump"); assert!(matches!( threaded.instr.pseudo(), Some(PseudoInstruction::Jump { .. }) From 6f7fc418798b22622d49e9a436d9493b05ed03d0 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:32:33 +0300 Subject: [PATCH 063/351] Update some tests to `3.14.6` (#8204) * Apply skip at the correct place * Update `test_msvcrt.yp` to 3.14.6 * Add `test_external_inspection.py` --- Lib/test/test_external_inspection.py | 1304 ++++++++++++++++++++++++++ Lib/test/test_mmap.py | 5 +- Lib/test/test_msvcrt.py | 9 +- 3 files changed, 1312 insertions(+), 6 deletions(-) create mode 100644 Lib/test/test_external_inspection.py diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py new file mode 100644 index 00000000000..08779bdb008 --- /dev/null +++ b/Lib/test/test_external_inspection.py @@ -0,0 +1,1304 @@ +import unittest +import os +import textwrap +import importlib +import sys +import socket +import threading +import time +from asyncio import staggered, taskgroups, base_events, tasks +from unittest.mock import ANY +from test.support import ( + os_helper, + SHORT_TIMEOUT, + busy_retry, + requires_gil_enabled, +) +from test.support.import_helper import import_module +from test.support.script_helper import make_script +from test.support.socket_helper import find_unused_port + +import subprocess + +PROCESS_VM_READV_SUPPORTED = False + +try: + from _remote_debugging import PROCESS_VM_READV_SUPPORTED + from _remote_debugging import RemoteUnwinder + from _remote_debugging import FrameInfo, CoroInfo, TaskInfo +except ImportError: + raise unittest.SkipTest( + "Test only runs when _remote_debugging is available" + ) + + +def _make_test_script(script_dir, script_basename, source): + to_return = make_script(script_dir, script_basename, source) + importlib.invalidate_caches() + return to_return + + +skip_if_not_supported = unittest.skipIf( + ( + sys.platform != "darwin" + and sys.platform != "linux" + and sys.platform != "win32" + ), + "Test only runs on Linux, Windows and MacOS", +) + + +def get_stack_trace(pid): + unwinder = RemoteUnwinder(pid, all_threads=True, debug=True) + return unwinder.get_stack_trace() + + +def get_async_stack_trace(pid): + unwinder = RemoteUnwinder(pid, debug=True) + return unwinder.get_async_stack_trace() + + +def get_all_awaited_by(pid): + unwinder = RemoteUnwinder(pid, debug=True) + return unwinder.get_all_awaited_by() + + +class TestGetStackTrace(unittest.TestCase): + maxDiff = None + + @skip_if_not_supported + @unittest.skipIf( + sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, + "Test only runs on Linux with process_vm_readv support", + ) + def test_remote_stack_trace(self): + # Spawn a process with some realistic Python code + port = find_unused_port() + script = textwrap.dedent( + f"""\ + import time, sys, socket, threading + # Connect to the test process + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect(('localhost', {port})) + + def bar(): + for x in range(100): + if x == 50: + baz() + + def baz(): + foo() + + def foo(): + sock.sendall(b"ready:thread\\n"); time.sleep(10_000) # same line number + + t = threading.Thread(target=bar) + t.start() + sock.sendall(b"ready:main\\n"); t.join() # same line number + """ + ) + stack_trace = None + with os_helper.temp_dir() as work_dir: + script_dir = os.path.join(work_dir, "script_pkg") + os.mkdir(script_dir) + + # Create a socket server to communicate with the target process + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server_socket.bind(("localhost", port)) + server_socket.settimeout(SHORT_TIMEOUT) + server_socket.listen(1) + + script_name = _make_test_script(script_dir, "script", script) + client_socket = None + try: + p = subprocess.Popen([sys.executable, script_name]) + client_socket, _ = server_socket.accept() + server_socket.close() + response = b"" + while ( + b"ready:main" not in response + or b"ready:thread" not in response + ): + response += client_socket.recv(1024) + stack_trace = get_stack_trace(p.pid) + except PermissionError: + self.skipTest( + "Insufficient permissions to read the stack trace" + ) + finally: + if client_socket is not None: + client_socket.close() + p.kill() + p.terminate() + p.wait(timeout=SHORT_TIMEOUT) + + thread_expected_stack_trace = [ + FrameInfo([script_name, 15, "foo"]), + FrameInfo([script_name, 12, "baz"]), + FrameInfo([script_name, 9, "bar"]), + FrameInfo([threading.__file__, ANY, "Thread.run"]), + ] + # Is possible that there are more threads, so we check that the + # expected stack traces are in the result (looking at you Windows!) + self.assertIn((ANY, thread_expected_stack_trace), stack_trace) + + # Check that the main thread stack trace is in the result + frame = FrameInfo([script_name, 19, ""]) + for _, stack in stack_trace: + if frame in stack: + break + else: + self.fail("Main thread stack trace not found in result") + + @skip_if_not_supported + @unittest.skipIf( + sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, + "Test only runs on Linux with process_vm_readv support", + ) + def test_self_trace_after_ctypes_import(self): + """Test that RemoteUnwinder works on the same process after _ctypes import. + + When _ctypes is imported, it may call dlopen on the libpython shared + library, creating a duplicate mapping in the process address space. + The remote debugging code must skip these uninitialized duplicate + mappings and find the real PyRuntime. See gh-144563. + """ + + # Skip the test if the _ctypes module is missing. + import_module("_ctypes") + + # Run the test in a subprocess to avoid side effects + script = textwrap.dedent("""\ + import os + import _remote_debugging + + # Should work before _ctypes import + unwinder = _remote_debugging.RemoteUnwinder(os.getpid()) + + import _ctypes + + # Should still work after _ctypes import (gh-144563) + unwinder = _remote_debugging.RemoteUnwinder(os.getpid()) + """) + + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + timeout=SHORT_TIMEOUT, + ) + self.assertEqual( + result.returncode, 0, + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + + @skip_if_not_supported + @unittest.skipIf( + sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, + "Test only runs on Linux with process_vm_readv support", + ) + def test_async_remote_stack_trace(self): + # Spawn a process with some realistic Python code + port = find_unused_port() + script = textwrap.dedent( + f"""\ + import asyncio + import time + import sys + import socket + # Connect to the test process + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect(('localhost', {port})) + + def c5(): + sock.sendall(b"ready"); time.sleep(10_000) # same line number + + async def c4(): + await asyncio.sleep(0) + c5() + + async def c3(): + await c4() + + async def c2(): + await c3() + + async def c1(task): + await task + + async def main(): + async with asyncio.TaskGroup() as tg: + task = tg.create_task(c2(), name="c2_root") + tg.create_task(c1(task), name="sub_main_1") + tg.create_task(c1(task), name="sub_main_2") + + def new_eager_loop(): + loop = asyncio.new_event_loop() + eager_task_factory = asyncio.create_eager_task_factory( + asyncio.Task) + loop.set_task_factory(eager_task_factory) + return loop + + asyncio.run(main(), loop_factory={{TASK_FACTORY}}) + """ + ) + stack_trace = None + for task_factory_variant in "asyncio.new_event_loop", "new_eager_loop": + with ( + self.subTest(task_factory_variant=task_factory_variant), + os_helper.temp_dir() as work_dir, + ): + script_dir = os.path.join(work_dir, "script_pkg") + os.mkdir(script_dir) + server_socket = socket.socket( + socket.AF_INET, socket.SOCK_STREAM + ) + server_socket.setsockopt( + socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 + ) + server_socket.bind(("localhost", port)) + server_socket.settimeout(SHORT_TIMEOUT) + server_socket.listen(1) + script_name = _make_test_script( + script_dir, + "script", + script.format(TASK_FACTORY=task_factory_variant), + ) + client_socket = None + try: + p = subprocess.Popen([sys.executable, script_name]) + client_socket, _ = server_socket.accept() + server_socket.close() + response = client_socket.recv(1024) + self.assertEqual(response, b"ready") + stack_trace = get_async_stack_trace(p.pid) + except PermissionError: + self.skipTest( + "Insufficient permissions to read the stack trace" + ) + finally: + if client_socket is not None: + client_socket.close() + p.kill() + p.terminate() + p.wait(timeout=SHORT_TIMEOUT) + + # First check all the tasks are present + tasks_names = [ + task.task_name for task in stack_trace[0].awaited_by + ] + for task_name in ["c2_root", "sub_main_1", "sub_main_2"]: + self.assertIn(task_name, tasks_names) + + # Now ensure that the awaited_by_relationships are correct + id_to_task = { + task.task_id: task for task in stack_trace[0].awaited_by + } + task_name_to_awaited_by = { + task.task_name: set( + id_to_task[awaited.task_name].task_name + for awaited in task.awaited_by + ) + for task in stack_trace[0].awaited_by + } + self.assertEqual( + task_name_to_awaited_by, + { + "c2_root": {"Task-1", "sub_main_1", "sub_main_2"}, + "Task-1": set(), + "sub_main_1": {"Task-1"}, + "sub_main_2": {"Task-1"}, + }, + ) + + # Now ensure that the coroutine stacks are correct + coroutine_stacks = { + task.task_name: sorted( + tuple(tuple(frame) for frame in coro.call_stack) + for coro in task.coroutine_stack + ) + for task in stack_trace[0].awaited_by + } + self.assertEqual( + coroutine_stacks, + { + "Task-1": [ + ( + tuple( + [ + taskgroups.__file__, + ANY, + "TaskGroup._aexit", + ] + ), + tuple( + [ + taskgroups.__file__, + ANY, + "TaskGroup.__aexit__", + ] + ), + tuple([script_name, 26, "main"]), + ) + ], + "c2_root": [ + ( + tuple([script_name, 10, "c5"]), + tuple([script_name, 14, "c4"]), + tuple([script_name, 17, "c3"]), + tuple([script_name, 20, "c2"]), + ) + ], + "sub_main_1": [(tuple([script_name, 23, "c1"]),)], + "sub_main_2": [(tuple([script_name, 23, "c1"]),)], + }, + ) + + # Now ensure the coroutine stacks for the awaited_by relationships are correct. + awaited_by_coroutine_stacks = { + task.task_name: sorted( + ( + id_to_task[coro.task_name].task_name, + tuple(tuple(frame) for frame in coro.call_stack), + ) + for coro in task.awaited_by + ) + for task in stack_trace[0].awaited_by + } + self.assertEqual( + awaited_by_coroutine_stacks, + { + "Task-1": [], + "c2_root": [ + ( + "Task-1", + ( + tuple( + [ + taskgroups.__file__, + ANY, + "TaskGroup._aexit", + ] + ), + tuple( + [ + taskgroups.__file__, + ANY, + "TaskGroup.__aexit__", + ] + ), + tuple([script_name, 26, "main"]), + ), + ), + ("sub_main_1", (tuple([script_name, 23, "c1"]),)), + ("sub_main_2", (tuple([script_name, 23, "c1"]),)), + ], + "sub_main_1": [ + ( + "Task-1", + ( + tuple( + [ + taskgroups.__file__, + ANY, + "TaskGroup._aexit", + ] + ), + tuple( + [ + taskgroups.__file__, + ANY, + "TaskGroup.__aexit__", + ] + ), + tuple([script_name, 26, "main"]), + ), + ) + ], + "sub_main_2": [ + ( + "Task-1", + ( + tuple( + [ + taskgroups.__file__, + ANY, + "TaskGroup._aexit", + ] + ), + tuple( + [ + taskgroups.__file__, + ANY, + "TaskGroup.__aexit__", + ] + ), + tuple([script_name, 26, "main"]), + ), + ) + ], + }, + ) + + @skip_if_not_supported + @unittest.skipIf( + sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, + "Test only runs on Linux with process_vm_readv support", + ) + def test_asyncgen_remote_stack_trace(self): + # Spawn a process with some realistic Python code + port = find_unused_port() + script = textwrap.dedent( + f"""\ + import asyncio + import time + import sys + import socket + # Connect to the test process + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect(('localhost', {port})) + + async def gen_nested_call(): + sock.sendall(b"ready"); time.sleep(10_000) # same line number + + async def gen(): + for num in range(2): + yield num + if num == 1: + await gen_nested_call() + + async def main(): + async for el in gen(): + pass + + asyncio.run(main()) + """ + ) + stack_trace = None + with os_helper.temp_dir() as work_dir: + script_dir = os.path.join(work_dir, "script_pkg") + os.mkdir(script_dir) + # Create a socket server to communicate with the target process + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server_socket.bind(("localhost", port)) + server_socket.settimeout(SHORT_TIMEOUT) + server_socket.listen(1) + script_name = _make_test_script(script_dir, "script", script) + client_socket = None + try: + p = subprocess.Popen([sys.executable, script_name]) + client_socket, _ = server_socket.accept() + server_socket.close() + response = client_socket.recv(1024) + self.assertEqual(response, b"ready") + stack_trace = get_async_stack_trace(p.pid) + except PermissionError: + self.skipTest( + "Insufficient permissions to read the stack trace" + ) + finally: + if client_socket is not None: + client_socket.close() + p.kill() + p.terminate() + p.wait(timeout=SHORT_TIMEOUT) + + # For this simple asyncgen test, we only expect one task with the full coroutine stack + self.assertEqual(len(stack_trace[0].awaited_by), 1) + task = stack_trace[0].awaited_by[0] + self.assertEqual(task.task_name, "Task-1") + + # Check the coroutine stack - based on actual output, only shows main + coroutine_stack = sorted( + tuple(tuple(frame) for frame in coro.call_stack) + for coro in task.coroutine_stack + ) + self.assertEqual( + coroutine_stack, + [ + ( + tuple([script_name, 10, "gen_nested_call"]), + tuple([script_name, 16, "gen"]), + tuple([script_name, 19, "main"]), + ) + ], + ) + + # No awaited_by relationships expected for this simple case + self.assertEqual(task.awaited_by, []) + + @skip_if_not_supported + @unittest.skipIf( + sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, + "Test only runs on Linux with process_vm_readv support", + ) + def test_async_gather_remote_stack_trace(self): + # Spawn a process with some realistic Python code + port = find_unused_port() + script = textwrap.dedent( + f"""\ + import asyncio + import time + import sys + import socket + # Connect to the test process + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect(('localhost', {port})) + + async def deep(): + await asyncio.sleep(0) + sock.sendall(b"ready"); time.sleep(10_000) # same line number + + async def c1(): + await asyncio.sleep(0) + await deep() + + async def c2(): + await asyncio.sleep(0) + + async def main(): + await asyncio.gather(c1(), c2()) + + asyncio.run(main()) + """ + ) + stack_trace = None + with os_helper.temp_dir() as work_dir: + script_dir = os.path.join(work_dir, "script_pkg") + os.mkdir(script_dir) + # Create a socket server to communicate with the target process + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server_socket.bind(("localhost", port)) + server_socket.settimeout(SHORT_TIMEOUT) + server_socket.listen(1) + script_name = _make_test_script(script_dir, "script", script) + client_socket = None + try: + p = subprocess.Popen([sys.executable, script_name]) + client_socket, _ = server_socket.accept() + server_socket.close() + response = client_socket.recv(1024) + self.assertEqual(response, b"ready") + stack_trace = get_async_stack_trace(p.pid) + except PermissionError: + self.skipTest( + "Insufficient permissions to read the stack trace" + ) + finally: + if client_socket is not None: + client_socket.close() + p.kill() + p.terminate() + p.wait(timeout=SHORT_TIMEOUT) + + # First check all the tasks are present + tasks_names = [ + task.task_name for task in stack_trace[0].awaited_by + ] + for task_name in ["Task-1", "Task-2"]: + self.assertIn(task_name, tasks_names) + + # Now ensure that the awaited_by_relationships are correct + id_to_task = { + task.task_id: task for task in stack_trace[0].awaited_by + } + task_name_to_awaited_by = { + task.task_name: set( + id_to_task[awaited.task_name].task_name + for awaited in task.awaited_by + ) + for task in stack_trace[0].awaited_by + } + self.assertEqual( + task_name_to_awaited_by, + { + "Task-1": set(), + "Task-2": {"Task-1"}, + }, + ) + + # Now ensure that the coroutine stacks are correct + coroutine_stacks = { + task.task_name: sorted( + tuple(tuple(frame) for frame in coro.call_stack) + for coro in task.coroutine_stack + ) + for task in stack_trace[0].awaited_by + } + self.assertEqual( + coroutine_stacks, + { + "Task-1": [(tuple([script_name, 21, "main"]),)], + "Task-2": [ + ( + tuple([script_name, 11, "deep"]), + tuple([script_name, 15, "c1"]), + ) + ], + }, + ) + + # Now ensure the coroutine stacks for the awaited_by relationships are correct. + awaited_by_coroutine_stacks = { + task.task_name: sorted( + ( + id_to_task[coro.task_name].task_name, + tuple(tuple(frame) for frame in coro.call_stack), + ) + for coro in task.awaited_by + ) + for task in stack_trace[0].awaited_by + } + self.assertEqual( + awaited_by_coroutine_stacks, + { + "Task-1": [], + "Task-2": [ + ("Task-1", (tuple([script_name, 21, "main"]),)) + ], + }, + ) + + @skip_if_not_supported + @unittest.skipIf( + sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, + "Test only runs on Linux with process_vm_readv support", + ) + def test_async_staggered_race_remote_stack_trace(self): + # Spawn a process with some realistic Python code + port = find_unused_port() + script = textwrap.dedent( + f"""\ + import asyncio.staggered + import time + import sys + import socket + # Connect to the test process + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect(('localhost', {port})) + + async def deep(): + await asyncio.sleep(0) + sock.sendall(b"ready"); time.sleep(10_000) # same line number + + async def c1(): + await asyncio.sleep(0) + await deep() + + async def c2(): + await asyncio.sleep(10_000) + + async def main(): + await asyncio.staggered.staggered_race( + [c1, c2], + delay=None, + ) + + asyncio.run(main()) + """ + ) + stack_trace = None + with os_helper.temp_dir() as work_dir: + script_dir = os.path.join(work_dir, "script_pkg") + os.mkdir(script_dir) + # Create a socket server to communicate with the target process + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server_socket.bind(("localhost", port)) + server_socket.settimeout(SHORT_TIMEOUT) + server_socket.listen(1) + script_name = _make_test_script(script_dir, "script", script) + client_socket = None + try: + p = subprocess.Popen([sys.executable, script_name]) + client_socket, _ = server_socket.accept() + server_socket.close() + response = client_socket.recv(1024) + self.assertEqual(response, b"ready") + stack_trace = get_async_stack_trace(p.pid) + except PermissionError: + self.skipTest( + "Insufficient permissions to read the stack trace" + ) + finally: + if client_socket is not None: + client_socket.close() + p.kill() + p.terminate() + p.wait(timeout=SHORT_TIMEOUT) + + # First check all the tasks are present + tasks_names = [ + task.task_name for task in stack_trace[0].awaited_by + ] + for task_name in ["Task-1", "Task-2"]: + self.assertIn(task_name, tasks_names) + + # Now ensure that the awaited_by_relationships are correct + id_to_task = { + task.task_id: task for task in stack_trace[0].awaited_by + } + task_name_to_awaited_by = { + task.task_name: set( + id_to_task[awaited.task_name].task_name + for awaited in task.awaited_by + ) + for task in stack_trace[0].awaited_by + } + self.assertEqual( + task_name_to_awaited_by, + { + "Task-1": set(), + "Task-2": {"Task-1"}, + }, + ) + + # Now ensure that the coroutine stacks are correct + coroutine_stacks = { + task.task_name: sorted( + tuple(tuple(frame) for frame in coro.call_stack) + for coro in task.coroutine_stack + ) + for task in stack_trace[0].awaited_by + } + self.assertEqual( + coroutine_stacks, + { + "Task-1": [ + ( + tuple([staggered.__file__, ANY, "staggered_race"]), + tuple([script_name, 21, "main"]), + ) + ], + "Task-2": [ + ( + tuple([script_name, 11, "deep"]), + tuple([script_name, 15, "c1"]), + tuple( + [ + staggered.__file__, + ANY, + "staggered_race..run_one_coro", + ] + ), + ) + ], + }, + ) + + # Now ensure the coroutine stacks for the awaited_by relationships are correct. + awaited_by_coroutine_stacks = { + task.task_name: sorted( + ( + id_to_task[coro.task_name].task_name, + tuple(tuple(frame) for frame in coro.call_stack), + ) + for coro in task.awaited_by + ) + for task in stack_trace[0].awaited_by + } + self.assertEqual( + awaited_by_coroutine_stacks, + { + "Task-1": [], + "Task-2": [ + ( + "Task-1", + ( + tuple( + [staggered.__file__, ANY, "staggered_race"] + ), + tuple([script_name, 21, "main"]), + ), + ) + ], + }, + ) + + @skip_if_not_supported + @unittest.skipIf( + sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, + "Test only runs on Linux with process_vm_readv support", + ) + def test_async_global_awaited_by(self): + port = find_unused_port() + script = textwrap.dedent( + f"""\ + import asyncio + import os + import random + import sys + import socket + from string import ascii_lowercase, digits + from test.support import socket_helper, SHORT_TIMEOUT + + HOST = '127.0.0.1' + PORT = socket_helper.find_unused_port() + connections = 0 + + # Connect to the test process + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect(('localhost', {port})) + + class EchoServerProtocol(asyncio.Protocol): + def connection_made(self, transport): + global connections + connections += 1 + self.transport = transport + + def data_received(self, data): + self.transport.write(data) + self.transport.close() + + async def echo_client(message): + reader, writer = await asyncio.open_connection(HOST, PORT) + writer.write(message.encode()) + await writer.drain() + + data = await reader.read(100) + assert message == data.decode() + writer.close() + await writer.wait_closed() + # Signal we are ready to sleep + sock.sendall(b"ready") + await asyncio.sleep(SHORT_TIMEOUT) + + async def echo_client_spam(server): + async with asyncio.TaskGroup() as tg: + while connections < 1000: + msg = list(ascii_lowercase + digits) + random.shuffle(msg) + tg.create_task(echo_client("".join(msg))) + await asyncio.sleep(0) + # at least a 1000 tasks created. Each task will signal + # when is ready to avoid the race caused by the fact that + # tasks are waited on tg.__exit__ and we cannot signal when + # that happens otherwise + # at this point all client tasks completed without assertion errors + # let's wrap up the test + server.close() + await server.wait_closed() + + async def main(): + loop = asyncio.get_running_loop() + server = await loop.create_server(EchoServerProtocol, HOST, PORT) + async with server: + async with asyncio.TaskGroup() as tg: + tg.create_task(server.serve_forever(), name="server task") + tg.create_task(echo_client_spam(server), name="echo client spam") + + asyncio.run(main()) + """ + ) + stack_trace = None + with os_helper.temp_dir() as work_dir: + script_dir = os.path.join(work_dir, "script_pkg") + os.mkdir(script_dir) + # Create a socket server to communicate with the target process + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server_socket.bind(("localhost", port)) + server_socket.settimeout(SHORT_TIMEOUT) + server_socket.listen(1) + script_name = _make_test_script(script_dir, "script", script) + client_socket = None + try: + p = subprocess.Popen([sys.executable, script_name]) + client_socket, _ = server_socket.accept() + server_socket.close() + for _ in range(1000): + expected_response = b"ready" + response = client_socket.recv(len(expected_response)) + self.assertEqual(response, expected_response) + for _ in busy_retry(SHORT_TIMEOUT): + try: + all_awaited_by = get_all_awaited_by(p.pid) + except RuntimeError as re: + # This call reads a linked list in another process with + # no synchronization. That occasionally leads to invalid + # reads. Here we avoid making the test flaky. + msg = str(re) + if msg.startswith("Task list appears corrupted"): + continue + elif msg.startswith( + "Invalid linked list structure reading remote memory" + ): + continue + elif msg.startswith("Unknown error reading memory"): + continue + elif msg.startswith("Unhandled frame owner"): + continue + raise # Unrecognized exception, safest not to ignore it + else: + break + # expected: a list of two elements: 1 thread, 1 interp + self.assertEqual(len(all_awaited_by), 2) + # expected: a tuple with the thread ID and the awaited_by list + self.assertEqual(len(all_awaited_by[0]), 2) + # expected: no tasks in the fallback per-interp task list + self.assertEqual(all_awaited_by[1], (0, [])) + entries = all_awaited_by[0][1] + # expected: at least 1000 pending tasks + self.assertGreaterEqual(len(entries), 1000) + # the first three tasks stem from the code structure + main_stack = [ + FrameInfo([taskgroups.__file__, ANY, "TaskGroup._aexit"]), + FrameInfo( + [taskgroups.__file__, ANY, "TaskGroup.__aexit__"] + ), + FrameInfo([script_name, 60, "main"]), + ] + self.assertIn( + TaskInfo( + [ANY, "Task-1", [CoroInfo([main_stack, ANY])], []] + ), + entries, + ) + self.assertIn( + TaskInfo( + [ + ANY, + "server task", + [ + CoroInfo( + [ + [ + FrameInfo( + [ + base_events.__file__, + ANY, + "Server.serve_forever", + ] + ) + ], + ANY, + ] + ) + ], + [ + CoroInfo( + [ + [ + FrameInfo( + [ + taskgroups.__file__, + ANY, + "TaskGroup._aexit", + ] + ), + FrameInfo( + [ + taskgroups.__file__, + ANY, + "TaskGroup.__aexit__", + ] + ), + FrameInfo( + [script_name, ANY, "main"] + ), + ], + ANY, + ] + ) + ], + ] + ), + entries, + ) + self.assertIn( + TaskInfo( + [ + ANY, + "Task-4", + [ + CoroInfo( + [ + [ + FrameInfo( + [tasks.__file__, ANY, "sleep"] + ), + FrameInfo( + [ + script_name, + 38, + "echo_client", + ] + ), + ], + ANY, + ] + ) + ], + [ + CoroInfo( + [ + [ + FrameInfo( + [ + taskgroups.__file__, + ANY, + "TaskGroup._aexit", + ] + ), + FrameInfo( + [ + taskgroups.__file__, + ANY, + "TaskGroup.__aexit__", + ] + ), + FrameInfo( + [ + script_name, + 41, + "echo_client_spam", + ] + ), + ], + ANY, + ] + ) + ], + ] + ), + entries, + ) + + expected_awaited_by = [ + CoroInfo( + [ + [ + FrameInfo( + [ + taskgroups.__file__, + ANY, + "TaskGroup._aexit", + ] + ), + FrameInfo( + [ + taskgroups.__file__, + ANY, + "TaskGroup.__aexit__", + ] + ), + FrameInfo( + [script_name, 41, "echo_client_spam"] + ), + ], + ANY, + ] + ) + ] + tasks_with_awaited = [ + task + for task in entries + if task.awaited_by == expected_awaited_by + ] + self.assertGreaterEqual(len(tasks_with_awaited), 1000) + + # the final task will have some random number, but it should for + # sure be one of the echo client spam horde (In windows this is not true + # for some reason) + if sys.platform != "win32": + self.assertEqual( + tasks_with_awaited[-1].awaited_by, + entries[-1].awaited_by, + ) + except PermissionError: + self.skipTest( + "Insufficient permissions to read the stack trace" + ) + finally: + if client_socket is not None: + client_socket.close() + p.kill() + p.terminate() + p.wait(timeout=SHORT_TIMEOUT) + + @skip_if_not_supported + @unittest.skipIf( + sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, + "Test only runs on Linux with process_vm_readv support", + ) + def test_self_trace(self): + stack_trace = get_stack_trace(os.getpid()) + # Is possible that there are more threads, so we check that the + # expected stack traces are in the result (looking at you Windows!) + this_tread_stack = None + for thread_id, stack in stack_trace: + if thread_id == threading.get_native_id(): + this_tread_stack = stack + break + self.assertIsNotNone(this_tread_stack) + self.assertEqual( + stack[:2], + [ + FrameInfo( + [ + __file__, + get_stack_trace.__code__.co_firstlineno + 2, + "get_stack_trace", + ] + ), + FrameInfo( + [ + __file__, + self.test_self_trace.__code__.co_firstlineno + 6, + "TestGetStackTrace.test_self_trace", + ] + ), + ], + ) + + @skip_if_not_supported + @unittest.skipIf( + sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, + "Test only runs on Linux with process_vm_readv support", + ) + @requires_gil_enabled("Free threaded builds don't have an 'active thread'") + def test_only_active_thread(self): + # Test that only_active_thread parameter works correctly + port = find_unused_port() + script = textwrap.dedent( + f"""\ + import time, sys, socket, threading + + # Connect to the test process + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect(('localhost', {port})) + + def worker_thread(name, barrier, ready_event): + barrier.wait() # Synchronize thread start + ready_event.wait() # Wait for main thread signal + # Sleep to keep thread alive + time.sleep(10_000) + + def main_work(): + # Do busy work to hold the GIL + sock.sendall(b"working\\n") + count = 0 + while count < 100000000: + count += 1 + if count % 10000000 == 0: + pass # Keep main thread busy + sock.sendall(b"done\\n") + + # Create synchronization primitives + num_threads = 3 + barrier = threading.Barrier(num_threads + 1) # +1 for main thread + ready_event = threading.Event() + + # Start worker threads + threads = [] + for i in range(num_threads): + t = threading.Thread(target=worker_thread, args=(f"Worker-{{i}}", barrier, ready_event)) + t.start() + threads.append(t) + + # Wait for all threads to be ready + barrier.wait() + + # Signal ready to parent process + sock.sendall(b"ready\\n") + + # Signal threads to start waiting + ready_event.set() + + # Now do busy work to hold the GIL + main_work() + """ + ) + + with os_helper.temp_dir() as work_dir: + script_dir = os.path.join(work_dir, "script_pkg") + os.mkdir(script_dir) + + # Create a socket server to communicate with the target process + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server_socket.bind(("localhost", port)) + server_socket.settimeout(SHORT_TIMEOUT) + server_socket.listen(1) + + script_name = _make_test_script(script_dir, "script", script) + client_socket = None + try: + p = subprocess.Popen([sys.executable, script_name]) + client_socket, _ = server_socket.accept() + server_socket.close() + + # Wait for ready signal + response = b"" + while b"ready" not in response: + response += client_socket.recv(1024) + + # Wait for the main thread to start its busy work + while b"working" not in response: + response += client_socket.recv(1024) + + # Get stack trace with all threads + unwinder_all = RemoteUnwinder(p.pid, all_threads=True) + for _ in range(10): + # Wait for the main thread to start its busy work + all_traces = unwinder_all.get_stack_trace() + found = False + for thread_id, stack in all_traces: + if not stack: + continue + current_frame = stack[0] + if ( + current_frame.funcname == "main_work" + and current_frame.lineno > 15 + ): + found = True + + if found: + break + # Give a bit of time to take the next sample + time.sleep(0.1) + else: + self.fail( + "Main thread did not start its busy work on time" + ) + + # Get stack trace with only GIL holder + unwinder_gil = RemoteUnwinder(p.pid, only_active_thread=True) + gil_traces = unwinder_gil.get_stack_trace() + + except PermissionError: + self.skipTest( + "Insufficient permissions to read the stack trace" + ) + finally: + if client_socket is not None: + client_socket.close() + p.kill() + p.terminate() + p.wait(timeout=SHORT_TIMEOUT) + + # Verify we got multiple threads in all_traces + self.assertGreater( + len(all_traces), 1, "Should have multiple threads" + ) + + # Verify we got exactly one thread in gil_traces + self.assertEqual( + len(gil_traces), 1, "Should have exactly one GIL holder" + ) + + # The GIL holder should be in the all_traces list + gil_thread_id = gil_traces[0][0] + all_thread_ids = [trace[0] for trace in all_traces] + self.assertIn( + gil_thread_id, + all_thread_ids, + "GIL holder should be among all threads", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/Lib/test/test_mmap.py b/Lib/test/test_mmap.py index 6b67c9e5074..a1c46706fee 100644 --- a/Lib/test/test_mmap.py +++ b/Lib/test/test_mmap.py @@ -33,13 +33,10 @@ def random_tagname(length=10): raise unittest.SkipTest("incompatible with Emscripten's mmap emulation.") +@unittest.skipIf(os.name == "nt", "TODO: RUSTPYTHON; Errors on setUp") class MmapTests(unittest.TestCase): def setUp(self): - # TODO: RUSTPYTHON; Remove this once windows doesn't get errored on setup:/ - if os.name == "nt": - raise unittest.SkipTest("TODO: RUSTPYTHON; Error during class setUp") - if os.path.exists(TESTFN): os.unlink(TESTFN) diff --git a/Lib/test/test_msvcrt.py b/Lib/test/test_msvcrt.py index 1c6905bd1ee..fef86ce323e 100644 --- a/Lib/test/test_msvcrt.py +++ b/Lib/test/test_msvcrt.py @@ -4,6 +4,7 @@ import unittest from textwrap import dedent +from test import support from test.support import os_helper, requires_resource from test.support.os_helper import TESTFN, TESTFN_ASCII @@ -67,8 +68,12 @@ def run_in_separated_process(self, code): # Run test in a separated process to avoid stdin conflicts. # See: gh-110147 cmd = [sys.executable, '-c', code] - subprocess.run(cmd, check=True, capture_output=True, - creationflags=subprocess.CREATE_NEW_CONSOLE) + try: + subprocess.run(cmd, check=True, capture_output=True, + creationflags=subprocess.CREATE_NEW_CONSOLE) + except subprocess.CalledProcessError as exc: + support.skip_on_low_desktop_heap_memory_subprocess(exc.returncode) + raise def test_kbhit(self): code = dedent(''' From e1ef518fbf65056bc5c5f3fbc0c045aeb78b6e10 Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:38:33 -0400 Subject: [PATCH 064/351] Bump unicode_names2 and rustls-graviola (#8203) --- Cargo.lock | 197 ++++------------------------------------------------- Cargo.toml | 7 +- 2 files changed, 16 insertions(+), 188 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 90f5c318b8d..eb0f64095ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -704,17 +704,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core-models" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "657f625ff361906f779745d08375ae3cc9fef87a35fba5f22874cf773010daf4" -dependencies = [ - "hax-lib", - "pastey", - "rand 0.9.4", -] - [[package]] name = "cpubits" version = "0.1.1" @@ -1483,9 +1472,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "graviola" -version = "0.3.4" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4387e0458389da24c6fe732531e65595c7c4a32b027f98f4789e512e28224465" +checksum = "e8596c4fa98466aae2fcf4c72a665bc0e021c0aaab1e47d82044d3dc3e309a76" dependencies = [ "cfg-if", "getrandom 0.3.4", @@ -1520,43 +1509,6 @@ dependencies = [ "foldhash", ] -[[package]] -name = "hax-lib" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "543f93241d32b3f00569201bfce9d7a93c92c6421b23c77864ac929dc947b9fc" -dependencies = [ - "hax-lib-macros", - "num-bigint", - "num-traits", -] - -[[package]] -name = "hax-lib-macros" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8755751e760b11021765bb04cb4a6c4e24742688d9f3aa14c2079638f537b0f" -dependencies = [ - "hax-lib-macros-types", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "hax-lib-macros-types" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f177c9ae8ea456e2f71ff3c1ea47bf4464f772a05133fcbba56cd5ba169035a2" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "serde_json", - "uuid", -] - [[package]] name = "heck" version = "0.5.0" @@ -2014,70 +1966,6 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libcrux-intrinsics" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1b5db005ff8001e026b73a6842ee81bbef8ec5ff0e1915a67ae65fd2a9fafa5" -dependencies = [ - "core-models", - "hax-lib", -] - -[[package]] -name = "libcrux-ml-kem" -version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a14ab3e477de9df6ee1273a114018ff62c4996ca9220070c4e5cb1743f94a67d" -dependencies = [ - "hax-lib", - "libcrux-intrinsics", - "libcrux-platform", - "libcrux-secrets", - "libcrux-sha3", - "libcrux-traits", -] - -[[package]] -name = "libcrux-platform" -version = "0.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9e21d7ed31a92ac539bd69a8c970b183ee883872d2d19ce27036e24cb8ecc4" -dependencies = [ - "libc", -] - -[[package]] -name = "libcrux-secrets" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ce650f3041b44ba40d4263852347d007cd2cd9d1cc856a6f6c8b2e10c3fd40b" -dependencies = [ - "hax-lib", -] - -[[package]] -name = "libcrux-sha3" -version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1ae0b7d0e1cc4793a609fd0ff2ca3b3a3fabae523770c619a3d4bc86417b0d7" -dependencies = [ - "hax-lib", - "libcrux-intrinsics", - "libcrux-platform", - "libcrux-traits", -] - -[[package]] -name = "libcrux-traits" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e4fa89f3f5e34b47f928b22b1b78395a0d4ec23b1f583db635f128159d65f" -dependencies = [ - "libcrux-secrets", - "rand 0.9.4", -] - [[package]] name = "libffi" version = "5.1.1" @@ -2614,12 +2502,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pastey" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" - [[package]] name = "pbkdf2" version = "0.13.0" @@ -2859,28 +2741,6 @@ dependencies = [ "syn", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "proc-macro-utils" version = "0.10.0" @@ -3051,20 +2911,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.1" @@ -3086,16 +2936,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -3105,15 +2945,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -3338,12 +3169,11 @@ dependencies = [ [[package]] name = "rustls-graviola" -version = "0.3.4" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323c712e50c59ceb2ba9ad4d79dcfd3e0046a082d61efa87fcdf8f59af04473c" +checksum = "bf5d0a370be690f7f1aa4e1b912fde3f5f9a5c53285062fd2d6ac1a52ab3e883" dependencies = [ "graviola", - "libcrux-ml-kem", "rustls", ] @@ -3476,7 +3306,7 @@ dependencies = [ "rustpython-ruff_text_size", "rustpython-wtf8", "thiserror", - "unicode_names2 2.0.0", + "unicode_names2 3.1.0", ] [[package]] @@ -3499,7 +3329,7 @@ dependencies = [ "rustpython-literal", "rustpython-wtf8", "siphasher", - "unicode_names2 2.0.0", + "unicode_names2 3.1.0", ] [[package]] @@ -3782,7 +3612,7 @@ dependencies = [ "system-configuration", "tcl-sys", "tk-sys", - "unicode_names2 2.0.0", + "unicode_names2 3.1.0", "uuid", "webpki-roots", "widestring", @@ -4591,12 +4421,12 @@ dependencies = [ [[package]] name = "unicode_names2" -version = "2.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d189085656ca1203291e965444e7f6a2723fbdd1dd9f34f8482e79bafd8338a0" +checksum = "82c3e18d850bb6ebd57735e5654f0af65e572b05c2397e7b2b1a7c6a792cc29c" dependencies = [ "phf 0.11.3", - "unicode_names2_generator 2.0.0", + "unicode_names2_generator 3.1.0", ] [[package]] @@ -4613,9 +4443,9 @@ dependencies = [ [[package]] name = "unicode_names2_generator" -version = "2.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1262662dc96937c71115228ce2e1d30f41db71a7a45d3459e98783ef94052214" +checksum = "849744a58c479122ff24910d7ca57f312b62613ef0412dc327776ae6b235d16a" dependencies = [ "phf_codegen", "rand 0.8.6", @@ -4652,7 +4482,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "atomic", - "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index 3b4cd480b2d..a661dfbd6ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,6 @@ env_logger = "0.11" flamescope = { version = "0.1.2", optional = true } rustls = { workspace = true, optional = true } -rustls-graviola = { workspace = true, optional = true } [target.'cfg(windows)'.dependencies] libc = { workspace = true } @@ -60,6 +59,7 @@ rustyline = { workspace = true } [dev-dependencies] criterion = { workspace = true } pyo3 = { workspace = true, features = ["auto-initialize"] } +rustls-graviola = { workspace = true } rustpython-stdlib = { workspace = true } ruff_python_parser = { workspace = true } @@ -79,7 +79,6 @@ path = "src/main.rs" name = "custom_tls_providers" path = "examples/custom_tls_providers.rs" required-features = [ - "rustls-graviola", "rustls/ring", "rustpython-pylib/freeze-stdlib", "rustpython-stdlib/ssl-rustls", @@ -278,7 +277,7 @@ rapidhash = "4.4.1" result-like = "0.5.0" rustix = { version = "1.1", features = ["event", "fs", "param", "system"] } rustls = { version = "0.23.39", default-features = false } -rustls-graviola = "0.3" +rustls-graviola = "0.4" rustls-native-certs = "0.8" rustls-pemfile = "2.2" rustls-platform-verifier = "0.7" @@ -311,7 +310,7 @@ icu_locale = "2" icu_properties = "2" icu_normalizer = "2" uuid = "1.23.2" -unicode_names2 = "2.0.0" +unicode_names2 = "3" widestring = "1.2.0" windows-sys = "0.61.2" wasm-bindgen = "0.2.106" From f196dc401c98a1c1e8fc2f308ed038d17f859076 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:41:31 +0300 Subject: [PATCH 065/351] Align symtable flag names with CPython (#8184) * ASSIGNED -> DEF_LOCAL * GLOBAL -> DEF_GLOBAL * REFERENCED -> USE * PARAMETER -> DEF_PARAM * expose symbol flags directly as a pyattr * FREE_CLASS -> DEF_FREE_CLASS * IMPORTED -> DEF_IMPORT * ANNOTATED -> DEF_ANNOT * DEF_COMP_ITER DEF_COMP_CELL DEF_TYPE_PARAM * DEF_NONLOCAL DEF_BOUND * Align flag values * unmark passing tests --- Lib/test/test_symtable.py | 3 - crates/codegen/src/compile.rs | 24 ++--- crates/codegen/src/symboltable.rs | 154 +++++++++++++++++------------- crates/vm/src/stdlib/_symtable.rs | 39 ++++---- 4 files changed, 115 insertions(+), 105 deletions(-) diff --git a/Lib/test/test_symtable.py b/Lib/test/test_symtable.py index b55adab6baf..8cd1da1e972 100644 --- a/Lib/test/test_symtable.py +++ b/Lib/test/test_symtable.py @@ -269,7 +269,6 @@ def test_globals(self): self.assertTrue(self.top.lookup("some_non_assigned_global_var").is_global()) self.assertTrue(self.top.lookup("some_assigned_global_var").is_global()) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_nonlocal(self): self.assertFalse(self.spam.lookup("some_var").is_nonlocal()) self.assertTrue(self.other_internal.lookup("some_var").is_nonlocal()) @@ -288,7 +287,6 @@ def test_local(self): def test_free(self): self.assertTrue(self.internal.lookup("x").is_free()) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_referenced(self): self.assertTrue(self.internal.lookup("x").is_referenced()) self.assertTrue(self.spam.lookup("internal").is_referenced()) @@ -358,7 +356,6 @@ def test_annotated(self): ' x: int', 'test', 'exec') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_imported(self): self.assertTrue(self.top.lookup("sys").is_imported()) diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index 6664ec34c8e..0663375d339 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -1473,7 +1473,7 @@ impl<'warnings> Compiler<'warnings> { self.symbol_table_stack .first() .and_then(|table| table.symbols.get(name)) - .is_some_and(|sym| sym.flags.contains(SymbolFlags::IMPORTED)) + .is_some_and(|sym| sym.flags.contains(SymbolFlags::DEF_IMPORT)) } /// Get the cell-relative index of a free variable. @@ -1751,10 +1751,10 @@ impl<'warnings> Compiler<'warnings> { } // Check if __class__ is available as a cell/free variable - // The scope must be Free (from enclosing class) or have FREE_CLASS flag + // The scope must be Free (from enclosing class) or have DEF_FREE_CLASS flag if let Some(symbol) = table.lookup("__class__") { if symbol.scope != SymbolScope::Free - && !symbol.flags.contains(SymbolFlags::FREE_CLASS) + && !symbol.flags.contains(SymbolFlags::DEF_FREE_CLASS) { return None; } @@ -1872,7 +1872,7 @@ impl<'warnings> Compiler<'warnings> { .symbols .iter() .filter(|(_, s)| { - s.scope == SymbolScope::Cell || s.flags.contains(SymbolFlags::COMP_CELL) + s.scope == SymbolScope::Cell || s.flags.contains(SymbolFlags::DEF_COMP_CELL) }) .map(|(name, _)| name.clone()) .collect(); @@ -1920,9 +1920,9 @@ impl<'warnings> Compiler<'warnings> { .filter(|(_, s)| { s.scope == SymbolScope::Free || (scope_type != CompilerScope::Class - && s.flags.contains(SymbolFlags::FREE_CLASS)) + && s.flags.contains(SymbolFlags::DEF_FREE_CLASS)) || (scope_type == CompilerScope::Class - && s.flags.contains(SymbolFlags::FREE_CLASS) + && s.flags.contains(SymbolFlags::DEF_FREE_CLASS) && self.has_enclosing_non_module_code_scope()) }) .filter(|(name, symbol)| { @@ -3132,7 +3132,7 @@ impl<'warnings> Compiler<'warnings> { .rev() .find(|table| table.typ == CompilerScope::Class) .and_then(|table| table.lookup(name.as_ref())) - .is_some_and(|symbol| symbol.flags.contains(SymbolFlags::GLOBAL)); + .is_some_and(|symbol| symbol.flags.contains(SymbolFlags::DEF_GLOBAL)); ( symbol.map(|s| s.scope), @@ -5597,7 +5597,7 @@ impl<'warnings> Compiler<'warnings> { Some(symbol) => match symbol.scope { SymbolScope::Cell => Ok(SymbolScope::Cell), SymbolScope::Free => Ok(SymbolScope::Free), - _ if symbol.flags.contains(SymbolFlags::FREE_CLASS) => Ok(SymbolScope::Free), + _ if symbol.flags.contains(SymbolFlags::DEF_FREE_CLASS) => Ok(SymbolScope::Free), _ => Err(CodegenErrorType::SyntaxError(format!( "get_ref_type: invalid scope for '{name}'" ))), @@ -10888,13 +10888,13 @@ impl<'warnings> Compiler<'warnings> { let mut pushed_locals: Vec = Vec::new(); let mut fast_hidden_locals: Vec = Vec::new(); for (name, sym) in &comp_table.symbols { - if sym.flags.contains(SymbolFlags::PARAMETER) { + if sym.flags.contains(SymbolFlags::DEF_PARAM) { continue; // skip .0 } let is_local = sym .flags - .intersects(SymbolFlags::ASSIGNED | SymbolFlags::ITER) - && !sym.flags.contains(SymbolFlags::NONLOCAL); + .intersects(SymbolFlags::DEF_LOCAL | SymbolFlags::ITER) + && !sym.flags.contains(SymbolFlags::DEF_NONLOCAL); if is_local { pushed_locals.push(name.clone()); } @@ -10908,7 +10908,7 @@ impl<'warnings> Compiler<'warnings> { // module/class scopes, also enable temporary fast locals for // comprehension-bound names only. for (name, comp_sym) in &comp_table.symbols { - if comp_sym.flags.contains(SymbolFlags::PARAMETER) { + if comp_sym.flags.contains(SymbolFlags::DEF_PARAM) { continue; // skip .0 } let comp_scope = comp_sym.scope; diff --git a/crates/codegen/src/symboltable.rs b/crates/codegen/src/symboltable.rs index a72b839f400..b1aadbe2c5d 100644 --- a/crates/codegen/src/symboltable.rs +++ b/crates/codegen/src/symboltable.rs @@ -161,7 +161,7 @@ impl SymbolTable { .or_insert_with(|| Symbol::new(name)); symbol .flags - .insert(SymbolFlags::PARAMETER | SymbolFlags::REFERENCED); + .insert(SymbolFlags::DEF_PARAM | SymbolFlags::USE); if !self.varnames.iter().any(|varname| varname == name) { self.varnames.push(name.to_owned()); } @@ -289,18 +289,11 @@ impl From for i32 { bitflags! { #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub struct SymbolFlags: u16 { - const REFERENCED = 0x001; // USE - const ASSIGNED = 0x002; // DEF_LOCAL - const PARAMETER = 0x004; // DEF_PARAM - const ANNOTATED = 0x008; // DEF_ANNOT - const IMPORTED = 0x010; // DEF_IMPORT - const NONLOCAL = 0x020; // DEF_NONLOCAL - // indicates if the symbol gets a value assigned by a named expression in a comprehension - // this is required to correct the scope in the analysis. - const ASSIGNED_IN_COMPREHENSION = 0x040; - // indicates that the symbol is used a bound iterator variable. We distinguish this case - // from normal assignment to detect disallowed re-assignment to iterator variables. - const ITER = 0x080; + const DEF_GLOBAL = 1; + const DEF_LOCAL = 2; + const DEF_PARAM = 2 << 1; + const DEF_NONLOCAL = 2 << 2; + const USE = 2 << 3; /// indicates that the symbol is a free variable in a class method from the scope that the /// class is defined in, e.g.: /// ```python @@ -309,12 +302,29 @@ bitflags! { /// def method(self): /// return x // is_free_class /// ``` - const FREE_CLASS = 0x100; // DEF_FREE_CLASS - const GLOBAL = 0x200; // DEF_GLOBAL - const COMP_ITER = 0x400; // DEF_COMP_ITER - const COMP_CELL = 0x800; // DEF_COMP_CELL - const TYPE_PARAM = 0x1000; // DEF_TYPE_PARAM - const BOUND = Self::ASSIGNED.bits() | Self::PARAMETER.bits() | Self::IMPORTED.bits() | Self::ITER.bits() | Self::TYPE_PARAM.bits(); + const DEF_FREE_CLASS = 2 << 5; + const DEF_IMPORT = 2 << 6; + const DEF_ANNOT = 2 << 7; + const DEF_COMP_ITER = 2 << 8; + const DEF_TYPE_PARAM = 2 << 9; + const DEF_COMP_CELL = 2 << 10; + const DEF_BOUND = ( + Self::DEF_LOCAL.bits() + | Self::DEF_PARAM.bits() + | Self::DEF_IMPORT.bits() + | Self::ITER.bits() + | Self::DEF_TYPE_PARAM.bits() + ); + + + // TODO: Remove these, RustPython specific + + // indicates if the symbol gets a value assigned by a named expression in a comprehension + // this is required to correct the scope in the analysis. + const ASSIGNED_IN_COMPREHENSION = 2 << 11; + // indicates that the symbol is used a bound iterator variable. We distinguish this case + // from normal assignment to detect disallowed re-assignment to iterator variables. + const ITER = 2 << 12; } } @@ -354,7 +364,7 @@ impl Symbol { #[must_use] pub const fn is_bound(&self) -> bool { - self.flags.intersects(SymbolFlags::BOUND) + self.flags.intersects(SymbolFlags::DEF_BOUND) } } @@ -443,13 +453,13 @@ fn inline_comprehension( let mut removed_class_implicits = IndexSet::default(); for (name, sub_symbol) in &comp.symbols { // Skip the .0 parameter - if sub_symbol.flags.contains(SymbolFlags::PARAMETER) { + if sub_symbol.flags.contains(SymbolFlags::DEF_PARAM) { continue; } // Track inlined cells if sub_symbol.scope == SymbolScope::Cell - || sub_symbol.flags.contains(SymbolFlags::COMP_CELL) + || sub_symbol.flags.contains(SymbolFlags::DEF_COMP_CELL) { inlined_cells.insert(name.clone()); } @@ -707,7 +717,7 @@ impl SymbolTableAnalyzer { for symbol in symbol_table.symbols.values_mut() { if inlined_cells.contains(&symbol.name) { - symbol.flags.insert(SymbolFlags::COMP_CELL); + symbol.flags.insert(SymbolFlags::DEF_COMP_CELL); } } @@ -730,7 +740,9 @@ impl SymbolTableAnalyzer { } // Collect free variables from this scope - if symbol.scope == SymbolScope::Free || symbol.flags.contains(SymbolFlags::FREE_CLASS) { + if symbol.scope == SymbolScope::Free + || symbol.flags.contains(SymbolFlags::DEF_FREE_CLASS) + { newfree.insert(symbol.name.clone()); } } @@ -763,7 +775,7 @@ impl SymbolTableAnalyzer { if symbol_table.typ == CompilerScope::Class || symbol_table.can_see_class_scope { for name in &newfree { if let Some(symbol) = symbol_table.symbols.get_mut(name) { - symbol.flags.insert(SymbolFlags::FREE_CLASS); + symbol.flags.insert(SymbolFlags::DEF_FREE_CLASS); } } } @@ -797,10 +809,10 @@ impl SymbolTableAnalyzer { }); } // Check if the nonlocal binding refers to a type parameter - if symbol.flags.contains(SymbolFlags::NONLOCAL) { + if symbol.flags.contains(SymbolFlags::DEF_NONLOCAL) { for (symbols, _typ, _skip) in self.tables.iter().rev() { if let Some(sym) = symbols.get(&symbol.name) { - if sym.flags.contains(SymbolFlags::TYPE_PARAM) { + if sym.flags.contains(SymbolFlags::DEF_TYPE_PARAM) { return Err(SymbolTableError { error: format!( "nonlocal binding not allowed for type parameter '{}'", @@ -830,7 +842,7 @@ impl SymbolTableAnalyzer { SymbolScope::Unknown => { // Try hard to figure out what the scope of this symbol is. let scope = if symbol.is_bound() { - if symbol.flags.contains(SymbolFlags::COMP_CELL) + if symbol.flags.contains(SymbolFlags::DEF_COMP_CELL) && matches!(st_typ, CompilerScope::Module | CompilerScope::Class) { // CPython keeps comprehension-only cells in @@ -846,7 +858,7 @@ impl SymbolTableAnalyzer { } else if let Some(scope) = class_entry .and_then(|class_symbols| class_symbols.get(&symbol.name)) .and_then(|class_sym| { - if class_sym.flags.contains(SymbolFlags::GLOBAL) { + if class_sym.flags.contains(SymbolFlags::DEF_GLOBAL) { Some(SymbolScope::GlobalExplicit) } else if class_sym.is_bound() && class_sym.scope != SymbolScope::Free { // If name is bound in enclosing class, use GlobalImplicit @@ -947,10 +959,10 @@ impl SymbolTableAnalyzer { for (table, typ, _skip) in self.tables.iter_mut().rev().take(decl_depth) { if let CompilerScope::Class = typ { if let Some(free_class) = table.get_mut(name) { - free_class.flags.insert(SymbolFlags::FREE_CLASS) + free_class.flags.insert(SymbolFlags::DEF_FREE_CLASS) } else { let mut symbol = Symbol::new(name); - symbol.flags.insert(SymbolFlags::FREE_CLASS); + symbol.flags.insert(SymbolFlags::DEF_FREE_CLASS); symbol.scope = SymbolScope::Free; table.insert(name.to_owned(), symbol); } @@ -989,7 +1001,7 @@ impl SymbolTableAnalyzer { } let sym = st.symbols.get(name)?; if sym.scope == SymbolScope::Free - || (sym.flags.contains(SymbolFlags::FREE_CLASS) + || (sym.flags.contains(SymbolFlags::DEF_FREE_CLASS) && !matches!(st_typ, CompilerScope::Module)) { if st_typ == CompilerScope::Class && name != "__class__" { @@ -1307,7 +1319,7 @@ impl SymbolTableBuilder { symbol.scope = SymbolScope::Free; symbol .flags - .insert(SymbolFlags::REFERENCED | SymbolFlags::FREE_CLASS); + .insert(SymbolFlags::USE | SymbolFlags::DEF_FREE_CLASS); } fn add_conditional_annotations_freevar(&mut self) { @@ -1320,7 +1332,7 @@ impl SymbolTableBuilder { symbol.scope = SymbolScope::Free; symbol .flags - .insert(SymbolFlags::REFERENCED | SymbolFlags::FREE_CLASS); + .insert(SymbolFlags::USE | SymbolFlags::DEF_FREE_CLASS); } /// Walk up the scope chain to determine if we're inside an async function. @@ -1834,9 +1846,11 @@ impl SymbolTableBuilder { .last() .is_some_and(|table| table.typ != CompilerScope::Module) && let Some(flags) = existing_flags - && flags.intersects(SymbolFlags::GLOBAL | SymbolFlags::NONLOCAL) + && flags.intersects( + SymbolFlags::DEF_GLOBAL | SymbolFlags::DEF_NONLOCAL, + ) { - let usage = if flags.contains(SymbolFlags::GLOBAL) { + let usage = if flags.contains(SymbolFlags::DEF_GLOBAL) { "global" } else { "nonlocal" @@ -3106,17 +3120,17 @@ impl SymbolTableBuilder { let parent_is_global = self.tables[table_idx] .symbols .get(mangled.as_str()) - .is_some_and(|symbol| symbol.flags.contains(SymbolFlags::GLOBAL)); + .is_some_and(|symbol| symbol.flags.contains(SymbolFlags::DEF_GLOBAL)); let current = self.tables.last_mut().unwrap(); let current_symbol = current .symbols .entry(mangled.clone()) .or_insert_with(|| Symbol::new(mangled.as_str())); if parent_is_global { - current_symbol.flags.insert(SymbolFlags::GLOBAL); + current_symbol.flags.insert(SymbolFlags::DEF_GLOBAL); current_symbol.scope = SymbolScope::GlobalExplicit; } else { - current_symbol.flags.insert(SymbolFlags::NONLOCAL); + current_symbol.flags.insert(SymbolFlags::DEF_NONLOCAL); current_symbol.scope = SymbolScope::Free; } @@ -3124,7 +3138,7 @@ impl SymbolTableBuilder { .symbols .entry(mangled.clone()) .or_insert_with(|| Symbol::new(mangled.as_str())); - symbol.flags.insert(SymbolFlags::ASSIGNED); + symbol.flags.insert(SymbolFlags::DEF_LOCAL); return Ok(()); } CompilerScope::Module => { @@ -3133,14 +3147,14 @@ impl SymbolTableBuilder { .symbols .entry(mangled.clone()) .or_insert_with(|| Symbol::new(mangled.as_str())); - current_symbol.flags.insert(SymbolFlags::GLOBAL); + current_symbol.flags.insert(SymbolFlags::DEF_GLOBAL); current_symbol.scope = SymbolScope::GlobalExplicit; let symbol = self.tables[table_idx] .symbols .entry(mangled.clone()) .or_insert_with(|| Symbol::new(mangled.as_str())); - symbol.flags.insert(SymbolFlags::GLOBAL); + symbol.flags.insert(SymbolFlags::DEF_GLOBAL); symbol.scope = SymbolScope::GlobalExplicit; return Ok(()); } @@ -3249,7 +3263,7 @@ impl SymbolTableBuilder { if matches!( role, SymbolUsage::Parameter | SymbolUsage::AnnotationParameter - ) && flags.contains(SymbolFlags::PARAMETER) + ) && flags.contains(SymbolFlags::DEF_PARAM) { return Err(SymbolTableError { error: format!("duplicate argument '{original_name}' in function definition"), @@ -3258,7 +3272,8 @@ impl SymbolTableBuilder { } // Role already set.. - if matches!(role, SymbolUsage::TypeParam) && flags.contains(SymbolFlags::TYPE_PARAM) { + if matches!(role, SymbolUsage::TypeParam) && flags.contains(SymbolFlags::DEF_TYPE_PARAM) + { return Err(SymbolTableError { error: format!("duplicate type parameter '{name}'"), location, @@ -3266,25 +3281,25 @@ impl SymbolTableBuilder { } match role { SymbolUsage::Global if !symbol.is_global() => { - if flags.contains(SymbolFlags::PARAMETER) { + if flags.contains(SymbolFlags::DEF_PARAM) { return Err(SymbolTableError { error: format!("name '{name}' is parameter and global"), location, }); } - if flags.contains(SymbolFlags::REFERENCED) { + if flags.contains(SymbolFlags::USE) { return Err(SymbolTableError { error: format!("name '{name}' is used prior to global declaration"), location, }); } - if flags.contains(SymbolFlags::ANNOTATED) { + if flags.contains(SymbolFlags::DEF_ANNOT) { return Err(SymbolTableError { error: format!("annotated name '{name}' can't be global"), location, }); } - if flags.contains(SymbolFlags::ASSIGNED) { + if flags.contains(SymbolFlags::DEF_LOCAL) { return Err(SymbolTableError { error: format!( "name '{name}' is assigned to before global declaration" @@ -3294,25 +3309,25 @@ impl SymbolTableBuilder { } } SymbolUsage::Nonlocal => { - if flags.contains(SymbolFlags::PARAMETER) { + if flags.contains(SymbolFlags::DEF_PARAM) { return Err(SymbolTableError { error: format!("name '{name}' is parameter and nonlocal"), location, }); } - if flags.contains(SymbolFlags::REFERENCED) { + if flags.contains(SymbolFlags::USE) { return Err(SymbolTableError { error: format!("name '{name}' is used prior to nonlocal declaration"), location, }); } - if flags.contains(SymbolFlags::ANNOTATED) { + if flags.contains(SymbolFlags::DEF_ANNOT) { return Err(SymbolTableError { error: format!("annotated name '{name}' can't be nonlocal"), location, }); } - if flags.contains(SymbolFlags::ASSIGNED) { + if flags.contains(SymbolFlags::DEF_LOCAL) { return Err(SymbolTableError { error: format!( "name '{name}' is assigned to before nonlocal declaration" @@ -3323,9 +3338,10 @@ impl SymbolTableBuilder { } SymbolUsage::AnnotationAssigned if current_scope != CompilerScope::Module - && flags.intersects(SymbolFlags::GLOBAL | SymbolFlags::NONLOCAL) => + && flags + .intersects(SymbolFlags::DEF_GLOBAL | SymbolFlags::DEF_NONLOCAL) => { - let usage = if flags.contains(SymbolFlags::GLOBAL) { + let usage = if flags.contains(SymbolFlags::DEF_GLOBAL) { "global" } else { "nonlocal" @@ -3368,13 +3384,13 @@ impl SymbolTableBuilder { match role { SymbolUsage::Nonlocal => { symbol.scope = SymbolScope::Free; - flags.insert(SymbolFlags::NONLOCAL); + flags.insert(SymbolFlags::DEF_NONLOCAL); } SymbolUsage::Imported => { - flags.insert(SymbolFlags::ASSIGNED | SymbolFlags::IMPORTED); + flags.insert(SymbolFlags::DEF_LOCAL | SymbolFlags::DEF_IMPORT); } SymbolUsage::Parameter => { - flags.insert(SymbolFlags::PARAMETER); + flags.insert(SymbolFlags::DEF_PARAM); // Parameters are always added to varnames first let name_str = symbol.name.clone(); if !self.current_varnames.contains(&name_str) { @@ -3382,7 +3398,7 @@ impl SymbolTableBuilder { } } SymbolUsage::AnnotationParameter => { - flags.insert(SymbolFlags::PARAMETER | SymbolFlags::ANNOTATED); + flags.insert(SymbolFlags::DEF_PARAM | SymbolFlags::DEF_ANNOT); // Annotated parameters are also added to varnames let name_str = symbol.name.clone(); if !self.current_varnames.contains(&name_str) { @@ -3390,26 +3406,26 @@ impl SymbolTableBuilder { } } SymbolUsage::AnnotationAssigned => { - flags.insert(SymbolFlags::ASSIGNED | SymbolFlags::ANNOTATED); + flags.insert(SymbolFlags::DEF_LOCAL | SymbolFlags::DEF_ANNOT); } SymbolUsage::Assigned => { - flags.insert(SymbolFlags::ASSIGNED); + flags.insert(SymbolFlags::DEF_LOCAL); } SymbolUsage::AssignedNamedExprInComprehension => { - flags.insert(SymbolFlags::ASSIGNED | SymbolFlags::ASSIGNED_IN_COMPREHENSION); + flags.insert(SymbolFlags::DEF_LOCAL | SymbolFlags::ASSIGNED_IN_COMPREHENSION); } SymbolUsage::Global => { symbol.scope = SymbolScope::GlobalExplicit; - flags.insert(SymbolFlags::GLOBAL); + flags.insert(SymbolFlags::DEF_GLOBAL); } SymbolUsage::Used => { - flags.insert(SymbolFlags::REFERENCED); + flags.insert(SymbolFlags::USE); } SymbolUsage::Iter => { - flags.insert(SymbolFlags::ITER | SymbolFlags::COMP_ITER); + flags.insert(SymbolFlags::ITER | SymbolFlags::DEF_COMP_ITER); } SymbolUsage::TypeParam => { - flags.insert(SymbolFlags::ASSIGNED | SymbolFlags::TYPE_PARAM); + flags.insert(SymbolFlags::DEF_LOCAL | SymbolFlags::DEF_TYPE_PARAM); } } @@ -3558,7 +3574,7 @@ mod tests { .expect("missing comprehension iteration target"); assert!( - symbol.flags.contains(SymbolFlags::COMP_ITER), + symbol.flags.contains(SymbolFlags::DEF_COMP_ITER), "CPython symtable_add_def_helper sets DEF_COMP_ITER on comprehension iteration targets" ); } @@ -3621,7 +3637,7 @@ mod tests { assert!( format .flags - .contains(SymbolFlags::PARAMETER | SymbolFlags::REFERENCED), + .contains(SymbolFlags::DEF_PARAM | SymbolFlags::USE), "CPython symtable_enter_block() adds both DEF_PARAM and USE for annotation-like .format" ); @@ -3637,7 +3653,7 @@ mod tests { assert!( format .flags - .contains(SymbolFlags::PARAMETER | SymbolFlags::REFERENCED), + .contains(SymbolFlags::DEF_PARAM | SymbolFlags::USE), "CPython TypeAliasBlock .format has DEF_PARAM | USE" ); @@ -3658,7 +3674,7 @@ mod tests { assert!( format .flags - .contains(SymbolFlags::PARAMETER | SymbolFlags::REFERENCED), + .contains(SymbolFlags::DEF_PARAM | SymbolFlags::USE), "CPython TypeVariableBlock .format has DEF_PARAM | USE" ); } diff --git a/crates/vm/src/stdlib/_symtable.rs b/crates/vm/src/stdlib/_symtable.rs index adcddeacc1f..38d63208d9e 100644 --- a/crates/vm/src/stdlib/_symtable.rs +++ b/crates/vm/src/stdlib/_symtable.rs @@ -20,43 +20,40 @@ mod _symtable { // https://github.com/python/cpython/blob/6cb20a219a860eaf687b2d968b41c480c7461909/Include/internal/pycore_symtable.h#L156 #[pyattr] - pub(super) const DEF_GLOBAL: i32 = 1; + pub(super) const DEF_GLOBAL: i32 = SymbolFlags::DEF_GLOBAL.bits() as i32; #[pyattr] - pub(super) const DEF_LOCAL: i32 = 2; + pub(super) const DEF_LOCAL: i32 = SymbolFlags::DEF_LOCAL.bits() as i32; #[pyattr] - pub(super) const DEF_PARAM: i32 = 2 << 1; + pub(super) const DEF_PARAM: i32 = SymbolFlags::DEF_PARAM.bits() as i32; #[pyattr] - pub(super) const DEF_NONLOCAL: i32 = 2 << 2; + pub(super) const DEF_NONLOCAL: i32 = SymbolFlags::DEF_NONLOCAL.bits() as i32; #[pyattr] - pub(super) const USE: i32 = 2 << 3; + pub(super) const USE: i32 = SymbolFlags::USE.bits() as i32; #[pyattr] - pub(super) const DEF_FREE: i32 = 2 << 4; + pub(super) const DEF_FREE_CLASS: i32 = SymbolFlags::DEF_FREE_CLASS.bits() as i32; #[pyattr] - pub(super) const DEF_FREE_CLASS: i32 = 2 << 5; + pub(super) const DEF_IMPORT: i32 = SymbolFlags::DEF_IMPORT.bits() as i32; #[pyattr] - pub(super) const DEF_IMPORT: i32 = 2 << 6; + pub(super) const DEF_ANNOT: i32 = SymbolFlags::DEF_ANNOT.bits() as i32; #[pyattr] - pub(super) const DEF_ANNOT: i32 = 2 << 7; + pub(super) const DEF_COMP_ITER: i32 = SymbolFlags::DEF_COMP_ITER.bits() as i32; #[pyattr] - pub(super) const DEF_COMP_ITER: i32 = 2 << 8; + pub(super) const DEF_TYPE_PARAM: i32 = SymbolFlags::DEF_TYPE_PARAM.bits() as i32; #[pyattr] - pub(super) const DEF_TYPE_PARAM: i32 = 2 << 9; + pub(super) const DEF_COMP_CELL: i32 = SymbolFlags::DEF_COMP_CELL.bits() as i32; #[pyattr] - pub(super) const DEF_COMP_CELL: i32 = 2 << 10; - - #[pyattr] - pub(super) const DEF_BOUND: i32 = DEF_LOCAL | DEF_PARAM | DEF_IMPORT; + pub(super) const DEF_BOUND: i32 = SymbolFlags::DEF_BOUND.bits() as i32; #[pyattr] pub(super) const SCOPE_MASK: i32 = DEF_GLOBAL | DEF_LOCAL | DEF_PARAM | DEF_NONLOCAL; @@ -263,7 +260,7 @@ mod _symtable { #[pymethod] const fn is_imported(&self) -> bool { - self.symbol.flags.contains(SymbolFlags::IMPORTED) + self.symbol.flags.contains(SymbolFlags::DEF_IMPORT) } #[pymethod] @@ -274,22 +271,22 @@ mod _symtable { #[pymethod] const fn is_nonlocal(&self) -> bool { - self.symbol.flags.contains(SymbolFlags::NONLOCAL) + self.symbol.flags.contains(SymbolFlags::DEF_NONLOCAL) } #[pymethod] const fn is_referenced(&self) -> bool { - self.symbol.flags.contains(SymbolFlags::REFERENCED) + self.symbol.flags.contains(SymbolFlags::USE) } #[pymethod] const fn is_assigned(&self) -> bool { - self.symbol.flags.contains(SymbolFlags::ASSIGNED) + self.symbol.flags.contains(SymbolFlags::DEF_LOCAL) } #[pymethod] const fn is_parameter(&self) -> bool { - self.symbol.flags.contains(SymbolFlags::PARAMETER) + self.symbol.flags.contains(SymbolFlags::DEF_PARAM) } #[pymethod] @@ -304,7 +301,7 @@ mod _symtable { #[pymethod] const fn is_annotated(&self) -> bool { - self.symbol.flags.contains(SymbolFlags::ANNOTATED) + self.symbol.flags.contains(SymbolFlags::DEF_ANNOT) } #[pymethod] From 52e13a982211122c3e1e2b412084782a51406c54 Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:13:45 +0200 Subject: [PATCH 066/351] Use PyO3 main & enable c-api tests (#8000) Enable `abi3t` support in PyO3 Add left over c-api functions Fix PyLong_AsUnsignedLongLongMask Update pyo3 to `0.29` Add `PyInt::as_u64_mask` Fix `PyInt::as_u32_mask` --- .cspell.dict/cpython.txt | 1 + .github/workflows/ci.yaml | 1 + .github/workflows/update-caches.yml | 1 + crates/capi/Cargo.toml | 2 +- crates/capi/pyo3-rustpython.config | 6 +- crates/capi/src/abstract_.rs | 37 +++++++++ crates/capi/src/abstract_/iter.rs | 2 +- crates/capi/src/abstract_/mapping.rs | 2 +- crates/capi/src/abstract_/number.rs | 2 +- crates/capi/src/abstract_/sequence.rs | 2 +- crates/capi/src/bytearrayobject.rs | 2 +- crates/capi/src/bytesobject.rs | 9 +- crates/capi/src/ceval.rs | 6 +- crates/capi/src/complexobject.rs | 4 +- crates/capi/src/critical_section.rs | 31 +++++++ crates/capi/src/descrobject.rs | 4 +- crates/capi/src/dictobject.rs | 45 +++++++++- crates/capi/src/floatobject.rs | 4 +- crates/capi/src/genericaliasobject.rs | 15 ++++ crates/capi/src/import.rs | 60 +++++++++++++- crates/capi/src/lib.rs | 3 + crates/capi/src/listobject.rs | 2 +- crates/capi/src/longobject.rs | 9 +- crates/capi/src/methodobject.rs | 8 +- crates/capi/src/object.rs | 2 +- crates/capi/src/osmodule.rs | 12 +++ crates/capi/src/pycapsule.rs | 4 +- crates/capi/src/pyerrors.rs | 101 ++++++++++++++++++++++- crates/capi/src/pylifecycle.rs | 33 +++++++- crates/capi/src/pystate.rs | 26 +++++- crates/capi/src/refcount.rs | 31 ++++++- crates/capi/src/setobject.rs | 2 +- crates/capi/src/sliceobject.rs | 2 +- crates/capi/src/tupleobject.rs | 8 +- crates/capi/src/unicodeobject.rs | 2 +- crates/capi/src/warnings.rs | 2 +- crates/capi/src/weakrefobject.rs | 2 +- crates/vm/src/stdlib/_ctypes/function.rs | 23 ++++++ 38 files changed, 449 insertions(+), 59 deletions(-) create mode 100644 crates/capi/src/critical_section.rs create mode 100644 crates/capi/src/genericaliasobject.rs create mode 100644 crates/capi/src/osmodule.rs diff --git a/.cspell.dict/cpython.txt b/.cspell.dict/cpython.txt index 11440e30f8a..47ec7dd60c8 100644 --- a/.cspell.dict/cpython.txt +++ b/.cspell.dict/cpython.txt @@ -170,6 +170,7 @@ nvars opname opnames orelse +osmodule outparam outparm paramfunc diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9f76984bea9..7ac163e835c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -343,6 +343,7 @@ jobs: with: openssl: true + # Keep features in sync with update-caches.yml CARGO_ARGS. - name: build rustpython run: cargo build --release --verbose --features=threading,jit ${{ env.CARGO_ARGS }} diff --git a/.github/workflows/update-caches.yml b/.github/workflows/update-caches.yml index 3a48418a502..fd3dddb7ae2 100644 --- a/.github/workflows/update-caches.yml +++ b/.github/workflows/update-caches.yml @@ -19,6 +19,7 @@ env: CARGO_PROFILE_TEST_DEBUG: 0 CARGO_PROFILE_DEV_DEBUG: 0 CARGO_PROFILE_RELEASE_DEBUG: 0 + # Keep feature list in sync with CI's release build in .github/workflows/ci.yaml. CARGO_ARGS: --workspace --no-default-features --features stdlib,importlib,stdio,encodings,sqlite,ssl-rustls-aws-lc,host_env,threading,jit --exclude rustpython_wasm --exclude rustpython-compiler-source --exclude rustpython-venvlauncher jobs: diff --git a/crates/capi/Cargo.toml b/crates/capi/Cargo.toml index e408d7ab0fe..671c8d1640c 100644 --- a/crates/capi/Cargo.toml +++ b/crates/capi/Cargo.toml @@ -22,7 +22,7 @@ rustpython-stdlib = {workspace = true, features = ["threading"] } rustpython-pylib = { workspace = true } [dev-dependencies] -pyo3 = { workspace = true, features = ["auto-initialize", "abi3"] } +pyo3 = { workspace = true, features = ["auto-initialize", "abi3t"] } [lints] workspace = true diff --git a/crates/capi/pyo3-rustpython.config b/crates/capi/pyo3-rustpython.config index fe59e46e895..601b56440d7 100644 --- a/crates/capi/pyo3-rustpython.config +++ b/crates/capi/pyo3-rustpython.config @@ -1,5 +1,5 @@ -implementation=CPython -version=3.14 +implementation=RustPython +version=3.15 shared=true -abi3=true +target_abi=RustPython-abi3t-3.15 suppress_build_script_link_lines=true diff --git a/crates/capi/src/abstract_.rs b/crates/capi/src/abstract_.rs index d01e31e9626..cd390135be4 100644 --- a/crates/capi/src/abstract_.rs +++ b/crates/capi/src/abstract_.rs @@ -178,3 +178,40 @@ pub unsafe extern "C" fn PyObject_Size(obj: *mut PyObject) -> isize { obj.length(vm) }) } + +#[cfg(test)] +mod tests { + use pyo3::prelude::*; + use pyo3::types::{PyDict, PyString}; + + #[test] + fn call_method1() { + Python::attach(|py| { + let string = PyString::new(py, "Hello, World!"); + assert!( + string + .call_method1("endswith", ("!",)) + .unwrap() + .is_truthy() + .unwrap() + ); + }) + } + + #[test] + fn object_set_get_del_item() { + Python::attach(|py| { + let obj = PyDict::new(py).into_any(); + obj.set_item("key", "value").unwrap(); + assert_eq!( + obj.get_item("key") + .unwrap() + .cast_into::() + .unwrap(), + "value" + ); + obj.del_item("key").unwrap(); + assert!(obj.get_item("key").is_err()); + }) + } +} diff --git a/crates/capi/src/abstract_/iter.rs b/crates/capi/src/abstract_/iter.rs index 1ba5bd04d19..fbd1440e0d0 100644 --- a/crates/capi/src/abstract_/iter.rs +++ b/crates/capi/src/abstract_/iter.rs @@ -89,7 +89,7 @@ pub unsafe extern "C" fn PyIter_Send( }) } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::prelude::*; use pyo3::types::{PyAnyMethods, PyIterator, PyList, PySendResult}; diff --git a/crates/capi/src/abstract_/mapping.rs b/crates/capi/src/abstract_/mapping.rs index 143d5a97744..6fec18bffd6 100644 --- a/crates/capi/src/abstract_/mapping.rs +++ b/crates/capi/src/abstract_/mapping.rs @@ -194,7 +194,7 @@ pub unsafe extern "C" fn PyMapping_SetItemString( }) } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::prelude::*; use pyo3::types::{PyDict, PyMapping, PyMappingMethods, PyTuple}; diff --git a/crates/capi/src/abstract_/number.rs b/crates/capi/src/abstract_/number.rs index b1dc627d828..ffc78ea4e24 100644 --- a/crates/capi/src/abstract_/number.rs +++ b/crates/capi/src/abstract_/number.rs @@ -232,7 +232,7 @@ pub unsafe extern "C" fn PyNumber_Subtract(o1: *mut PyObject, o2: *mut PyObject) with_vm(|vm| vm._sub(unsafe { &*o1 }, unsafe { &*o2 })) } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::prelude::*; diff --git a/crates/capi/src/abstract_/sequence.rs b/crates/capi/src/abstract_/sequence.rs index f6022b93e91..d011dfdb8eb 100644 --- a/crates/capi/src/abstract_/sequence.rs +++ b/crates/capi/src/abstract_/sequence.rs @@ -177,7 +177,7 @@ pub unsafe extern "C" fn PySequence_In(obj: *mut PyObject, value: *mut PyObject) unsafe { PySequence_Contains(obj, value) } } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::prelude::*; use pyo3::types::{PyAnyMethods, PyDict, PyList, PySequence, PySequenceMethods, PyTuple}; diff --git a/crates/capi/src/bytearrayobject.rs b/crates/capi/src/bytearrayobject.rs index 2bc56895ba8..cc9db5dd50e 100644 --- a/crates/capi/src/bytearrayobject.rs +++ b/crates/capi/src/bytearrayobject.rs @@ -71,7 +71,7 @@ pub unsafe extern "C" fn PyByteArray_Resize(bytearray: *mut PyObject, len: isize }) } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::prelude::*; use pyo3::types::{PyByteArray, PyBytes}; diff --git a/crates/capi/src/bytesobject.rs b/crates/capi/src/bytesobject.rs index 1fe535efba5..f4db16af6b3 100644 --- a/crates/capi/src/bytesobject.rs +++ b/crates/capi/src/bytesobject.rs @@ -1,6 +1,5 @@ -use crate::PyObject; use crate::object::define_py_check; -use crate::pystate::with_vm; +use crate::{PyObject, pystate::with_vm}; use core::ffi::c_char; use rustpython_vm::builtins::PyBytes; @@ -46,13 +45,13 @@ pub unsafe extern "C" fn PyBytes_AsString(bytes: *mut PyObject) -> *mut c_char { }) } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::prelude::*; use pyo3::types::PyBytes; #[test] - fn test_bytes() { + fn bytes() { Python::attach(|py| { let bytes = PyBytes::new(py, b"Hello, World!"); assert_eq!(bytes.as_bytes(), b"Hello, World!"); @@ -60,7 +59,7 @@ mod tests { } #[test] - fn test_bytes_uninit() { + fn bytes_uninit() { Python::attach(|py| { let bytes = PyBytes::new_with(py, 13, |data| { data.copy_from_slice(b"Hello, World!"); diff --git a/crates/capi/src/ceval.rs b/crates/capi/src/ceval.rs index 867c39e0388..d137cb17dab 100644 --- a/crates/capi/src/ceval.rs +++ b/crates/capi/src/ceval.rs @@ -52,13 +52,13 @@ pub extern "C" fn PyEval_GetBuiltins() -> *mut PyObject { }) } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::exceptions::PyException; use pyo3::prelude::*; #[test] - fn test_code_eval() { + fn code_eval() { Python::attach(|py| { let result = py.eval(c"1 + 1", None, None).unwrap(); assert_eq!(result.extract::().unwrap(), 2); @@ -66,7 +66,7 @@ mod tests { } #[test] - fn test_code_run_exception() { + fn code_run_exception() { Python::attach(|py| { let err = py.run(c"raise Exception()", None, None).unwrap_err(); assert!(err.is_instance_of::(py)); diff --git a/crates/capi/src/complexobject.rs b/crates/capi/src/complexobject.rs index a6b2bb731a0..79f1804d1bd 100644 --- a/crates/capi/src/complexobject.rs +++ b/crates/capi/src/complexobject.rs @@ -36,13 +36,13 @@ pub unsafe extern "C" fn PyComplex_ImagAsDouble(obj: *mut PyObject) -> c_double with_vm(|vm| try_to_complex(vm, unsafe { &*obj }).map(|complex| complex.im)) } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::prelude::*; use pyo3::types::PyComplex; #[test] - fn test_py_int() { + fn py_int() { Python::attach(|py| { let number = PyComplex::from_doubles(py, 1.0, 2.0); assert_eq!(number.real(), 1.0); diff --git a/crates/capi/src/critical_section.rs b/crates/capi/src/critical_section.rs new file mode 100644 index 00000000000..f50f2b61607 --- /dev/null +++ b/crates/capi/src/critical_section.rs @@ -0,0 +1,31 @@ +use crate::PyObject; + +#[repr(C)] +pub struct PyCriticalSection; + +#[repr(C)] +pub struct PyCriticalSection2; + +#[unsafe(no_mangle)] +pub extern "C" fn PyCriticalSection_Begin(c: *mut PyCriticalSection, op: *mut PyObject) { + let _ = (c, op); +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyCriticalSection_End(c: *mut PyCriticalSection) { + let _ = c; +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyCriticalSection2_Begin( + c: *mut PyCriticalSection2, + a: *mut PyObject, + b: *mut PyObject, +) { + let _ = (c, a, b); +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyCriticalSection2_End(c: *mut PyCriticalSection2) { + let _ = c; +} diff --git a/crates/capi/src/descrobject.rs b/crates/capi/src/descrobject.rs index 5232634fabb..b0d24667dc7 100644 --- a/crates/capi/src/descrobject.rs +++ b/crates/capi/src/descrobject.rs @@ -11,7 +11,7 @@ pub unsafe extern "C" fn PyDictProxy_New(mapping: *mut PyObject) -> *mut PyObjec }) } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::prelude::*; use pyo3::types::{PyDict, PyInt, PyMappingProxy}; @@ -23,7 +23,7 @@ mod tests { dict.set_item("x", 7).unwrap(); let mapping = dict.as_mapping(); - let proxy = PyMappingProxy::new(py, &mapping); + let proxy = PyMappingProxy::new(py, mapping); let value = proxy.get_item("x").unwrap().cast_into::().unwrap(); assert_eq!(value, 7); }) diff --git a/crates/capi/src/dictobject.rs b/crates/capi/src/dictobject.rs index ebc4a827f36..e326ba87e3a 100644 --- a/crates/capi/src/dictobject.rs +++ b/crates/capi/src/dictobject.rs @@ -57,6 +57,43 @@ pub unsafe extern "C" fn PyDict_GetItemRef( }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyDict_SetDefaultRef( + dict: *mut PyObject, + key: *mut PyObject, + default_value: *mut PyObject, + result: *mut *mut PyObject, +) -> c_int { + with_vm(|vm| { + let result = NonNull::new(result); + if let Some(result) = result { + unsafe { + result.write(core::ptr::null_mut()); + } + } + let dict = unsafe { &*dict }.try_downcast_ref::(vm)?; + let key = unsafe { &*key }; + + if let Some(value) = dict.inner_getitem_opt(key, vm)? { + if let Some(result) = result { + unsafe { + result.write(value.into_raw().as_ptr()); + } + } + Ok(true) + } else { + let value = unsafe { &*default_value }.to_owned(); + dict.inner_setitem(key, value.clone(), vm)?; + if let Some(result) = result { + unsafe { + result.write(value.into_raw().as_ptr()); + } + } + Ok(false) + } + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyDict_Size(dict: *mut PyObject) -> isize { with_vm(|vm| { @@ -187,13 +224,13 @@ pub unsafe extern "C" fn PyDict_Next( }) } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::prelude::*; use pyo3::types::{IntoPyDict, PyDict, PyDictMethods, PyInt, PyList}; #[test] - fn test_create_empty_dict() { + fn create_empty_dict() { Python::attach(|py| { let dict = PyDict::new(py); assert!(dict.is_instance_of::()); @@ -201,7 +238,7 @@ mod tests { } #[test] - fn test_create_dict_with_items() { + fn create_dict_with_items() { Python::attach(|py| { let dict = [(1, 2), (3, 4)].into_py_dict(py)?; let value = dict.get_item(1)?.unwrap().cast_into::()?; @@ -214,7 +251,7 @@ mod tests { } #[test] - fn test_dict_iter() { + fn dict_iter() { Python::attach(|py| { let dict = [(1, 2), (3, 4)].into_py_dict(py).unwrap(); let values = dict diff --git a/crates/capi/src/floatobject.rs b/crates/capi/src/floatobject.rs index 831ca32af1e..ead09c219bb 100644 --- a/crates/capi/src/floatobject.rs +++ b/crates/capi/src/floatobject.rs @@ -57,14 +57,14 @@ pub unsafe extern "C" fn PyFloat_FromString(obj: *mut PyObject) -> *mut PyObject }) } -#[cfg(false)] +#[cfg(test)] mod tests { use core::f64::consts::PI; use pyo3::prelude::*; use pyo3::types::PyFloat; #[test] - fn test_py_float() { + fn py_float() { Python::attach(|py| { let pi = PyFloat::new(py, PI); assert!(pi.is_instance_of::()); diff --git a/crates/capi/src/genericaliasobject.rs b/crates/capi/src/genericaliasobject.rs new file mode 100644 index 00000000000..bcd31308679 --- /dev/null +++ b/crates/capi/src/genericaliasobject.rs @@ -0,0 +1,15 @@ +use crate::{PyObject, pystate::with_vm}; +use rustpython_vm::PyPayload; +use rustpython_vm::builtins::PyGenericAlias; + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Py_GenericAlias( + origin: *mut PyObject, + args: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| { + let origin = unsafe { &*origin }.to_owned(); + let args = unsafe { &*args }.to_owned(); + PyGenericAlias::from_args(origin, args, vm).into_pyobject(vm) + }) +} diff --git a/crates/capi/src/import.rs b/crates/capi/src/import.rs index 3550baa1440..c6d5ce85ed6 100644 --- a/crates/capi/src/import.rs +++ b/crates/capi/src/import.rs @@ -1,5 +1,7 @@ use crate::{PyObject, pystate::with_vm}; -use rustpython_vm::builtins::PyStr; +use core::ffi::{CStr, c_char}; +use rustpython_vm::builtins::{PyCode, PyDict, PyModule, PyStr}; +use rustpython_vm::import::import_code_obj; #[unsafe(no_mangle)] pub unsafe extern "C" fn PyImport_Import(name: *mut PyObject) -> *mut PyObject { @@ -9,12 +11,64 @@ pub unsafe extern "C" fn PyImport_Import(name: *mut PyObject) -> *mut PyObject { }) } -#[cfg(false)] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyImport_AddModuleRef(name: *const c_char) -> *mut PyObject { + with_vm(|vm| { + let name = unsafe { CStr::from_ptr(name) } + .to_str() + .map_err(|_| vm.new_system_error("PyImport_AddModuleRef called with non utf8 name"))?; + + let sys_modules = vm + .sys_module + .get_attr(rustpython_vm::identifier!(vm, modules), vm)?; + + sys_modules + .try_downcast_ref::(vm)? + .get_item_opt(name, vm)? + .map_or_else( + || { + let module = vm.new_module(name, vm.ctx.new_dict(), None); + sys_modules.set_item(name, module.clone().into(), vm)?; + Ok(module) + }, + |module| { + let module = module.try_downcast_ref::(vm)?; + Ok(module.to_owned()) + }, + ) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyImport_ExecCodeModuleEx( + name: *const c_char, + co: *mut PyObject, + pathname: *const c_char, +) -> *mut PyObject { + with_vm(|vm| { + let name = unsafe { CStr::from_ptr(name) }.to_str().map_err(|_| { + vm.new_system_error("PyImport_ExecCodeModuleEx called with non utf8 name") + })?; + let code = unsafe { &*co }.try_downcast_ref::(vm)?; + let module = import_code_obj(vm, name, code.to_owned(), false)?; + + if !pathname.is_null() { + let pathname = unsafe { CStr::from_ptr(pathname) }.to_str().map_err(|_| { + vm.new_system_error("PyImport_ExecCodeModuleEx called with non utf8 pathname") + })?; + module.set_attr("__file__", vm.ctx.new_str(pathname), vm)?; + } + + Ok(module) + }) +} + +#[cfg(test)] mod tests { use pyo3::prelude::*; #[test] - fn test_import() { + fn import() { Python::attach(|py| { let _module = py.import("sys").unwrap(); }) diff --git a/crates/capi/src/lib.rs b/crates/capi/src/lib.rs index 638a78d6327..eba7de2786d 100644 --- a/crates/capi/src/lib.rs +++ b/crates/capi/src/lib.rs @@ -14,15 +14,18 @@ pub mod bytearrayobject; pub mod bytesobject; pub mod ceval; pub mod complexobject; +pub mod critical_section; pub mod descrobject; pub mod dictobject; pub mod floatobject; +pub mod genericaliasobject; pub mod import; pub mod listobject; pub mod longobject; pub mod methodobject; pub mod moduleobject; pub mod object; +pub mod osmodule; pub mod pycapsule; pub mod pyerrors; pub mod pylifecycle; diff --git a/crates/capi/src/listobject.rs b/crates/capi/src/listobject.rs index 796720f99d7..03069b0495b 100644 --- a/crates/capi/src/listobject.rs +++ b/crates/capi/src/listobject.rs @@ -165,7 +165,7 @@ pub unsafe extern "C" fn PyList_Sort(list: *mut PyObject) -> c_int { }) } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::exceptions::PyIndexError; use pyo3::prelude::*; diff --git a/crates/capi/src/longobject.rs b/crates/capi/src/longobject.rs index 0668f8df643..d88523a7ddc 100644 --- a/crates/capi/src/longobject.rs +++ b/crates/capi/src/longobject.rs @@ -1,6 +1,5 @@ -use crate::PyObject; use crate::object::define_py_check; -use crate::pystate::with_vm; +use crate::{PyObject, pystate::with_vm}; use bitflags::bitflags; use core::ffi::{CStr, c_char, c_double, c_int, c_long, c_longlong, c_ulong, c_ulonglong, c_void}; use malachite_bigint::{BigInt, Sign}; @@ -373,13 +372,13 @@ pub unsafe extern "C" fn PyLong_AsUnsignedLongLong(obj: *mut PyObject) -> c_ulon }) } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::prelude::*; use pyo3::types::PyInt; #[test] - fn test_py_int_u32() { + fn py_int_u32() { Python::attach(|py| { let number = PyInt::new(py, 123); assert!(number.is_instance_of::()); @@ -388,7 +387,7 @@ mod tests { } #[test] - fn test_py_int_u64() { + fn py_int_u64() { Python::attach(|py| { let number = PyInt::new(py, 123u64); assert!(number.is_instance_of::()); diff --git a/crates/capi/src/methodobject.rs b/crates/capi/src/methodobject.rs index c0a6611a01f..b234ba76a9c 100644 --- a/crates/capi/src/methodobject.rs +++ b/crates/capi/src/methodobject.rs @@ -306,7 +306,7 @@ pub unsafe extern "C" fn PyCFunction_NewEx( unsafe { PyCMethod_New(ml, slf, module, core::ptr::null_mut()) } } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::exceptions::PyException; use pyo3::ffi::{PyLong_FromLong, PyObject}; @@ -314,7 +314,7 @@ mod tests { use pyo3::types::{PyCFunction, PyInt, PyString}; #[test] - fn test_closure_function() { + fn closure_function() { Python::attach(|py| { let f = PyCFunction::new_closure(py, None, None, |_args, _kwargs| "Hello from Rust!") .unwrap(); @@ -327,7 +327,7 @@ mod tests { } #[test] - fn test_function_no_args() { + fn function_no_args() { Python::attach(|py| { unsafe extern "C" fn c_fn(_self: *mut PyObject, _args: *mut PyObject) -> *mut PyObject { assert!(_self.is_null()); @@ -352,7 +352,7 @@ mod tests { } #[test] - fn test_closure_function_error() { + fn closure_function_error() { Python::attach(|py| { let f = PyCFunction::new_closure(py, None, None, |_args, _kwargs| { Err::<(), _>(PyException::new_err("Something went wrong")) diff --git a/crates/capi/src/object.rs b/crates/capi/src/object.rs index d92e9977f2c..9a5d2682ee2 100644 --- a/crates/capi/src/object.rs +++ b/crates/capi/src/object.rs @@ -541,7 +541,7 @@ pub unsafe extern "C" fn PyObject_GenericSetDict( }) } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::class::basic::CompareOp; use pyo3::prelude::*; diff --git a/crates/capi/src/osmodule.rs b/crates/capi/src/osmodule.rs new file mode 100644 index 00000000000..132935ee8af --- /dev/null +++ b/crates/capi/src/osmodule.rs @@ -0,0 +1,12 @@ +use crate::{PyObject, pystate::with_vm}; +use rustpython_vm::convert::ToPyObject; +use rustpython_vm::function::FsPath; + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyOS_FSPath(path: *mut PyObject) -> *mut PyObject { + with_vm(|vm| { + let path = unsafe { &*path }.to_owned(); + let fspath = FsPath::try_from_path_like(path, false, vm)?; + Ok(fspath.to_pyobject(vm)) + }) +} diff --git a/crates/capi/src/pycapsule.rs b/crates/capi/src/pycapsule.rs index 7a5d599c851..a1b5effd88c 100644 --- a/crates/capi/src/pycapsule.rs +++ b/crates/capi/src/pycapsule.rs @@ -142,13 +142,13 @@ fn checked_capsule<'a>( Ok(capsule) } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::prelude::*; use pyo3::types::PyCapsule; #[test] - fn test_capsule_new() { + fn capsule_new() { Python::attach(|py| { let value = String::from("Some data"); let capsule = PyCapsule::new_with_value(py, value, c"my_capsule").unwrap(); diff --git a/crates/capi/src/pyerrors.rs b/crates/capi/src/pyerrors.rs index b767b7c4090..d7efd1a0d6f 100644 --- a/crates/capi/src/pyerrors.rs +++ b/crates/capi/src/pyerrors.rs @@ -3,6 +3,7 @@ use crate::{PyObject, pystate::with_vm}; use core::convert::Infallible; use core::ffi::{CStr, c_char, c_int}; use core::ptr::NonNull; +use core::slice; use rustpython_vm::builtins::{PyBaseException, PyTuple, PyType}; use rustpython_vm::convert::IntoObject; use rustpython_vm::exceptions::ExceptionZoo; @@ -299,9 +300,90 @@ pub unsafe extern "C" fn PyException_GetContext(exc: *mut PyObject) -> *mut PyOb }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyException_SetCause(exc: *mut PyObject, cause: *mut PyObject) { + with_vm(|vm| { + let exc = unsafe { &*exc }.try_downcast_ref::(vm)?; + let cause = NonNull::new(cause) + .map(|obj| unsafe { PyObjectRef::from_raw(obj).downcast_unchecked() }); + exc.set___cause__(cause); + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyException_SetContext(exc: *mut PyObject, context: *mut PyObject) { + with_vm(|vm| { + let exc = unsafe { &*exc }.try_downcast_ref::(vm)?; + let context = NonNull::new(context) + .map(|obj| unsafe { PyObjectRef::from_raw(obj).downcast_unchecked() }); + exc.set___context__(context); + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicodeDecodeError_Create( + encoding: *const c_char, + object: *const c_char, + length: isize, + start: isize, + end: isize, + reason: *const c_char, +) -> *mut PyObject { + with_vm(|vm| { + let encoding = unsafe { CStr::from_ptr(encoding) } + .to_str() + .map_err(|_| vm.new_system_error("encoding must be valid UTF-8"))?; + let reason = unsafe { CStr::from_ptr(reason) } + .to_str() + .map_err(|_| vm.new_system_error("reason must be valid UTF-8"))?; + let length: usize = length + .try_into() + .map_err(|_| vm.new_system_error("length must be non-negative"))?; + let start: usize = start + .try_into() + .map_err(|_| vm.new_system_error("start must be non-negative"))?; + let end: usize = end + .try_into() + .map_err(|_| vm.new_system_error("end must be non-negative"))?; + + let bytes = if object.is_null() { + if length != 0 { + return Err(vm.new_system_error( + "PyUnicodeDecodeError_Create called with null object and non-zero length", + )); + } + Vec::new() + } else { + unsafe { slice::from_raw_parts(object.cast::(), length) }.to_vec() + }; + + let exc = vm.new_unicode_decode_error_real( + vm.ctx.new_str(encoding), + vm.ctx.new_bytes(bytes), + start, + end, + vm.ctx.new_str(reason), + ); + Ok(exc) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyException_SetTraceback(exc: *mut PyObject, tb: *mut PyObject) -> c_int { + with_vm(|vm| { + let exc = unsafe { &*exc }.try_downcast_ref::(vm)?; + let traceback = unsafe { tb.as_ref() }.map(|obj| obj.to_owned()); + exc.set___traceback__(vm.unwrap_or_none(traceback), vm) + }) +} + #[cfg(test)] mod tests { - use pyo3::exceptions::PyTypeError; + use pyo3::PyTypeInfo; + use pyo3::create_exception; + use pyo3::exceptions::{PyException, PyTypeError}; use pyo3::prelude::*; #[test] @@ -309,7 +391,7 @@ mod tests { Python::attach(|py| { PyTypeError::new_err(py.None()).restore(py); assert!(PyErr::occurred(py)); - assert!(unsafe { !pyo3::ffi::PyErr_GetRaisedException().is_null() }); + assert!(PyErr::take(py).is_some()); assert!(!PyErr::occurred(py)); }) } @@ -321,4 +403,19 @@ mod tests { assert!(err.is_instance_of::(py)); }) } + + #[test] + fn new_exception_type() { + create_exception!(my_module, MyError, PyException, "Some description."); + + Python::attach(|py| { + let exc = MyError::new_err("This is a new exception"); + assert!(exc.is_instance_of::(py)); + let exc_type = MyError::type_object(py); + assert_eq!( + exc_type.fully_qualified_name().unwrap(), + "my_module.MyError" + ); + }) + } } diff --git a/crates/capi/src/pylifecycle.rs b/crates/capi/src/pylifecycle.rs index df964d11753..de673b33f0a 100644 --- a/crates/capi/src/pylifecycle.rs +++ b/crates/capi/src/pylifecycle.rs @@ -1,11 +1,13 @@ use crate::get_main_interpreter; use crate::pyerrors::init_exception_statics; use crate::pystate::ensure_thread_has_vm_attached; -use core::ffi::c_int; +use alloc::ffi::CString; +use core::ffi::{c_char, c_int, c_ulong}; use rustpython_vm::common::rc::PyRc; +use rustpython_vm::version::{MAJOR, MICRO, MINOR, VERSION_HEX}; use rustpython_vm::vm::thread::ThreadedVirtualMachine; use rustpython_vm::{Context, Interpreter}; -use std::sync::Mutex; +use std::sync::{LazyLock, Mutex}; pub(crate) static MAIN_INTERP: Mutex> = Mutex::new(None); @@ -17,6 +19,9 @@ pub(crate) fn request_vm_from_interpreter() -> ThreadedVirtualMachine { .enter(|vm| vm.new_thread()) } +#[unsafe(no_mangle)] +pub static Py_Version: c_ulong = VERSION_HEX as c_ulong; + #[unsafe(no_mangle)] pub extern "C" fn Py_IsInitialized() -> c_int { get_main_interpreter().is_some() as c_int @@ -65,3 +70,27 @@ pub extern "C" fn Py_FinalizeEx() -> c_int { pub extern "C" fn Py_IsFinalizing() -> c_int { 0 } + +#[unsafe(no_mangle)] +pub extern "C" fn Py_GetVersion() -> *const c_char { + static VERSION: LazyLock = LazyLock::new(|| { + CString::new(format!("{MAJOR}.{MINOR}.{MICRO}")) + .expect("version string must not contain interior NULs") + }); + VERSION.as_ptr() +} + +#[cfg(test)] +mod tests { + use pyo3::prelude::*; + + #[test] + fn get_version() { + Python::attach(|py| { + let version = py.version_info(); + assert!(version >= (3, 14)); + }); + + assert!(unsafe { pyo3::ffi::Py_Version } >= 0x030d0000); + } +} diff --git a/crates/capi/src/pystate.rs b/crates/capi/src/pystate.rs index f3d12f04a0b..1a2f66de9f1 100644 --- a/crates/capi/src/pystate.rs +++ b/crates/capi/src/pystate.rs @@ -1,11 +1,12 @@ +use crate::get_main_interpreter; use crate::pylifecycle::request_vm_from_interpreter; use crate::util::FfiResult; use core::ffi::c_int; use core::ptr; -use rustpython_vm::VirtualMachine; use rustpython_vm::vm::thread::{ CurrentVmAttachState, attach_current_thread, release_current_thread, with_current_vm, }; +use rustpython_vm::{Interpreter, VirtualMachine}; pub(crate) fn with_vm, O>(f: impl FnOnce(&VirtualMachine) -> R) -> O { with_current_vm(|vm| f(vm).into_output(vm)) @@ -16,9 +17,11 @@ type PyGILState_STATE = c_int; const PYGILSTATE_LOCKED: PyGILState_STATE = 0; const PYGILSTATE_UNLOCKED: PyGILState_STATE = 1; +pub type PyInterpreterState = Interpreter; + #[repr(C)] pub struct PyThreadState { - _interp: *mut core::ffi::c_void, + pub interp: *mut PyInterpreterState, } /// Make sure this thread has a running vm attached. This only creates a new vm if we don't already @@ -50,6 +53,25 @@ pub extern "C" fn PyEval_SaveThread() -> *mut PyThreadState { #[unsafe(no_mangle)] pub extern "C" fn PyEval_RestoreThread(_state: *mut PyThreadState) {} +#[unsafe(no_mangle)] +pub extern "C" fn PyInterpreterState_Get() -> *mut PyInterpreterState { + get_main_interpreter() + .as_ref() + .map(|interp| interp as *const PyInterpreterState) + .expect("PyInterpreterState_Get called but no main interpreter was found") + .cast_mut() +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyInterpreterState_GetID(interp: *mut PyInterpreterState) -> i64 { + with_vm(|vm| { + if interp.is_null() { + return Err(vm.new_system_error("PyInterpreterState_GetID called with null interp")); + } + Ok(interp as usize as i64) + }) +} + #[cfg(test)] mod tests { use crate::get_main_interpreter; diff --git a/crates/capi/src/refcount.rs b/crates/capi/src/refcount.rs index 917dfeec2b9..849b130e292 100644 --- a/crates/capi/src/refcount.rs +++ b/crates/capi/src/refcount.rs @@ -1,4 +1,4 @@ -use crate::PyObject; +use crate::{PyObject, pystate::with_vm}; use core::ptr::NonNull; use rustpython_vm::PyObjectRef; @@ -13,3 +13,32 @@ pub unsafe extern "C" fn _Py_IncRef(op: *mut PyObject) { // Don't drop the owned value, as we just want to increment the refcount. core::mem::forget(unsafe { (*op).to_owned() }); } + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Py_NewRef(op: *mut PyObject) -> *mut PyObject { + with_vm(|_vm| unsafe { (*op).to_owned() }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Py_REFCNT(op: *mut PyObject) -> isize { + with_vm(|_vm| unsafe { &*op }.strong_count()) +} + +#[cfg(test)] +mod tests { + use pyo3::prelude::*; + use pyo3::types::PyInt; + use pyo3::{PyTypeInfo, ffi}; + + #[test] + fn refcount() { + Python::attach(|py| unsafe { + let obj = PyInt::type_object(py); + let ref_count = ffi::Py_REFCNT(obj.as_ptr()); + let obj_clone = obj.clone(); + assert_eq!(ffi::Py_REFCNT(obj.as_ptr()), ref_count + 1); + drop(obj_clone); + assert_eq!(ffi::Py_REFCNT(obj.as_ptr()), ref_count); + }); + } +} diff --git a/crates/capi/src/setobject.rs b/crates/capi/src/setobject.rs index cc479371b27..1036bb1473a 100644 --- a/crates/capi/src/setobject.rs +++ b/crates/capi/src/setobject.rs @@ -116,7 +116,7 @@ pub unsafe extern "C" fn PySet_Size(anyset: *mut PyObject) -> isize { }) } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::prelude::*; use pyo3::types::{PyFrozenSet, PyInt, PySet}; diff --git a/crates/capi/src/sliceobject.rs b/crates/capi/src/sliceobject.rs index 2a625fab523..fb588181531 100644 --- a/crates/capi/src/sliceobject.rs +++ b/crates/capi/src/sliceobject.rs @@ -72,7 +72,7 @@ pub unsafe extern "C" fn PySlice_AdjustIndices( slice_len as isize } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::prelude::*; use pyo3::types::{PySlice, PySliceMethods}; diff --git a/crates/capi/src/tupleobject.rs b/crates/capi/src/tupleobject.rs index 985141f6d4c..60c4b81b370 100644 --- a/crates/capi/src/tupleobject.rs +++ b/crates/capi/src/tupleobject.rs @@ -90,13 +90,13 @@ pub unsafe extern "C" fn PyTuple_GetSlice( }) } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::prelude::*; use pyo3::types::PyTuple; #[test] - fn test_empty_tuple() { + fn empty_tuple() { Python::attach(|py| { let tuple = PyTuple::empty(py); assert_eq!(tuple.len(), 0); @@ -104,7 +104,7 @@ mod tests { } #[test] - fn test_tuple_into_python() { + fn tuple_into_python() { Python::attach(|py| { let tuple = (1, 2, 3).into_pyobject(py).unwrap(); assert_eq!(tuple.len(), 3); @@ -112,7 +112,7 @@ mod tests { } #[test] - fn test_tuple_get_slice() { + fn tuple_get_slice() { Python::attach(|py| { let tuple = (1, 2, 3).into_pyobject(py).unwrap(); let slice = tuple.get_slice(1, 2); diff --git a/crates/capi/src/unicodeobject.rs b/crates/capi/src/unicodeobject.rs index 33d46692602..787e31ea571 100644 --- a/crates/capi/src/unicodeobject.rs +++ b/crates/capi/src/unicodeobject.rs @@ -241,7 +241,7 @@ pub unsafe extern "C" fn PyUnicode_EqualToUTF8AndSize( }) } -#[cfg(false)] +#[cfg(test)] mod tests { use std::ffi::{OsStr, OsString}; diff --git a/crates/capi/src/warnings.rs b/crates/capi/src/warnings.rs index 22ffd6ab939..4966cd60d6d 100644 --- a/crates/capi/src/warnings.rs +++ b/crates/capi/src/warnings.rs @@ -92,7 +92,7 @@ pub unsafe extern "C" fn PyErr_WarnExplicit( }) } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::exceptions::{PyRuntimeWarning, PyUserWarning}; use pyo3::prelude::*; diff --git a/crates/capi/src/weakrefobject.rs b/crates/capi/src/weakrefobject.rs index 706de84b53d..de095e5e594 100644 --- a/crates/capi/src/weakrefobject.rs +++ b/crates/capi/src/weakrefobject.rs @@ -65,7 +65,7 @@ pub unsafe extern "C" fn PyWeakref_NewRef( }) } -#[cfg(false)] +#[cfg(test)] mod tests { use pyo3::prelude::*; use pyo3::types::PyAnyMethods; diff --git a/crates/vm/src/stdlib/_ctypes/function.rs b/crates/vm/src/stdlib/_ctypes/function.rs index 25cbcdcd9a1..2cf3eda13e1 100644 --- a/crates/vm/src/stdlib/_ctypes/function.rs +++ b/crates/vm/src/stdlib/_ctypes/function.rs @@ -19,6 +19,7 @@ use crate::{ use alloc::borrow::Cow; use core::ffi::c_void; use core::fmt::Debug; +use core::ptr::NonNull; use num_traits::{Signed, ToPrimitive}; use rustpython_common::lock::PyRwLock; #[cfg(windows)] @@ -1432,6 +1433,28 @@ fn convert_raw_result( let info = stg_info.unwrap(); + // py_object: interpret return value as PyObject* and materialize it. + if let Ok(type_attr) = restype_type + .as_object() + .get_attr(vm.ctx.intern_str("_type_"), vm) + && let Some(type_str) = type_attr.downcast_ref::() + && type_str.to_str() == Some("O") + { + let ptr = match raw_result { + RawResult::Pointer(p) => *p, + RawResult::Value(v) => *v as usize, + RawResult::Void => 0, + }; + let ptr = NonNull::new(ptr as *mut PyObject).or_else(|| { + vm.set_exception(Some(vm.new_value_error("PyObject is NULL"))); + None + })?; + unsafe { + let obj = PyObjectRef::from_raw(ptr); + return Some(obj); + } + } + // 5. Simple type with getfunc → use bytes_to_pyobject (info->getfunc) // is_simple_instance returns TRUE for c_int, c_void_p, etc. if super::base::is_simple_instance(&restype_type) { From cdcb13d3cf724e9749ba23522fb638e1c809e3b7 Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:15:23 -0400 Subject: [PATCH 067/351] host_env: os.mkdir for Windows, Redox (#8128) Python supports `mkdir` on Windows. I deferred to calling Rust's implementation for now. However, like `rename`, Python's implementation supports additional features that are currently unsupported on RustPython. I added a note for future reference. Redox supports `mkdirat` which significantly cleans up the implementation. --- crates/host_env/Cargo.toml | 2 ++ crates/host_env/src/crt_fd.rs | 22 +++++++------- crates/host_env/src/lib.rs | 3 ++ crates/host_env/src/posix.rs | 28 +++++++----------- crates/host_env/src/posix_wasi.rs | 20 ++++++------- crates/host_env/src/posix_windows.rs | 22 ++++++++++++++ crates/vm/src/stdlib/os.rs | 44 +++++++++++++--------------- 7 files changed, 79 insertions(+), 62 deletions(-) create mode 100644 crates/host_env/src/posix_windows.rs diff --git a/crates/host_env/Cargo.toml b/crates/host_env/Cargo.toml index 718e3f41aab..91a56ea91df 100644 --- a/crates/host_env/Cargo.toml +++ b/crates/host_env/Cargo.toml @@ -20,6 +20,8 @@ paste = { workspace = true } [target.'cfg(unix)'.dependencies] nix = { workspace = true } + +[target.'cfg(any(unix, target_os = "macos", target_os = "redox", target_os = "wasi"))'.dependencies] rustix = { workspace = true } [target.'cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))'.dependencies] diff --git a/crates/host_env/src/crt_fd.rs b/crates/host_env/src/crt_fd.rs index ee21934bee2..b681081be89 100644 --- a/crates/host_env/src/crt_fd.rs +++ b/crates/host_env/src/crt_fd.rs @@ -5,7 +5,7 @@ use alloc::fmt; use core::cmp; use std::{ffi, io}; -#[cfg(unix)] +#[cfg(any(unix, target_os = "wasi"))] use std::os::fd::AsFd; #[cfg(not(windows))] use std::os::fd::{AsRawFd, BorrowedFd, FromRawFd, IntoRawFd, OwnedFd, RawFd}; @@ -209,42 +209,42 @@ impl Owned { } } -#[cfg(unix)] +#[cfg(any(unix, target_os = "wasi"))] impl From for OwnedFd { fn from(fd: Owned) -> Self { fd.inner } } -#[cfg(unix)] +#[cfg(any(unix, target_os = "wasi"))] impl From for Owned { fn from(fd: OwnedFd) -> Self { Self { inner: fd } } } -#[cfg(unix)] +#[cfg(any(unix, target_os = "wasi"))] impl AsFd for Owned { fn as_fd(&self) -> BorrowedFd<'_> { self.inner.as_fd() } } -#[cfg(unix)] +#[cfg(any(unix, target_os = "wasi"))] impl AsRawFd for Owned { fn as_raw_fd(&self) -> RawFd { self.as_raw() } } -#[cfg(unix)] +#[cfg(any(unix, target_os = "wasi"))] impl FromRawFd for Owned { unsafe fn from_raw_fd(fd: RawFd) -> Self { unsafe { Self::from_raw(fd) } } } -#[cfg(unix)] +#[cfg(any(unix, target_os = "wasi"))] impl IntoRawFd for Owned { fn into_raw_fd(self) -> RawFd { self.into_raw() @@ -287,28 +287,28 @@ impl Borrowed<'_> { } } -#[cfg(unix)] +#[cfg(any(unix, target_os = "wasi"))] impl<'fd> From> for BorrowedFd<'fd> { fn from(fd: Borrowed<'fd>) -> Self { fd.inner } } -#[cfg(unix)] +#[cfg(any(unix, target_os = "wasi"))] impl<'fd> From> for Borrowed<'fd> { fn from(fd: BorrowedFd<'fd>) -> Self { Self { inner: fd } } } -#[cfg(unix)] +#[cfg(any(unix, target_os = "wasi"))] impl AsFd for Borrowed<'_> { fn as_fd(&self) -> BorrowedFd<'_> { self.inner.as_fd() } } -#[cfg(unix)] +#[cfg(any(unix, target_os = "wasi"))] impl AsRawFd for Borrowed<'_> { fn as_raw_fd(&self) -> RawFd { self.as_raw() diff --git a/crates/host_env/src/lib.rs b/crates/host_env/src/lib.rs index 99f67b2b496..15f816ea8f4 100644 --- a/crates/host_env/src/lib.rs +++ b/crates/host_env/src/lib.rs @@ -52,6 +52,9 @@ pub mod posix; #[cfg(target_os = "wasi")] #[path = "posix_wasi.rs"] pub mod posix; +#[cfg(windows)] +#[path = "posix_windows.rs"] +pub mod posix; #[cfg(unix)] pub mod pwd; #[cfg(unix)] diff --git a/crates/host_env/src/posix.rs b/crates/host_env/src/posix.rs index f10e99ac938..1239d3526c4 100644 --- a/crates/host_env/src/posix.rs +++ b/crates/host_env/src/posix.rs @@ -10,6 +10,8 @@ use std::os::fd::FromRawFd; use std::os::fd::{AsFd, AsRawFd, BorrowedFd, IntoRawFd, OwnedFd}; use std::path::Path; +use crate::crt_fd; + pub struct UnameInfo { pub sysname: String, pub nodename: String, @@ -174,24 +176,14 @@ pub fn fcopyfile(in_fd: i32, out_fd: i32, flags: u32) -> std::io::Result<()> { } } -#[cfg(not(windows))] -pub fn make_dir(path: &CStr, mode: u32) -> std::io::Result<()> { - let ret = unsafe { libc::mkdir(path.as_ptr(), mode as _) }; - if ret < 0 { - Err(std::io::Error::last_os_error()) - } else { - Ok(()) - } -} - -#[cfg(all(not(windows), not(target_os = "redox")))] -pub fn make_dir_at(dir_fd: i32, path: &CStr, mode: u32) -> std::io::Result<()> { - let ret = unsafe { libc::mkdirat(dir_fd, path.as_ptr(), mode as _) }; - if ret < 0 { - Err(std::io::Error::last_os_error()) - } else { - Ok(()) - } +#[cfg(any(unix, target_os = "wasi"))] +pub fn make_dir( + dir_fd: Option>, + path: &impl AsRef, + mode: libc::mode_t, +) -> std::io::Result<()> { + let dir_fd = dir_fd.as_ref().map_or(rustix::fs::CWD, AsFd::as_fd); + rustix::fs::mkdirat(dir_fd, path.as_ref(), mode.into()).map_err(Into::into) } #[cfg(unix)] diff --git a/crates/host_env/src/posix_wasi.rs b/crates/host_env/src/posix_wasi.rs index d4eff8c6866..f1883fccd58 100644 --- a/crates/host_env/src/posix_wasi.rs +++ b/crates/host_env/src/posix_wasi.rs @@ -1,17 +1,17 @@ use alloc::ffi::CString; use core::{ffi::CStr, time::Duration}; -use std::{ffi::OsStr, io}; +use rustix::fd::AsFd; +use std::{ffi::OsStr, io, path::Path}; -use crate::os::CheckLibcResult; +use crate::{crt_fd, os::CheckLibcResult}; -pub fn make_dir(path: &CStr, mode: u32) -> io::Result<()> { - unsafe { libc::mkdir(path.as_ptr(), mode as _) }.check_libc_neg()?; - Ok(()) -} - -pub fn make_dir_at(dir_fd: i32, path: &CStr, mode: u32) -> io::Result<()> { - unsafe { libc::mkdirat(dir_fd, path.as_ptr(), mode as _) }.check_libc_neg()?; - Ok(()) +pub fn make_dir( + dir_fd: Option>, + path: &impl AsRef, + mode: libc::mode_t, +) -> std::io::Result<()> { + let dir_fd = dir_fd.as_ref().map_or(rustix::fs::CWD, AsFd::as_fd); + rustix::fs::mkdirat(dir_fd, path.as_ref(), mode.into()).map_err(Into::into) } pub fn remove_dir_at(dir_fd: i32, path: &CStr) -> io::Result<()> { diff --git a/crates/host_env/src/posix_windows.rs b/crates/host_env/src/posix_windows.rs new file mode 100644 index 00000000000..ac9e2cfa808 --- /dev/null +++ b/crates/host_env/src/posix_windows.rs @@ -0,0 +1,22 @@ +//! POSIX-compatible API for Windows. +//! +//! Python wraps POSIX syscalls such as `mkdir` and `open`. Windows doesn't directly implement +//! these syscalls, but they can be emulated with a mix of the Windows API and the Rust standard +//! library, the latter of which calls the former. + +use std::{fs, io, path::Path}; + +use crate::crt_fd; + +#[expect(non_camel_case_types)] +pub type mode_t = u32; + +pub fn make_dir( + dir_fd: Option>, + path: &impl AsRef, + _mode: mode_t, +) -> io::Result<()> { + debug_assert!(dir_fd.is_none()); + // TODO: On Windows, Python has an override if the mode is 0o700 + fs::create_dir(path) +} diff --git a/crates/vm/src/stdlib/os.rs b/crates/vm/src/stdlib/os.rs index 3c216168f3f..94dd19f79d6 100644 --- a/crates/vm/src/stdlib/os.rs +++ b/crates/vm/src/stdlib/os.rs @@ -10,6 +10,12 @@ use crate::{ }; use std::{io, path::Path}; +#[cfg(not(windows))] +use libc::mode_t; + +#[cfg(windows)] +use crate::host_env::posix::mode_t; + pub(crate) fn fs_metadata>( path: P, follow_symlink: bool, @@ -29,7 +35,7 @@ pub struct TargetIsDirectory { } cfg_select! { - all(any(unix, target_os = "wasi"), not(target_os = "redox")) => { + any(unix, target_os = "wasi") => { use libc::AT_FDCWD; } _ => { @@ -154,7 +160,7 @@ impl ToPyObject for crt_fd::Borrowed<'_> { #[pymodule(sub)] pub(super) mod _os { - use super::{DirFd, FollowSymlinks, SupportFunc}; + use super::{DirFd, FollowSymlinks, SupportFunc, mode_t}; use crate::host_env::fileutils::StatStruct; #[cfg(any(unix, windows))] use crate::utils::ToCString; @@ -184,7 +190,7 @@ pub(super) mod _os { use std::{fs, io, path::PathBuf, time::SystemTime}; const OPEN_DIR_FD: bool = cfg!(not(any(windows, target_os = "redox"))); - pub(crate) const MKDIR_DIR_FD: bool = cfg!(not(any(windows, target_os = "redox"))); + pub(crate) const MKDIR_DIR_FD: bool = cfg!(any(unix, target_os = "wasi")); const STAT_DIR_FD: bool = cfg!(not(any(windows, target_os = "redox"))); const UTIME_DIR_FD: bool = cfg!(not(any(windows, target_os = "redox"))); pub(crate) const SYMLINK_DIR_FD: bool = cfg!(not(any(windows, target_os = "redox"))); @@ -338,32 +344,24 @@ pub(super) mod _os { } } - #[cfg(not(windows))] #[pyfunction] fn mkdir( path: OsPath, - mode: OptionalArg, - dir_fd: DirFd<'_, { MKDIR_DIR_FD as usize }>, + mode: OptionalArg, + #[cfg_attr(not(any(unix, target_os = "wasi")), expect(unused_variables))] dir_fd: DirFd< + '_, + { MKDIR_DIR_FD as usize }, + >, vm: &VirtualMachine, ) -> PyResult<()> { let mode = mode.unwrap_or(0o777); - let c_path = path.clone().into_cstring(vm)?; - #[cfg(not(target_os = "redox"))] - if let Some(fd) = dir_fd.raw_opt() { - return if let Err(err) = - crate::host_env::posix::make_dir_at(fd, c_path.as_c_str(), mode as u32) - { - Err(OSErrorBuilder::with_filename(&err, path, vm)) - } else { - Ok(()) - }; - } - #[cfg(target_os = "redox")] - let [] = dir_fd.0; - if let Err(err) = crate::host_env::posix::make_dir(c_path.as_c_str(), mode as u32) { - return Err(OSErrorBuilder::with_filename(&err, path, vm)); - } - Ok(()) + #[cfg(any(unix, target_os = "wasi"))] + let dir_fd = dir_fd.get_opt(); + #[cfg(not(any(unix, target_os = "wasi")))] + let dir_fd = None; + + crate::host_env::posix::make_dir(dir_fd, &path.path, mode) + .map_err(|err| OSErrorBuilder::with_filename(&err, path, vm)) } #[pyfunction] From 2ffde783fccc695fd4dd7d530fdbb38441363f2b Mon Sep 17 00:00:00 2001 From: Kwak Byoung Min <67214970+doma17@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:17:46 +0900 Subject: [PATCH 068/351] Fix csv.writer QUOTE_NONE with quotechar=None (#8201) * Prevent csv QUOTE_NONE writer panics without quotechar Handle csv.writer rows with QUOTE_NONE through a RustPython-owned unquoted writer path so quotechar=None no longer reaches the unfinished csv_core configuration branch. Escape delimiter, newline, quotechar, and escapechar bytes according to the active dialect, and preserve CPython's single-empty-field error behavior. Constraint: Match CPython csv.writer behavior without changing Lib/ copied stdlib files. Rejected: Relying on csv_core QuoteStyle::Never | it does not model CPython QUOTE_NONE escaping with quotechar=None. Confidence: high Scope-risk: narrow Directive: Keep QUOTE_NONE writer behavior separate unless csv_core gains matching CPython semantics. Tested: prek run --all-files; cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher; cargo build --release --features sqlite; pytest -v in extra_tests Assisted-by: Codex:gpt-5.5 * Keep csv parity tests aligned with QUOTE_NONE support Remove the stale RustPython expected-failure marker for the CPython escaped-field writer test that now passes, and cover CR/LF escaping in the snippet regression requested during review. Constraint: RustPython test policy allows removing expectedFailure markers only when the upstream test passes. Rejected: Refactoring writer row boilerplate in this follow-up | review marked it as a heavy-lift nitpick and it would broaden the CI fix. Confidence: high Scope-risk: narrow Tested: cargo run --release --features sqlite -- -m test test_csv; cargo run -- extra_tests/snippets/stdlib_csv.py; PATH=/tmp/pyshim:$PATH prek run --all-files; git diff --check; cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher; pytest -v in extra_tests Assisted-by: Codex:gpt-5.5 * Keep csv quote-style dispatch coherent Review feedback pointed out that adjacent branches selected behavior from the same quoting discriminator. Use one match so future quote-style additions are routed in a single place. Constraint: RustPython review requested merging adjacent quote-style checks. Confidence: high Scope-risk: narrow Tested: cargo fmt --check Tested: target/release/rustpython extra_tests/snippets/stdlib_csv.py Tested: target/release/rustpython -m test test_csv Tested: PATH=/tmp/pyshim:$PATH prek run --all-files Tested: RUST_TEST_THREADS=1 cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher -- --test-threads=1 Tested: cargo clippy --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher --all-targets Assisted-by: Codex:gpt-5.5 --- Lib/test/test_csv.py | 1 - crates/stdlib/src/csv.rs | 92 ++++++++++++++++++++++++++---- extra_tests/snippets/stdlib_csv.py | 62 ++++++++++++++++++++ 3 files changed, 143 insertions(+), 12 deletions(-) diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py index 0e1f020d389..494cce50a2a 100644 --- a/Lib/test/test_csv.py +++ b/Lib/test/test_csv.py @@ -854,7 +854,6 @@ class EscapedExcel(csv.excel): class TestEscapedExcel(TestCsvBase): dialect = EscapedExcel() - @unittest.expectedFailure # TODO: RUSTPYTHON def test_escape_fieldsep(self): self.writerAssertEqual([['abc,def']], 'abc\\,def\r\n') diff --git a/crates/stdlib/src/csv.rs b/crates/stdlib/src/csv.rs index bd80cf7eccf..aaffab18252 100644 --- a/crates/stdlib/src/csv.rs +++ b/crates/stdlib/src/csv.rs @@ -910,12 +910,8 @@ mod _csv { writer = writer.delimiter(t); } - if let Some(t) = self.quotechar { - if let Some(u) = t { - writer = writer.quote(u); - } else { - todo!() - } + if let Some(Some(t)) = self.quotechar { + writer = writer.quote(t); } if let Some(t) = self.doublequote { @@ -1148,6 +1144,24 @@ mod _csv { Ok(()) } + fn write_unquoted_field( + output: &mut Vec, + data: &[u8], + dialect: PyDialect, + vm: &VirtualMachine, + ) -> PyResult<()> { + for &byte in data { + if field_needs_escape(byte, dialect) { + let escapechar = dialect + .escapechar + .ok_or_else(|| new_csv_error(vm, "need to escape, but no escapechar set"))?; + output.push(escapechar); + } + output.push(byte); + } + Ok(()) + } + fn field_needs_quotes(data: &[u8], dialect: PyDialect) -> bool { data.iter().any(|&byte| { byte == dialect.delimiter @@ -1157,6 +1171,14 @@ mod _csv { }) } + fn field_needs_escape(byte: u8, dialect: PyDialect) -> bool { + byte == dialect.delimiter + || dialect.quotechar == Some(byte) + || dialect.escapechar == Some(byte) + || matches!(byte, b'\r' | b'\n') + || matches!(dialect.lineterminator, Terminator::Any(t) if byte == t) + } + fn write_lineterminator(output: &mut Vec, terminator: Terminator) { match terminator { Terminator::CRLF => output.extend_from_slice(b"\r\n"), @@ -1222,13 +1244,61 @@ mod _csv { self.write.call((s,), vm) } + fn writerow_quote_none(&self, row: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let _state = self.state.lock(); + + let row: ArgIterable = ArgIterable::try_from_object(vm, row.clone()).map_err(|_e| { + new_csv_error( + vm, + format!("'{}' object is not iterable", row.class().name()), + ) + })?; + + let fields = row.iter(vm)?.collect::>>()?; + let single_field = fields.len() == 1; + let mut output = Vec::new(); + + for (index, field) in fields.into_iter().enumerate() { + if index > 0 { + output.push(self.dialect.delimiter); + } + + let stringified; + let data: &[u8] = match_class!(match field { + ref s @ PyStr => s.as_bytes(), + crate::builtins::PyNone => b"", + ref obj => { + stringified = obj.str(vm)?; + stringified.as_bytes() + } + }); + + if single_field && data.is_empty() { + return Err(new_csv_error( + vm, + "single empty field record must be quoted", + )); + } + + write_unquoted_field(&mut output, data, self.dialect, vm)?; + } + + write_lineterminator(&mut output, self.dialect.lineterminator); + + let s = core::str::from_utf8(&output) + .map_err(|_| vm.new_unicode_decode_error("csv not utf8"))?; + + self.write.call((s,), vm) + } + #[pymethod] fn writerow(&self, row: PyObjectRef, vm: &VirtualMachine) -> PyResult { - if matches!( - self.dialect.quoting, - QuoteStyle::Strings | QuoteStyle::Notnull - ) { - return self.writerow_quoted_strings(row, vm); + match self.dialect.quoting { + QuoteStyle::None => return self.writerow_quote_none(row, vm), + QuoteStyle::Strings | QuoteStyle::Notnull => { + return self.writerow_quoted_strings(row, vm); + } + _ => {} } let mut state = self.state.lock(); diff --git a/extra_tests/snippets/stdlib_csv.py b/extra_tests/snippets/stdlib_csv.py index aa7b41223b6..b9c741cbb16 100644 --- a/extra_tests/snippets/stdlib_csv.py +++ b/extra_tests/snippets/stdlib_csv.py @@ -72,3 +72,65 @@ def test_quote_strings_and_notnull_writer(): test_quote_strings_and_notnull_writer() + + +def test_quote_none_writer_without_quotechar(): + no_quotechar_buf = io.StringIO() + csv.writer( + no_quotechar_buf, + quoting=csv.QUOTE_NONE, + quotechar=None, + escapechar="\\", + ).writerow(["a,b", 'x"y']) + assert no_quotechar_buf.getvalue() == 'a\\,b,x"y\r\n' + + default_quotechar_buf = io.StringIO() + csv.writer( + default_quotechar_buf, + quoting=csv.QUOTE_NONE, + escapechar="\\", + ).writerow(["a,b", 'x"y']) + assert default_quotechar_buf.getvalue() == 'a\\,b,x\\"y\r\n' + + escapechar_buf = io.StringIO() + csv.writer( + escapechar_buf, + quoting=csv.QUOTE_NONE, + quotechar=None, + escapechar="\\", + ).writerow(["a\\b"]) + assert escapechar_buf.getvalue() == "a\\\\b\r\n" + + linebreak_buf = io.StringIO() + csv.writer( + linebreak_buf, + quoting=csv.QUOTE_NONE, + quotechar=None, + escapechar="\\", + ).writerow(["a\rb", "c\nd"]) + assert linebreak_buf.getvalue() == "a\\\rb,c\\\nd\r\n" + + with assert_raises(csv.Error): + csv.writer(io.StringIO(), quoting=csv.QUOTE_NONE, quotechar=None).writerow( + ["a,b"] + ) + + with assert_raises(csv.Error): + csv.writer( + io.StringIO(), + quoting=csv.QUOTE_NONE, + quotechar=None, + escapechar="\\", + ).writerow([None]) + + two_empty_buf = io.StringIO() + csv.writer( + two_empty_buf, + quoting=csv.QUOTE_NONE, + quotechar=None, + escapechar="\\", + ).writerow([None, ""]) + assert two_empty_buf.getvalue() == ",\r\n" + + +test_quote_none_writer_without_quotechar() From 045a6d5d1c4d1e8197fdb7e537521fa9169aa3d6 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:18:37 +0300 Subject: [PATCH 069/351] Move some free standing functions to `ir::InstructionInfo` methods (#8209) * Move some free standing functions to methods * align tests * mark loads_const as const fn * coderabbit suggestion --- crates/codegen/src/ir.rs | 687 +++++++++++++++++++-------------------- 1 file changed, 329 insertions(+), 358 deletions(-) diff --git a/crates/codegen/src/ir.rs b/crates/codegen/src/ir.rs index 28554aa846f..12fba37f6c3 100644 --- a/crates/codegen/src/ir.rs +++ b/crates/codegen/src/ir.rs @@ -320,84 +320,206 @@ pub struct InstructionInfo { pub lineno_override: Option, } -/// Exception handler information for an instruction. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ExceptHandlerInfo { - /// Block to jump to when exception occurs - pub handler_block: BlockIdx, - /// Whether to push lasti before exception - pub preserve_lasti: bool, -} +impl InstructionInfo { + /// flowgraph.c INSTR_SET_OP0 + fn instr_set_op0(&mut self, instr: AnyInstruction) { + debug_assert!(!AnyOpcode::from(instr).has_arg()); + self.instr = instr; + self.arg = OpArg::new(0); + } -/// flowgraph.c INSTR_SET_OP0 -fn instr_set_op0(info: &mut InstructionInfo, instr: AnyInstruction) { - debug_assert!(!AnyOpcode::from(instr).has_arg()); - info.instr = instr; - info.arg = OpArg::new(0); -} + /// flowgraph.c INSTR_SET_OP1 + fn instr_set_op1(&mut self, instr: AnyInstruction, arg: OpArg) { + debug_assert!(AnyOpcode::from(instr).has_arg()); + self.instr = instr; + self.arg = arg; + } -/// flowgraph.c INSTR_SET_OP1 -fn instr_set_op1(info: &mut InstructionInfo, instr: AnyInstruction, arg: OpArg) { - debug_assert!(AnyOpcode::from(instr).has_arg()); - info.instr = instr; - info.arg = arg; -} + /// flowgraph.c INSTR_SET_LOC + fn instr_set_loc( + &mut self, + location: SourceLocation, + end_location: SourceLocation, + lineno_override: Option, + ) { + self.location = location; + self.end_location = end_location; + self.lineno_override = lineno_override; + } -/// flowgraph.c INSTR_SET_LOC -fn instr_set_loc( - info: &mut InstructionInfo, - location: SourceLocation, - end_location: SourceLocation, - lineno_override: Option, -) { - info.location = location; - info.end_location = end_location; - info.lineno_override = lineno_override; -} + fn instr_location(&self) -> InstructionLocation { + InstructionLocation { + location: self.location, + end_location: self.end_location, + lineno_override: self.lineno_override, + } + } -fn instr_location(info: &InstructionInfo) -> InstructionLocation { - InstructionLocation { - location: info.location, - end_location: info.end_location, - lineno_override: info.lineno_override, + fn instr_set_location(&mut self, loc: InstructionLocation) { + self.instr_set_loc(loc.location, loc.end_location, loc.lineno_override); } -} -fn instr_set_location(info: &mut InstructionInfo, loc: InstructionLocation) { - instr_set_loc(info, loc.location, loc.end_location, loc.lineno_override); -} + fn set_to_nop(&mut self) { + self.instr_set_op0(Instruction::Nop.into()); + } -fn no_instruction_location() -> InstructionLocation { - InstructionLocation { - location: SourceLocation::default(), - end_location: SourceLocation::default(), - lineno_override: Some(NO_LOCATION_OVERRIDE), + fn nop_out_no_location(&mut self) { + self.set_to_nop(); + self.instr_set_loc( + SourceLocation::default(), + SourceLocation::default(), + Some(NO_LOCATION_OVERRIDE), + ); } -} -fn set_to_nop(info: &mut InstructionInfo) { - instr_set_op0(info, Instruction::Nop.into()); + #[must_use] + fn empty() -> Self { + Self { + instr: Instruction::Nop.into(), + arg: OpArg::new(0), + target: BlockIdx::NULL, + location: SourceLocation::default(), + end_location: SourceLocation::default(), + except_handler: None, + lineno_override: None, + } + } + + /// instruction_sequence.c _PyInstructionSequence_Addop asserts. + fn instruction_sequence_debug_check_addop(&self) { + let opcode = AnyOpcode::from(self.instr); + debug_assert!(is_within_opcode_range(opcode)); + debug_assert!( + opcode.has_arg() || self.instr.has_target() || u32::from(self.arg) == 0, + "CPython _PyInstructionSequence_Addop requires either OPCODE_HAS_ARG, HAS_TARGET, or oparg == 0" + ); + debug_assert!( + u32::from(self.arg) < (1 << 30), + "CPython _PyInstructionSequence_Addop requires 0 <= oparg < (1 << 30)" + ); + } + + /// assemble.c instr_size + fn instr_size(&self) -> usize { + let opcode = self.instr.expect_real(); + let oparg = u32::from(self.arg) as i32; + debug_assert!( + self.instr.has_arg() || oparg == 0, + "CPython assemble.c instr_size requires OPCODE_HAS_ARG or oparg == 0" + ); + let extended_args = + (0xFF_FFFF < oparg) as usize + (0xFF_FF < oparg) as usize + (0xFF < oparg) as usize; + let caches = opcode.cache_entries(); + extended_args + 1 + caches + } + + fn instruction_linetable_location(&self) -> LineTableLocation { + match self.lineno_override { + Some(NO_LOCATION_OVERRIDE) => LineTableLocation { + line: NO_LOCATION_OVERRIDE, + end_line: NO_LOCATION_OVERRIDE, + col: NO_LOCATION_OVERRIDE, + end_col: NO_LOCATION_OVERRIDE, + }, + Some(LINE_ONLY_LOCATION_OVERRIDE) => LineTableLocation { + line: self.location.line.get() as i32, + end_line: self.end_location.line.get() as i32, + col: -1, + end_col: -1, + }, + Some(NEXT_LOCATION_OVERRIDE) => next_linetable_location(), + Some(lineno) => LineTableLocation { + line: lineno, + end_line: self.end_location.line.get() as i32, + col: self.location.character_offset.to_zero_indexed() as i32, + end_col: self.end_location.character_offset.to_zero_indexed() as i32, + }, + None => LineTableLocation { + line: self.location.line.get() as i32, + end_line: self.end_location.line.get() as i32, + col: self.location.character_offset.to_zero_indexed() as i32, + end_col: self.end_location.character_offset.to_zero_indexed() as i32, + }, + } + } + + /// flowgraph.c loads_const + const fn loads_const(&self) -> bool { + self.instr.has_const() || matches!(self.instr.real_opcode(), Some(Opcode::LoadSmallInt)) + } + + /// flowgraph.c STORES_TO + fn stores_to(&self) -> i32 { + match self.instr.into() { + AnyOpcode::Real(Opcode::StoreFast) + | AnyOpcode::Pseudo(PseudoOpcode::StoreFastMaybeNull) => u32::from(self.arg) as i32, + _ => -1, + } + } + + /// flowgraph.c maybe_instr_make_load_smallint + fn maybe_instr_make_load_smallint(&mut self, constant: &ConstantData) -> bool { + if let ConstantData::Integer { value } = constant + && let Some(small) = value.to_i32().filter(|v| (0..=255).contains(v)) + { + self.instr_set_op1(Opcode::LoadSmallInt.into(), OpArg::new(small as u32)); + return true; + } + false + } + + /// flowgraph.c make_super_instruction + fn make_super_instruction(inst1: &mut Self, inst2: &mut Self, super_op: AnyInstruction) { + let line1 = inst1.instruction_lineno(); + let line2 = inst2.instruction_lineno(); + if line1 >= 0 && line2 >= 0 && line1 != line2 { + return; + } + let arg1 = u32::from(inst1.arg); + let arg2 = u32::from(inst2.arg); + if arg1 >= 16 || arg2 >= 16 { + return; + } + inst1.instr_set_op1(super_op, OpArg::new((arg1 << 4) | arg2)); + inst2.set_to_nop(); + } + + fn instruction_lineno(&self) -> i32 { + match self.lineno_override { + Some(LINE_ONLY_LOCATION_OVERRIDE) | None => self.location.line.get() as i32, + Some(lineno) => lineno, + } + } + + fn instruction_is_no_location(&self) -> bool { + self.instruction_lineno() == NO_LOCATION_OVERRIDE + } + + /// flowgraph.c is_jump + fn is_jump(&self) -> bool { + self.instr.has_jump() + } + + /// flowgraph.c is_block_push + fn is_block_push(&self) -> bool { + self.instr.is_block_push() + } } -fn nop_out_no_location(info: &mut InstructionInfo) { - set_to_nop(info); - instr_set_loc( - info, - SourceLocation::default(), - SourceLocation::default(), - Some(NO_LOCATION_OVERRIDE), - ); +/// Exception handler information for an instruction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ExceptHandlerInfo { + /// Block to jump to when exception occurs + pub handler_block: BlockIdx, + /// Whether to push lasti before exception + pub preserve_lasti: bool, } -fn empty_instruction_info() -> InstructionInfo { - InstructionInfo { - instr: Instruction::Nop.into(), - arg: OpArg::new(0), - target: BlockIdx::NULL, +fn no_instruction_location() -> InstructionLocation { + InstructionLocation { location: SourceLocation::default(), end_location: SourceLocation::default(), - except_handler: None, - lineno_override: None, + lineno_override: Some(NO_LOCATION_OVERRIDE), } } @@ -585,20 +707,6 @@ fn instruction_sequence_new_label(seq: &mut InstructionSequence) -> InstructionS InstructionSequenceLabel(seq.next_free_label) } -/// instruction_sequence.c _PyInstructionSequence_Addop asserts. -fn instruction_sequence_debug_check_addop(info: &InstructionInfo) { - let opcode = AnyOpcode::from(info.instr); - debug_assert!(is_within_opcode_range(opcode)); - debug_assert!( - opcode.has_arg() || info.instr.has_target() || u32::from(info.arg) == 0, - "CPython _PyInstructionSequence_Addop requires either OPCODE_HAS_ARG, HAS_TARGET, or oparg == 0" - ); - debug_assert!( - u32::from(info.arg) < (1 << 30), - "CPython _PyInstructionSequence_Addop requires 0 <= oparg < (1 << 30)" - ); -} - /// instruction_sequence.c _PyInstructionSequence_SetAnnotationsCode fn instruction_sequence_set_annotations_code( seq: &mut InstructionSequence, @@ -653,7 +761,7 @@ fn instruction_sequence_addop( seq: &mut InstructionSequence, info: InstructionInfo, ) -> crate::InternalResult<&mut InstructionSequenceEntry> { - instruction_sequence_debug_check_addop(&info); + info.instruction_sequence_debug_check_addop(); let idx = instruction_sequence_next_inst(seq)?; let entry = &mut seq.instrs[idx]; entry.info = info; @@ -725,20 +833,6 @@ fn instruction_sequence_apply_label_map(instrs: &mut InstructionSequence) { instrs.label_map_allocation = 0; } -/// assemble.c instr_size -fn instr_size(instr: &InstructionInfo) -> usize { - let opcode = instr.instr.expect_real(); - let oparg = u32::from(instr.arg) as i32; - debug_assert!( - instr.instr.has_arg() || oparg == 0, - "CPython assemble.c instr_size requires OPCODE_HAS_ARG or oparg == 0" - ); - let extended_args = - (0xFF_FFFF < oparg) as usize + (0xFF_FF < oparg) as usize + (0xFF < oparg) as usize; - let caches = opcode.cache_entries(); - extended_args + 1 + caches -} - /// pycore_opcode_metadata.h is_pseudo_target const fn is_pseudo_target(pseudo: PseudoOpcode, target: Opcode) -> bool { match pseudo { @@ -821,22 +915,24 @@ fn resolve_jump_offsets(instr_sequence: &mut InstructionSequence) { instr.i_target = u32::from(instr.info.arg) as i32; } } + let mut extended_arg_recompile; loop { let mut totsize = 0i32; for i in 0..instr_sequence.instr_used { let instr = &mut instr_sequence.instrs[i]; instr.i_offset = totsize; - let isize = instr_size(&instr.info); - totsize += isize as i32; + let instr_size = instr.info.instr_size(); + totsize += instr_size as i32; } + extended_arg_recompile = false; let mut offset = 0i32; for i in 0..instr_sequence.instr_used { - let isize = instr_size(&instr_sequence.instrs[i].info); + let i_size = instr_sequence.instrs[i].info.instr_size(); // Jump offsets are computed relative to the instruction pointer // after fetching the jump instruction. - offset += isize as i32; + offset += i_size as i32; let opcode = instr_sequence.instrs[i].info.instr.expect_real(); if opcode.has_jump() { @@ -862,7 +958,7 @@ fn resolve_jump_offsets(instr_sequence: &mut InstructionSequence) { oparg -= offset; } info.arg = OpArg::new(oparg as u32); - if instr_size(info) != isize { + if info.instr_size() != i_size { extended_arg_recompile = true; } } @@ -890,36 +986,6 @@ fn same_location(a: LineTableLocation, b: LineTableLocation) -> bool { a.line == b.line && a.end_line == b.end_line && a.col == b.col && a.end_col == b.end_col } -fn instruction_linetable_location(info: &InstructionInfo) -> LineTableLocation { - match info.lineno_override { - Some(NO_LOCATION_OVERRIDE) => LineTableLocation { - line: NO_LOCATION_OVERRIDE, - end_line: NO_LOCATION_OVERRIDE, - col: NO_LOCATION_OVERRIDE, - end_col: NO_LOCATION_OVERRIDE, - }, - Some(LINE_ONLY_LOCATION_OVERRIDE) => LineTableLocation { - line: info.location.line.get() as i32, - end_line: info.end_location.line.get() as i32, - col: -1, - end_col: -1, - }, - Some(NEXT_LOCATION_OVERRIDE) => next_linetable_location(), - Some(lineno) => LineTableLocation { - line: lineno, - end_line: info.end_location.line.get() as i32, - col: info.location.character_offset.to_zero_indexed() as i32, - end_col: info.end_location.character_offset.to_zero_indexed() as i32, - }, - None => LineTableLocation { - line: info.location.line.get() as i32, - end_line: info.end_location.line.get() as i32, - col: info.location.character_offset.to_zero_indexed() as i32, - end_col: info.end_location.character_offset.to_zero_indexed() as i32, - }, - } -} - /// assemble.c write_instr fn write_instr(instructions: &mut Vec, info: &InstructionInfo, ilen: usize) { let opcode = info.instr.expect_real(); @@ -963,7 +1029,7 @@ fn assemble_emit_instr( instructions: &mut Vec, info: &mut InstructionInfo, ) -> crate::InternalResult<()> { - let size = instr_size(info); + let size = info.instr_size(); let required = instructions .len() .checked_add(size) @@ -982,7 +1048,9 @@ fn assemble_location_info( debug_ranges: bool, ) -> crate::InternalResult> { for i in (0..instr_sequence.instr_used).rev() { - let loc = instruction_linetable_location(&instr_sequence.instrs[i].info); + let loc = instr_sequence.instrs[i] + .info + .instruction_linetable_location(); if same_location(loc, next_linetable_location()) { if instr_sequence.instrs[i] .info @@ -994,8 +1062,7 @@ fn assemble_location_info( } else { debug_assert!(i < instr_sequence.instr_used - 1); let next = instr_sequence.instrs[i + 1].info; - instr_set_loc( - &mut instr_sequence.instrs[i].info, + instr_sequence.instrs[i].info.instr_set_loc( next.location, next.end_location, next.lineno_override, @@ -1011,13 +1078,13 @@ fn assemble_location_info( let mut size = 0; for i in 0..instr_sequence.instr_used { let entry = &instr_sequence.instrs[i]; - let instr_loc = instruction_linetable_location(&entry.info); + let instr_loc = entry.info.instruction_linetable_location(); if !same_location(loc, instr_loc) { assemble_emit_location(&mut linetable, loc, size, &mut prev_line, debug_ranges)?; loc = instr_loc; size = 0; } - size += instr_size(&entry.info); + size += entry.info.instr_size(); } assemble_emit_location(&mut linetable, loc, size, &mut prev_line, debug_ranges)?; Ok(linetable.into_boxed_slice()) @@ -1227,7 +1294,7 @@ impl Block { .try_reserve_exact(new_allocation - self.instructions.len()) .map_err(|_| InternalError::MalformedControlFlowGraph)?; self.instructions - .resize_with(new_allocation, empty_instruction_info); + .resize_with(new_allocation, InstructionInfo::empty); } self.instruction_allocation = new_allocation; } @@ -1360,7 +1427,7 @@ impl Block { fn basicblock_has_no_lineno(&self) -> bool { let mut i = 0; while i < self.instruction_used { - if instruction_lineno(&self.instructions[i]) >= 0 { + if self.instructions[i].instruction_lineno() >= 0 { return false; } i += 1; @@ -1377,7 +1444,7 @@ impl Block { /// flowgraph.c nop_out fn nop_out(&mut self, instrs: &[usize]) { for &i in instrs { - nop_out_no_location(&mut self.instructions[i]); + self.instructions[i].nop_out_no_location(); } } @@ -1395,21 +1462,26 @@ impl Block { if start >= self.instruction_used { return Ok(None); } + let instr = &self.instructions[start]; if !matches!(instr.instr.real(), Some(Instruction::Nop)) { - if !loads_const(instr) { + if !instr.loads_const() { return Ok(None); } + indices.push(start); if indices.len() == size { break; } } + let Some(prev) = start.checked_sub(1) else { return Ok(None); }; + start = prev; } + indices.reverse(); Ok(Some(indices)) } @@ -1423,7 +1495,7 @@ impl Block { } let info = &self.instructions[i]; - let info_lineno = instruction_lineno(info); + let info_lineno = info.instruction_lineno(); if lineno >= 0 && info_lineno != lineno { return None; @@ -1515,7 +1587,7 @@ impl Block { } while current >= 0 { - set_to_nop(&mut self.instructions[*ix + current as usize]); + self.instructions[*ix + current as usize].set_to_nop(); current -= 1; } *ix += len - 1; @@ -1547,7 +1619,7 @@ impl Block { let Some(j) = self.next_swappable_instruction(idx, -1) else { return; }; - let lineno = instruction_lineno(&self.instructions[j]); + let lineno = self.instructions[j].instruction_lineno(); let mut k = j; for _ in 1..swap_arg { let Some(next) = self.next_swappable_instruction(k, lineno) else { @@ -1556,15 +1628,15 @@ impl Block { k = next; } - let store_j = stores_to(&self.instructions[j]); - let store_k = stores_to(&self.instructions[k]); + let store_j = self.instructions[j].stores_to(); + let store_k = self.instructions[k].stores_to(); if store_j >= 0 || store_k >= 0 { if store_j == store_k { return; } let mut idx = j + 1; while idx < k { - let store_idx = stores_to(&self.instructions[idx]); + let store_idx = self.instructions[idx].stores_to(); if store_idx >= 0 && (store_idx == store_j || store_idx == store_k) { return; } @@ -1572,7 +1644,7 @@ impl Block { } } - set_to_nop(&mut self.instructions[idx]); + self.instructions[idx].set_to_nop(); self.instructions.swap(j, k); i -= 1; } @@ -1640,7 +1712,7 @@ impl Blocks { let instr_count = self[idx].instruction_used; for i in 0..instr_count { let instr = self[idx].instructions[i]; - if is_jump(&instr) || is_block_push(&instr) { + if instr.is_jump() || instr.is_block_push() { let target = instr.target; debug_assert!(target != BlockIdx::NULL); let target_idx = target.idx(); @@ -1704,7 +1776,7 @@ impl Blocks { continue; }; - if is_jump(&last) { + if last.is_jump() { debug_assert!(last.target != BlockIdx::NULL); let target = next_nonempty_block(self, last.target); @@ -1715,10 +1787,7 @@ impl Blocks { && self[target].predecessors > 1 { let new_target = self.copy_basicblock(target)?; - instr_set_location( - &mut self[new_target].instructions[0], - instr_location(&last), - ); + self[new_target].instructions[0].instr_set_location(last.instr_location()); let last_mut = self[b].basicblock_last_instr_mut().unwrap(); last_mut.target = new_target; self[target].predecessors -= 1; @@ -1743,7 +1812,7 @@ impl Blocks { let last = *self[b] .basicblock_last_instr() .expect("block has instructions"); - instr_set_location(&mut self[next].instructions[0], instr_location(&last)); + self[next].instructions[0].instr_set_location(last.instr_location()); } b = self[b].next; } @@ -1772,7 +1841,7 @@ impl Blocks { except_handler: None, lineno_override: None, }; - instr_set_op0(&mut nop, Instruction::Nop.into()); + nop.instr_set_op0(Instruction::Nop.into()); let mut i = 0; while i < self[block_idx].instruction_used { let inst = self[block_idx].instructions[i]; @@ -1800,13 +1869,13 @@ impl Blocks { { match oparg { 1 => { - set_to_nop(&mut self[block_idx].instructions[i]); - set_to_nop(&mut self[block_idx].instructions[i + 1]); + self[block_idx].instructions[i].set_to_nop(); + self[block_idx].instructions[i + 1].set_to_nop(); i += 1; continue; } 2 | 3 => { - set_to_nop(&mut self[block_idx].instructions[i]); + self[block_idx].instructions[i].set_to_nop(); self[block_idx].instructions[i + 1].instr = Opcode::Swap.into(); i += 1; continue; @@ -1907,32 +1976,28 @@ impl Blocks { if matches!(nextop, Some(Instruction::StoreFast { .. })) && u32::from(inst.arg) == u32::from(self[block_idx].instructions[i + 1].arg) - && instruction_lineno(&self[block_idx].instructions[i]) - == instruction_lineno(&self[block_idx].instructions[i + 1]) => + && self[block_idx].instructions[i].instruction_lineno() + == self[block_idx].instructions[i + 1].instruction_lineno() => { self[block_idx].instructions[i].instr = Instruction::PopTop.into(); self[block_idx].instructions[i].arg = OpArg::NULL; } AnyInstruction::Real(Instruction::Swap { .. }) if u32::from(inst.arg) == 1 => { - set_to_nop(&mut self[block_idx].instructions[i]); + self[block_idx].instructions[i].set_to_nop(); } AnyInstruction::Real(Instruction::LoadGlobal { .. }) if matches!(nextop, Some(Instruction::PushNull)) && (u32::from(inst.arg) & 1) == 0 => { - instr_set_op1( - &mut self[block_idx].instructions[i], - inst.instr, - OpArg::new(u32::from(inst.arg) | 1), - ); - set_to_nop(&mut self[block_idx].instructions[i + 1]); + self[block_idx].instructions[i] + .instr_set_op1(inst.instr, OpArg::new(u32::from(inst.arg) | 1)); + self[block_idx].instructions[i + 1].set_to_nop(); } AnyInstruction::Real(Instruction::CompareOp { .. }) if matches!(nextop, Some(Instruction::ToBool)) => { - set_to_nop(&mut self[block_idx].instructions[i]); - instr_set_op1( - &mut self[block_idx].instructions[i + 1], + self[block_idx].instructions[i].set_to_nop(); + self[block_idx].instructions[i + 1].instr_set_op1( inst.instr, OpArg::new(u32::from(inst.arg) | oparg::COMPARE_OP_BOOL_MASK), ); @@ -1942,46 +2007,39 @@ impl Blocks { AnyInstruction::Real(Instruction::ContainsOp { .. } | Instruction::IsOp { .. }) if matches!(nextop, Some(Instruction::ToBool)) => { - set_to_nop(&mut self[block_idx].instructions[i]); - instr_set_op1( - &mut self[block_idx].instructions[i + 1], - inst.instr, - inst.arg, - ); + self[block_idx].instructions[i].set_to_nop(); + self[block_idx].instructions[i + 1].instr_set_op1(inst.instr, inst.arg); i += 1; continue; } AnyInstruction::Real(Instruction::ContainsOp { .. } | Instruction::IsOp { .. }) if matches!(nextop, Some(Instruction::UnaryNot)) => { - set_to_nop(&mut self[block_idx].instructions[i]); + self[block_idx].instructions[i].set_to_nop(); let inverted = u32::from(inst.arg) ^ 1; debug_assert!(inverted == 0 || inverted == 1); - instr_set_op1( - &mut self[block_idx].instructions[i + 1], - inst.instr, - OpArg::new(inverted), - ); + self[block_idx].instructions[i + 1] + .instr_set_op1(inst.instr, OpArg::new(inverted)); i += 1; continue; } AnyInstruction::Real(Instruction::ToBool) if matches!(nextop, Some(Instruction::ToBool)) => { - set_to_nop(&mut self[block_idx].instructions[i]); + self[block_idx].instructions[i].set_to_nop(); i += 1; continue; } AnyInstruction::Real(Instruction::UnaryNot) => { if matches!(nextop, Some(Instruction::ToBool)) { - set_to_nop(&mut self[block_idx].instructions[i]); - instr_set_op0(&mut self[block_idx].instructions[i + 1], inst.instr); + self[block_idx].instructions[i].set_to_nop(); + self[block_idx].instructions[i + 1].instr_set_op0(inst.instr); i += 1; continue; } if matches!(nextop, Some(Instruction::UnaryNot)) { - set_to_nop(&mut self[block_idx].instructions[i]); - set_to_nop(&mut self[block_idx].instructions[i + 1]); + self[block_idx].instructions[i].set_to_nop(); + self[block_idx].instructions[i + 1].set_to_nop(); i += 1; continue; } @@ -1994,7 +2052,7 @@ impl Blocks { match func.get(inst.arg) { IntrinsicFunction1::ListToTuple => { if matches!(nextop, Some(Instruction::GetIter)) { - set_to_nop(&mut self[block_idx].instructions[i]); + self[block_idx].instructions[i].set_to_nop(); } else { fold_constant_intrinsic_list_to_tuple( metadata, @@ -2267,7 +2325,7 @@ impl Blocks { let target_depth = refs.size - num_popped + num_pushed; load_fast_push_block(&mut worklist, self, target, target_depth); } - if !is_block_push(&info) { + if !info.is_block_push() { for _ in 0..num_popped { let _ = ref_stack_pop(&mut refs); } @@ -2333,10 +2391,10 @@ impl Blocks { let mut prev_location = no_instruction_location(); for i in 0..self[current].instruction_used { - if instruction_is_no_location(&self[current].instructions[i]) { - instr_set_location(&mut self[current].instructions[i], prev_location); + if self[current].instructions[i].instruction_is_no_location() { + self[current].instructions[i].instr_set_location(prev_location); } else { - prev_location = instr_location(&self[current].instructions[i]); + prev_location = self[current].instructions[i].instr_location(); } } @@ -2346,19 +2404,19 @@ impl Blocks { if next != BlockIdx::NULL && self[next].predecessors == 1 && self[next].instruction_used != 0 - && instruction_is_no_location(&self[next].instructions[0]) + && self[next].instructions[0].instruction_is_no_location() { - instr_set_location(&mut self[next].instructions[0], prev_location); + self[next].instructions[0].instr_set_location(prev_location); } } - if is_jump(&last) { + if last.is_jump() { let target = last.target; debug_assert!(target != BlockIdx::NULL); if self[target].predecessors == 1 { let instr = self[target].basicblock_raw_first_instr_mut(); - if instruction_is_no_location(instr) { - instr_set_location(instr, prev_location); + if instr.instruction_is_no_location() { + instr.instr_set_location(prev_location); } } } @@ -2406,14 +2464,14 @@ impl Blocks { if is_redundant_pair { let (prev_block, prev_instr_idx) = prev_instr.expect("redundant pair has previous"); - set_to_nop(&mut self[prev_block].instructions[prev_instr_idx]); - set_to_nop(&mut self[block_idx].instructions[instr_idx]); + self[prev_block].instructions[prev_instr_idx].set_to_nop(); + self[block_idx].instructions[instr_idx].set_to_nop(); done = false; } } let instr_is_jump = instr.is_some_and(|(instr_block, instr_idx)| { - is_jump(&self[instr_block].instructions[instr_idx]) + self[instr_block].instructions[instr_idx].is_jump() }); let block = &self[block_idx]; @@ -2621,37 +2679,30 @@ impl Blocks { .then(|| block.instructions[i + 1].instr.real_opcode()) .flatten(); - match (block.instructions[i].instr.real_opcode(), nextop) { - (Some(Opcode::LoadFast), _) => { - if matches!(nextop, Some(Opcode::LoadFast)) { - let (inst1, rest) = block.instructions[i..].split_at_mut(1); - make_super_instruction( - &mut inst1[0], - &mut rest[0], - Opcode::LoadFastLoadFast.into(), - ); - } + let super_op = match (block.instructions[i].instr.real_opcode(), nextop) { + (Some(Opcode::LoadFast), Some(Opcode::LoadFast)) => { + Some(Opcode::LoadFastLoadFast) } (Some(Opcode::StoreFast), Some(Opcode::LoadFast)) => { - let (inst1, rest) = block.instructions[i..].split_at_mut(1); - make_super_instruction( - &mut inst1[0], - &mut rest[0], - Opcode::StoreFastLoadFast.into(), - ); + Some(Opcode::StoreFastLoadFast) } (Some(Opcode::StoreFast), Some(Opcode::StoreFast)) => { - let (inst1, rest) = block.instructions[i..].split_at_mut(1); - make_super_instruction( - &mut inst1[0], - &mut rest[0], - Opcode::StoreFastStoreFast.into(), - ); + Some(Opcode::StoreFastStoreFast) } - (_, _) => {} + (_, _) => None, + }; + + if let Some(super_op) = super_op { + let (inst1, rest) = block.instructions[i..].split_at_mut(1); + + InstructionInfo::make_super_instruction( + &mut inst1[0], + &mut rest[0], + super_op.into(), + ); } } @@ -2684,7 +2735,7 @@ impl Blocks { let instr_count = self[block_idx].instruction_used; for i in 0..instr_count { let instr = self[block_idx].instructions[i]; - if is_block_push(&instr) { + if instr.is_block_push() { debug_assert!(instr.target != BlockIdx::NULL); self[instr.target].except_handler = true; } @@ -2728,7 +2779,7 @@ impl Blocks { let instr_count = self[block_idx].instruction_used; for i in 0..instr_count { let instr = self[block_idx].instructions[i]; - if is_jump(&instr) { + if instr.is_jump() { let target = instr.target; debug_assert!(target != BlockIdx::NULL); if !self[target].visited { @@ -2780,7 +2831,7 @@ impl Blocks { let instr_count = self[block_idx].instruction_used; for i in 0..instr_count { let instr = self[block_idx].instructions[i]; - if is_jump(&instr) { + if instr.is_jump() { debug_assert_eq!(i, instr_count - 1); let target = instr.target; debug_assert!(target != BlockIdx::NULL); @@ -2914,15 +2965,17 @@ impl Blocks { target: &InstructionInfo, opcode: AnyInstruction, ) -> crate::InternalResult { - debug_assert!(is_jump(&self[block_idx].instructions[instr_idx])); - debug_assert!(is_jump(target)); + debug_assert!(self[block_idx].instructions[instr_idx].is_jump()); + debug_assert!(target.is_jump()); debug_assert_eq!(instr_idx + 1, self[block_idx].instruction_used); debug_assert!(target.target != BlockIdx::NULL); + if self[block_idx].instructions[instr_idx].target != target.target { - set_to_nop(&mut self[block_idx].instructions[instr_idx]); + self[block_idx].instructions[instr_idx].set_to_nop(); self.basicblock_add_jump(block_idx, opcode, target.target, target)?; return Ok(true); } + Ok(false) } @@ -2935,7 +2988,7 @@ impl Blocks { loc_source: &InstructionInfo, ) -> crate::InternalResult<()> { let last = self[block_idx].basicblock_last_instr(); - if last.is_some_and(is_jump) { + if last.is_some_and(|l| l.is_jump()) { return Err(InternalError::MalformedControlFlowGraph); } debug_assert!(target != BlockIdx::NULL); @@ -3117,12 +3170,12 @@ impl Blocks { let no_lineno_no_fallthrough = self[target].basicblock_has_no_lineno() && !self[target].bb_has_fallthrough(); if small_exit_block || no_lineno_no_fallthrough { - debug_assert!(is_jump(&last)); + debug_assert!(last.is_jump()); let removed_jump_opcode = last.instr; let last = self[block_idx] .basicblock_last_instr_mut() .expect("non-empty block has last instruction"); - set_to_nop(last); + last.set_to_nop(); self.basicblock_append_block_instructions(block_idx, target)?; if no_lineno_no_fallthrough { let last = self[block_idx].basicblock_last_instr_mut().unwrap(); @@ -3169,7 +3222,7 @@ impl Blocks { for src in 0..instr_count { let instr = self[block_idx].instructions[src]; - let lineno = instruction_lineno(&instr); + let lineno = instr.instruction_lineno(); if matches!(instr.instr.real(), Some(Instruction::Nop)) { if lineno < 0 { @@ -3179,13 +3232,12 @@ impl Blocks { continue; } if src < instr_count - 1 { - let next_lineno = instruction_lineno(&self[block_idx].instructions[src + 1]); + let next_lineno = self[block_idx].instructions[src + 1].instruction_lineno(); if next_lineno == lineno { continue; } if next_lineno < 0 { - instr_set_loc( - &mut self[block_idx].instructions[src + 1], + self[block_idx].instructions[src + 1].instr_set_loc( instr.location, instr.end_location, instr.lineno_override, @@ -3200,12 +3252,12 @@ impl Blocks { while next_i < self[next].instruction_used { let instr = self[next].instructions[next_i]; if matches!(instr.instr.real(), Some(Instruction::Nop)) - && instruction_lineno(&instr) < 0 + && instr.instruction_lineno() < 0 { next_i += 1; continue; } - next_loc = instruction_linetable_location(&instr); + next_loc = instr.instruction_linetable_location(); break; } if lineno == next_loc.line { @@ -3267,7 +3319,7 @@ impl Blocks { if jump_target == next { changes += 1; let last = self[current].basicblock_last_instr_mut().unwrap(); - set_to_nop(last); + last.set_to_nop(); } } current = self[current].next; @@ -3288,10 +3340,11 @@ impl Blocks { let jump_target = next_nonempty_block(self, last.target); if jump_target == next { assert!(next != BlockIdx::NULL); - if instruction_lineno(last) == instruction_lineno(&self[next].instructions[0]) { + if last.instruction_lineno() == self[next].instructions[0].instruction_lineno() + { assert_ne!( - instruction_lineno(last), - instruction_lineno(&self[next].instructions[0]), + last.instruction_lineno(), + self[next].instructions[0].instruction_lineno(), "redundant jump has same line as fallthrough target" ); return false; @@ -4206,16 +4259,12 @@ fn instr_make_load_const( instr: &mut InstructionInfo, constant: ConstantData, ) -> crate::InternalResult<()> { - if maybe_instr_make_load_smallint(instr, &constant) { + if instr.maybe_instr_make_load_smallint(&constant) { return Ok(()); } let const_idx = add_const(metadata, constant)?; - instr_set_op1( - instr, - Opcode::LoadConst.into(), - OpArg::new(const_idx as u32), - ); + instr.instr_set_op1(Opcode::LoadConst.into(), OpArg::new(const_idx as u32)); Ok(()) } @@ -4300,11 +4349,6 @@ fn fold_const_binop( Ok(true) } -/// flowgraph.c loads_const -fn loads_const(info: &InstructionInfo) -> bool { - info.instr.has_const() || matches!(info.instr.real_opcode(), Some(Opcode::LoadSmallInt)) -} - /// flowgraph.c get_const_value fn get_const_value(metadata: &CodeUnitMetadata, info: &InstructionInfo) -> Option { match info.instr.real_opcode() { @@ -5076,13 +5120,13 @@ fn fold_constant_intrinsic_list_to_tuple( if matches!(block.instructions[idx].instr.real(), Some(Instruction::Nop)) { continue; } - if loads_const(&block.instructions[idx]) { + if block.instructions[idx].loads_const() { let Some(value) = get_const_value(metadata, &block.instructions[idx]) else { return Ok(false); }; elements.push(value); } - nop_out_no_location(&mut block.instructions[idx]); + block.instructions[idx].nop_out_no_location(); } debug_assert_eq!(elements.len(), consts_found); elements.reverse(); @@ -5101,7 +5145,7 @@ fn fold_constant_intrinsic_list_to_tuple( return Ok(false); } } else { - if !loads_const(instr) { + if !instr.loads_const() { return Ok(false); } consts_found += 1; @@ -5146,7 +5190,7 @@ fn optimize_lists_and_sets( }) else { if contains_or_iter && is_list { let arg = block.instructions[i].arg; - instr_set_op1(&mut block.instructions[i], Opcode::BuildTuple.into(), arg); + block.instructions[i].instr_set_op1(Opcode::BuildTuple.into(), arg); return Ok(true); } return Ok(false); @@ -5172,7 +5216,7 @@ fn optimize_lists_and_sets( if !contains_or_iter { debug_assert!(i >= 2); - let folded_loc = instr_location(&block.instructions[i]); + let folded_loc = block.instructions[i].instr_location(); block.nop_out(&operand_indices); @@ -5182,35 +5226,24 @@ fn optimize_lists_and_sets( Opcode::BuildSet } .into(); - instr_set_op1(&mut block.instructions[i - 2], build_instr, OpArg::new(0)); - instr_set_location(&mut block.instructions[i - 2], folded_loc); + block.instructions[i - 2].instr_set_op1(build_instr, OpArg::new(0)); + block.instructions[i - 2].instr_set_location(folded_loc); - instr_set_op1( - &mut block.instructions[i - 1], - Opcode::LoadConst.into(), - OpArg::new(const_idx as u32), - ); + block.instructions[i - 1] + .instr_set_op1(Opcode::LoadConst.into(), OpArg::new(const_idx as u32)); let extend_instr = if is_list { Opcode::ListExtend } else { Opcode::SetUpdate }; - instr_set_op1( - &mut block.instructions[i], - extend_instr.into(), - OpArg::new(1), - ); + block.instructions[i].instr_set_op1(extend_instr.into(), OpArg::new(1)); return Ok(true); } block.nop_out(&operand_indices); - instr_set_op1( - &mut block.instructions[i], - Opcode::LoadConst.into(), - OpArg::new(const_idx as u32), - ); + block.instructions[i].instr_set_op1(Opcode::LoadConst.into(), OpArg::new(const_idx as u32)); Ok(true) } @@ -5226,26 +5259,6 @@ fn is_swappable(instr: AnyInstruction) -> bool { ) } -/// flowgraph.c STORES_TO -fn stores_to(info: &InstructionInfo) -> i32 { - match info.instr.into() { - AnyOpcode::Real(Opcode::StoreFast) - | AnyOpcode::Pseudo(PseudoOpcode::StoreFastMaybeNull) => u32::from(info.arg) as i32, - _ => -1, - } -} - -/// flowgraph.c maybe_instr_make_load_smallint -fn maybe_instr_make_load_smallint(instr: &mut InstructionInfo, constant: &ConstantData) -> bool { - if let ConstantData::Integer { value } = constant - && let Some(small) = value.to_i32().filter(|v| (0..=255).contains(v)) - { - instr_set_op1(instr, Opcode::LoadSmallInt.into(), OpArg::new(small as u32)); - return true; - } - false -} - /// flowgraph.c basicblock_optimize_load_const fn basicblock_optimize_load_const( metadata: &mut CodeUnitMetadata, @@ -5260,7 +5273,7 @@ fn basicblock_optimize_load_const( Some(Instruction::LoadConst { .. }) ) && let Some(constant) = get_const_value(metadata, &block.instructions[i]) { - maybe_instr_make_load_smallint(&mut block.instructions[i], &constant); + block.instructions[i].maybe_instr_make_load_smallint(&constant); } let curr = block.instructions[i]; @@ -5302,12 +5315,12 @@ fn basicblock_optimize_load_const( }; if let Some((jump_if_true, pops_condition)) = const_jump { if pops_condition { - set_to_nop(&mut block.instructions[i]); + block.instructions[i].set_to_nop(); } if is_true == jump_if_true { block.instructions[i + 1].instr = PseudoOpcode::Jump.into(); } else { - set_to_nop(&mut block.instructions[i + 1]); + block.instructions[i + 1].set_to_nop(); } i += 1; continue; @@ -5335,7 +5348,7 @@ fn basicblock_optimize_load_const( block.instructions[jump_idx].instr.real(), Some(Instruction::ToBool) ) { - set_to_nop(&mut block.instructions[jump_idx]); + block.instructions[jump_idx].set_to_nop(); jump_idx += 1; if jump_idx >= block.instruction_used { i += 1; @@ -5363,8 +5376,8 @@ fn basicblock_optimize_load_const( } }; - set_to_nop(&mut block.instructions[i]); - set_to_nop(&mut block.instructions[i + 1]); + block.instructions[i].set_to_nop(); + block.instructions[i + 1].set_to_nop(); block.instructions[jump_idx].instr = if invert { Opcode::PopJumpIfNotNone } else { @@ -5383,12 +5396,10 @@ fn basicblock_optimize_load_const( && let Some(value) = load_const_truthiness(const_instr, const_arg, metadata) { let const_idx = add_const(metadata, ConstantData::Boolean { value })?; - set_to_nop(&mut block.instructions[i]); - instr_set_op1( - &mut block.instructions[i + 1], - Opcode::LoadConst.into(), - OpArg::new(const_idx as u32), - ); + block.instructions[i].set_to_nop(); + + block.instructions[i + 1] + .instr_set_op1(Opcode::LoadConst.into(), OpArg::new(const_idx as u32)); i += 1; continue; } @@ -5445,8 +5456,9 @@ impl CodeInfo { }, block_return, ); + for info in &block.instructions[..block.instruction_used] { - let lineno = instruction_lineno(info); + let lineno = info.instruction_lineno(); let _ = writeln!( out, " [disp={}:{} raw={}:{}-{}:{} override={:?}] {:?} arg={} target={}", @@ -5607,26 +5619,6 @@ impl InstrDisplayContext for CodeInfo { const NOT_LOCAL: isize = -1; const DUMMY_INSTR: isize = -1; -/// flowgraph.c make_super_instruction -fn make_super_instruction( - inst1: &mut InstructionInfo, - inst2: &mut InstructionInfo, - super_op: AnyInstruction, -) { - let line1 = instruction_lineno(inst1); - let line2 = instruction_lineno(inst2); - if line1 >= 0 && line2 >= 0 && line1 != line2 { - return; - } - let arg1 = u32::from(inst1.arg); - let arg2 = u32::from(inst2.arg); - if arg1 >= 16 || arg2 >= 16 { - return; - } - instr_set_op1(inst1, super_op, OpArg::new((arg1 << 4) | arg2)); - set_to_nop(inst2); -} - /// flowgraph.c LoadFastInstrFlag #[derive(Clone, Copy, Eq, PartialEq)] #[repr(u8)] @@ -6085,7 +6077,7 @@ fn assemble_exception_table( start = ioffset; handler = instr.except_handler; } - ioffset += instr_size(&instr.info) as i32; + ioffset += instr.info.instr_size() as i32; } if handler.h_label >= 0 { @@ -6448,7 +6440,7 @@ fn scan_block_for_locals( let last = blocks[idx].basicblock_last_instr().copied(); if let Some(last) = last - && is_jump(&last) + && last.is_jump() { let target = last.target; debug_assert!(target != BlockIdx::NULL); @@ -6546,33 +6538,12 @@ fn next_nonempty_block(blocks: &Blocks, mut idx: BlockIdx) -> BlockIdx { idx } -fn instruction_lineno(instr: &InstructionInfo) -> i32 { - match instr.lineno_override { - Some(LINE_ONLY_LOCATION_OVERRIDE) | None => instr.location.line.get() as i32, - Some(lineno) => lineno, - } -} - -fn instruction_is_no_location(instr: &InstructionInfo) -> bool { - instruction_lineno(instr) == NO_LOCATION_OVERRIDE -} - /// flowgraph.c add_checks_for_loads_of_uninitialized_variables uses uint64_t masks. const LOCAL_UNSAFE_MASK_BITS: usize = 64; /// flowgraph.c MAX_COPY_SIZE const MAX_COPY_SIZE: usize = 4; -/// flowgraph.c is_jump -fn is_jump(instr: &InstructionInfo) -> bool { - instr.instr.has_jump() -} - -/// flowgraph.c is_block_push -fn is_block_push(instr: &InstructionInfo) -> bool { - instr.instr.is_block_push() -} - /// flowgraph.c get_max_label fn get_max_label(blocks: &Blocks) -> i32 { let mut lbl = -1; @@ -6620,7 +6591,7 @@ fn push_except_block( setup: InstructionInfo, blocks: &mut Blocks, ) -> Option { - debug_assert!(is_block_push(&setup)); + debug_assert!(setup.is_block_push()); let instr = setup.instr; let target = setup.target; debug_assert!(target != BlockIdx::NULL); @@ -6672,7 +6643,7 @@ pub(crate) fn label_exception_targets(blocks: &mut Blocks) -> crate::InternalRes let target = info.target; let arg = info.arg; - if is_block_push(&info) { + if info.is_block_push() { debug_assert!(target != BlockIdx::NULL); if !blocks[target].visited { blocks[target].except_stack = Some(copy_except_stack( @@ -6688,8 +6659,8 @@ pub(crate) fn label_exception_targets(blocks: &mut Blocks) -> crate::InternalRes ); } else if instr.is_pop_block() { handler = pop_except_block(stack.as_mut().expect("active exception stack"), blocks); - set_to_nop(&mut blocks[bi].instructions[i]); - } else if is_jump(&blocks[bi].instructions[i]) { + blocks[bi].instructions[i].set_to_nop(); + } else if blocks[bi].instructions[i].is_jump() { blocks[bi].instructions[i].except_handler = handler; debug_assert_eq!(i, instr_count - 1); @@ -6763,8 +6734,8 @@ pub(crate) fn convert_pseudo_ops(blocks: &mut Blocks) -> crate::InternalResult<( let block = &mut blocks[block_idx]; for i in 0..block.instruction_used { let info = &mut block.instructions[i]; - if is_block_push(info) { - set_to_nop(info); + if info.is_block_push() { + info.set_to_nop(); } else if matches!( info.instr.pseudo(), Some(PseudoInstruction::LoadClosure { .. }) @@ -7365,7 +7336,7 @@ mod tests { #[test] fn instr_set_op0_nop_preserves_cpython_stale_target() { let mut info = test_jump(BlockIdx::new(1), 50); - set_to_nop(&mut info); + info.set_to_nop(); assert_eq!(info.target, BlockIdx::new(1)); @@ -7679,9 +7650,9 @@ mod tests { blocks[duplicate].cpython_label, InstructionSequenceLabel::from_index(3) ); - assert_eq!(instruction_lineno(&blocks[duplicate].instructions[0]), 10); + assert_eq!(blocks[duplicate].instructions[0].instruction_lineno(), 10); assert_eq!(blocks[1].instructions[0].target, exit); - assert_eq!(instruction_lineno(&blocks[exit].instructions[0]), 20); + assert_eq!(blocks[exit].instructions[0].instruction_lineno(), 20); } #[test] @@ -7732,7 +7703,7 @@ mod tests { // for jump targets without checking `b_iused`. If // `remove_redundant_nops()` emptied the target, that writes the stale // backing slot rather than an active instruction. - assert_eq!(instruction_lineno(&blocks[1].instructions[0]), 10); + assert_eq!(blocks[1].instructions[0].instruction_lineno(), 10); } #[test] From 7e4ae697c9001cecec190a440edef393073d65db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:12:11 +0900 Subject: [PATCH 070/351] Bump cmov from 0.5.3 to 0.5.4 (#8210) Bumps [cmov](https://github.com/RustCrypto/utils) from 0.5.3 to 0.5.4. - [Commits](https://github.com/RustCrypto/utils/compare/cmov-v0.5.3...cmov-v0.5.4) --- updated-dependencies: - dependency-name: cmov dependency-version: 0.5.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eb0f64095ff..8300eef59db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -599,9 +599,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "collection_literals" From 3f9d80b29e6d0f00e2c9c9329a8d9c7ce5594ea6 Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Mon, 6 Jul 2026 07:12:52 +0200 Subject: [PATCH 071/351] Add more abstract functions to c-api (#8202) --- crates/capi/src/abstract_.rs | 89 +++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/crates/capi/src/abstract_.rs b/crates/capi/src/abstract_.rs index cd390135be4..36d949a3022 100644 --- a/crates/capi/src/abstract_.rs +++ b/crates/capi/src/abstract_.rs @@ -1,6 +1,6 @@ use crate::{PyObject, pystate::with_vm}; use alloc::slice; -use core::ffi::c_int; +use core::ffi::{CStr, c_char, c_int}; pub use iter::*; pub use mapping::*; pub use number::*; @@ -57,6 +57,21 @@ pub unsafe extern "C" fn PyObject_CallNoArgs(callable: *mut PyObject) -> *mut Py with_vm(|vm| unsafe { &*callable }.call((), vm)) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_CallObject( + callable: *mut PyObject, + args: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| { + let callable = unsafe { &*callable }; + if let Some(args) = unsafe { args.as_ref() } { + callable.call(tuple_to_args(args.try_downcast_ref::(vm)?), vm) + } else { + callable.call((), vm) + } + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyObject_Vectorcall( callable: *mut PyObject, @@ -121,6 +136,42 @@ pub unsafe extern "C" fn PyObject_VectorcallMethod( }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyVectorcall_Call( + callable: *mut PyObject, + tuple: *mut PyObject, + kwargs: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| { + let callable = unsafe { &*callable }; + let tuple = unsafe { &*tuple }.try_downcast_ref::(vm)?; + + let mut args = tuple.iter().cloned().collect::>(); + let num_positional_args = args.len(); + + let mut kwnames = Vec::new(); + if let Some(kwargs) = unsafe { kwargs.as_ref() } { + let kwargs = kwargs.try_downcast_ref::(vm)?; + for (key, value) in kwargs.items_vec() { + let key = key + .downcast_ref::() + .map(ToOwned::to_owned) + .ok_or_else(|| vm.new_type_error("keywords must be strings"))?; + kwnames.push(key.into()); + args.push(value); + } + } + + let kwnames = if kwnames.is_empty() { + None + } else { + Some(kwnames.as_slice()) + }; + + callable.vectorcall(args, num_positional_args, kwnames, vm) + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyObject_GetItem(obj: *mut PyObject, key: *mut PyObject) -> *mut PyObject { with_vm(|vm| { @@ -153,6 +204,32 @@ pub unsafe extern "C" fn PyObject_DelItem(obj: *mut PyObject, key: *mut PyObject }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_DelItemString(obj: *mut PyObject, key: *const c_char) -> c_int { + with_vm(|vm| { + let obj = unsafe { &*obj }; + let key = unsafe { CStr::from_ptr(key) } + .to_str() + .map_err(|_| vm.new_value_error("mapping key must be valid UTF-8"))?; + obj.del_item(key, vm) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_Format( + obj: *mut PyObject, + format_spec: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| { + let obj = unsafe { &*obj }; + let spec = unsafe { format_spec.as_ref() } + .map(|spec| spec.try_downcast_ref::(vm)) + .transpose()? + .unwrap_or_else(|| vm.ctx.empty_str); + vm.format(obj, spec.to_owned()) + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyObject_IsSubclass(derived: *mut PyObject, cls: *mut PyObject) -> c_int { with_vm(|vm| { @@ -179,6 +256,16 @@ pub unsafe extern "C" fn PyObject_Size(obj: *mut PyObject) -> isize { }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_Length(obj: *mut PyObject) -> isize { + unsafe { PyObject_Size(obj) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_Type(obj: *mut PyObject) -> *mut PyObject { + with_vm(|_vm| unsafe { &*obj }.obj_type()) +} + #[cfg(test)] mod tests { use pyo3::prelude::*; From 0f6309b83b8ef433165e20be148cd25f7fd29f4f Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:05:56 +0900 Subject: [PATCH 072/351] Move tuple/frozenset hash algorithms into common::hash (#8213) * Move tuple/frozenset hash algorithms into common::hash Move the pure tuplehash fold (xxHash-based) into `hash::hash_tuple`, taking a fallible iterator of element hashes, and the frozenset XOR-fold into a `hash::FrozenSetHash` streaming accumulator. `builtins::tuple` and `builtins::set` keep only the element-hash loop and delegate the arithmetic. Assisted-by: Claude * common/hash: rename acc_pyhash to acc_py_hash Drop the now-unused `// cspell:ignore pyhash` from tuple.rs left behind by the tuple hash move; the only remaining occurrence was the acc_pyhash local. Renaming it lets cspell pass without an ignore. Assisted-by: Claude --- crates/common/src/hash.rs | 98 +++++++++++++++++++++++++++++++++ crates/vm/src/builtins/set.rs | 30 +++------- crates/vm/src/builtins/tuple.rs | 51 +---------------- 3 files changed, 108 insertions(+), 71 deletions(-) diff --git a/crates/common/src/hash.rs b/crates/common/src/hash.rs index 91fd5e1cba0..56e6e000676 100644 --- a/crates/common/src/hash.rs +++ b/crates/common/src/hash.rs @@ -198,3 +198,101 @@ pub fn keyed_hash(key: u64, buf: &[u8]) -> u64 { buf.hash(&mut hasher); hasher.finish() } + +/// tuplehash: fold the element hashes of a tuple (xxHash-based). +/// +/// The caller supplies each element's hash lazily; a hash computation may fail, +/// in which case the error short-circuits the fold. +pub fn hash_tuple( + element_hashes: impl IntoIterator>, +) -> Result { + const PRIME1: PyUHash = cfg_select! { + target_pointer_width = "64" => 11400714785074694791, + target_pointer_width = "32" => 2654435761, + _ => unreachable!(), + }; + + const PRIME2: PyUHash = cfg_select! { + target_pointer_width = "64" => 14029467366897019727, + target_pointer_width = "32" => 2246822519, + _ => unreachable!(), + }; + + const PRIME5: PyUHash = cfg_select! { + target_pointer_width = "64" => 2870177450012600261, + target_pointer_width = "32" => 374761393, + _ => unreachable!(), + }; + + const ROTATE: u32 = cfg_select! { + target_pointer_width = "64" => 31, + target_pointer_width = "32" => 13, + _ => unreachable!(), + }; + + let mut acc = PRIME5; + let mut len: PyUHash = 0; + + for element_hash in element_hashes { + let lane = element_hash? as PyUHash; + acc = acc.wrapping_add(lane.wrapping_mul(PRIME2)); + acc = acc.rotate_left(ROTATE); + acc = acc.wrapping_mul(PRIME1); + len += 1; + } + + acc = acc.wrapping_add(len ^ (PRIME5 ^ 3527539)); + + let acc_py_hash = acc as PyHash; + if acc_py_hash == -1 { + return Ok(1546275796); + } + + Ok(acc_py_hash) +} + +/// frozenset_hash: order-independent XOR-fold of a frozenset's element hashes. +/// +/// The entry hashes are fed in one at a time via [`FrozenSetHash::add`], so the +/// caller keeps ownership of the iteration (which may hold a lock and compute +/// each element hash fallibly). The fold is commutative, so element order does +/// not affect the result. +pub struct FrozenSetHash { + hash: u64, +} + +impl FrozenSetHash { + #[must_use] + pub fn new(len: usize) -> Self { + // Factor in the number of active entries + Self { + hash: (len as u64 + 1).wrapping_mul(1927868237), + } + } + + pub fn add(&mut self, element_hash: PyHash) { + // Work to increase the bit dispersion for closely spaced hash values. + // This is important because some use cases have many combinations of a + // small number of elements with nearby hashes so that many distinct + // combinations collapse to only a handful of distinct hash values. + const fn shuffle_bits(h: u64) -> u64 { + ((h ^ 89869747) ^ (h.wrapping_shl(16))).wrapping_mul(3644798167) + } + // Xor-in shuffled bits from every entry's hash field because xor is + // commutative and a frozenset hash should be independent of order. + self.hash ^= shuffle_bits(element_hash as u64); + } + + #[must_use] + pub fn finish(self) -> PyHash { + let mut hash = self.hash; + // Disperse patterns arising in nested frozen-sets + hash ^= (hash >> 11) ^ (hash >> 25); + hash = hash.wrapping_mul(69069).wrapping_add(907133923); + // -1 is reserved as an error code + if hash == u64::MAX { + hash = 590923713; + } + hash as PyHash + } +} diff --git a/crates/vm/src/builtins/set.rs b/crates/vm/src/builtins/set.rs index 8b38223fce1..44dc9a22180 100644 --- a/crates/vm/src/builtins/set.rs +++ b/crates/vm/src/builtins/set.rs @@ -442,28 +442,14 @@ impl PySetInner { } fn hash(&self, vm: &VirtualMachine) -> PyResult { - // Work to increase the bit dispersion for closely spaced hash values. - // This is important because some use cases have many combinations of a - // small number of elements with nearby hashes so that many distinct - // combinations collapse to only a handful of distinct hash values. - const fn _shuffle_bits(h: u64) -> u64 { - ((h ^ 89869747) ^ (h.wrapping_shl(16))).wrapping_mul(3644798167) - } - // Factor in the number of active entries - let mut hash: u64 = (self.len() as u64 + 1).wrapping_mul(1927868237); - // Xor-in shuffled bits from every entry's hash field because xor is - // commutative and a frozenset hash should be independent of order. - hash = self.content.try_fold_keys(hash, |h, element| { - Ok(h ^ _shuffle_bits(element.hash(vm)? as u64)) - })?; - // Disperse patterns arising in nested frozen-sets - hash ^= (hash >> 11) ^ (hash >> 25); - hash = hash.wrapping_mul(69069).wrapping_add(907133923); - // -1 is reserved as an error code - if hash == u64::MAX { - hash = 590923713; - } - Ok(hash as PyHash) + let hasher = self.content.try_fold_keys( + hash::FrozenSetHash::new(self.len()), + |mut hasher, element| { + hasher.add(element.hash(vm)?); + Ok(hasher) + }, + )?; + Ok(hasher.finish()) } // Run operation, on failure, if item is a set/set subclass, convert it diff --git a/crates/vm/src/builtins/tuple.rs b/crates/vm/src/builtins/tuple.rs index 4606509fd19..7ed815ce24d 100644 --- a/crates/vm/src/builtins/tuple.rs +++ b/crates/vm/src/builtins/tuple.rs @@ -1,14 +1,8 @@ -// cspell:ignore pyhash - use super::{ PositionIterInternal, PyGenericAlias, PyStrRef, PyType, PyTypeRef, iter::builtins_iter, }; use crate::common::lock::LazyLock; -use crate::common::{ - hash::{PyHash, PyUHash}, - lock::PyMutex, - wtf8::wtf8_concat, -}; +use crate::common::{hash, hash::PyHash, lock::PyMutex, wtf8::wtf8_concat}; use crate::object::{Traverse, TraverseFn}; use crate::{ AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, @@ -727,46 +721,5 @@ pub(crate) fn init(context: &'static Context) { } pub(super) fn tuple_hash(elements: &[PyObjectRef], vm: &VirtualMachine) -> PyResult { - const PRIME1: PyUHash = cfg_select! { - target_pointer_width = "64" => 11400714785074694791, - target_pointer_width = "32" => 2654435761, - _ => unreachable!(), - }; - - const PRIME2: PyUHash = cfg_select! { - target_pointer_width = "64" => 14029467366897019727, - target_pointer_width = "32" => 2246822519, - _ => unreachable!(), - }; - - const PRIME5: PyUHash = cfg_select! { - target_pointer_width = "64" => 2870177450012600261, - target_pointer_width = "32" => 374761393, - _ => unreachable!(), - }; - - const ROTATE: u32 = cfg_select! { - target_pointer_width = "64" => 31, - target_pointer_width = "32" => 13, - _ => unreachable!(), - }; - - let mut acc = PRIME5; - let len = elements.len() as PyUHash; - - for val in elements { - let lane = val.hash(vm)? as PyUHash; - acc = acc.wrapping_add(lane.wrapping_mul(PRIME2)); - acc = acc.rotate_left(ROTATE); - acc = acc.wrapping_mul(PRIME1); - } - - acc = acc.wrapping_add(len ^ (PRIME5 ^ 3527539)); - - let acc_pyhash = acc as PyHash; - if acc_pyhash == -1 { - return Ok(1546275796); - } - - Ok(acc_pyhash) + hash::hash_tuple(elements.iter().map(|val| val.hash(vm))) } From de387fcfc65cf7831f962036e45010488bd3aef7 Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:52:33 +0300 Subject: [PATCH 073/351] Set python args to be like CPython in CI (#8181) * Set python args to be like CPython in CI (ish) * Use `--slow-ci` * `--dont-add-python-opts` * unmark passing test --- .github/workflows/ci.yaml | 13 ++++++++++--- Lib/test/test_future_stmt/test_future.py | 1 - 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7ac163e835c..3bcd44a5fdd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,6 +34,7 @@ env: CARGO_PROFILE_RELEASE_DEBUG: 0 CARGO_TERM_COLOR: always CI: true + FORCE_COLOR: 1 jobs: determine_changes: @@ -294,6 +295,8 @@ jobs: - os: macos-latest extra_test_args: - '-u all' + - '--timeout 600' + - '--dont-add-python-opts' env_polluting_tests: - test_set skips: [] @@ -301,6 +304,8 @@ jobs: - os: ubuntu-latest extra_test_args: - '-u all' + - '--timeout 600' + - '--dont-add-python-opts' env_polluting_tests: - test_set skips: [] @@ -308,6 +313,8 @@ jobs: - os: windows-2025 extra_test_args: - '-u all' + - '--timeout 600' + - '--dont-add-python-opts' env_polluting_tests: - test_set skips: [] @@ -360,7 +367,7 @@ jobs: - name: Run CPython tests run: | - target/release/rustpython -m test -j ${{ steps.cores.outputs.cores }} ${{ join(matrix.extra_test_args, ' ') }} --slowest --fail-env-changed --timeout 600 -v -x ${{ env.FLAKY_MP_TESTS }} ${{ join(matrix.skips, ' ') }} + target/release/rustpython -u -m test --slow-ci -j ${{ steps.cores.outputs.cores }} ${{ join(matrix.extra_test_args, ' ') }} -x ${{ env.FLAKY_MP_TESTS }} ${{ join(matrix.skips, ' ') }} timeout-minutes: ${{ matrix.timeout }} env: RUSTPYTHON_SKIP_ENV_POLLUTERS: true @@ -371,7 +378,7 @@ jobs: echo "::group::Attempt ${attempt}" set +e - target/release/rustpython -m test -j 1 ${{ join(matrix.extra_test_args, ' ') }} --slowest --fail-env-changed --timeout 600 -v ${{ env.FLAKY_MP_TESTS }} + target/release/rustpython -u -m test --slow-ci -j 1 ${{ join(matrix.extra_test_args, ' ') }} ${{ env.FLAKY_MP_TESTS }} status=$? set -e @@ -396,7 +403,7 @@ jobs: for thing in "${target_array[@]}"; do for i in $(seq 1 10); do set +e - target/release/rustpython -m test -j 1 --slowest --fail-env-changed --timeout 600 -v "${thing}" + target/release/rustpython -u -m test --slow-ci -u all -j 1 --timeout 600 --dont-add-python-opts "${thing}" exit_code=$? set -e if [ "${exit_code}" -eq 3 ]; then diff --git a/Lib/test/test_future_stmt/test_future.py b/Lib/test/test_future_stmt/test_future.py index 8d2050a3936..02690919cf3 100644 --- a/Lib/test/test_future_stmt/test_future.py +++ b/Lib/test/test_future_stmt/test_future.py @@ -177,7 +177,6 @@ def test_unicode_literals_exec(self): exec("from __future__ import unicode_literals; x = ''", {}, scope) self.assertIsInstance(scope["x"], str) - @unittest.expectedFailure # TODO: RUSTPYTHON; barry_as_FLUFL (<> operator) not supported def test_syntactical_future_repl(self): p = spawn_python('-i') p.stdin.write(b"from __future__ import barry_as_FLUFL\n") From 5e402568303e7cf1b4fe1c58dde53dd8faab7b40 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:56:47 +0900 Subject: [PATCH 074/351] Bump j178/prek-action from 2.0.4 to 2.0.5 (#8217) Bumps [j178/prek-action](https://github.com/j178/prek-action) from 2.0.4 to 2.0.5. - [Release notes](https://github.com/j178/prek-action/releases) - [Commits](https://github.com/j178/prek-action/compare/bdca6f102f98e2b4c7029491a53dfd366469e33d...e98a699c41eb69ab013a45817a0406469a748f8d) --- updated-dependencies: - dependency-name: j178/prek-action dependency-version: 2.0.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3bcd44a5fdd..7dbe28dbffc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -551,7 +551,7 @@ jobs: - name: install prek id: prek - uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4 + uses: j178/prek-action@e98a699c41eb69ab013a45817a0406469a748f8d # v2.0.5 with: cache: false show-verbose-logs: false From 28829a71d7d0e69f469d2f007dcb5efe60611023 Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:03:43 +0200 Subject: [PATCH 075/351] Add pyframe functions to c-api (#8215) --- .cspell.dict/cpython.txt | 1 + crates/capi/src/lib.rs | 1 + crates/capi/src/pyframe.rs | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+) create mode 100644 crates/capi/src/pyframe.rs diff --git a/.cspell.dict/cpython.txt b/.cspell.dict/cpython.txt index 47ec7dd60c8..64a2e7479fc 100644 --- a/.cspell.dict/cpython.txt +++ b/.cspell.dict/cpython.txt @@ -191,6 +191,7 @@ pycore pyinner pydecimal pyerrors +pyframe Pyfunc pylifecycle pymain diff --git a/crates/capi/src/lib.rs b/crates/capi/src/lib.rs index eba7de2786d..15632648961 100644 --- a/crates/capi/src/lib.rs +++ b/crates/capi/src/lib.rs @@ -28,6 +28,7 @@ pub mod object; pub mod osmodule; pub mod pycapsule; pub mod pyerrors; +pub mod pyframe; pub mod pylifecycle; pub mod pymem; pub mod pystate; diff --git a/crates/capi/src/pyframe.rs b/crates/capi/src/pyframe.rs new file mode 100644 index 00000000000..d8e9d124bb0 --- /dev/null +++ b/crates/capi/src/pyframe.rs @@ -0,0 +1,19 @@ +use crate::PyObject; +use crate::pystate::with_vm; +use rustpython_vm::frame::Frame; + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyFrame_GetCode(frame: *mut PyObject) -> *mut PyObject { + with_vm(|vm| { + let frame = unsafe { &*frame }.try_downcast_ref::(vm)?; + Ok(frame.f_code()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyFrame_GetLineNumber(frame: *mut PyObject) -> core::ffi::c_int { + with_vm(|vm| { + let frame = unsafe { &*frame }.try_downcast_ref::(vm)?; + Ok(frame.f_lineno() as core::ffi::c_int) + }) +} From 6626dd5c3f82f0fd81bef55f54ec499ec7e3d058 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:04:23 +0900 Subject: [PATCH 076/351] Bump https://github.com/rbubley/mirrors-prettier from v3.8.4 to 3.9.1 (#8218) Bumps [https://github.com/rbubley/mirrors-prettier](https://github.com/rbubley/mirrors-prettier) from v3.8.4 to 3.9.1. - [Commits](https://github.com/rbubley/mirrors-prettier/compare/v3.8.4...v3.9.1) --- updated-dependencies: - dependency-name: https://github.com/rbubley/mirrors-prettier dependency-version: 3.9.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 52245bb3333..f3c9a882c5f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -77,7 +77,7 @@ repos: priority: 0 - repo: https://github.com/rbubley/mirrors-prettier - rev: v3.8.4 + rev: v3.9.1 hooks: - id: prettier files: '^wasm/.*$' From 0b8ca71dc96673966284c5a28aaf35b35844cf19 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:04:32 +0900 Subject: [PATCH 077/351] Bump https://github.com/astral-sh/ruff-pre-commit (#8219) Bumps [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit) from v0.15.18 to 0.15.20. - [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases) - [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.18...v0.15.20) --- updated-dependencies: - dependency-name: https://github.com/astral-sh/ruff-pre-commit dependency-version: 0.15.20 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f3c9a882c5f..a3cfaac09a4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: priority: 0 - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.18 + rev: v0.15.20 hooks: - id: ruff-format priority: 0 From f76be9e399ee1691077959daa3524714c81198ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:04:41 +0900 Subject: [PATCH 078/351] Bump github/gh-aw/actions/setup from 0.80.9 to 0.81.6 (#8220) Bumps [github/gh-aw/actions/setup](https://github.com/github/gh-aw) from 0.80.9 to 0.81.6. - [Release notes](https://github.com/github/gh-aw/releases) - [Changelog](https://github.com/github/gh-aw/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/gh-aw/compare/a3624368c4e7d877586ff2784b61de73405e2cdd...eed4304d8740f0593f2797276cb8299d228ffd9b) --- updated-dependencies: - dependency-name: github/gh-aw/actions/setup dependency-version: 0.81.6 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/upgrade-pylib.lock.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/upgrade-pylib.lock.yml b/.github/workflows/upgrade-pylib.lock.yml index 318657d52f5..f9176f8694d 100644 --- a/.github/workflows/upgrade-pylib.lock.yml +++ b/.github/workflows/upgrade-pylib.lock.yml @@ -58,7 +58,7 @@ jobs: comment_repo: "" steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@a3624368c4e7d877586ff2784b61de73405e2cdd # v0.80.9 + uses: github/gh-aw/actions/setup@eed4304d8740f0593f2797276cb8299d228ffd9b # v0.81.6 with: destination: /opt/gh-aw/actions - name: Check workflow file timestamps @@ -99,7 +99,7 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@a3624368c4e7d877586ff2784b61de73405e2cdd # v0.80.9 + uses: github/gh-aw/actions/setup@eed4304d8740f0593f2797276cb8299d228ffd9b # v0.81.6 with: destination: /opt/gh-aw/actions - name: Checkout repository @@ -806,7 +806,7 @@ jobs: total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@a3624368c4e7d877586ff2784b61de73405e2cdd # v0.80.9 + uses: github/gh-aw/actions/setup@eed4304d8740f0593f2797276cb8299d228ffd9b # v0.81.6 with: destination: /opt/gh-aw/actions - name: Download agent output artifact @@ -927,7 +927,7 @@ jobs: success: ${{ steps.parse_results.outputs.success }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@a3624368c4e7d877586ff2784b61de73405e2cdd # v0.80.9 + uses: github/gh-aw/actions/setup@eed4304d8740f0593f2797276cb8299d228ffd9b # v0.81.6 with: destination: /opt/gh-aw/actions - name: Download agent artifacts @@ -1039,7 +1039,7 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@a3624368c4e7d877586ff2784b61de73405e2cdd # v0.80.9 + uses: github/gh-aw/actions/setup@eed4304d8740f0593f2797276cb8299d228ffd9b # v0.81.6 with: destination: /opt/gh-aw/actions - name: Download agent output artifact From 3f60bfc2de6cdee8daed33dac576b0db87fe8298 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:05:26 +0900 Subject: [PATCH 079/351] Bump taiki-e/install-action from 2.81.11 to 2.82.6 (#8222) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.81.11 to 2.82.6. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/15449e3094499af05d8d964a1c884208e4b8b595...9bcaee1dcae34154180f412e2fa69355a7cda9f6) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.82.6 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cron-ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cron-ci.yaml b/.github/workflows/cron-ci.yaml index a3b91fde449..ec86ec95afe 100644 --- a/.github/workflows/cron-ci.yaml +++ b/.github/workflows/cron-ci.yaml @@ -33,7 +33,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 + - uses: taiki-e/install-action@9bcaee1dcae34154180f412e2fa69355a7cda9f6 # v2.82.6 with: tool: cargo-llvm-cov From ec436ed01df24718bc6362639727645079a97857 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:06:08 +0900 Subject: [PATCH 080/351] Bump itertools from 0.14.0 to 0.15.0 (#8226) Bumps [itertools](https://github.com/rust-itertools/itertools) from 0.14.0 to 0.15.0. - [Changelog](https://github.com/rust-itertools/itertools/blob/master/CHANGELOG.md) - [Commits](https://github.com/rust-itertools/itertools/compare/v0.14.0...v0.15.0) --- updated-dependencies: - dependency-name: itertools dependency-version: 0.15.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 25 +++++++++++++++++-------- Cargo.toml | 2 +- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8300eef59db..bab008f86c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1790,6 +1790,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -3276,7 +3285,7 @@ name = "rustpython-capi" version = "0.5.0" dependencies = [ "bitflags 2.13.0", - "itertools 0.14.0", + "itertools 0.15.0", "libc", "malachite-bigint", "num-complex", @@ -3292,7 +3301,7 @@ version = "0.5.0" dependencies = [ "bitflags 2.13.0", "indexmap", - "itertools 0.14.0", + "itertools 0.15.0", "log", "malachite-bigint", "memchr", @@ -3316,7 +3325,7 @@ dependencies = [ "ascii", "bitflags 2.13.0", "getrandom 0.4.3", - "itertools 0.14.0", + "itertools 0.15.0", "libc", "lock_api", "malachite-base", @@ -3351,7 +3360,7 @@ version = "0.5.0" dependencies = [ "bitflags 2.13.0", "bitflagset", - "itertools 0.14.0", + "itertools 0.15.0", "lz4_flex", "malachite-bigint", "num-complex", @@ -3381,7 +3390,7 @@ dependencies = [ name = "rustpython-derive-impl" version = "0.5.0" dependencies = [ - "itertools 0.14.0", + "itertools 0.15.0", "proc-macro2", "quote", "rustpython-compiler-core", @@ -3566,7 +3575,7 @@ dependencies = [ "icu_properties", "indexmap", "insta", - "itertools 0.14.0", + "itertools 0.15.0", "libc", "libsqlite3-sys", "libz-rs-sys", @@ -3648,7 +3657,7 @@ dependencies = [ "icu_properties", "indexmap", "is-macro", - "itertools 0.14.0", + "itertools 0.15.0", "libc", "log", "malachite-bigint", @@ -3696,7 +3705,7 @@ version = "0.5.0" dependencies = [ "ascii", "bstr", - "itertools 0.14.0", + "itertools 0.15.0", "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index a661dfbd6ab..fb023e03e4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -227,7 +227,7 @@ hexf-parse = "0.2.1" hmac = "0.13" indexmap = { version = "2.14.0", features = ["std"] } insta = "1.47" -itertools = "0.14.0" +itertools = "0.15.0" is-macro = "0.3.7" js-sys = "0.3" junction = "2.0.0" From 7044fdcc86b182885a4c7def474bba0d0623cd30 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:29:31 +0900 Subject: [PATCH 081/351] Re-export mmap libc constants through host_env (#8214) * Re-export mmap libc constants through host_env Move direct libc MADV_*, MAP_*, PROT_*, EOVERFLOW references in stdlib::mmap to host_env::mmap re-exports. Replace libc::c_int with core::ffi::c_int. * host_env/mmap: gate EOVERFLOW re-export on cfg(windows) Its only consumer is the cfg(windows) named-mapping overflow check in stdlib::mmap; the re-export was gated cfg(unix), so it was configured out on Windows and the reference failed to resolve. Assisted-by: Claude --- crates/host_env/src/mmap.rs | 57 +++++++++++++++++++++++++++++++++++++ crates/stdlib/src/mmap.rs | 38 ++++++++++++++----------- 2 files changed, 78 insertions(+), 17 deletions(-) diff --git a/crates/host_env/src/mmap.rs b/crates/host_env/src/mmap.rs index 62dc50f1c31..ce5575061e3 100644 --- a/crates/host_env/src/mmap.rs +++ b/crates/host_env/src/mmap.rs @@ -5,6 +5,63 @@ use std::io; +#[cfg(unix)] +pub use libc::{ + MADV_DONTNEED, MADV_NORMAL, MADV_RANDOM, MADV_SEQUENTIAL, MADV_WILLNEED, MAP_ANON, + MAP_ANONYMOUS, MAP_PRIVATE, MAP_SHARED, PROT_EXEC, PROT_READ, PROT_WRITE, +}; + +#[cfg(target_os = "macos")] +pub use libc::{MADV_FREE_REUSABLE, MADV_FREE_REUSE}; + +#[cfg(any( + target_os = "android", + target_os = "dragonfly", + target_os = "fuchsia", + target_os = "freebsd", + target_os = "linux", + target_os = "netbsd", + target_os = "openbsd", + target_vendor = "apple" +))] +pub use libc::MADV_FREE; + +#[cfg(target_os = "linux")] +pub use libc::{ + MADV_DODUMP, MADV_DOFORK, MADV_DONTDUMP, MADV_DONTFORK, MADV_HUGEPAGE, MADV_HWPOISON, + MADV_MERGEABLE, MADV_NOHUGEPAGE, MADV_REMOVE, MADV_UNMERGEABLE, +}; + +#[cfg(any( + target_os = "android", + all( + target_os = "linux", + any( + target_arch = "aarch64", + target_arch = "arm", + target_arch = "powerpc", + target_arch = "powerpc64", + target_arch = "s390x", + target_arch = "x86", + target_arch = "x86_64", + target_arch = "sparc64" + ) + ) +))] +pub use libc::MADV_SOFT_OFFLINE; + +#[cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"))] +pub use libc::{MAP_DENYWRITE, MAP_EXECUTABLE, MAP_POPULATE}; + +#[cfg(any(target_os = "linux", target_os = "openbsd", target_os = "netbsd"))] +pub use libc::MAP_STACK; + +#[cfg(target_os = "freebsd")] +pub use libc::{MADV_AUTOSYNC, MADV_CORE, MADV_NOCORE, MADV_NOSYNC, MADV_PROTECT}; + +#[cfg(windows)] +pub use libc::EOVERFLOW; + #[cfg(windows)] use crate::windows::{CheckWin32Bool, HandleToOwned}; #[cfg(unix)] diff --git a/crates/stdlib/src/mmap.rs b/crates/stdlib/src/mmap.rs index 2332ee0e1ce..dac24b053c2 100644 --- a/crates/stdlib/src/mmap.rs +++ b/crates/stdlib/src/mmap.rs @@ -60,14 +60,14 @@ mod mmap { #[cfg(unix)] #[pyattr] - use libc::{ + use host_mmap::{ MADV_DONTNEED, MADV_NORMAL, MADV_RANDOM, MADV_SEQUENTIAL, MADV_WILLNEED, MAP_ANON, MAP_ANONYMOUS, MAP_PRIVATE, MAP_SHARED, PROT_EXEC, PROT_READ, PROT_WRITE, }; #[cfg(target_os = "macos")] #[pyattr] - use libc::{MADV_FREE_REUSABLE, MADV_FREE_REUSE}; + use host_mmap::{MADV_FREE_REUSABLE, MADV_FREE_REUSE}; #[cfg(any( target_os = "android", @@ -80,11 +80,11 @@ mod mmap { target_vendor = "apple" ))] #[pyattr] - use libc::MADV_FREE; + use host_mmap::MADV_FREE; #[cfg(target_os = "linux")] #[pyattr] - use libc::{ + use host_mmap::{ MADV_DODUMP, MADV_DOFORK, MADV_DONTDUMP, MADV_DONTFORK, MADV_HUGEPAGE, MADV_HWPOISON, MADV_MERGEABLE, MADV_NOHUGEPAGE, MADV_REMOVE, MADV_UNMERGEABLE, }; @@ -106,21 +106,21 @@ mod mmap { ) ))] #[pyattr] - use libc::MADV_SOFT_OFFLINE; + use host_mmap::MADV_SOFT_OFFLINE; #[cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"))] #[pyattr] - use libc::{MAP_DENYWRITE, MAP_EXECUTABLE, MAP_POPULATE}; + use host_mmap::{MAP_DENYWRITE, MAP_EXECUTABLE, MAP_POPULATE}; // MAP_STACK is available on Linux, OpenBSD, and NetBSD #[cfg(any(target_os = "linux", target_os = "openbsd", target_os = "netbsd"))] #[pyattr] - use libc::MAP_STACK; + use host_mmap::MAP_STACK; // FreeBSD-specific MADV constants #[cfg(target_os = "freebsd")] #[pyattr] - use libc::{MADV_AUTOSYNC, MADV_CORE, MADV_NOCORE, MADV_NOSYNC, MADV_PROTECT}; + use host_mmap::{MADV_AUTOSYNC, MADV_CORE, MADV_NOCORE, MADV_NOSYNC, MADV_PROTECT}; #[pyattr] const ACCESS_DEFAULT: u32 = AccessMode::Default as u32; @@ -212,10 +212,10 @@ mod mmap { fileno: i32, #[pyarg(any)] length: isize, - #[pyarg(any, default = libc::MAP_SHARED)] - flags: libc::c_int, - #[pyarg(any, default = libc::PROT_WRITE | libc::PROT_READ)] - prot: libc::c_int, + #[pyarg(any, default = host_mmap::MAP_SHARED)] + flags: core::ffi::c_int, + #[pyarg(any, default = host_mmap::PROT_WRITE | host_mmap::PROT_READ)] + prot: core::ffi::c_int, #[pyarg(any, default = AccessMode::Default)] access: AccessMode, #[pyarg(any, default = 0)] @@ -294,7 +294,7 @@ mod mmap { #[derive(FromArgs)] pub(super) struct AdviseOptions { #[pyarg(positional)] - option: libc::c_int, + option: core::ffi::c_int, #[pyarg(positional, default)] start: Option, #[pyarg(positional, default)] @@ -303,7 +303,11 @@ mod mmap { #[cfg(all(unix, not(target_os = "redox")))] impl AdviseOptions { - fn values(self, len: usize, vm: &VirtualMachine) -> PyResult<(libc::c_int, usize, usize)> { + fn values( + self, + len: usize, + vm: &VirtualMachine, + ) -> PyResult<(core::ffi::c_int, usize, usize)> { let start = self .start .map(|s| { @@ -342,7 +346,7 @@ mod mmap { #[cfg(unix)] fn py_new(_cls: &Py, args: Self::Args, vm: &VirtualMachine) -> PyResult { - use libc::{MAP_PRIVATE, MAP_SHARED, PROT_READ, PROT_WRITE}; + use host_mmap::{MAP_PRIVATE, MAP_SHARED, PROT_READ, PROT_WRITE}; let mut map_size = args.validate_new_args(vm)?; let MmapNewArgs { @@ -552,7 +556,7 @@ mod mmap { map_size, ) .map_err(|err| { - if err.raw_os_error() == Some(libc::EOVERFLOW) { + if err.raw_os_error() == Some(host_mmap::EOVERFLOW) { vm.new_overflow_error("mmap offset plus size would overflow") } else { err.to_pyexception(vm) @@ -1074,7 +1078,7 @@ mod mmap { fn seek( &self, dist: isize, - whence: OptionalArg, + whence: OptionalArg, vm: &VirtualMachine, ) -> PyResult<()> { let how = whence.unwrap_or(0); From a92c1ae3823ff6faee73aa1682cbf23f635039de Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:22:03 +0900 Subject: [PATCH 082/351] Bump actions/cache/restore from 5.0.5 to 6.1.0 (#8223) Bumps [actions/cache/restore](https://github.com/actions/cache) from 5.0.5 to 6.1.0. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/27d5ce7f107fe9357f9df03efb73ab90386fccae...55cc8345863c7cc4c66a329aec7e433d2d1c52a9) --- updated-dependencies: - dependency-name: actions/cache/restore dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7dbe28dbffc..e42882bd806 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -95,7 +95,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - name: Restore cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ @@ -218,7 +218,7 @@ jobs: gcc-aarch64-linux-gnu: ${{ matrix.dependencies.gcc-aarch64-linux-gnu || false }} - name: Restore cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ @@ -328,7 +328,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - name: Restore cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ @@ -475,7 +475,7 @@ jobs: components: clippy - name: Restore cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ @@ -544,7 +544,7 @@ jobs: uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 - name: restore prek cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: prek-${{ hashFiles('.pre-commit-config.yaml') }} path: ~/.cache/prek @@ -615,7 +615,7 @@ jobs: components: miri - name: Restore cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ @@ -649,7 +649,7 @@ jobs: components: clippy - name: Restore cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ @@ -689,7 +689,7 @@ jobs: run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT" - name: Restore npm cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 # don't restore on main or release if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/release' with: @@ -764,7 +764,7 @@ jobs: target: wasm32-wasip1 - name: Restore cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ @@ -814,7 +814,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - name: Restore cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ From 85e3ea88697e6fb737b2092a50b5ac39e9358060 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:19:25 +0900 Subject: [PATCH 083/351] unicode crate (#8211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Extract unicodedata core into rustpython-unicode crate Move the vm-free Unicode character database access — the generated UCD 3.2.0 / latest tables, their build-time generation, and the icu4x/unicode_names2 lookups for category/bidirectional/combining/east_asian_width/mirrored/decomposition/ normalize/is_normalized/digit/decimal/numeric/name/lookup — into a new leaf crate over char/CodePoint/&Wtf8. stdlib/unicodedata.rs keeps the UCD pyclass binding: it extracts the code point, boxes results, and maps errors. The unicode data files, build.rs table generation, and the icu_properties/icu_normalizer/unicode_names2 dependencies move out of stdlib. Assisted-by: Claude * Route str predicates, casefold, and printable through rustpython-unicode Add classify/case/identifier modules to the shared crate over char/&str/&Wtf8: isalpha/isalnum/isdecimal/isdigit/isnumeric/isspace/isprintable classification, XID identifier predicates, and full-mapping casefold. vm/builtins/str.rs and literal/char.rs now call these instead of icu_properties/icu_casemap directly. String-level iteration with final-sigma handling (lower/upper/title/capitalize, islower/isupper) stays in the runtime. Assisted-by: Claude * Route sre_engine char classes through rustpython-unicode Add the regex module with the SRE character-class and case predicates (is_word/is_space/is_digit, is_uni_* Unicode variants, ascii/locale/unicode case folding). sre_engine/string.rs now forwards to it, keeping its public API and behavior identical — including the ASCII-only is_uni_digit and the hardcoded is_uni_space table. Assisted-by: Claude * Route \N{} name lookups through rustpython-unicode codegen/string_parser.rs (\N{...} escapes) and common/encodings.rs (the namereplace error handler) now resolve character names via rustpython-unicode instead of depending on unicode_names2 directly, leaving the crate as the sole owner of the name database. Assisted-by: Claude * Add differential Unicode sweep and shared-crate snippet test tests/differential.rs sweeps the full 0..0x110000 range and compares each str classification predicate against a committed CPython 3.14 reference dataset (tests/data/cpython3.14_predicates.txt, produced by generate_reference.py). Code points that differ only because the Rust std / icu4x build ships a later Unicode release than CPython 3.14's 16.0.0 are recorded in tests/data/version_skew_cpython3.14.txt, regenerable via RUSTPYTHON_UNICODE_REGEN_SKEW=1; the regen refuses to record any cpython=true/crate=false divergence, so only newly-assigned code points are allowed. Any other divergence fails. Both data files use a run-length `predicate start:end,...` encoding. extra_tests/snippets/stdlib_unicode_shared.py exercises the routed surface end to end (str predicates, casefold, identifiers, unicodedata, normalize, \N{}, and re character classes) and passes identically on CPython 3.14 and RustPython. Assisted-by: Claude * Match regex \d to Unicode decimal digits is_uni_digit previously matched only ASCII 0-9, so re's \d in Unicode mode missed decimal digits like ٥ and ५. SRE_UNI_IS_DIGIT matches Py_UNICODE_ISDECIMAL (category Nd), so route it through classify::is_decimal. Unmasks test_bug_6561 in test_re and extends the shared-crate snippet with the Nd/Nl/No cases. Assisted-by: Claude * Replace shallow Unicode wrappers with re-exports Address review feedback on the extraction. Functions that only forwarded to the shared crate become `use ... as` re-exports rather than hand-written wrappers: - literal: is_printable is dropped; escape.rs calls rustpython_unicode::classify::is_repr_printable directly. - unicode::identifier::is_continue re-exports is_xid_continue. - unicode::data::lookup_character re-exports unicode_names2::character. - Drop the unused repr(u8) on DecompositionType. The SRE character-class and case predicates move back into sre_engine::string (their pre-extraction home) instead of living in a unicode::regex module that only sre_engine and the vm _sre binding used; is_uni_digit/is_uni_alnum still delegate to rustpython_unicode::classify. engine.rs and _sre consume them from sre_engine::string again. The unicode crate's tests move from a single root tests module in lib.rs into per-module test submodules (case, classify, data, identifier, normalize). Assisted-by: Claude * no-std * Address review feedback: test module, dead variant, WTF-8 normalization - differential.rs: move the sweep tests into a `mod tests` block and drop the file-level allow of clippy::tests_outside_test_module and std_instead_of_alloc; the test uses alloc collections instead. - data.rs: remove the never-constructed DecompositionType::Canonical variant and its allow(unused); compatibility decomposition never produces it and canonical decomposition is handled through icu4x. - normalize.rs: is_normalized now takes &Wtf8 and checks each UTF-8 run, skipping lone surrogates, matching normalize's run-wise behavior. - unicodedata.rs: pass as_wtf8() to is_normalized. Assisted-by: Claude --- Cargo.lock | 25 +- Cargo.toml | 11 +- Lib/test/test_pkgutil.py | 1 - Lib/test/test_re.py | 1 - crates/codegen/Cargo.toml | 2 +- crates/codegen/src/string_parser.rs | 2 +- crates/common/Cargo.toml | 2 +- crates/common/src/encodings.rs | 2 +- crates/literal/Cargo.toml | 2 +- crates/literal/src/char.rs | 26 - crates/literal/src/escape.rs | 4 +- crates/literal/src/lib.rs | 1 - crates/sre_engine/Cargo.toml | 2 +- crates/sre_engine/src/string.rs | 42 +- crates/stdlib/Cargo.toml | 11 +- crates/stdlib/build.rs | 616 +----------------- crates/stdlib/src/unicodedata.rs | 393 ++--------- crates/unicode/Cargo.toml | 24 + crates/unicode/build.rs | 612 +++++++++++++++++ crates/unicode/src/case.rs | 69 ++ crates/unicode/src/classify.rs | 116 ++++ crates/unicode/src/data.rs | 373 +++++++++++ crates/unicode/src/identifier.rs | 37 ++ crates/unicode/src/lib.rs | 19 + crates/unicode/src/normalize.rs | 111 ++++ .../tests/data/cpython3.14_predicates.txt | 9 + .../tests/data/version_skew_cpython3.14.txt | 11 + crates/unicode/tests/differential.rs | 235 +++++++ crates/unicode/tests/generate_reference.py | 74 +++ crates/{stdlib => unicode}/unicode/README.md | 0 .../unicode/latest/DerivedNumericValues.txt | 0 .../latest/NormalizationCorrections.txt | 0 .../unicode/latest/UnicodeData.txt | 0 .../unicode/ucd32/DerivedBidiClass-3.2.0.txt | 0 .../ucd32/DerivedBinaryProperties-3.2.0.txt | 0 .../ucd32/DerivedCombiningClass-3.2.0.txt | 0 .../ucd32/DerivedEastAsianWidth-3.2.0.txt | 0 .../ucd32/DerivedGeneralCategory-3.2.0.txt | 0 .../ucd32/DerivedNumericType-3.2.0.txt | 0 .../ucd32/DerivedNumericValues-3.2.0.txt | 0 crates/vm/Cargo.toml | 1 + crates/vm/src/builtins/str.rs | 77 +-- crates/wtf8/Cargo.toml | 4 +- crates/wtf8/src/lib.rs | 2 +- extra_tests/snippets/stdlib_unicode_shared.py | 93 +++ 45 files changed, 1910 insertions(+), 1100 deletions(-) delete mode 100644 crates/literal/src/char.rs create mode 100644 crates/unicode/Cargo.toml create mode 100644 crates/unicode/build.rs create mode 100644 crates/unicode/src/case.rs create mode 100644 crates/unicode/src/classify.rs create mode 100644 crates/unicode/src/data.rs create mode 100644 crates/unicode/src/identifier.rs create mode 100644 crates/unicode/src/lib.rs create mode 100644 crates/unicode/src/normalize.rs create mode 100644 crates/unicode/tests/data/cpython3.14_predicates.txt create mode 100644 crates/unicode/tests/data/version_skew_cpython3.14.txt create mode 100644 crates/unicode/tests/differential.rs create mode 100644 crates/unicode/tests/generate_reference.py rename crates/{stdlib => unicode}/unicode/README.md (100%) rename crates/{stdlib => unicode}/unicode/latest/DerivedNumericValues.txt (100%) rename crates/{stdlib => unicode}/unicode/latest/NormalizationCorrections.txt (100%) rename crates/{stdlib => unicode}/unicode/latest/UnicodeData.txt (100%) rename crates/{stdlib => unicode}/unicode/ucd32/DerivedBidiClass-3.2.0.txt (100%) rename crates/{stdlib => unicode}/unicode/ucd32/DerivedBinaryProperties-3.2.0.txt (100%) rename crates/{stdlib => unicode}/unicode/ucd32/DerivedCombiningClass-3.2.0.txt (100%) rename crates/{stdlib => unicode}/unicode/ucd32/DerivedEastAsianWidth-3.2.0.txt (100%) rename crates/{stdlib => unicode}/unicode/ucd32/DerivedGeneralCategory-3.2.0.txt (100%) rename crates/{stdlib => unicode}/unicode/ucd32/DerivedNumericType-3.2.0.txt (100%) rename crates/{stdlib => unicode}/unicode/ucd32/DerivedNumericValues-3.2.0.txt (100%) create mode 100644 extra_tests/snippets/stdlib_unicode_shared.py diff --git a/Cargo.lock b/Cargo.lock index bab008f86c0..31086945509 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3313,9 +3313,9 @@ dependencies = [ "rustpython-ruff_python_ast", "rustpython-ruff_python_parser", "rustpython-ruff_text_size", + "rustpython-unicode", "rustpython-wtf8", "thiserror", - "unicode_names2 3.1.0", ] [[package]] @@ -3336,9 +3336,9 @@ dependencies = [ "parking_lot", "radium", "rustpython-literal", + "rustpython-unicode", "rustpython-wtf8", "siphasher", - "unicode_names2 3.1.0", ] [[package]] @@ -3452,11 +3452,11 @@ name = "rustpython-literal" version = "0.5.0" dependencies = [ "hexf-parse", - "icu_properties", "is-macro", "lexical-parse-float", "num-traits", "rand 0.10.1", + "rustpython-unicode", "rustpython-wtf8", ] @@ -3541,9 +3541,9 @@ version = "0.5.0" dependencies = [ "bitflags 2.13.0", "criterion", - "icu_properties", "num_enum", "optional", + "rustpython-unicode", "rustpython-wtf8", ] @@ -3571,8 +3571,6 @@ dependencies = [ "gethostname", "hex", "hmac", - "icu_normalizer", - "icu_properties", "indexmap", "insta", "itertools 0.15.0", @@ -3612,6 +3610,7 @@ dependencies = [ "rustpython-ruff_python_parser", "rustpython-ruff_source_file", "rustpython-ruff_text_size", + "rustpython-unicode", "rustpython-vm", "sha1 0.11.0", "sha2", @@ -3621,7 +3620,6 @@ dependencies = [ "system-configuration", "tcl-sys", "tk-sys", - "unicode_names2 3.1.0", "uuid", "webpki-roots", "widestring", @@ -3632,6 +3630,18 @@ dependencies = [ "xz-sys", ] +[[package]] +name = "rustpython-unicode" +version = "0.5.0" +dependencies = [ + "icu_casemap", + "icu_normalizer", + "icu_properties", + "rustpython-wtf8", + "unicode_names2 3.1.0", + "writeable", +] + [[package]] name = "rustpython-venvlauncher" version = "0.5.0" @@ -3684,6 +3694,7 @@ dependencies = [ "rustpython-ruff_python_parser", "rustpython-ruff_text_size", "rustpython-sre_engine", + "rustpython-unicode", "rustyline", "scopeguard", "serde_core", diff --git a/Cargo.toml b/Cargo.toml index fb023e03e4a..d8081f50166 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -179,6 +179,7 @@ rustpython-vm = { path = "crates/vm", default-features = false, version = "0.5.0 rustpython-pylib = { path = "crates/pylib", version = "0.5.0" } rustpython-stdlib = { path = "crates/stdlib", default-features = false, version = "0.5.0" } rustpython-sre_engine = { path = "crates/sre_engine", version = "0.5.0" } +rustpython-unicode = { path = "crates/unicode", version = "0.5.0" } rustpython-wtf8 = { path = "crates/wtf8", version = "0.5.0" } rustpython-doc = { path = "crates/doc", version = "0.5.0" } @@ -192,12 +193,12 @@ der = { version = "0.8", features = ["alloc", "oid", "pem", "zeroize"] } phf = { version = "0.14.0", default-features = false, features = ["macros"]} adler32 = "1.2.0" approx = "0.5.1" -ascii = "1.1" +ascii = { version = "1.1", default-features = false } base64 = "0.22" blake2 = "0.11.0-rc.6" bitflags = "2.11.0" bitflagset = "0.0.3" -bstr = "1" +bstr = { version = "1", default-features = false, features = ["unicode"] } bzip2 = "0.6" chrono = { version = "0.4.44", default-features = false, features = ["clock", "std"] } console_error_panic_hook = "0.1" @@ -227,7 +228,7 @@ hexf-parse = "0.2.1" hmac = "0.13" indexmap = { version = "2.14.0", features = ["std"] } insta = "1.47" -itertools = "0.15.0" +itertools = { version = "0.15.0", default-features = false, features = ["use_alloc"] } is-macro = "0.3.7" js-sys = "0.3" junction = "2.0.0" @@ -248,7 +249,7 @@ malachite-bigint = "0.9.1" malachite-q = "0.9.1" malachite-base = "0.9.1" md-5 = "0.11" -memchr = "2.8.1" +memchr = { version = "2.8.1", default-features = false, features = ["alloc"] } memmap2 = "0.9.10" mt19937 = "3.3" num-complex = "0.4.6" @@ -310,7 +311,7 @@ icu_locale = "2" icu_properties = "2" icu_normalizer = "2" uuid = "1.23.2" -unicode_names2 = "3" +unicode_names2 = { version = "3", default-features = false, features = ["no_std"] } widestring = "1.2.0" windows-sys = "0.61.2" wasm-bindgen = "0.2.106" diff --git a/Lib/test/test_pkgutil.py b/Lib/test/test_pkgutil.py index f5444409593..d4faaaeca00 100644 --- a/Lib/test/test_pkgutil.py +++ b/Lib/test/test_pkgutil.py @@ -231,7 +231,6 @@ def test_walk_packages_raises_on_string_or_bytes_input(self): with self.assertRaises((TypeError, ValueError)): list(pkgutil.walk_packages(bytes_input)) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_name_resolution(self): import logging import logging.handlers diff --git a/Lib/test/test_re.py b/Lib/test/test_re.py index 8ac6daecc32..1d396e4f31c 100644 --- a/Lib/test/test_re.py +++ b/Lib/test/test_re.py @@ -1734,7 +1734,6 @@ def test_bug_817234(self): self.assertEqual(next(iter).span(), (4, 4)) self.assertRaises(StopIteration, next, iter) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_bug_6561(self): # '\d' should match characters in Unicode category 'Nd' # (Number, Decimal Digit), but not those in 'Nl' (Number, diff --git a/crates/codegen/Cargo.toml b/crates/codegen/Cargo.toml index 031f3b96521..c43bf1bcb08 100644 --- a/crates/codegen/Cargo.toml +++ b/crates/codegen/Cargo.toml @@ -15,6 +15,7 @@ std = ["thiserror/std", "itertools/use_std"] [dependencies] rustpython-compiler-core = { workspace = true } rustpython-literal = {workspace = true } +rustpython-unicode = { workspace = true } rustpython-wtf8 = { workspace = true } ruff_python_ast = { workspace = true } ruff_text_size = { workspace = true } @@ -29,7 +30,6 @@ thiserror = { workspace = true } malachite-bigint = { workspace = true } memchr = { workspace = true } rapidhash = { workspace = true } -unicode_names2 = { workspace = true } [dev-dependencies] ruff_python_parser = { workspace = true } diff --git a/crates/codegen/src/string_parser.rs b/crates/codegen/src/string_parser.rs index 0b5bcfffc9c..622488a2177 100644 --- a/crates/codegen/src/string_parser.rs +++ b/crates/codegen/src/string_parser.rs @@ -114,7 +114,7 @@ impl StringParser { let name_and_ending = self.skip_bytes(close_idx + 1); let name = &name_and_ending[..name_and_ending.len() - 1]; - unicode_names2::character(name).ok_or_else(|| unreachable!()) + rustpython_unicode::lookup_character(name).ok_or_else(|| unreachable!()) } /// Parse an escaped character, returning the new character. diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 725be665f73..4498e74ca49 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -16,6 +16,7 @@ wasm_js = ["getrandom/wasm_js"] [dependencies] rustpython-literal = { workspace = true } +rustpython-unicode = { workspace = true } rustpython-wtf8 = { workspace = true } ascii = { workspace = true } @@ -28,7 +29,6 @@ malachite-q = { workspace = true } malachite-base = { workspace = true } num-traits = { workspace = true } parking_lot = { workspace = true, optional = true } -unicode_names2 = { workspace = true } radium = { workspace = true } lock_api = { workspace = true } diff --git a/crates/common/src/encodings.rs b/crates/common/src/encodings.rs index 913f0521e16..b9ce02b88cb 100644 --- a/crates/common/src/encodings.rs +++ b/crates/common/src/encodings.rs @@ -414,7 +414,7 @@ pub mod errors { let mut out = String::with_capacity(num_chars * 4); for c in err_str.code_points() { let c_u32 = c.to_u32(); - if let Some(c_name) = c.to_char().and_then(unicode_names2::name) { + if let Some(c_name) = c.to_char().and_then(rustpython_unicode::character_name) { write!(out, "\\N{{{c_name}}}").unwrap(); } else if c_u32 >= 0x10000 { write!(out, "\\U{c_u32:08x}").unwrap(); diff --git a/crates/literal/Cargo.toml b/crates/literal/Cargo.toml index b9795a771eb..60350f7937d 100644 --- a/crates/literal/Cargo.toml +++ b/crates/literal/Cargo.toml @@ -9,13 +9,13 @@ license = { workspace = true } rust-version = { workspace = true } [dependencies] +rustpython-unicode = { workspace = true } rustpython-wtf8 = { workspace = true } hexf-parse = { workspace = true } is-macro.workspace = true lexical-parse-float = { workspace = true, features = ["format"] } num-traits = { workspace = true } -icu_properties = { workspace = true } [dev-dependencies] rand = { workspace = true } diff --git a/crates/literal/src/char.rs b/crates/literal/src/char.rs deleted file mode 100644 index 5b446cc1a19..00000000000 --- a/crates/literal/src/char.rs +++ /dev/null @@ -1,26 +0,0 @@ -use icu_properties::props::{EnumeratedProperty, GeneralCategory}; - -/// According to python following categories aren't printable: -/// * Cc (Other, Control) -/// * Cf (Other, Format) -/// * Cs (Other, Surrogate) -/// * Co (Other, Private Use) -/// * Cn (Other, Not Assigned) -/// * Zl Separator, Line ('\u2028', LINE SEPARATOR) -/// * Zp Separator, Paragraph ('\u2029', PARAGRAPH SEPARATOR) -/// * Zs (Separator, Space) other than ASCII space('\x20'). -pub fn is_printable(c: char) -> bool { - let cat = GeneralCategory::for_char(c); - - !matches!( - cat, - GeneralCategory::SpaceSeparator - | GeneralCategory::LineSeparator - | GeneralCategory::ParagraphSeparator - | GeneralCategory::Control - | GeneralCategory::Format - | GeneralCategory::Surrogate - | GeneralCategory::PrivateUse - | GeneralCategory::Unassigned - ) -} diff --git a/crates/literal/src/escape.rs b/crates/literal/src/escape.rs index 1099c0a02bc..50dce8b264c 100644 --- a/crates/literal/src/escape.rs +++ b/crates/literal/src/escape.rs @@ -204,7 +204,7 @@ impl UnicodeEscape<'_> { '\\' | '\t' | '\r' | '\n' => 2, ch if ch < ' ' || ch as u32 == 0x7f => 4, // \xHH ch if ch.is_ascii() => 1, - ch if crate::char::is_printable(ch) => { + ch if rustpython_unicode::classify::is_repr_printable(ch) => { // max = std::cmp::max(ch, max); ch.len_utf8() } @@ -238,7 +238,7 @@ impl UnicodeEscape<'_> { ch if ch.is_ascii() => { write!(formatter, "\\x{:02x}", ch as u8) } - ch if crate::char::is_printable(ch) => formatter.write_char(ch), + ch if rustpython_unicode::classify::is_repr_printable(ch) => formatter.write_char(ch), '\0'..='\u{ff}' => { write!(formatter, "\\x{:02x}", ch as u32) } diff --git a/crates/literal/src/lib.rs b/crates/literal/src/lib.rs index a863dd87738..6d520900142 100644 --- a/crates/literal/src/lib.rs +++ b/crates/literal/src/lib.rs @@ -2,7 +2,6 @@ extern crate alloc; -pub mod char; pub mod complex; pub mod escape; pub mod float; diff --git a/crates/sre_engine/Cargo.toml b/crates/sre_engine/Cargo.toml index 8400a34b567..03b3f609801 100644 --- a/crates/sre_engine/Cargo.toml +++ b/crates/sre_engine/Cargo.toml @@ -15,11 +15,11 @@ name = "benches" harness = false [dependencies] +rustpython-unicode = { workspace = true } rustpython-wtf8 = { workspace = true } num_enum = { workspace = true } bitflags = { workspace = true } optional = { workspace = true } -icu_properties = { workspace = true } [dev-dependencies] criterion = { workspace = true } diff --git a/crates/sre_engine/src/string.rs b/crates/sre_engine/src/string.rs index 6c8b9a567b4..6468c6d0cfd 100644 --- a/crates/sre_engine/src/string.rs +++ b/crates/sre_engine/src/string.rs @@ -1,4 +1,3 @@ -use icu_properties::props::{EnumeratedProperty, GeneralCategory, GeneralCategoryGroup}; use rustpython_wtf8::Wtf8; #[derive(Debug, Clone, Copy)] @@ -333,55 +332,75 @@ const fn utf8_is_cont_byte(byte: u8) -> bool { /// Mask of the value bits of a continuation byte. const CONT_MASK: u8 = 0b0011_1111; +// Character-class and case predicates for the SRE engine. +// +// Every predicate takes a raw `u32` code point (SRE decodes strings into `u32`s, +// including lone surrogates) and returns whether it belongs to the class. +// ASCII-mode predicates only ever consider byte values; Unicode-mode predicates +// consult the shared property tables in `rustpython_unicode::classify`. + +const UNDERSCORE: u32 = '_' as u32; + const fn is_py_ascii_whitespace(b: u8) -> bool { matches!(b, b'\t' | b'\n' | b'\x0C' | b'\r' | b' ' | b'\x0B') } #[inline] pub(crate) fn is_word(ch: u32) -> bool { - ch == '_' as u32 || u8::try_from(ch).is_ok_and(|x| x.is_ascii_alphanumeric()) + ch == UNDERSCORE || u8::try_from(ch).is_ok_and(|x| x.is_ascii_alphanumeric()) } + #[inline] pub(crate) fn is_space(ch: u32) -> bool { u8::try_from(ch).is_ok_and(is_py_ascii_whitespace) } + #[inline] pub(crate) fn is_digit(ch: u32) -> bool { u8::try_from(ch).is_ok_and(|x| x.is_ascii_digit()) } + #[inline] pub(crate) fn is_loc_alnum(ch: u32) -> bool { // FIXME: Ignore the locales u8::try_from(ch).is_ok_and(|x| x.is_ascii_alphanumeric()) } + #[inline] pub(crate) fn is_loc_word(ch: u32) -> bool { - ch == '_' as u32 || is_loc_alnum(ch) + ch == UNDERSCORE || is_loc_alnum(ch) } + #[inline] pub(crate) const fn is_linebreak(ch: u32) -> bool { ch == '\n' as u32 } + #[inline] #[must_use] pub fn lower_ascii(ch: u32) -> u32 { u8::try_from(ch).map_or(ch, |x| x.to_ascii_lowercase() as u32) } + #[inline] pub(crate) fn lower_locate(ch: u32) -> u32 { // FIXME: Ignore the locales lower_ascii(ch) } + #[inline] pub(crate) fn upper_locate(ch: u32) -> u32 { // FIXME: Ignore the locales u8::try_from(ch).map_or(ch, |x| x.to_ascii_uppercase() as u32) } + #[inline] pub(crate) fn is_uni_digit(ch: u32) -> bool { - // TODO: check with cpython - char::try_from(ch).is_ok_and(|x| x.is_ascii_digit()) + // SRE_UNI_IS_DIGIT matches Unicode decimal digits (Py_UNICODE_ISDECIMAL), + // not just ASCII 0-9. + char::try_from(ch).is_ok_and(rustpython_unicode::classify::is_decimal) } + #[inline] pub(crate) fn is_uni_space(ch: u32) -> bool { // TODO: check with cpython @@ -419,6 +438,7 @@ pub(crate) fn is_uni_space(ch: u32) -> bool { | 0x3000 ) } + #[inline] pub(crate) const fn is_uni_linebreak(ch: u32) -> bool { matches!( @@ -426,25 +446,25 @@ pub(crate) const fn is_uni_linebreak(ch: u32) -> bool { 0x000A | 0x000B | 0x000C | 0x000D | 0x001C | 0x001D | 0x001E | 0x0085 | 0x2028 | 0x2029 ) } + #[inline] pub(crate) fn is_uni_alnum(ch: u32) -> bool { // TODO: check with cpython - char::try_from(ch).is_ok_and(|c| { - GeneralCategoryGroup::Letter - .union(GeneralCategoryGroup::Number) - .contains(GeneralCategory::for_char(c)) - }) + char::try_from(ch).is_ok_and(rustpython_unicode::classify::is_alnum) } + #[inline] pub(crate) fn is_uni_word(ch: u32) -> bool { - ch == '_' as u32 || is_uni_alnum(ch) + ch == UNDERSCORE || is_uni_alnum(ch) } + #[inline] #[must_use] pub fn lower_unicode(ch: u32) -> u32 { // TODO: check with cpython char::try_from(ch).map_or(ch, |x| x.to_lowercase().next().unwrap() as u32) } + #[inline] #[must_use] pub fn upper_unicode(ch: u32) -> u32 { diff --git a/crates/stdlib/Cargo.toml b/crates/stdlib/Cargo.toml index a24811c4aea..1ba6148e8cf 100644 --- a/crates/stdlib/Cargo.toml +++ b/crates/stdlib/Cargo.toml @@ -32,6 +32,7 @@ rustpython-derive = { workspace = true } rustpython-vm = { workspace = true, default-features = false, features = ["compiler"]} rustpython-common = { workspace = true } rustpython-host_env = { workspace = true } +rustpython-unicode = { workspace = true } ruff_python_parser = { workspace = true } ruff_python_ast = { workspace = true } @@ -76,12 +77,6 @@ hmac = { workspace = true } pbkdf2 = { workspace = true, features = ["hmac"] } constant_time_eq = { workspace = true } -## unicode stuff -unicode_names2 = { workspace = true } -# update version all at the same time -icu_properties = { workspace = true } -icu_normalizer = { workspace = true } - # compression adler32 = { workspace = true } crc32fast = { workspace = true } @@ -141,9 +136,5 @@ system-configuration = { workspace = true } insta = { workspace = true } rustpython-pylib = { workspace = true, features = [ "freeze-stdlib" ] } -[build-dependencies] -icu_normalizer = { workspace = true } -icu_properties = { workspace = true } - [lints] workspace = true diff --git a/crates/stdlib/build.rs b/crates/stdlib/build.rs index 4cf7b21d4b7..95c34c4fb3c 100644 --- a/crates/stdlib/build.rs +++ b/crates/stdlib/build.rs @@ -1,606 +1,4 @@ -#![allow( - clippy::disallowed_methods, - reason = "build scripts cannot use rustpython-host_env" -)] - -// spell-checker:ignore decomp DECOMP ossl osslconf - -extern crate alloc; - -use core::num::NonZeroUsize; - -use alloc::collections::{BTreeMap, BTreeSet}; - -use std::{ - env, - fs::{self, File}, - io::{self, BufRead, BufReader, BufWriter, Write}, - path::{Path, PathBuf}, - thread, -}; - -use icu_properties::props::{EnumeratedProperty, GeneralCategory, NumericType}; - -fn generate_unicode_3_2() { - let path = PathBuf::from(env::var("OUT_DIR").unwrap()) - .join("generated") - .join("unicode_3_2.rs"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - let mut writer = BufWriter::new(File::create(&path).unwrap()); - - let base = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("unicode") - .join("ucd32"); - - write_derived( - &base, - "DerivedGeneralCategory-3.2.0.txt", - "GENERAL_CATEGORY", - "(u32, u32, GeneralCategory)", - NonZeroUsize::new(1).unwrap(), - &mut writer, - |start, end, id, _| { - let id = parse_general(id); - if id != GeneralCategory::Unassigned { - Some((start, end, id)) - } else { - None - } - }, - |writer, mut values| { - values.sort_unstable_by_key(|(start, _, _)| *start); - write!(writer, "[").unwrap(); - for (start, end, id) in values { - write!(writer, "({start}, {end}, GeneralCategory::{id:?}),").unwrap(); - } - write!(writer, "];").unwrap(); - }, - ); - - write_derived( - &base, - "DerivedEastAsianWidth-3.2.0.txt", - "EAST_ASIAN_WIDTH", - "(u32, u32, EastAsianWidth)", - NonZeroUsize::new(1).unwrap(), - &mut writer, - |start, end, id, _| { - let id = parse_eaw(id); - if id != "EastAsianWidth::Neutral" { - Some((start, end, id)) - } else { - None - } - }, - |writer, mut values| { - values.sort_unstable_by_key(|(start, _, _)| *start); - write!(writer, "[").unwrap(); - for (start, end, id) in values { - write!(writer, "({start}, {end}, {id}),").unwrap(); - } - write!(writer, "];").unwrap(); - }, - ); - - write_derived( - &base, - "DerivedBidiClass-3.2.0.txt", - "BIDI_CLASS", - "(u32, u32, BidiClass)", - NonZeroUsize::new(1).unwrap(), - &mut writer, - |start, end, id, _| { - let id = parse_bidi(id); - if id != "BidiClass::LeftToRight" { - Some((start, end, id)) - } else { - None - } - }, - |writer, mut values| { - values.sort_unstable_by_key(|(start, _, _)| *start); - write!(writer, "[").unwrap(); - for (start, end, id) in values { - write!(writer, "({start}, {end}, {id}),").unwrap(); - } - write!(writer, "];").unwrap(); - }, - ); - - write_derived( - &base, - "DerivedBinaryProperties-3.2.0.txt", - "BIDI_MIRRORED", - "(u32, u32)", - NonZeroUsize::new(1).unwrap(), - &mut writer, - |start, end, id, _| { - assert_eq!( - "Bidi_Mirrored", - id.trim(), - "DerivedBinaryProperties-3.2.0 only has Bidi_Mirrored" - ); - Some((start, end)) - }, - |writer, mut values| { - values.sort_unstable_by_key(|(start, _)| *start); - writeln!(writer, "{values:?};").unwrap(); - }, - ); - - write_derived( - &base, - "DerivedCombiningClass-3.2.0.txt", - "COMBINING_CLASS", - "(u32, u32, CanonicalCombiningClass)", - NonZeroUsize::new(1).unwrap(), - &mut writer, - |start, end, id, _| { - let id: u8 = id.parse().unwrap(); - if id == 0 { - return None; - } - Some((start, end, id)) - }, - |writer, mut values| { - values.sort_unstable_by_key(|(start, _, _)| *start); - write!(writer, "[").unwrap(); - for (start, end, id) in values { - write!( - writer, - "({start}, {end}, CanonicalCombiningClass::from_icu4c_value({id}))," - ) - .unwrap(); - } - writeln!(writer, "];").unwrap(); - }, - ); -} - -fn generate_numeric_type() { - let path = PathBuf::from(env::var("OUT_DIR").unwrap()) - .join("generated") - .join("unicode_num_type.rs"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - let mut writer = BufWriter::new(File::create(&path).unwrap()); - - let base = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("unicode") - .join("ucd32"); - - write_derived( - &base, - "DerivedNumericType-3.2.0.txt", - "NUMERIC_TYPE_DIFF", - "(u32, u32, NumericType)", - NonZeroUsize::new(1).unwrap(), - &mut writer, - |start, end, id, _| { - let id = parse_numeric_type_str(id); - let differs = (start..=end).any(|c| match char::from_u32(c) { - Some(c) => { - let modern = parse_numeric_type_val(NumericType::for_char(c)); - modern != id - } - None => true, - }); - - if differs { - Some((start, end, id)) - } else { - None - } - }, - |writer, mut values| { - values.sort_unstable_by_key(|(start, _, _)| *start); - write!(writer, "[").unwrap(); - for (start, end, id) in values { - write!(writer, "({start}, {end}, {id}),").unwrap(); - } - writeln!(writer, "];").unwrap(); - }, - ); -} - -fn generate_numeric_value() { - let path = PathBuf::from(env::var("OUT_DIR").unwrap()) - .join("generated") - .join("unicode_numeric_value.rs"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - let mut writer = BufWriter::new(File::create(&path).unwrap()); - - // Ideally, this would store the diffs between the two tables. However, we need 3.2.0 - // membership as well as different chars. The final tables are both smaller than storing the - // full 3.2.0 value table. - let ucd32 = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("unicode") - .join("ucd32"); - let mut ucd32_diffs = BTreeMap::new(); - let mut ucd32_member = BTreeSet::new(); - let numeric_32 = - BufReader::new(File::open(ucd32.join("DerivedNumericValues-3.2.0.txt")).unwrap()); - parse_unicode_3_2( - numeric_32, - NonZeroUsize::new(1).unwrap(), - &mut io::empty(), - |start, end, value, _| { - let value: f64 = value - .parse() - .expect("Unicode data contains valid properties"); - ucd32_diffs.insert((start, end), value); - ucd32_member.insert((start, end)); - Option::<()>::None - }, - |_writer, _values| {}, - ); - - let ucd_latest = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("unicode") - .join("latest"); - - write_derived( - &ucd_latest, - "DerivedNumericValues.txt", - "NUMERIC_VALUES", - "(u32, u32, f64)", - NonZeroUsize::new(1).unwrap(), - &mut writer, - |start, end, value, _| { - let value: f64 = value - .parse() - .expect("Unicode data contains valid properties"); - - if ucd32_diffs - .get(&(start, end)) - .is_some_and(|old_v| *old_v == value) - { - ucd32_diffs.remove(&(start, end)); - } - - Some((start, end, value)) - }, - |writer, mut values| { - values.sort_unstable_by_key(|(ch, _, _)| *ch); - writeln!(writer, "{values:?};").unwrap(); - }, - ); - - // TODO: More flexible parser - writeln!( - writer, - "static NUMERIC_VALUES_DIFF: &[(u32, u32, f64)] = &[" - ) - .unwrap(); - for ((start, end), value) in ucd32_diffs { - write!(writer, "({start}, {end}, {value:?}),").unwrap(); - } - writeln!(writer, "];").unwrap(); - - // Compress membership table - let mut iter = ucd32_member.iter(); - let &(mut start_prev, mut end_prev) = iter.next().unwrap(); - let mut membership = Vec::new(); - - for &(start, end) in iter { - if start <= end_prev + 1 { - end_prev = end_prev.max(end); - } else { - membership.push((start_prev, end_prev)); - start_prev = start; - end_prev = end; - } - } - membership.push((start_prev, end_prev)); - membership.sort_unstable_by_key(|&(start, _)| start); - - writeln!(writer, "static NUMERIC_VAL_EXISTS_32: &[(u32, u32)] = &").unwrap(); - write!(writer, "{membership:?};").unwrap(); -} - -fn generate_unicode_latest() { - let path = PathBuf::from(env::var("OUT_DIR").unwrap()) - .join("generated") - .join("unicode_latest.rs"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - let mut writer = BufWriter::new(File::create(&path).unwrap()); - - let base = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("unicode") - .join("latest"); - - // NOTE: - // This ONLY parses compatibility decomposition because Python exposes the tags. The tags are - // the "", "", et cetera bits before the decomposition. Thus, we can save space - // by using icu4x's CanonicalDecomposer for non-compatibility decomposition. - let mut decomp_ranges = Vec::new(); - write_derived( - &base, - "UnicodeData.txt", - "DECOMP_COMPAT", - "(u32, DecompositionType, usize)", - NonZeroUsize::new(5).unwrap(), - &mut writer, - |start, _end, value, _| { - // We're building a sparse array. Most characters don't decompose, so we don't - // need to literally store a row for each char. - if value.is_empty() { - return None; - } - - let (dtype, decomp) = value.split_once('>').map(|(dtype, decomp)| { - let dtype = dtype.strip_prefix('<').unwrap_or_else(|| { - panic!("Compatibility decomp; expected \n\tgot: {value}") - }); - ( - parse_decomp_type(dtype), - decomp - .split_whitespace() - .map(|s| u32::from_str_radix(s, 16).unwrap()), - ) - })?; - - decomp_ranges.extend(decomp); - let end = decomp_ranges.len(); - - Some((start, dtype, end)) - }, - |writer, values| { - // UnicodeData.txt should already be sorted - write!(writer, "[").unwrap(); - for (start, dtype, end) in values { - write!(writer, "({start}, DecompositionType::{dtype:?}, {end}),").unwrap(); - } - writeln!(writer, "];").unwrap(); - }, - ); - - writeln!(writer, "static DECOMP_RANGE: &[u32] = &{decomp_ranges:?};").unwrap(); - - // Normalization corrections is super small - only a handful chars at the time of writing. - write_derived( - &base, - "NormalizationCorrections.txt", - "DECOMP_UPDATES", - "(u32, u32)", - NonZeroUsize::new(1).unwrap(), - &mut writer, - |start, _end, value, line| { - let original = u32::from_str_radix(value.trim(), 16).unwrap_or_else(|e| { - panic!("field 2 of decomp corrections should be a char in hex: {value} {e}") - }); - let version = line - .rsplit(';') - .next() - .unwrap_or_else(|| { - panic!("field 4 of decomp corrections should be a UCD version: {line}") - }) - .split_once('#') - .unwrap() - .0 - .trim(); - - // `version` = when the char was updated. Therefore, we use the incorrect chars past - // 3.2.0 but skip the chars fixed in 3.2.0 because they'll already be right. - if version != "3.2.0" { - Some((start, original)) - } else { - None - } - }, - |writer, mut values| { - values.sort_unstable_by_key(|(c, _)| *c); - write!(writer, "{values:?};").unwrap(); - }, - ); -} - -#[expect(clippy::too_many_arguments)] -fn write_derived( - base: &Path, - file_name: &str, - static_name: &str, - array_type: &str, - field: NonZeroUsize, - writer: &mut W, - parse: P, - write_vec: FW, -) where - W: Write, - P: FnMut(u32, u32, &str, &str) -> Option, - FW: FnMut(&mut W, Vec), -{ - let path = base.join(file_name); - let reader = BufReader::new(File::open(path).unwrap()); - writeln!(writer, "static {static_name}: &[{array_type}] = &").unwrap(); - parse_unicode_3_2(reader, field, writer, parse, write_vec); -} - -/// Parse Unicode 3.2.0 property files. -fn parse_unicode_3_2( - reader: impl BufRead, - field: NonZeroUsize, - writer: &mut W, - mut parse: P, - mut write_vec: FW, -) where - W: Write, - P: FnMut(u32, u32, &str, &str) -> Option, - FW: FnMut(&mut W, Vec), -{ - let mut parsed = Vec::new(); - - for line in reader.lines().map(Result::unwrap) { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - - let mut fields = line.split(';'); - let range = fields.next().expect("Unicode data is missing a char range"); - let id = fields - .nth(field.get().saturating_sub(1)) - .expect("Unicode data is missing a property"); - let (start, end) = match range.split_once("..") { - Some((left, right)) => { - let start = u32::from_str_radix(left.trim(), 16).unwrap(); - let end = u32::from_str_radix(right.trim(), 16).unwrap(); - (start, end) - } - None => { - let start = u32::from_str_radix(range.trim(), 16).unwrap(); - (start, start) - } - }; - - let id = id.split_once('#').map_or(id, |(left, _)| left).trim(); - if let Some(val) = parse(start, end, id, line) { - parsed.push(val); - } - } - write_vec(writer, parsed); -} - -fn parse_general(id: &str) -> GeneralCategory { - match id.trim() { - "Cn" => GeneralCategory::Unassigned, - "Lu" => GeneralCategory::UppercaseLetter, - "Ll" => GeneralCategory::LowercaseLetter, - "Lt" => GeneralCategory::TitlecaseLetter, - "Lm" => GeneralCategory::ModifierLetter, - "Lo" => GeneralCategory::OtherLetter, - "Mn" => GeneralCategory::NonspacingMark, - "Mc" => GeneralCategory::SpacingMark, - "Me" => GeneralCategory::EnclosingMark, - "Nd" => GeneralCategory::DecimalNumber, - "Nl" => GeneralCategory::LetterNumber, - "No" => GeneralCategory::OtherNumber, - "Zs" => GeneralCategory::SpaceSeparator, - "Zl" => GeneralCategory::LineSeparator, - "Zp" => GeneralCategory::ParagraphSeparator, - "Cc" => GeneralCategory::Control, - "Cf" => GeneralCategory::Format, - "Co" => GeneralCategory::PrivateUse, - "Cs" => GeneralCategory::Surrogate, - "Pd" => GeneralCategory::DashPunctuation, - "Ps" => GeneralCategory::OpenPunctuation, - "Pe" => GeneralCategory::ClosePunctuation, - "Pc" => GeneralCategory::ConnectorPunctuation, - "Pi" => GeneralCategory::InitialPunctuation, - "Pf" => GeneralCategory::FinalPunctuation, - "Po" => GeneralCategory::OtherPunctuation, - "Sm" => GeneralCategory::MathSymbol, - "Sc" => GeneralCategory::CurrencySymbol, - "Sk" => GeneralCategory::ModifierSymbol, - "So" => GeneralCategory::OtherSymbol, - invalid => unreachable!("Unicode data contains valid properties: {invalid}"), - } -} - -fn parse_eaw(id: &str) -> &'static str { - match id.trim() { - "N" => "EastAsianWidth::Neutral", - "A" => "EastAsianWidth::Ambiguous", - "H" => "EastAsianWidth::Halfwidth", - "F" => "EastAsianWidth::Fullwidth", - "Na" => "EastAsianWidth::Narrow", - "W" => "EastAsianWidth::Wide", - invalid => unreachable!("Unicode data contains valid properties: {invalid}"), - } -} - -fn parse_bidi(id: &str) -> &'static str { - match id.trim() { - "L" => "BidiClass::LeftToRight", - "R" => "BidiClass::RightToLeft", - "EN" => "BidiClass::EuropeanNumber", - "ES" => "BidiClass::EuropeanSeparator", - "ET" => "BidiClass::EuropeanTerminator", - "AN" => "BidiClass::ArabicNumber", - "CS" => "BidiClass::CommonSeparator", - "B" => "BidiClass::ParagraphSeparator", - "S" => "BidiClass::SegmentSeparator", - "WS" => "BidiClass::WhiteSpace", - "ON" => "BidiClass::OtherNeutral", - "LRE" => "BidiClass::LeftToRightEmbedding", - "LRO" => "BidiClass::LeftToRightOverride", - "AL" => "BidiClass::ArabicLetter", - "RLE" => "BidiClass::RightToLeftEmbedding", - "RLO" => "BidiClass::RightToLeftOverride", - "PDF" => "BidiClass::PopDirectionalFormat", - "NSM" => "BidiClass::NonspacingMark", - "BN" => "BidiClass::BoundaryNeutral", - "FSI" => "BidiClass::FirstStrongIsolate", - "LRI" => "BidiClass::LeftToRightIsolate", - "RLI" => "BidiClass::RightToLeftIsolate", - "PDI" => "BidiClass::PopDirectionalIsolate", - invalid => unreachable!("Unicode data contains valid properties: {invalid}"), - } -} - -fn parse_numeric_type_val(val: NumericType) -> &'static str { - match val { - NumericType::None => "none", - NumericType::Decimal => "decimal", - NumericType::Digit => "digit", - NumericType::Numeric => "numeric", - _ => unreachable!("Unicode data contains valid properties"), - } -} - -fn parse_numeric_type_str(id: &str) -> &'static str { - match id { - "none" => "NumericType::None", - "decimal" => "NumericType::Decimal", - "digit" => "NumericType::Digit", - "numeric" => "NumericType::Numeric", - invalid => unreachable!("Unicode data contains valid properties: {invalid}"), - } -} - -#[derive(Debug, Default)] -enum DecompositionType { - #[default] - Canonical, - Compat, - Circle, - Final, - Font, - Fraction, - Initial, - Isolated, - Medial, - Narrow, - Nobreak, - Small, - Square, - Sub, - Super, - Vertical, - Wide, -} - -fn parse_decomp_type(id: &str) -> DecompositionType { - match id { - "canonical" => DecompositionType::Canonical, - "compat" => DecompositionType::Compat, - "circle" => DecompositionType::Circle, - "final" => DecompositionType::Final, - "font" => DecompositionType::Font, - "fraction" => DecompositionType::Fraction, - "initial" => DecompositionType::Initial, - "isolated" => DecompositionType::Isolated, - "medial" => DecompositionType::Medial, - "narrow" => DecompositionType::Narrow, - "noBreak" => DecompositionType::Nobreak, - "small" => DecompositionType::Small, - "square" => DecompositionType::Square, - "sub" => DecompositionType::Sub, - "super" => DecompositionType::Super, - "vertical" => DecompositionType::Vertical, - "wide" => DecompositionType::Wide, - invalid => unreachable!("Unicode data contains valid properties: {invalid}"), - } -} +// spell-checker:ignore ossl osslconf fn main() { println!(r#"cargo::rustc-check-cfg=cfg(osslconf, values("OPENSSL_NO_COMP"))"#); @@ -655,16 +53,4 @@ fn main() { println!("cargo::rustc-cfg=openssl_vendored") } } - - println!("cargo:rerun-if-changed=unicode/ucd32"); - println!("cargo:rerun-if-changed=unicode/latest"); - - let t_32 = thread::spawn(generate_unicode_3_2); - let t_numeric_type = thread::spawn(generate_numeric_type); - let t_numeric_value = thread::spawn(generate_numeric_value); - let t_latest = thread::spawn(generate_unicode_latest); - t_32.join().unwrap(); - t_numeric_type.join().unwrap(); - t_numeric_value.join().unwrap(); - t_latest.join().unwrap(); } diff --git a/crates/stdlib/src/unicodedata.rs b/crates/stdlib/src/unicodedata.rs index 0d6e7b97226..134332bc9d5 100644 --- a/crates/stdlib/src/unicodedata.rs +++ b/crates/stdlib/src/unicodedata.rs @@ -2,130 +2,26 @@ See also: https://docs.python.org/3/library/unicodedata.html */ -// spell-checker:ignore codep decomp DECOMP nfkc unistr unidata - -use core::{cmp::Ordering, hint::cold_path}; +// spell-checker:ignore nfkc unistr unidata pub(crate) use unicodedata::module_def; -use icu_properties::props::{ - BidiClass, CanonicalCombiningClass, EastAsianWidth, GeneralCategory, NumericType, -}; +use rustpython_unicode::{self as unicode_core, NormalizeForm}; use crate::vm::{ PyObject, PyResult, VirtualMachine, builtins::PyStr, convert::TryFromBorrowedObject, }; -include!(concat!(env!("OUT_DIR"), "/generated/unicode_3_2.rs")); -include!(concat!(env!("OUT_DIR"), "/generated/unicode_latest.rs")); -include!(concat!(env!("OUT_DIR"), "/generated/unicode_num_type.rs")); -include!(concat!( - env!("OUT_DIR"), - "/generated/unicode_numeric_value.rs" -)); - -#[derive(Clone, Copy)] -#[repr(u8)] -enum DecompositionType { - #[allow(unused)] - Canonical, - Compat, - Circle, - Final, - Font, - Fraction, - Initial, - Isolated, - Medial, - Narrow, - Nobreak, - Small, - Square, - Sub, - Super, - Vertical, - Wide, -} - -impl DecompositionType { - const fn type_tag(self) -> &'static str { - match self { - Self::Canonical => "canonical", - Self::Compat => "compat", - Self::Circle => "circle", - Self::Final => "final", - Self::Font => "font", - Self::Fraction => "fraction", - Self::Initial => "initial", - Self::Isolated => "isolated", - Self::Medial => "medial", - Self::Narrow => "narrow", - Self::Nobreak => "noBreak", - Self::Small => "small", - Self::Square => "square", - Self::Sub => "sub", - Self::Super => "super", - Self::Vertical => "vertical", - Self::Wide => "wide", - } - } -} - -#[derive(Clone, Copy, Eq, PartialEq)] -enum NormalizeForm { - Nfc, - Nfkc, - Nfd, - Nfkd, -} +struct NormalizeFormArg(NormalizeForm); -fn lookup_property(table: &[(u32, u32, T)], ch: char) -> Option { - let ch = ch as u32; - table - .binary_search_by(|&(start, end, _)| { - if ch > end { - Ordering::Less - } else if ch < start { - Ordering::Greater - } else { - Ordering::Equal - } - }) - .ok() - .map(|i| table[i].2) -} - -fn lookup_numeric_val(ch: char, modern: bool) -> Option { - if modern { - lookup_property(NUMERIC_VALUES, ch) - } else { - cold_path(); - lookup_property(NUMERIC_VALUES_DIFF, ch).or_else(|| { - NUMERIC_VAL_EXISTS_32 - .binary_search_by(|&(start, end)| { - let ch = ch as u32; - if ch > end { - Ordering::Less - } else if ch < start { - Ordering::Greater - } else { - Ordering::Equal - } - }) - .ok() - .and_then(|_| lookup_property(NUMERIC_VALUES, ch)) - }) - } -} - -impl<'a> TryFromBorrowedObject<'a> for NormalizeForm { +impl<'a> TryFromBorrowedObject<'a> for NormalizeFormArg { fn try_from_borrowed_object(vm: &VirtualMachine, obj: &'a PyObject) -> PyResult { obj.try_value_with( |form: &PyStr| match form.as_bytes() { - b"NFC" => Ok(Self::Nfc), - b"NFKC" => Ok(Self::Nfkc), - b"NFD" => Ok(Self::Nfd), - b"NFKD" => Ok(Self::Nfkd), + b"NFC" => Ok(Self(NormalizeForm::Nfc)), + b"NFKC" => Ok(Self(NormalizeForm::Nfkc)), + b"NFD" => Ok(Self(NormalizeForm::Nfd)), + b"NFKD" => Ok(Self(NormalizeForm::Nfkd)), _ => Err(vm.new_value_error("invalid normalization form")), }, vm, @@ -135,27 +31,12 @@ impl<'a> TryFromBorrowedObject<'a> for NormalizeForm { #[pymodule] mod unicodedata { - use core::{cmp::Ordering, fmt::Write, hint::cold_path}; - - use super::{ - BIDI_CLASS, BIDI_MIRRORED, COMBINING_CLASS, DECOMP_COMPAT, DECOMP_RANGE, DECOMP_UPDATES, - EAST_ASIAN_WIDTH, GENERAL_CATEGORY, NUMERIC_TYPE_DIFF, NormalizeForm, lookup_numeric_val, - lookup_property, - }; + use super::{NormalizeFormArg, unicode_core}; use crate::vm::{ Py, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, builtins::{PyModule, PyStrRef}, function::OptionalArg, }; - - use icu_normalizer::{ - ComposingNormalizerBorrowed, DecomposingNormalizerBorrowed, - properties::{CanonicalDecomposition, Decomposed}, - }; - use icu_properties::props::{ - BidiClass, BidiMirrored, BinaryProperty, CanonicalCombiningClass, EastAsianWidth, - EnumeratedProperty, GeneralCategory, NamedEnumeratedProperty, NumericType, - }; use itertools::Itertools; use rustpython_common::wtf8::{CodePoint, Wtf8Buf}; @@ -190,12 +71,14 @@ mod unicodedata { #[pyclass(name = "UCD")] #[derive(Debug, PyPayload)] pub(super) struct Ucd { - modern: bool, + inner: unicode_core::Ucd, } impl Ucd { pub(super) const fn new(modern: bool) -> Self { - Self { modern } + Self { + inner: unicode_core::Ucd::new(modern), + } } fn extract_char(&self, character: PyStrRef, vm: &VirtualMachine) -> PyResult { @@ -211,26 +94,14 @@ mod unicodedata { impl Ucd { #[pymethod] fn category(&self, character: PyStrRef, vm: &VirtualMachine) -> PyResult<&'static str> { - self.extract_char(character, vm).map(|c| { - let Some(c) = c.to_char() else { - return GeneralCategory::Surrogate.short_name(); - }; - if self.modern { - Some(GeneralCategory::for_char(c)) - } else { - cold_path(); - lookup_property(GENERAL_CATEGORY, c) - } - .unwrap_or(GeneralCategory::Unassigned) - .short_name() - }) + self.extract_char(character, vm) + .map(|c| self.inner.category(c)) } - // TODO: Names needs to account for Unicode 3.2.0 and 16.0.0 #[pymethod] fn lookup(&self, name: PyStrRef, vm: &VirtualMachine) -> PyResult { if let Some(name_str) = name.to_str() - && let Some(character) = unicode_names2::character(name_str) + && let Some(character) = unicode_core::lookup_character(name_str) { return Ok(character.to_string()); } @@ -241,7 +112,6 @@ mod unicodedata { )) } - // TODO: Names needs to account for Unicode 3.2.0 and 16.0.0 #[pymethod] fn name( &self, @@ -252,9 +122,9 @@ mod unicodedata { if let Some(name) = self .extract_char(character, vm)? .to_char() - .and_then(unicode_names2::name) + .and_then(unicode_core::character_name) { - return Ok(vm.ctx.new_str(name.to_string()).into()); + return Ok(vm.ctx.new_str(name).into()); } default.ok_or_else(|| vm.new_value_error("no such name")) } @@ -265,19 +135,8 @@ mod unicodedata { character: PyStrRef, vm: &VirtualMachine, ) -> PyResult<&'static str> { - self.extract_char(character, vm).map(|c| { - c.to_char() - .and_then(|c| { - if self.modern { - Some(BidiClass::for_char(c)) - } else { - cold_path(); - lookup_property(BIDI_CLASS, c) - } - }) - .unwrap_or(BidiClass::LeftToRight) - .short_name() - }) + self.extract_char(character, vm) + .map(|c| self.inner.bidirectional(c)) } #[pymethod] @@ -286,180 +145,36 @@ mod unicodedata { character: PyStrRef, vm: &VirtualMachine, ) -> PyResult<&'static str> { - self.extract_char(character, vm).map(|c| { - c.to_char() - .and_then(|c| { - if self.modern { - Some(EastAsianWidth::for_char(c)) - } else { - cold_path(); - // CPython overrides characters in the PUA for 3.2.0. - // Basic Multilingual Plane: - // https://en.wikipedia.org/wiki/Plane_(Unicode)#Basic_Multilingual_Plane - // https://en.wikipedia.org/wiki/Private_Use_Areas - // https://www.unicode.org/reports/tr11/tr11-10.html - // https://www.unicode.org/reports/tr11/ - // - // Currently, this implementation is incomplete because I can't figure - // out what CPython is doing. - lookup_property(EAST_ASIAN_WIDTH, c) - } - }) - .unwrap_or(EastAsianWidth::Neutral) - .short_name() - }) + self.extract_char(character, vm) + .map(|c| self.inner.east_asian_width(c)) } #[pymethod] - fn normalize(&self, form: super::NormalizeForm, unistr: PyStrRef) -> Wtf8Buf { - let text = unistr.as_wtf8(); - match form { - NormalizeForm::Nfc => { - let normalizer = ComposingNormalizerBorrowed::new_nfc(); - text.map_utf8(|s| normalizer.normalize_iter(s.chars())) - .collect() - } - NormalizeForm::Nfkc => { - let normalizer = ComposingNormalizerBorrowed::new_nfkc(); - text.map_utf8(|s| normalizer.normalize_iter(s.chars())) - .collect() - } - NormalizeForm::Nfd => { - let normalizer = DecomposingNormalizerBorrowed::new_nfd(); - text.map_utf8(|s| normalizer.normalize_iter(s.chars())) - .collect() - } - NormalizeForm::Nfkd => { - let normalizer = DecomposingNormalizerBorrowed::new_nfkd(); - text.map_utf8(|s| normalizer.normalize_iter(s.chars())) - .collect() - } - } + fn normalize(&self, form: NormalizeFormArg, unistr: PyStrRef) -> Wtf8Buf { + unicode_core::normalize(form.0, unistr.as_wtf8()) } #[pymethod] - fn is_normalized(&self, form: super::NormalizeForm, unistr: PyStrRef) -> bool { - match form { - NormalizeForm::Nfc => { - ComposingNormalizerBorrowed::new_nfc().is_normalized_utf8(unistr.as_bytes()) - } - NormalizeForm::Nfkc => { - ComposingNormalizerBorrowed::new_nfkc().is_normalized_utf8(unistr.as_bytes()) - } - NormalizeForm::Nfd => { - DecomposingNormalizerBorrowed::new_nfd().is_normalized_utf8(unistr.as_bytes()) - } - NormalizeForm::Nfkd => { - DecomposingNormalizerBorrowed::new_nfkd().is_normalized_utf8(unistr.as_bytes()) - } - } + fn is_normalized(&self, form: NormalizeFormArg, unistr: PyStrRef) -> bool { + unicode_core::is_normalized(form.0, unistr.as_wtf8()) } #[pymethod] fn mirrored(&self, character: PyStrRef, vm: &VirtualMachine) -> PyResult { - self.extract_char(character, vm).map(|c| { - c.to_char().map_or(0, |c| { - (if self.modern { - BidiMirrored::for_char(c) - } else { - cold_path(); - let c = c as u32; - BIDI_MIRRORED - .binary_search_by(|&(start, end)| { - if c > end { - Ordering::Less - } else if c < start { - Ordering::Greater - } else { - Ordering::Equal - } - }) - .is_ok() - }) as i32 - }) - }) + self.extract_char(character, vm) + .map(|c| self.inner.mirrored(c)) } #[pymethod] fn combining(&self, character: PyStrRef, vm: &VirtualMachine) -> PyResult { - self.extract_char(character, vm).map(|c| { - c.to_char() - .and_then(|c| { - if self.modern { - Some(CanonicalCombiningClass::for_char(c)) - } else { - cold_path(); - lookup_property(COMBINING_CLASS, c) - } - }) - .unwrap_or(CanonicalCombiningClass::NotReordered) - .to_icu4c_value() - }) + self.extract_char(character, vm) + .map(|c| self.inner.combining(c)) } #[pymethod] fn decomposition(&self, character: PyStrRef, vm: &VirtualMachine) -> PyResult { - let Some(ch) = self.extract_char(character, vm).map(CodePoint::to_char)? else { - return Ok(String::new()); - }; - - // Decomposition is remarkable stable according to the normalization file, - // so the updates slice is very small - only about four char pairs. Linearly searching - // it is very fast. The file lists the original, incorrect decomp and the fixed char. - // For 3.2.0, we use the original decomp for compatibility while ignoring the update. - // - // Finally, we don't have to do anything for the latest UCD as it's already updated. - if self.modern - && let Some((_, original)) = DECOMP_UPDATES - .iter() - .find(|&&(codep, _original)| codep == ch as u32) - { - Ok(format!("{original:04X}")) - } else if let Ok(i) = - DECOMP_COMPAT.binary_search_by_key(&(ch as u32), |&(codep, _, _)| codep) - { - // Compatibility decomposition - // `icu4x` doesn't expose a non-recursive, compatibility decomposer so we - // have to do it manually for now. - let tag = DECOMP_COMPAT[i].1.type_tag(); - let end = DECOMP_COMPAT[i].2; - let start = i - .checked_sub(1) - .map(|i| DECOMP_COMPAT[i].2) - .unwrap_or_default(); - - let decomp = &DECOMP_RANGE[start..end]; - let cap = decomp.len() * 10 + decomp.len() + tag.len() + 1; - let mut out = String::with_capacity(cap); - - write!(out, "<{tag}>").unwrap(); - for ch in decomp { - write!(out, " {ch:04X}").unwrap(); - } - - Ok(out) - } else { - // Canonical decomposition - let decomposed = CanonicalDecomposition::new().decompose(ch); - match decomposed { - Decomposed::Default => Ok(String::new()), - Decomposed::Singleton(ch) => Ok(format!("{:04X}", ch as u32)), - Decomposed::Expansion(l, r) => Ok(format!("{:04X} {:04X}", l as u32, r as u32)), - } - } - } - - fn numeric_type_matches(&self, ch: CodePoint, expected: &[NumericType]) -> Option { - let ch = ch.to_char()?; - - let actual = if self.modern { - NumericType::for_char(ch) - } else { - cold_path(); - lookup_property(NUMERIC_TYPE_DIFF, ch).unwrap_or_else(|| NumericType::for_char(ch)) - }; - - expected.contains(&actual).then_some(ch) + self.extract_char(character, vm) + .map(|c| self.inner.decomposition(c)) } #[pymethod] @@ -470,12 +185,9 @@ mod unicodedata { vm: &VirtualMachine, ) -> PyResult> { let ch = self.extract_char(character, vm)?; - let expected = [NumericType::Decimal, NumericType::Digit]; - self.numeric_type_matches(ch, &expected) - .and_then(|ch| { - let value = lookup_numeric_val(ch, true)?; - (value.trunc() == value).then(|| vm.ctx.new_int(value as u64).into()) - }) + self.inner + .digit(ch) + .map(|value| vm.ctx.new_int(value).into()) .or_else(|| default.present()) .map(Option::Some) .ok_or_else(|| vm.new_value_error("not a digit")) @@ -489,12 +201,9 @@ mod unicodedata { vm: &VirtualMachine, ) -> PyResult> { let ch = self.extract_char(character, vm)?; - let expected = [NumericType::Decimal]; - self.numeric_type_matches(ch, &expected) - .and_then(|ch| { - let value = lookup_numeric_val(ch, self.modern)?; - (value.trunc() == value).then(|| vm.ctx.new_int(value as u64).into()) - }) + self.inner + .decimal(ch) + .map(|value| vm.ctx.new_int(value).into()) .or_else(|| default.present()) .map(Option::Some) .ok_or_else(|| vm.new_value_error("not a decimal")) @@ -508,11 +217,9 @@ mod unicodedata { vm: &VirtualMachine, ) -> PyResult> { let ch = self.extract_char(character, vm)?; - let expected = &NumericType::ALL_VALUES[1..]; - self.numeric_type_matches(ch, expected) - .and_then(|ch| { - lookup_numeric_val(ch, self.modern).map(|value| vm.ctx.new_float(value).into()) - }) + self.inner + .numeric(ch) + .map(|value| vm.ctx.new_float(value).into()) .or_else(|| default.present()) .map(Option::Some) .ok_or_else(|| vm.new_value_error("not a numeric character")) @@ -520,16 +227,7 @@ mod unicodedata { #[pygetset] fn unidata_version(&self) -> String { - if self.modern { - format!( - "{}.{}.{}", - char::UNICODE_VERSION.0, - char::UNICODE_VERSION.1, - char::UNICODE_VERSION.2 - ) - } else { - "3.2.0".into() - } + self.inner.unidata_version() } } @@ -540,11 +238,6 @@ mod unicodedata { #[pyattr] fn unidata_version(_vm: &VirtualMachine) -> String { - format!( - "{}.{}.{}", - char::UNICODE_VERSION.0, - char::UNICODE_VERSION.1, - char::UNICODE_VERSION.2 - ) + unicode_core::unicode_version() } } diff --git a/crates/unicode/Cargo.toml b/crates/unicode/Cargo.toml new file mode 100644 index 00000000000..678305ee09e --- /dev/null +++ b/crates/unicode/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "rustpython-unicode" +description = "Runtime-independent CPython-compatible Unicode semantics and data for RustPython and related Python tooling." +edition = { workspace = true } +version = { workspace = true } +authors = { workspace = true } +repository = { workspace = true } +license = { workspace = true } +rust-version = { workspace = true } + +[dependencies] +rustpython-wtf8 = { workspace = true } + +icu_casemap = { workspace = true } +icu_properties = { workspace = true } +icu_normalizer = { workspace = true } +unicode_names2 = { workspace = true } +writeable = { workspace = true } + +[build-dependencies] +icu_properties = { workspace = true } + +[lints] +workspace = true diff --git a/crates/unicode/build.rs b/crates/unicode/build.rs new file mode 100644 index 00000000000..3a82df85eb8 --- /dev/null +++ b/crates/unicode/build.rs @@ -0,0 +1,612 @@ +// spell-checker:ignore decomp DECOMP + +extern crate alloc; + +use core::num::NonZeroUsize; + +use alloc::collections::{BTreeMap, BTreeSet}; + +use std::{ + env, + fs::{self, File}, + io::{self, BufRead, BufReader, BufWriter, Write}, + path::{Path, PathBuf}, + thread, +}; + +use icu_properties::props::{EnumeratedProperty, GeneralCategory, NumericType}; + +fn generate_unicode_3_2() { + let path = PathBuf::from(env::var("OUT_DIR").unwrap()) + .join("generated") + .join("unicode_3_2.rs"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut writer = BufWriter::new(File::create(&path).unwrap()); + + let base = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("unicode") + .join("ucd32"); + + write_derived( + &base, + "DerivedGeneralCategory-3.2.0.txt", + "GENERAL_CATEGORY", + "(u32, u32, GeneralCategory)", + NonZeroUsize::new(1).unwrap(), + &mut writer, + |start, end, id, _| { + let id = parse_general(id); + if id != GeneralCategory::Unassigned { + Some((start, end, id)) + } else { + None + } + }, + |writer, mut values| { + values.sort_unstable_by_key(|(start, _, _)| *start); + write!(writer, "[").unwrap(); + for (start, end, id) in values { + write!(writer, "({start}, {end}, GeneralCategory::{id:?}),").unwrap(); + } + write!(writer, "];").unwrap(); + }, + ); + + write_derived( + &base, + "DerivedEastAsianWidth-3.2.0.txt", + "EAST_ASIAN_WIDTH", + "(u32, u32, EastAsianWidth)", + NonZeroUsize::new(1).unwrap(), + &mut writer, + |start, end, id, _| { + let id = parse_eaw(id); + if id != "EastAsianWidth::Neutral" { + Some((start, end, id)) + } else { + None + } + }, + |writer, mut values| { + values.sort_unstable_by_key(|(start, _, _)| *start); + write!(writer, "[").unwrap(); + for (start, end, id) in values { + write!(writer, "({start}, {end}, {id}),").unwrap(); + } + write!(writer, "];").unwrap(); + }, + ); + + write_derived( + &base, + "DerivedBidiClass-3.2.0.txt", + "BIDI_CLASS", + "(u32, u32, BidiClass)", + NonZeroUsize::new(1).unwrap(), + &mut writer, + |start, end, id, _| { + let id = parse_bidi(id); + if id != "BidiClass::LeftToRight" { + Some((start, end, id)) + } else { + None + } + }, + |writer, mut values| { + values.sort_unstable_by_key(|(start, _, _)| *start); + write!(writer, "[").unwrap(); + for (start, end, id) in values { + write!(writer, "({start}, {end}, {id}),").unwrap(); + } + write!(writer, "];").unwrap(); + }, + ); + + write_derived( + &base, + "DerivedBinaryProperties-3.2.0.txt", + "BIDI_MIRRORED", + "(u32, u32)", + NonZeroUsize::new(1).unwrap(), + &mut writer, + |start, end, id, _| { + assert_eq!( + "Bidi_Mirrored", + id.trim(), + "DerivedBinaryProperties-3.2.0 only has Bidi_Mirrored" + ); + Some((start, end)) + }, + |writer, mut values| { + values.sort_unstable_by_key(|(start, _)| *start); + writeln!(writer, "{values:?};").unwrap(); + }, + ); + + write_derived( + &base, + "DerivedCombiningClass-3.2.0.txt", + "COMBINING_CLASS", + "(u32, u32, CanonicalCombiningClass)", + NonZeroUsize::new(1).unwrap(), + &mut writer, + |start, end, id, _| { + let id: u8 = id.parse().unwrap(); + if id == 0 { + return None; + } + Some((start, end, id)) + }, + |writer, mut values| { + values.sort_unstable_by_key(|(start, _, _)| *start); + write!(writer, "[").unwrap(); + for (start, end, id) in values { + write!( + writer, + "({start}, {end}, CanonicalCombiningClass::from_icu4c_value({id}))," + ) + .unwrap(); + } + writeln!(writer, "];").unwrap(); + }, + ); +} + +fn generate_numeric_type() { + let path = PathBuf::from(env::var("OUT_DIR").unwrap()) + .join("generated") + .join("unicode_num_type.rs"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut writer = BufWriter::new(File::create(&path).unwrap()); + + let base = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("unicode") + .join("ucd32"); + + write_derived( + &base, + "DerivedNumericType-3.2.0.txt", + "NUMERIC_TYPE_DIFF", + "(u32, u32, NumericType)", + NonZeroUsize::new(1).unwrap(), + &mut writer, + |start, end, id, _| { + let id = parse_numeric_type_str(id); + let differs = (start..=end).any(|c| match char::from_u32(c) { + Some(c) => { + let modern = parse_numeric_type_val(NumericType::for_char(c)); + modern != id + } + None => true, + }); + + if differs { + Some((start, end, id)) + } else { + None + } + }, + |writer, mut values| { + values.sort_unstable_by_key(|(start, _, _)| *start); + write!(writer, "[").unwrap(); + for (start, end, id) in values { + write!(writer, "({start}, {end}, {id}),").unwrap(); + } + writeln!(writer, "];").unwrap(); + }, + ); +} + +fn generate_numeric_value() { + let path = PathBuf::from(env::var("OUT_DIR").unwrap()) + .join("generated") + .join("unicode_numeric_value.rs"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut writer = BufWriter::new(File::create(&path).unwrap()); + + // Ideally, this would store the diffs between the two tables. However, we need 3.2.0 + // membership as well as different chars. The final tables are both smaller than storing the + // full 3.2.0 value table. + let ucd32 = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("unicode") + .join("ucd32"); + let mut ucd32_diffs = BTreeMap::new(); + let mut ucd32_member = BTreeSet::new(); + let numeric_32 = + BufReader::new(File::open(ucd32.join("DerivedNumericValues-3.2.0.txt")).unwrap()); + parse_unicode_3_2( + numeric_32, + NonZeroUsize::new(1).unwrap(), + &mut io::empty(), + |start, end, value, _| { + let value: f64 = value + .parse() + .expect("Unicode data contains valid properties"); + ucd32_diffs.insert((start, end), value); + ucd32_member.insert((start, end)); + Option::<()>::None + }, + |_writer, _values| {}, + ); + + let ucd_latest = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("unicode") + .join("latest"); + + write_derived( + &ucd_latest, + "DerivedNumericValues.txt", + "NUMERIC_VALUES", + "(u32, u32, f64)", + NonZeroUsize::new(1).unwrap(), + &mut writer, + |start, end, value, _| { + let value: f64 = value + .parse() + .expect("Unicode data contains valid properties"); + + if ucd32_diffs + .get(&(start, end)) + .is_some_and(|old_v| *old_v == value) + { + ucd32_diffs.remove(&(start, end)); + } + + Some((start, end, value)) + }, + |writer, mut values| { + values.sort_unstable_by_key(|(ch, _, _)| *ch); + writeln!(writer, "{values:?};").unwrap(); + }, + ); + + // TODO: More flexible parser + writeln!( + writer, + "static NUMERIC_VALUES_DIFF: &[(u32, u32, f64)] = &[" + ) + .unwrap(); + for ((start, end), value) in ucd32_diffs { + write!(writer, "({start}, {end}, {value:?}),").unwrap(); + } + writeln!(writer, "];").unwrap(); + + // Compress membership table + let mut iter = ucd32_member.iter(); + let &(mut start_prev, mut end_prev) = iter.next().unwrap(); + let mut membership = Vec::new(); + + for &(start, end) in iter { + if start <= end_prev + 1 { + end_prev = end_prev.max(end); + } else { + membership.push((start_prev, end_prev)); + start_prev = start; + end_prev = end; + } + } + membership.push((start_prev, end_prev)); + membership.sort_unstable_by_key(|&(start, _)| start); + + writeln!(writer, "static NUMERIC_VAL_EXISTS_32: &[(u32, u32)] = &").unwrap(); + write!(writer, "{membership:?};").unwrap(); +} + +fn generate_unicode_latest() { + let path = PathBuf::from(env::var("OUT_DIR").unwrap()) + .join("generated") + .join("unicode_latest.rs"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut writer = BufWriter::new(File::create(&path).unwrap()); + + let base = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("unicode") + .join("latest"); + + // NOTE: + // This ONLY parses compatibility decomposition because Python exposes the tags. The tags are + // the "", "", et cetera bits before the decomposition. Thus, we can save space + // by using icu4x's CanonicalDecomposer for non-compatibility decomposition. + let mut decomp_ranges = Vec::new(); + write_derived( + &base, + "UnicodeData.txt", + "DECOMP_COMPAT", + "(u32, DecompositionType, usize)", + NonZeroUsize::new(5).unwrap(), + &mut writer, + |start, _end, value, _| { + // We're building a sparse array. Most characters don't decompose, so we don't + // need to literally store a row for each char. + if value.is_empty() { + return None; + } + + let (dtype, decomp) = value.split_once('>').map(|(dtype, decomp)| { + let dtype = dtype.strip_prefix('<').unwrap_or_else(|| { + panic!("Compatibility decomp; expected \n\tgot: {value}") + }); + ( + parse_decomp_type(dtype), + decomp + .split_whitespace() + .map(|s| u32::from_str_radix(s, 16).unwrap()), + ) + })?; + + decomp_ranges.extend(decomp); + let end = decomp_ranges.len(); + + Some((start, dtype, end)) + }, + |writer, values| { + // UnicodeData.txt should already be sorted + write!(writer, "[").unwrap(); + for (start, dtype, end) in values { + write!(writer, "({start}, DecompositionType::{dtype:?}, {end}),").unwrap(); + } + writeln!(writer, "];").unwrap(); + }, + ); + + writeln!(writer, "static DECOMP_RANGE: &[u32] = &{decomp_ranges:?};").unwrap(); + + // Normalization corrections is super small - only a handful chars at the time of writing. + write_derived( + &base, + "NormalizationCorrections.txt", + "DECOMP_UPDATES", + "(u32, u32)", + NonZeroUsize::new(1).unwrap(), + &mut writer, + |start, _end, value, line| { + let original = u32::from_str_radix(value.trim(), 16).unwrap_or_else(|e| { + panic!("field 2 of decomp corrections should be a char in hex: {value} {e}") + }); + let version = line + .rsplit(';') + .next() + .unwrap_or_else(|| { + panic!("field 4 of decomp corrections should be a UCD version: {line}") + }) + .split_once('#') + .unwrap() + .0 + .trim(); + + // `version` = when the char was updated. Therefore, we use the incorrect chars past + // 3.2.0 but skip the chars fixed in 3.2.0 because they'll already be right. + if version != "3.2.0" { + Some((start, original)) + } else { + None + } + }, + |writer, mut values| { + values.sort_unstable_by_key(|(c, _)| *c); + write!(writer, "{values:?};").unwrap(); + }, + ); +} + +#[expect(clippy::too_many_arguments)] +fn write_derived( + base: &Path, + file_name: &str, + static_name: &str, + array_type: &str, + field: NonZeroUsize, + writer: &mut W, + parse: P, + write_vec: FW, +) where + W: Write, + P: FnMut(u32, u32, &str, &str) -> Option, + FW: FnMut(&mut W, Vec), +{ + let path = base.join(file_name); + let reader = BufReader::new(File::open(path).unwrap()); + writeln!(writer, "static {static_name}: &[{array_type}] = &").unwrap(); + parse_unicode_3_2(reader, field, writer, parse, write_vec); +} + +/// Parse Unicode 3.2.0 property files. +fn parse_unicode_3_2( + reader: impl BufRead, + field: NonZeroUsize, + writer: &mut W, + mut parse: P, + mut write_vec: FW, +) where + W: Write, + P: FnMut(u32, u32, &str, &str) -> Option, + FW: FnMut(&mut W, Vec), +{ + let mut parsed = Vec::new(); + + for line in reader.lines().map(Result::unwrap) { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let mut fields = line.split(';'); + let range = fields.next().expect("Unicode data is missing a char range"); + let id = fields + .nth(field.get().saturating_sub(1)) + .expect("Unicode data is missing a property"); + let (start, end) = match range.split_once("..") { + Some((left, right)) => { + let start = u32::from_str_radix(left.trim(), 16).unwrap(); + let end = u32::from_str_radix(right.trim(), 16).unwrap(); + (start, end) + } + None => { + let start = u32::from_str_radix(range.trim(), 16).unwrap(); + (start, start) + } + }; + + let id = id.split_once('#').map_or(id, |(left, _)| left).trim(); + if let Some(val) = parse(start, end, id, line) { + parsed.push(val); + } + } + write_vec(writer, parsed); +} + +fn parse_general(id: &str) -> GeneralCategory { + match id.trim() { + "Cn" => GeneralCategory::Unassigned, + "Lu" => GeneralCategory::UppercaseLetter, + "Ll" => GeneralCategory::LowercaseLetter, + "Lt" => GeneralCategory::TitlecaseLetter, + "Lm" => GeneralCategory::ModifierLetter, + "Lo" => GeneralCategory::OtherLetter, + "Mn" => GeneralCategory::NonspacingMark, + "Mc" => GeneralCategory::SpacingMark, + "Me" => GeneralCategory::EnclosingMark, + "Nd" => GeneralCategory::DecimalNumber, + "Nl" => GeneralCategory::LetterNumber, + "No" => GeneralCategory::OtherNumber, + "Zs" => GeneralCategory::SpaceSeparator, + "Zl" => GeneralCategory::LineSeparator, + "Zp" => GeneralCategory::ParagraphSeparator, + "Cc" => GeneralCategory::Control, + "Cf" => GeneralCategory::Format, + "Co" => GeneralCategory::PrivateUse, + "Cs" => GeneralCategory::Surrogate, + "Pd" => GeneralCategory::DashPunctuation, + "Ps" => GeneralCategory::OpenPunctuation, + "Pe" => GeneralCategory::ClosePunctuation, + "Pc" => GeneralCategory::ConnectorPunctuation, + "Pi" => GeneralCategory::InitialPunctuation, + "Pf" => GeneralCategory::FinalPunctuation, + "Po" => GeneralCategory::OtherPunctuation, + "Sm" => GeneralCategory::MathSymbol, + "Sc" => GeneralCategory::CurrencySymbol, + "Sk" => GeneralCategory::ModifierSymbol, + "So" => GeneralCategory::OtherSymbol, + invalid => unreachable!("Unicode data contains valid properties: {invalid}"), + } +} + +fn parse_eaw(id: &str) -> &'static str { + match id.trim() { + "N" => "EastAsianWidth::Neutral", + "A" => "EastAsianWidth::Ambiguous", + "H" => "EastAsianWidth::Halfwidth", + "F" => "EastAsianWidth::Fullwidth", + "Na" => "EastAsianWidth::Narrow", + "W" => "EastAsianWidth::Wide", + invalid => unreachable!("Unicode data contains valid properties: {invalid}"), + } +} + +fn parse_bidi(id: &str) -> &'static str { + match id.trim() { + "L" => "BidiClass::LeftToRight", + "R" => "BidiClass::RightToLeft", + "EN" => "BidiClass::EuropeanNumber", + "ES" => "BidiClass::EuropeanSeparator", + "ET" => "BidiClass::EuropeanTerminator", + "AN" => "BidiClass::ArabicNumber", + "CS" => "BidiClass::CommonSeparator", + "B" => "BidiClass::ParagraphSeparator", + "S" => "BidiClass::SegmentSeparator", + "WS" => "BidiClass::WhiteSpace", + "ON" => "BidiClass::OtherNeutral", + "LRE" => "BidiClass::LeftToRightEmbedding", + "LRO" => "BidiClass::LeftToRightOverride", + "AL" => "BidiClass::ArabicLetter", + "RLE" => "BidiClass::RightToLeftEmbedding", + "RLO" => "BidiClass::RightToLeftOverride", + "PDF" => "BidiClass::PopDirectionalFormat", + "NSM" => "BidiClass::NonspacingMark", + "BN" => "BidiClass::BoundaryNeutral", + "FSI" => "BidiClass::FirstStrongIsolate", + "LRI" => "BidiClass::LeftToRightIsolate", + "RLI" => "BidiClass::RightToLeftIsolate", + "PDI" => "BidiClass::PopDirectionalIsolate", + invalid => unreachable!("Unicode data contains valid properties: {invalid}"), + } +} + +fn parse_numeric_type_val(val: NumericType) -> &'static str { + match val { + NumericType::None => "none", + NumericType::Decimal => "decimal", + NumericType::Digit => "digit", + NumericType::Numeric => "numeric", + _ => unreachable!("Unicode data contains valid properties"), + } +} + +fn parse_numeric_type_str(id: &str) -> &'static str { + match id { + "none" => "NumericType::None", + "decimal" => "NumericType::Decimal", + "digit" => "NumericType::Digit", + "numeric" => "NumericType::Numeric", + invalid => unreachable!("Unicode data contains valid properties: {invalid}"), + } +} + +#[derive(Debug, Default)] +enum DecompositionType { + #[default] + Canonical, + Compat, + Circle, + Final, + Font, + Fraction, + Initial, + Isolated, + Medial, + Narrow, + Nobreak, + Small, + Square, + Sub, + Super, + Vertical, + Wide, +} + +fn parse_decomp_type(id: &str) -> DecompositionType { + match id { + "canonical" => DecompositionType::Canonical, + "compat" => DecompositionType::Compat, + "circle" => DecompositionType::Circle, + "final" => DecompositionType::Final, + "font" => DecompositionType::Font, + "fraction" => DecompositionType::Fraction, + "initial" => DecompositionType::Initial, + "isolated" => DecompositionType::Isolated, + "medial" => DecompositionType::Medial, + "narrow" => DecompositionType::Narrow, + "noBreak" => DecompositionType::Nobreak, + "small" => DecompositionType::Small, + "square" => DecompositionType::Square, + "sub" => DecompositionType::Sub, + "super" => DecompositionType::Super, + "vertical" => DecompositionType::Vertical, + "wide" => DecompositionType::Wide, + invalid => unreachable!("Unicode data contains valid properties: {invalid}"), + } +} + +fn main() { + println!("cargo:rerun-if-changed=unicode/ucd32"); + println!("cargo:rerun-if-changed=unicode/latest"); + + let t_32 = thread::spawn(generate_unicode_3_2); + let t_numeric_type = thread::spawn(generate_numeric_type); + let t_numeric_value = thread::spawn(generate_numeric_value); + let t_latest = thread::spawn(generate_unicode_latest); + t_32.join().unwrap(); + t_numeric_type.join().unwrap(); + t_numeric_value.join().unwrap(); + t_latest.join().unwrap(); +} diff --git a/crates/unicode/src/case.rs b/crates/unicode/src/case.rs new file mode 100644 index 00000000000..872d0b29ac1 --- /dev/null +++ b/crates/unicode/src/case.rs @@ -0,0 +1,69 @@ +//! Case folding for Python `str.casefold`. +//! +//! Lower, upper, and title casing of `str` objects stay with the runtime +//! because they iterate the string with special final-sigma handling. Case +//! folding has no such context dependence, so it lives here and is shared with +//! other runtimes. + +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; + +use icu_casemap::CaseMapper; +use rustpython_wtf8::{Wtf8, Wtf8Buf, Wtf8Chunk}; +use writeable::Writeable; + +/// Full Unicode case fold of `text` (`str.casefold`). +#[must_use] +pub fn casefold_str(text: &str) -> String { + CaseMapper::new().fold_string(text).to_string() +} + +/// Full Unicode case fold of `text`, passing lone surrogates through unchanged. +#[must_use] +pub fn casefold_wtf8(text: &Wtf8) -> Wtf8Buf { + let mut out = Vec::with_capacity(text.len()); + let mapper = CaseMapper::new(); + for chunk in text.chunks() { + match chunk { + Wtf8Chunk::Utf8(s) => { + mapper + .fold(s) + .write_to(&mut FmtWriter(&mut out)) + .expect("writing to an in-memory buffer cannot fail"); + } + Wtf8Chunk::Surrogate(c) => { + let mut buf = Wtf8Buf::new(); + buf.push(c); + out.extend_from_slice(buf.as_bytes()); + } + } + } + // SAFETY: + // * CaseMapper only produces valid UTF-8. + // * Surrogates are appended as valid WTF-8 (encoded via Wtf8Buf::push). + unsafe { Wtf8Buf::from_bytes_unchecked(out) } +} + +/// Adapter so `icu`'s `Writeable` output can be appended to a byte buffer. +struct FmtWriter<'a>(&'a mut Vec); + +impl core::fmt::Write for FmtWriter<'_> { + fn write_str(&mut self, s: &str) -> core::fmt::Result { + self.0.extend_from_slice(s.as_bytes()); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::casefold_str; + + #[test] + fn casefold_full_mappings() { + // ß case-folds to "ss" + assert_eq!(casefold_str("ß"), "ss"); + assert_eq!(casefold_str("Σ"), "σ"); + } +} diff --git a/crates/unicode/src/classify.rs b/crates/unicode/src/classify.rs new file mode 100644 index 00000000000..7a333763a57 --- /dev/null +++ b/crates/unicode/src/classify.rs @@ -0,0 +1,116 @@ +//! Character classification predicates for Python `str` methods. +//! +//! Each predicate operates on a single Unicode scalar. Callers iterating over +//! WTF-8 text apply these per code point, treating lone surrogates as failing +//! every predicate. + +use icu_properties::props::{ + BidiClass, EnumeratedProperty, GeneralCategory, GeneralCategoryGroup, NumericType, +}; + +/// `str.isalpha` for a single character: any `Letter` general category. +#[must_use] +pub fn is_alpha(c: char) -> bool { + GeneralCategoryGroup::Letter.contains(GeneralCategory::for_char(c)) +} + +/// `str.isalnum` for a single character: any `Letter` or `Number` category. +#[must_use] +pub fn is_alnum(c: char) -> bool { + GeneralCategoryGroup::Letter + .union(GeneralCategoryGroup::Number) + .contains(GeneralCategory::for_char(c)) +} + +/// `str.isdecimal` for a single character: `Decimal_Number` general category. +#[must_use] +pub fn is_decimal(c: char) -> bool { + matches!(GeneralCategory::for_char(c), GeneralCategory::DecimalNumber) +} + +/// `str.isdigit` for a single character: `Numeric_Type` of `Digit` or `Decimal`. +#[must_use] +pub fn is_digit(c: char) -> bool { + matches!( + NumericType::for_char(c), + NumericType::Digit | NumericType::Decimal + ) +} + +/// `str.isnumeric` for a single character: any numeric `Numeric_Type`. +#[must_use] +pub fn is_numeric(c: char) -> bool { + matches!( + NumericType::for_char(c), + NumericType::Decimal | NumericType::Digit | NumericType::Numeric + ) +} + +/// `str.isspace` for a single character: `Space_Separator`, or a bidi +/// whitespace / paragraph / segment separator. +#[must_use] +pub fn is_space(c: char) -> bool { + matches!( + GeneralCategory::for_char(c), + GeneralCategory::SpaceSeparator + ) || matches!( + BidiClass::for_char(c), + BidiClass::WhiteSpace | BidiClass::ParagraphSeparator | BidiClass::SegmentSeparator + ) +} + +/// `str.isprintable` for a single character: ASCII space is printable, as are +/// all characters that survive [`is_repr_printable`]. +#[must_use] +pub fn is_printable(c: char) -> bool { + c == '\u{0020}' || is_repr_printable(c) +} + +/// Repr/escape printable semantics. +/// +/// The following categories are not printable: +/// * Cc (Other, Control) +/// * Cf (Other, Format) +/// * Cs (Other, Surrogate) +/// * Co (Other, Private Use) +/// * Cn (Other, Not Assigned) +/// * Zl (Separator, Line) +/// * Zp (Separator, Paragraph) +/// * Zs (Separator, Space), including ASCII space +#[must_use] +pub fn is_repr_printable(c: char) -> bool { + !matches!( + GeneralCategory::for_char(c), + GeneralCategory::SpaceSeparator + | GeneralCategory::LineSeparator + | GeneralCategory::ParagraphSeparator + | GeneralCategory::Control + | GeneralCategory::Format + | GeneralCategory::Surrogate + | GeneralCategory::PrivateUse + | GeneralCategory::Unassigned + ) +} + +#[cfg(test)] +mod tests { + use super::{is_decimal, is_digit, is_numeric}; + + #[test] + fn numeric_type_chain_holds() { + // isdecimal ⊂ isdigit ⊂ isnumeric + for c in ('\0'..='\u{2FFFF}').filter_map(|c| char::from_u32(c as u32)) { + if is_decimal(c) { + assert!(is_digit(c), "{c:?} decimal but not digit"); + } + if is_digit(c) { + assert!(is_numeric(c), "{c:?} digit but not numeric"); + } + } + assert!(is_decimal('5')); + assert!(!is_decimal('²')); + assert!(is_digit('²')); + assert!(!is_digit('⅓')); + assert!(is_numeric('⅓')); + } +} diff --git a/crates/unicode/src/data.rs b/crates/unicode/src/data.rs new file mode 100644 index 00000000000..83d81612b6a --- /dev/null +++ b/crates/unicode/src/data.rs @@ -0,0 +1,373 @@ +//! Access to the Unicode character database (`unicodedata`). +//! +//! Owns the generated Unicode 3.2.0 / latest tables and the +//! `icu4x`/`unicode_names2` lookups behind them. + +// spell-checker:ignore codep decomp DECOMP unidata + +use core::{cmp::Ordering, fmt::Write, hint::cold_path}; + +use alloc::{ + format, + string::{String, ToString}, +}; + +use icu_normalizer::properties::{CanonicalDecomposition, Decomposed}; +use icu_properties::props::{ + BidiClass, BidiMirrored, BinaryProperty, CanonicalCombiningClass, EastAsianWidth, + EnumeratedProperty, GeneralCategory, NamedEnumeratedProperty, NumericType, +}; +use rustpython_wtf8::CodePoint; + +include!(concat!(env!("OUT_DIR"), "/generated/unicode_3_2.rs")); +include!(concat!(env!("OUT_DIR"), "/generated/unicode_latest.rs")); +include!(concat!(env!("OUT_DIR"), "/generated/unicode_num_type.rs")); +include!(concat!( + env!("OUT_DIR"), + "/generated/unicode_numeric_value.rs" +)); + +#[derive(Clone, Copy)] +enum DecompositionType { + Compat, + Circle, + Final, + Font, + Fraction, + Initial, + Isolated, + Medial, + Narrow, + Nobreak, + Small, + Square, + Sub, + Super, + Vertical, + Wide, +} + +impl DecompositionType { + const fn type_tag(self) -> &'static str { + match self { + Self::Compat => "compat", + Self::Circle => "circle", + Self::Final => "final", + Self::Font => "font", + Self::Fraction => "fraction", + Self::Initial => "initial", + Self::Isolated => "isolated", + Self::Medial => "medial", + Self::Narrow => "narrow", + Self::Nobreak => "noBreak", + Self::Small => "small", + Self::Square => "square", + Self::Sub => "sub", + Self::Super => "super", + Self::Vertical => "vertical", + Self::Wide => "wide", + } + } +} + +fn lookup_property(table: &[(u32, u32, T)], ch: char) -> Option { + let ch = ch as u32; + table + .binary_search_by(|&(start, end, _)| { + if ch > end { + Ordering::Less + } else if ch < start { + Ordering::Greater + } else { + Ordering::Equal + } + }) + .ok() + .map(|i| table[i].2) +} + +fn lookup_numeric_val(ch: char, modern: bool) -> Option { + if modern { + lookup_property(NUMERIC_VALUES, ch) + } else { + cold_path(); + lookup_property(NUMERIC_VALUES_DIFF, ch).or_else(|| { + NUMERIC_VAL_EXISTS_32 + .binary_search_by(|&(start, end)| { + let ch = ch as u32; + if ch > end { + Ordering::Less + } else if ch < start { + Ordering::Greater + } else { + Ordering::Equal + } + }) + .ok() + .and_then(|_| lookup_property(NUMERIC_VALUES, ch)) + }) + } +} + +/// The version string of the latest Unicode database bundled with the standard +/// library (`unicodedata.unidata_version`). +#[must_use] +pub fn unicode_version() -> String { + format!( + "{}.{}.{}", + char::UNICODE_VERSION.0, + char::UNICODE_VERSION.1, + char::UNICODE_VERSION.2 + ) +} + +/// Look up a character by its Unicode name (`unicodedata.lookup`). +pub use unicode_names2::character as lookup_character; + +/// The Unicode name of `ch` (`unicodedata.name`), if any. +#[must_use] +pub fn character_name(ch: char) -> Option { + unicode_names2::name(ch).map(|name| name.to_string()) +} + +/// A view over the Unicode character database at a fixed version. +/// +/// `modern` selects the latest bundled UCD; otherwise the Unicode 3.2.0 tables +/// used by `unicodedata.ucd_3_2_0` are consulted. +#[derive(Debug, Clone, Copy)] +pub struct Ucd { + modern: bool, +} + +impl Ucd { + #[must_use] + pub const fn new(modern: bool) -> Self { + Self { modern } + } + + #[must_use] + pub fn category(&self, c: CodePoint) -> &'static str { + let Some(c) = c.to_char() else { + return GeneralCategory::Surrogate.short_name(); + }; + if self.modern { + Some(GeneralCategory::for_char(c)) + } else { + cold_path(); + lookup_property(GENERAL_CATEGORY, c) + } + .unwrap_or(GeneralCategory::Unassigned) + .short_name() + } + + #[must_use] + pub fn bidirectional(&self, c: CodePoint) -> &'static str { + c.to_char() + .and_then(|c| { + if self.modern { + Some(BidiClass::for_char(c)) + } else { + cold_path(); + lookup_property(BIDI_CLASS, c) + } + }) + .unwrap_or(BidiClass::LeftToRight) + .short_name() + } + + #[must_use] + pub fn east_asian_width(&self, c: CodePoint) -> &'static str { + c.to_char() + .and_then(|c| { + if self.modern { + Some(EastAsianWidth::for_char(c)) + } else { + cold_path(); + // CPython overrides characters in the PUA for 3.2.0. + // Basic Multilingual Plane: + // https://en.wikipedia.org/wiki/Plane_(Unicode)#Basic_Multilingual_Plane + // https://en.wikipedia.org/wiki/Private_Use_Areas + // https://www.unicode.org/reports/tr11/tr11-10.html + // https://www.unicode.org/reports/tr11/ + // + // Currently, this implementation is incomplete because I can't figure + // out what CPython is doing. + lookup_property(EAST_ASIAN_WIDTH, c) + } + }) + .unwrap_or(EastAsianWidth::Neutral) + .short_name() + } + + #[must_use] + pub fn mirrored(&self, c: CodePoint) -> i32 { + c.to_char().map_or(0, |c| { + (if self.modern { + BidiMirrored::for_char(c) + } else { + cold_path(); + let c = c as u32; + BIDI_MIRRORED + .binary_search_by(|&(start, end)| { + if c > end { + Ordering::Less + } else if c < start { + Ordering::Greater + } else { + Ordering::Equal + } + }) + .is_ok() + }) as i32 + }) + } + + #[must_use] + pub fn combining(&self, c: CodePoint) -> u8 { + c.to_char() + .and_then(|c| { + if self.modern { + Some(CanonicalCombiningClass::for_char(c)) + } else { + cold_path(); + lookup_property(COMBINING_CLASS, c) + } + }) + .unwrap_or(CanonicalCombiningClass::NotReordered) + .to_icu4c_value() + } + + #[must_use] + pub fn decomposition(&self, c: CodePoint) -> String { + let Some(ch) = c.to_char() else { + return String::new(); + }; + + // Decomposition is remarkable stable according to the normalization file, + // so the updates slice is very small - only about four char pairs. Linearly searching + // it is very fast. The file lists the original, incorrect decomp and the fixed char. + // For 3.2.0, we use the original decomp for compatibility while ignoring the update. + // + // Finally, we don't have to do anything for the latest UCD as it's already updated. + if self.modern + && let Some((_, original)) = DECOMP_UPDATES + .iter() + .find(|&&(codep, _original)| codep == ch as u32) + { + format!("{original:04X}") + } else if let Ok(i) = + DECOMP_COMPAT.binary_search_by_key(&(ch as u32), |&(codep, _, _)| codep) + { + // Compatibility decomposition + // `icu4x` doesn't expose a non-recursive, compatibility decomposer so we + // have to do it manually for now. + let tag = DECOMP_COMPAT[i].1.type_tag(); + let end = DECOMP_COMPAT[i].2; + let start = i + .checked_sub(1) + .map(|i| DECOMP_COMPAT[i].2) + .unwrap_or_default(); + + let decomp = &DECOMP_RANGE[start..end]; + let cap = decomp.len() * 10 + decomp.len() + tag.len() + 1; + let mut out = String::with_capacity(cap); + + write!(out, "<{tag}>").unwrap(); + for ch in decomp { + write!(out, " {ch:04X}").unwrap(); + } + + out + } else { + // Canonical decomposition + let decomposed = CanonicalDecomposition::new().decompose(ch); + match decomposed { + Decomposed::Default => String::new(), + Decomposed::Singleton(ch) => format!("{:04X}", ch as u32), + Decomposed::Expansion(l, r) => format!("{:04X} {:04X}", l as u32, r as u32), + } + } + } + + fn numeric_type_matches(self, ch: CodePoint, expected: &[NumericType]) -> Option { + let ch = ch.to_char()?; + + let actual = if self.modern { + NumericType::for_char(ch) + } else { + cold_path(); + lookup_property(NUMERIC_TYPE_DIFF, ch).unwrap_or_else(|| NumericType::for_char(ch)) + }; + + expected.contains(&actual).then_some(ch) + } + + /// The integer digit value of `c` (`unicodedata.digit`), if it has one. + #[must_use] + pub fn digit(&self, c: CodePoint) -> Option { + let expected = [NumericType::Decimal, NumericType::Digit]; + self.numeric_type_matches(c, &expected).and_then(|ch| { + let value = lookup_numeric_val(ch, true)?; + let int = value as u64; + (int as f64 == value).then_some(int) + }) + } + + /// The integer decimal value of `c` (`unicodedata.decimal`), if it has one. + #[must_use] + pub fn decimal(&self, c: CodePoint) -> Option { + let expected = [NumericType::Decimal]; + self.numeric_type_matches(c, &expected).and_then(|ch| { + let value = lookup_numeric_val(ch, self.modern)?; + let int = value as u64; + (int as f64 == value).then_some(int) + }) + } + + /// The numeric value of `c` (`unicodedata.numeric`), if it has one. + #[must_use] + pub fn numeric(&self, c: CodePoint) -> Option { + let expected = &NumericType::ALL_VALUES[1..]; + self.numeric_type_matches(c, expected) + .and_then(|ch| lookup_numeric_val(ch, self.modern)) + } + + #[must_use] + pub fn unidata_version(&self) -> String { + if self.modern { + unicode_version() + } else { + "3.2.0".into() + } + } +} + +#[cfg(test)] +mod tests { + use rustpython_wtf8::CodePoint; + + use super::{Ucd, character_name, lookup_character}; + + fn cp(ch: char) -> CodePoint { + CodePoint::from(ch) + } + + #[test] + fn data_queries_match_unicodedata_behavior() { + let ucd = Ucd::new(true); + assert_eq!(ucd.category(cp('A')), "Lu"); + assert_eq!(ucd.category(CodePoint::from_u32(0xD800).unwrap()), "Cs"); + assert_eq!(lookup_character("SNOWMAN"), Some('☃')); + assert_eq!(character_name('☃').as_deref(), Some("SNOWMAN")); + assert_eq!(ucd.decimal(cp('५')), Some(5)); + assert_eq!(ucd.digit(cp('²')), Some(2)); + let third = ucd.numeric(cp('⅓')).unwrap(); + assert!((third - 1.0 / 3.0).abs() < 1e-6, "got {third}"); + } + + #[test] + fn ucd_3_2_0_view_differs_from_modern() { + let legacy = Ucd::new(false); + assert_eq!(legacy.unidata_version(), "3.2.0"); + } +} diff --git a/crates/unicode/src/identifier.rs b/crates/unicode/src/identifier.rs new file mode 100644 index 00000000000..413c722feb0 --- /dev/null +++ b/crates/unicode/src/identifier.rs @@ -0,0 +1,37 @@ +//! Python identifier predicates (`str.isidentifier`). + +use icu_properties::props::{BinaryProperty, XidContinue, XidStart}; + +/// Whether `c` has the `XID_Start` property. +#[must_use] +pub fn is_xid_start(c: char) -> bool { + XidStart::for_char(c) +} + +/// Whether `c` has the `XID_Continue` property. +#[must_use] +pub fn is_xid_continue(c: char) -> bool { + XidContinue::for_char(c) +} + +/// Whether `c` may start a Python identifier: `_` or `XID_Start`. +#[must_use] +pub fn is_start(c: char) -> bool { + c == '_' || is_xid_start(c) +} + +/// Whether `c` may continue a Python identifier: `XID_Continue`. +pub use is_xid_continue as is_continue; + +#[cfg(test)] +mod tests { + use super::{is_continue, is_start}; + + #[test] + fn identifier_predicates() { + assert!(is_start('_')); + assert!(is_start('가')); + assert!(!is_start('1')); + assert!(is_continue('1')); + } +} diff --git a/crates/unicode/src/lib.rs b/crates/unicode/src/lib.rs new file mode 100644 index 00000000000..a3f3eceb1c7 --- /dev/null +++ b/crates/unicode/src/lib.rs @@ -0,0 +1,19 @@ +//! Runtime-independent CPython-compatible Unicode semantics and data. +//! +//! Every entry point operates on plain `char`/`u32`/`CodePoint`/`&Wtf8` values +//! so it can be shared by any Python runtime; argument extraction and Python +//! exception mapping stay with the caller. There is no global mutable state and +//! results depend only on inputs. + +#![no_std] + +extern crate alloc; + +pub mod case; +pub mod classify; +pub mod data; +pub mod identifier; +pub mod normalize; + +pub use data::{Ucd, character_name, lookup_character, unicode_version}; +pub use normalize::{NormalizeForm, is_normalized, normalize}; diff --git a/crates/unicode/src/normalize.rs b/crates/unicode/src/normalize.rs new file mode 100644 index 00000000000..e2f80d02439 --- /dev/null +++ b/crates/unicode/src/normalize.rs @@ -0,0 +1,111 @@ +//! Unicode normalization (`unicodedata.normalize` / `is_normalized`). + +// spell-checker:ignore nfkc + +use core::str::FromStr; + +use icu_normalizer::{ComposingNormalizerBorrowed, DecomposingNormalizerBorrowed}; +use rustpython_wtf8::{Wtf8, Wtf8Buf, Wtf8Chunk}; + +/// One of the four Unicode normalization forms. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum NormalizeForm { + Nfc, + Nfkc, + Nfd, + Nfkd, +} + +impl FromStr for NormalizeForm { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "NFC" => Ok(Self::Nfc), + "NFKC" => Ok(Self::Nfkc), + "NFD" => Ok(Self::Nfd), + "NFKD" => Ok(Self::Nfkd), + _ => Err(()), + } + } +} + +/// Normalize `text` to `form` (`unicodedata.normalize`). +/// +/// Lone surrogates are passed through unchanged; only the valid UTF-8 runs are +/// normalized. +#[must_use] +pub fn normalize(form: NormalizeForm, text: &Wtf8) -> Wtf8Buf { + match form { + NormalizeForm::Nfc => { + let normalizer = ComposingNormalizerBorrowed::new_nfc(); + text.map_utf8(|s| normalizer.normalize_iter(s.chars())) + .collect() + } + NormalizeForm::Nfkc => { + let normalizer = ComposingNormalizerBorrowed::new_nfkc(); + text.map_utf8(|s| normalizer.normalize_iter(s.chars())) + .collect() + } + NormalizeForm::Nfd => { + let normalizer = DecomposingNormalizerBorrowed::new_nfd(); + text.map_utf8(|s| normalizer.normalize_iter(s.chars())) + .collect() + } + NormalizeForm::Nfkd => { + let normalizer = DecomposingNormalizerBorrowed::new_nfkd(); + text.map_utf8(|s| normalizer.normalize_iter(s.chars())) + .collect() + } + } +} + +/// Whether `text` is already in `form` (`unicodedata.is_normalized`). +/// +/// Lone surrogates split the text into valid UTF-8 runs; each run is checked +/// independently, matching the run-wise normalization performed by [`normalize`]. +#[must_use] +pub fn is_normalized(form: NormalizeForm, text: &Wtf8) -> bool { + let check: fn(&str) -> bool = match form { + NormalizeForm::Nfc => |s| ComposingNormalizerBorrowed::new_nfc().is_normalized(s), + NormalizeForm::Nfkc => |s| ComposingNormalizerBorrowed::new_nfkc().is_normalized(s), + NormalizeForm::Nfd => |s| DecomposingNormalizerBorrowed::new_nfd().is_normalized(s), + NormalizeForm::Nfkd => |s| DecomposingNormalizerBorrowed::new_nfkd().is_normalized(s), + }; + text.chunks().all(|chunk| match chunk { + Wtf8Chunk::Utf8(s) => check(s), + Wtf8Chunk::Surrogate(_) => true, + }) +} + +#[cfg(test)] +mod tests { + use rustpython_wtf8::{CodePoint, Wtf8Buf}; + + use super::{NormalizeForm, is_normalized, normalize}; + + #[test] + fn normalization_round_trips() { + let composed = Wtf8Buf::from("é"); + let decomposed = normalize(NormalizeForm::Nfd, &composed); + assert_eq!(normalize(NormalizeForm::Nfc, &decomposed), composed); + assert!(is_normalized( + NormalizeForm::Nfc, + Wtf8Buf::from("é").as_ref() + )); + assert!(!is_normalized( + NormalizeForm::Nfd, + Wtf8Buf::from("é").as_ref() + )); + } + + #[test] + fn is_normalized_skips_lone_surrogates() { + // A lone surrogate splits the text into UTF-8 runs; each run is checked + // independently, so a surrogate next to normalized text stays normalized. + let mut buf = Wtf8Buf::from("é"); + buf.push(CodePoint::from_u32(0xD800).unwrap()); + assert!(is_normalized(NormalizeForm::Nfc, &buf)); + assert!(!is_normalized(NormalizeForm::Nfd, &buf)); + } +} diff --git a/crates/unicode/tests/data/cpython3.14_predicates.txt b/crates/unicode/tests/data/cpython3.14_predicates.txt new file mode 100644 index 00000000000..9339f4e4744 --- /dev/null +++ b/crates/unicode/tests/data/cpython3.14_predicates.txt @@ -0,0 +1,9 @@ +# unidata_version 16.0.0 +isalpha 41:5A,61:7A,AA:AA,B5:B5,BA:BA,C0:D6,D8:F6,F8:2C1,2C6:2D1,2E0:2E4,2EC:2EC,2EE:2EE,370:374,376:377,37A:37D,37F:37F,386:386,388:38A,38C:38C,38E:3A1,3A3:3F5,3F7:481,48A:52F,531:556,559:559,560:588,5D0:5EA,5EF:5F2,620:64A,66E:66F,671:6D3,6D5:6D5,6E5:6E6,6EE:6EF,6FA:6FC,6FF:6FF,710:710,712:72F,74D:7A5,7B1:7B1,7CA:7EA,7F4:7F5,7FA:7FA,800:815,81A:81A,824:824,828:828,840:858,860:86A,870:887,889:88E,8A0:8C9,904:939,93D:93D,950:950,958:961,971:980,985:98C,98F:990,993:9A8,9AA:9B0,9B2:9B2,9B6:9B9,9BD:9BD,9CE:9CE,9DC:9DD,9DF:9E1,9F0:9F1,9FC:9FC,A05:A0A,A0F:A10,A13:A28,A2A:A30,A32:A33,A35:A36,A38:A39,A59:A5C,A5E:A5E,A72:A74,A85:A8D,A8F:A91,A93:AA8,AAA:AB0,AB2:AB3,AB5:AB9,ABD:ABD,AD0:AD0,AE0:AE1,AF9:AF9,B05:B0C,B0F:B10,B13:B28,B2A:B30,B32:B33,B35:B39,B3D:B3D,B5C:B5D,B5F:B61,B71:B71,B83:B83,B85:B8A,B8E:B90,B92:B95,B99:B9A,B9C:B9C,B9E:B9F,BA3:BA4,BA8:BAA,BAE:BB9,BD0:BD0,C05:C0C,C0E:C10,C12:C28,C2A:C39,C3D:C3D,C58:C5A,C5D:C5D,C60:C61,C80:C80,C85:C8C,C8E:C90,C92:CA8,CAA:CB3,CB5:CB9,CBD:CBD,CDD:CDE,CE0:CE1,CF1:CF2,D04:D0C,D0E:D10,D12:D3A,D3D:D3D,D4E:D4E,D54:D56,D5F:D61,D7A:D7F,D85:D96,D9A:DB1,DB3:DBB,DBD:DBD,DC0:DC6,E01:E30,E32:E33,E40:E46,E81:E82,E84:E84,E86:E8A,E8C:EA3,EA5:EA5,EA7:EB0,EB2:EB3,EBD:EBD,EC0:EC4,EC6:EC6,EDC:EDF,F00:F00,F40:F47,F49:F6C,F88:F8C,1000:102A,103F:103F,1050:1055,105A:105D,1061:1061,1065:1066,106E:1070,1075:1081,108E:108E,10A0:10C5,10C7:10C7,10CD:10CD,10D0:10FA,10FC:1248,124A:124D,1250:1256,1258:1258,125A:125D,1260:1288,128A:128D,1290:12B0,12B2:12B5,12B8:12BE,12C0:12C0,12C2:12C5,12C8:12D6,12D8:1310,1312:1315,1318:135A,1380:138F,13A0:13F5,13F8:13FD,1401:166C,166F:167F,1681:169A,16A0:16EA,16F1:16F8,1700:1711,171F:1731,1740:1751,1760:176C,176E:1770,1780:17B3,17D7:17D7,17DC:17DC,1820:1878,1880:1884,1887:18A8,18AA:18AA,18B0:18F5,1900:191E,1950:196D,1970:1974,1980:19AB,19B0:19C9,1A00:1A16,1A20:1A54,1AA7:1AA7,1B05:1B33,1B45:1B4C,1B83:1BA0,1BAE:1BAF,1BBA:1BE5,1C00:1C23,1C4D:1C4F,1C5A:1C7D,1C80:1C8A,1C90:1CBA,1CBD:1CBF,1CE9:1CEC,1CEE:1CF3,1CF5:1CF6,1CFA:1CFA,1D00:1DBF,1E00:1F15,1F18:1F1D,1F20:1F45,1F48:1F4D,1F50:1F57,1F59:1F59,1F5B:1F5B,1F5D:1F5D,1F5F:1F7D,1F80:1FB4,1FB6:1FBC,1FBE:1FBE,1FC2:1FC4,1FC6:1FCC,1FD0:1FD3,1FD6:1FDB,1FE0:1FEC,1FF2:1FF4,1FF6:1FFC,2071:2071,207F:207F,2090:209C,2102:2102,2107:2107,210A:2113,2115:2115,2119:211D,2124:2124,2126:2126,2128:2128,212A:212D,212F:2139,213C:213F,2145:2149,214E:214E,2183:2184,2C00:2CE4,2CEB:2CEE,2CF2:2CF3,2D00:2D25,2D27:2D27,2D2D:2D2D,2D30:2D67,2D6F:2D6F,2D80:2D96,2DA0:2DA6,2DA8:2DAE,2DB0:2DB6,2DB8:2DBE,2DC0:2DC6,2DC8:2DCE,2DD0:2DD6,2DD8:2DDE,2E2F:2E2F,3005:3006,3031:3035,303B:303C,3041:3096,309D:309F,30A1:30FA,30FC:30FF,3105:312F,3131:318E,31A0:31BF,31F0:31FF,3400:4DBF,4E00:A48C,A4D0:A4FD,A500:A60C,A610:A61F,A62A:A62B,A640:A66E,A67F:A69D,A6A0:A6E5,A717:A71F,A722:A788,A78B:A7CD,A7D0:A7D1,A7D3:A7D3,A7D5:A7DC,A7F2:A801,A803:A805,A807:A80A,A80C:A822,A840:A873,A882:A8B3,A8F2:A8F7,A8FB:A8FB,A8FD:A8FE,A90A:A925,A930:A946,A960:A97C,A984:A9B2,A9CF:A9CF,A9E0:A9E4,A9E6:A9EF,A9FA:A9FE,AA00:AA28,AA40:AA42,AA44:AA4B,AA60:AA76,AA7A:AA7A,AA7E:AAAF,AAB1:AAB1,AAB5:AAB6,AAB9:AABD,AAC0:AAC0,AAC2:AAC2,AADB:AADD,AAE0:AAEA,AAF2:AAF4,AB01:AB06,AB09:AB0E,AB11:AB16,AB20:AB26,AB28:AB2E,AB30:AB5A,AB5C:AB69,AB70:ABE2,AC00:D7A3,D7B0:D7C6,D7CB:D7FB,F900:FA6D,FA70:FAD9,FB00:FB06,FB13:FB17,FB1D:FB1D,FB1F:FB28,FB2A:FB36,FB38:FB3C,FB3E:FB3E,FB40:FB41,FB43:FB44,FB46:FBB1,FBD3:FD3D,FD50:FD8F,FD92:FDC7,FDF0:FDFB,FE70:FE74,FE76:FEFC,FF21:FF3A,FF41:FF5A,FF66:FFBE,FFC2:FFC7,FFCA:FFCF,FFD2:FFD7,FFDA:FFDC,10000:1000B,1000D:10026,10028:1003A,1003C:1003D,1003F:1004D,10050:1005D,10080:100FA,10280:1029C,102A0:102D0,10300:1031F,1032D:10340,10342:10349,10350:10375,10380:1039D,103A0:103C3,103C8:103CF,10400:1049D,104B0:104D3,104D8:104FB,10500:10527,10530:10563,10570:1057A,1057C:1058A,1058C:10592,10594:10595,10597:105A1,105A3:105B1,105B3:105B9,105BB:105BC,105C0:105F3,10600:10736,10740:10755,10760:10767,10780:10785,10787:107B0,107B2:107BA,10800:10805,10808:10808,1080A:10835,10837:10838,1083C:1083C,1083F:10855,10860:10876,10880:1089E,108E0:108F2,108F4:108F5,10900:10915,10920:10939,10980:109B7,109BE:109BF,10A00:10A00,10A10:10A13,10A15:10A17,10A19:10A35,10A60:10A7C,10A80:10A9C,10AC0:10AC7,10AC9:10AE4,10B00:10B35,10B40:10B55,10B60:10B72,10B80:10B91,10C00:10C48,10C80:10CB2,10CC0:10CF2,10D00:10D23,10D4A:10D65,10D6F:10D85,10E80:10EA9,10EB0:10EB1,10EC2:10EC4,10F00:10F1C,10F27:10F27,10F30:10F45,10F70:10F81,10FB0:10FC4,10FE0:10FF6,11003:11037,11071:11072,11075:11075,11083:110AF,110D0:110E8,11103:11126,11144:11144,11147:11147,11150:11172,11176:11176,11183:111B2,111C1:111C4,111DA:111DA,111DC:111DC,11200:11211,11213:1122B,1123F:11240,11280:11286,11288:11288,1128A:1128D,1128F:1129D,1129F:112A8,112B0:112DE,11305:1130C,1130F:11310,11313:11328,1132A:11330,11332:11333,11335:11339,1133D:1133D,11350:11350,1135D:11361,11380:11389,1138B:1138B,1138E:1138E,11390:113B5,113B7:113B7,113D1:113D1,113D3:113D3,11400:11434,11447:1144A,1145F:11461,11480:114AF,114C4:114C5,114C7:114C7,11580:115AE,115D8:115DB,11600:1162F,11644:11644,11680:116AA,116B8:116B8,11700:1171A,11740:11746,11800:1182B,118A0:118DF,118FF:11906,11909:11909,1190C:11913,11915:11916,11918:1192F,1193F:1193F,11941:11941,119A0:119A7,119AA:119D0,119E1:119E1,119E3:119E3,11A00:11A00,11A0B:11A32,11A3A:11A3A,11A50:11A50,11A5C:11A89,11A9D:11A9D,11AB0:11AF8,11BC0:11BE0,11C00:11C08,11C0A:11C2E,11C40:11C40,11C72:11C8F,11D00:11D06,11D08:11D09,11D0B:11D30,11D46:11D46,11D60:11D65,11D67:11D68,11D6A:11D89,11D98:11D98,11EE0:11EF2,11F02:11F02,11F04:11F10,11F12:11F33,11FB0:11FB0,12000:12399,12480:12543,12F90:12FF0,13000:1342F,13441:13446,13460:143FA,14400:14646,16100:1611D,16800:16A38,16A40:16A5E,16A70:16ABE,16AD0:16AED,16B00:16B2F,16B40:16B43,16B63:16B77,16B7D:16B8F,16D40:16D6C,16E40:16E7F,16F00:16F4A,16F50:16F50,16F93:16F9F,16FE0:16FE1,16FE3:16FE3,17000:187F7,18800:18CD5,18CFF:18D08,1AFF0:1AFF3,1AFF5:1AFFB,1AFFD:1AFFE,1B000:1B122,1B132:1B132,1B150:1B152,1B155:1B155,1B164:1B167,1B170:1B2FB,1BC00:1BC6A,1BC70:1BC7C,1BC80:1BC88,1BC90:1BC99,1D400:1D454,1D456:1D49C,1D49E:1D49F,1D4A2:1D4A2,1D4A5:1D4A6,1D4A9:1D4AC,1D4AE:1D4B9,1D4BB:1D4BB,1D4BD:1D4C3,1D4C5:1D505,1D507:1D50A,1D50D:1D514,1D516:1D51C,1D51E:1D539,1D53B:1D53E,1D540:1D544,1D546:1D546,1D54A:1D550,1D552:1D6A5,1D6A8:1D6C0,1D6C2:1D6DA,1D6DC:1D6FA,1D6FC:1D714,1D716:1D734,1D736:1D74E,1D750:1D76E,1D770:1D788,1D78A:1D7A8,1D7AA:1D7C2,1D7C4:1D7CB,1DF00:1DF1E,1DF25:1DF2A,1E030:1E06D,1E100:1E12C,1E137:1E13D,1E14E:1E14E,1E290:1E2AD,1E2C0:1E2EB,1E4D0:1E4EB,1E5D0:1E5ED,1E5F0:1E5F0,1E7E0:1E7E6,1E7E8:1E7EB,1E7ED:1E7EE,1E7F0:1E7FE,1E800:1E8C4,1E900:1E943,1E94B:1E94B,1EE00:1EE03,1EE05:1EE1F,1EE21:1EE22,1EE24:1EE24,1EE27:1EE27,1EE29:1EE32,1EE34:1EE37,1EE39:1EE39,1EE3B:1EE3B,1EE42:1EE42,1EE47:1EE47,1EE49:1EE49,1EE4B:1EE4B,1EE4D:1EE4F,1EE51:1EE52,1EE54:1EE54,1EE57:1EE57,1EE59:1EE59,1EE5B:1EE5B,1EE5D:1EE5D,1EE5F:1EE5F,1EE61:1EE62,1EE64:1EE64,1EE67:1EE6A,1EE6C:1EE72,1EE74:1EE77,1EE79:1EE7C,1EE7E:1EE7E,1EE80:1EE89,1EE8B:1EE9B,1EEA1:1EEA3,1EEA5:1EEA9,1EEAB:1EEBB,20000:2A6DF,2A700:2B739,2B740:2B81D,2B820:2CEA1,2CEB0:2EBE0,2EBF0:2EE5D,2F800:2FA1D,30000:3134A,31350:323AF +isalnum 30:39,41:5A,61:7A,AA:AA,B2:B3,B5:B5,B9:BA,BC:BE,C0:D6,D8:F6,F8:2C1,2C6:2D1,2E0:2E4,2EC:2EC,2EE:2EE,370:374,376:377,37A:37D,37F:37F,386:386,388:38A,38C:38C,38E:3A1,3A3:3F5,3F7:481,48A:52F,531:556,559:559,560:588,5D0:5EA,5EF:5F2,620:64A,660:669,66E:66F,671:6D3,6D5:6D5,6E5:6E6,6EE:6FC,6FF:6FF,710:710,712:72F,74D:7A5,7B1:7B1,7C0:7EA,7F4:7F5,7FA:7FA,800:815,81A:81A,824:824,828:828,840:858,860:86A,870:887,889:88E,8A0:8C9,904:939,93D:93D,950:950,958:961,966:96F,971:980,985:98C,98F:990,993:9A8,9AA:9B0,9B2:9B2,9B6:9B9,9BD:9BD,9CE:9CE,9DC:9DD,9DF:9E1,9E6:9F1,9F4:9F9,9FC:9FC,A05:A0A,A0F:A10,A13:A28,A2A:A30,A32:A33,A35:A36,A38:A39,A59:A5C,A5E:A5E,A66:A6F,A72:A74,A85:A8D,A8F:A91,A93:AA8,AAA:AB0,AB2:AB3,AB5:AB9,ABD:ABD,AD0:AD0,AE0:AE1,AE6:AEF,AF9:AF9,B05:B0C,B0F:B10,B13:B28,B2A:B30,B32:B33,B35:B39,B3D:B3D,B5C:B5D,B5F:B61,B66:B6F,B71:B77,B83:B83,B85:B8A,B8E:B90,B92:B95,B99:B9A,B9C:B9C,B9E:B9F,BA3:BA4,BA8:BAA,BAE:BB9,BD0:BD0,BE6:BF2,C05:C0C,C0E:C10,C12:C28,C2A:C39,C3D:C3D,C58:C5A,C5D:C5D,C60:C61,C66:C6F,C78:C7E,C80:C80,C85:C8C,C8E:C90,C92:CA8,CAA:CB3,CB5:CB9,CBD:CBD,CDD:CDE,CE0:CE1,CE6:CEF,CF1:CF2,D04:D0C,D0E:D10,D12:D3A,D3D:D3D,D4E:D4E,D54:D56,D58:D61,D66:D78,D7A:D7F,D85:D96,D9A:DB1,DB3:DBB,DBD:DBD,DC0:DC6,DE6:DEF,E01:E30,E32:E33,E40:E46,E50:E59,E81:E82,E84:E84,E86:E8A,E8C:EA3,EA5:EA5,EA7:EB0,EB2:EB3,EBD:EBD,EC0:EC4,EC6:EC6,ED0:ED9,EDC:EDF,F00:F00,F20:F33,F40:F47,F49:F6C,F88:F8C,1000:102A,103F:1049,1050:1055,105A:105D,1061:1061,1065:1066,106E:1070,1075:1081,108E:108E,1090:1099,10A0:10C5,10C7:10C7,10CD:10CD,10D0:10FA,10FC:1248,124A:124D,1250:1256,1258:1258,125A:125D,1260:1288,128A:128D,1290:12B0,12B2:12B5,12B8:12BE,12C0:12C0,12C2:12C5,12C8:12D6,12D8:1310,1312:1315,1318:135A,1369:137C,1380:138F,13A0:13F5,13F8:13FD,1401:166C,166F:167F,1681:169A,16A0:16EA,16EE:16F8,1700:1711,171F:1731,1740:1751,1760:176C,176E:1770,1780:17B3,17D7:17D7,17DC:17DC,17E0:17E9,17F0:17F9,1810:1819,1820:1878,1880:1884,1887:18A8,18AA:18AA,18B0:18F5,1900:191E,1946:196D,1970:1974,1980:19AB,19B0:19C9,19D0:19DA,1A00:1A16,1A20:1A54,1A80:1A89,1A90:1A99,1AA7:1AA7,1B05:1B33,1B45:1B4C,1B50:1B59,1B83:1BA0,1BAE:1BE5,1C00:1C23,1C40:1C49,1C4D:1C7D,1C80:1C8A,1C90:1CBA,1CBD:1CBF,1CE9:1CEC,1CEE:1CF3,1CF5:1CF6,1CFA:1CFA,1D00:1DBF,1E00:1F15,1F18:1F1D,1F20:1F45,1F48:1F4D,1F50:1F57,1F59:1F59,1F5B:1F5B,1F5D:1F5D,1F5F:1F7D,1F80:1FB4,1FB6:1FBC,1FBE:1FBE,1FC2:1FC4,1FC6:1FCC,1FD0:1FD3,1FD6:1FDB,1FE0:1FEC,1FF2:1FF4,1FF6:1FFC,2070:2071,2074:2079,207F:2089,2090:209C,2102:2102,2107:2107,210A:2113,2115:2115,2119:211D,2124:2124,2126:2126,2128:2128,212A:212D,212F:2139,213C:213F,2145:2149,214E:214E,2150:2189,2460:249B,24EA:24FF,2776:2793,2C00:2CE4,2CEB:2CEE,2CF2:2CF3,2CFD:2CFD,2D00:2D25,2D27:2D27,2D2D:2D2D,2D30:2D67,2D6F:2D6F,2D80:2D96,2DA0:2DA6,2DA8:2DAE,2DB0:2DB6,2DB8:2DBE,2DC0:2DC6,2DC8:2DCE,2DD0:2DD6,2DD8:2DDE,2E2F:2E2F,3005:3007,3021:3029,3031:3035,3038:303C,3041:3096,309D:309F,30A1:30FA,30FC:30FF,3105:312F,3131:318E,3192:3195,31A0:31BF,31F0:31FF,3220:3229,3248:324F,3251:325F,3280:3289,32B1:32BF,3400:4DBF,4E00:A48C,A4D0:A4FD,A500:A60C,A610:A62B,A640:A66E,A67F:A69D,A6A0:A6EF,A717:A71F,A722:A788,A78B:A7CD,A7D0:A7D1,A7D3:A7D3,A7D5:A7DC,A7F2:A801,A803:A805,A807:A80A,A80C:A822,A830:A835,A840:A873,A882:A8B3,A8D0:A8D9,A8F2:A8F7,A8FB:A8FB,A8FD:A8FE,A900:A925,A930:A946,A960:A97C,A984:A9B2,A9CF:A9D9,A9E0:A9E4,A9E6:A9FE,AA00:AA28,AA40:AA42,AA44:AA4B,AA50:AA59,AA60:AA76,AA7A:AA7A,AA7E:AAAF,AAB1:AAB1,AAB5:AAB6,AAB9:AABD,AAC0:AAC0,AAC2:AAC2,AADB:AADD,AAE0:AAEA,AAF2:AAF4,AB01:AB06,AB09:AB0E,AB11:AB16,AB20:AB26,AB28:AB2E,AB30:AB5A,AB5C:AB69,AB70:ABE2,ABF0:ABF9,AC00:D7A3,D7B0:D7C6,D7CB:D7FB,F900:FA6D,FA70:FAD9,FB00:FB06,FB13:FB17,FB1D:FB1D,FB1F:FB28,FB2A:FB36,FB38:FB3C,FB3E:FB3E,FB40:FB41,FB43:FB44,FB46:FBB1,FBD3:FD3D,FD50:FD8F,FD92:FDC7,FDF0:FDFB,FE70:FE74,FE76:FEFC,FF10:FF19,FF21:FF3A,FF41:FF5A,FF66:FFBE,FFC2:FFC7,FFCA:FFCF,FFD2:FFD7,FFDA:FFDC,10000:1000B,1000D:10026,10028:1003A,1003C:1003D,1003F:1004D,10050:1005D,10080:100FA,10107:10133,10140:10178,1018A:1018B,10280:1029C,102A0:102D0,102E1:102FB,10300:10323,1032D:1034A,10350:10375,10380:1039D,103A0:103C3,103C8:103CF,103D1:103D5,10400:1049D,104A0:104A9,104B0:104D3,104D8:104FB,10500:10527,10530:10563,10570:1057A,1057C:1058A,1058C:10592,10594:10595,10597:105A1,105A3:105B1,105B3:105B9,105BB:105BC,105C0:105F3,10600:10736,10740:10755,10760:10767,10780:10785,10787:107B0,107B2:107BA,10800:10805,10808:10808,1080A:10835,10837:10838,1083C:1083C,1083F:10855,10858:10876,10879:1089E,108A7:108AF,108E0:108F2,108F4:108F5,108FB:1091B,10920:10939,10980:109B7,109BC:109CF,109D2:10A00,10A10:10A13,10A15:10A17,10A19:10A35,10A40:10A48,10A60:10A7E,10A80:10A9F,10AC0:10AC7,10AC9:10AE4,10AEB:10AEF,10B00:10B35,10B40:10B55,10B58:10B72,10B78:10B91,10BA9:10BAF,10C00:10C48,10C80:10CB2,10CC0:10CF2,10CFA:10D23,10D30:10D39,10D40:10D65,10D6F:10D85,10E60:10E7E,10E80:10EA9,10EB0:10EB1,10EC2:10EC4,10F00:10F27,10F30:10F45,10F51:10F54,10F70:10F81,10FB0:10FCB,10FE0:10FF6,11003:11037,11052:1106F,11071:11072,11075:11075,11083:110AF,110D0:110E8,110F0:110F9,11103:11126,11136:1113F,11144:11144,11147:11147,11150:11172,11176:11176,11183:111B2,111C1:111C4,111D0:111DA,111DC:111DC,111E1:111F4,11200:11211,11213:1122B,1123F:11240,11280:11286,11288:11288,1128A:1128D,1128F:1129D,1129F:112A8,112B0:112DE,112F0:112F9,11305:1130C,1130F:11310,11313:11328,1132A:11330,11332:11333,11335:11339,1133D:1133D,11350:11350,1135D:11361,11380:11389,1138B:1138B,1138E:1138E,11390:113B5,113B7:113B7,113D1:113D1,113D3:113D3,11400:11434,11447:1144A,11450:11459,1145F:11461,11480:114AF,114C4:114C5,114C7:114C7,114D0:114D9,11580:115AE,115D8:115DB,11600:1162F,11644:11644,11650:11659,11680:116AA,116B8:116B8,116C0:116C9,116D0:116E3,11700:1171A,11730:1173B,11740:11746,11800:1182B,118A0:118F2,118FF:11906,11909:11909,1190C:11913,11915:11916,11918:1192F,1193F:1193F,11941:11941,11950:11959,119A0:119A7,119AA:119D0,119E1:119E1,119E3:119E3,11A00:11A00,11A0B:11A32,11A3A:11A3A,11A50:11A50,11A5C:11A89,11A9D:11A9D,11AB0:11AF8,11BC0:11BE0,11BF0:11BF9,11C00:11C08,11C0A:11C2E,11C40:11C40,11C50:11C6C,11C72:11C8F,11D00:11D06,11D08:11D09,11D0B:11D30,11D46:11D46,11D50:11D59,11D60:11D65,11D67:11D68,11D6A:11D89,11D98:11D98,11DA0:11DA9,11EE0:11EF2,11F02:11F02,11F04:11F10,11F12:11F33,11F50:11F59,11FB0:11FB0,11FC0:11FD4,12000:12399,12400:1246E,12480:12543,12F90:12FF0,13000:1342F,13441:13446,13460:143FA,14400:14646,16100:1611D,16130:16139,16800:16A38,16A40:16A5E,16A60:16A69,16A70:16ABE,16AC0:16AC9,16AD0:16AED,16B00:16B2F,16B40:16B43,16B50:16B59,16B5B:16B61,16B63:16B77,16B7D:16B8F,16D40:16D6C,16D70:16D79,16E40:16E96,16F00:16F4A,16F50:16F50,16F93:16F9F,16FE0:16FE1,16FE3:16FE3,17000:187F7,18800:18CD5,18CFF:18D08,1AFF0:1AFF3,1AFF5:1AFFB,1AFFD:1AFFE,1B000:1B122,1B132:1B132,1B150:1B152,1B155:1B155,1B164:1B167,1B170:1B2FB,1BC00:1BC6A,1BC70:1BC7C,1BC80:1BC88,1BC90:1BC99,1CCF0:1CCF9,1D2C0:1D2D3,1D2E0:1D2F3,1D360:1D378,1D400:1D454,1D456:1D49C,1D49E:1D49F,1D4A2:1D4A2,1D4A5:1D4A6,1D4A9:1D4AC,1D4AE:1D4B9,1D4BB:1D4BB,1D4BD:1D4C3,1D4C5:1D505,1D507:1D50A,1D50D:1D514,1D516:1D51C,1D51E:1D539,1D53B:1D53E,1D540:1D544,1D546:1D546,1D54A:1D550,1D552:1D6A5,1D6A8:1D6C0,1D6C2:1D6DA,1D6DC:1D6FA,1D6FC:1D714,1D716:1D734,1D736:1D74E,1D750:1D76E,1D770:1D788,1D78A:1D7A8,1D7AA:1D7C2,1D7C4:1D7CB,1D7CE:1D7FF,1DF00:1DF1E,1DF25:1DF2A,1E030:1E06D,1E100:1E12C,1E137:1E13D,1E140:1E149,1E14E:1E14E,1E290:1E2AD,1E2C0:1E2EB,1E2F0:1E2F9,1E4D0:1E4EB,1E4F0:1E4F9,1E5D0:1E5ED,1E5F0:1E5FA,1E7E0:1E7E6,1E7E8:1E7EB,1E7ED:1E7EE,1E7F0:1E7FE,1E800:1E8C4,1E8C7:1E8CF,1E900:1E943,1E94B:1E94B,1E950:1E959,1EC71:1ECAB,1ECAD:1ECAF,1ECB1:1ECB4,1ED01:1ED2D,1ED2F:1ED3D,1EE00:1EE03,1EE05:1EE1F,1EE21:1EE22,1EE24:1EE24,1EE27:1EE27,1EE29:1EE32,1EE34:1EE37,1EE39:1EE39,1EE3B:1EE3B,1EE42:1EE42,1EE47:1EE47,1EE49:1EE49,1EE4B:1EE4B,1EE4D:1EE4F,1EE51:1EE52,1EE54:1EE54,1EE57:1EE57,1EE59:1EE59,1EE5B:1EE5B,1EE5D:1EE5D,1EE5F:1EE5F,1EE61:1EE62,1EE64:1EE64,1EE67:1EE6A,1EE6C:1EE72,1EE74:1EE77,1EE79:1EE7C,1EE7E:1EE7E,1EE80:1EE89,1EE8B:1EE9B,1EEA1:1EEA3,1EEA5:1EEA9,1EEAB:1EEBB,1F100:1F10C,1FBF0:1FBF9,20000:2A6DF,2A700:2B739,2B740:2B81D,2B820:2CEA1,2CEB0:2EBE0,2EBF0:2EE5D,2F800:2FA1D,30000:3134A,31350:323AF +isdecimal 30:39,660:669,6F0:6F9,7C0:7C9,966:96F,9E6:9EF,A66:A6F,AE6:AEF,B66:B6F,BE6:BEF,C66:C6F,CE6:CEF,D66:D6F,DE6:DEF,E50:E59,ED0:ED9,F20:F29,1040:1049,1090:1099,17E0:17E9,1810:1819,1946:194F,19D0:19D9,1A80:1A89,1A90:1A99,1B50:1B59,1BB0:1BB9,1C40:1C49,1C50:1C59,A620:A629,A8D0:A8D9,A900:A909,A9D0:A9D9,A9F0:A9F9,AA50:AA59,ABF0:ABF9,FF10:FF19,104A0:104A9,10D30:10D39,10D40:10D49,11066:1106F,110F0:110F9,11136:1113F,111D0:111D9,112F0:112F9,11450:11459,114D0:114D9,11650:11659,116C0:116C9,116D0:116E3,11730:11739,118E0:118E9,11950:11959,11BF0:11BF9,11C50:11C59,11D50:11D59,11DA0:11DA9,11F50:11F59,16130:16139,16A60:16A69,16AC0:16AC9,16B50:16B59,16D70:16D79,1CCF0:1CCF9,1D7CE:1D7FF,1E140:1E149,1E2F0:1E2F9,1E4F0:1E4F9,1E5F1:1E5FA,1E950:1E959,1FBF0:1FBF9 +isdigit 30:39,B2:B3,B9:B9,660:669,6F0:6F9,7C0:7C9,966:96F,9E6:9EF,A66:A6F,AE6:AEF,B66:B6F,BE6:BEF,C66:C6F,CE6:CEF,D66:D6F,DE6:DEF,E50:E59,ED0:ED9,F20:F29,1040:1049,1090:1099,1369:1371,17E0:17E9,1810:1819,1946:194F,19D0:19DA,1A80:1A89,1A90:1A99,1B50:1B59,1BB0:1BB9,1C40:1C49,1C50:1C59,2070:2070,2074:2079,2080:2089,2460:2468,2474:247C,2488:2490,24EA:24EA,24F5:24FD,24FF:24FF,2776:277E,2780:2788,278A:2792,A620:A629,A8D0:A8D9,A900:A909,A9D0:A9D9,A9F0:A9F9,AA50:AA59,ABF0:ABF9,FF10:FF19,104A0:104A9,10A40:10A43,10D30:10D39,10D40:10D49,10E60:10E68,11052:1105A,11066:1106F,110F0:110F9,11136:1113F,111D0:111D9,112F0:112F9,11450:11459,114D0:114D9,11650:11659,116C0:116C9,116D0:116E3,11730:11739,118E0:118E9,11950:11959,11BF0:11BF9,11C50:11C59,11D50:11D59,11DA0:11DA9,11F50:11F59,16130:16139,16A60:16A69,16AC0:16AC9,16B50:16B59,16D70:16D79,1CCF0:1CCF9,1D7CE:1D7FF,1E140:1E149,1E2F0:1E2F9,1E4F0:1E4F9,1E5F1:1E5FA,1E950:1E959,1F100:1F10A,1FBF0:1FBF9 +isnumeric 30:39,B2:B3,B9:B9,BC:BE,660:669,6F0:6F9,7C0:7C9,966:96F,9E6:9EF,9F4:9F9,A66:A6F,AE6:AEF,B66:B6F,B72:B77,BE6:BF2,C66:C6F,C78:C7E,CE6:CEF,D58:D5E,D66:D78,DE6:DEF,E50:E59,ED0:ED9,F20:F33,1040:1049,1090:1099,1369:137C,16EE:16F0,17E0:17E9,17F0:17F9,1810:1819,1946:194F,19D0:19DA,1A80:1A89,1A90:1A99,1B50:1B59,1BB0:1BB9,1C40:1C49,1C50:1C59,2070:2070,2074:2079,2080:2089,2150:2182,2185:2189,2460:249B,24EA:24FF,2776:2793,2CFD:2CFD,3007:3007,3021:3029,3038:303A,3192:3195,3220:3229,3248:324F,3251:325F,3280:3289,32B1:32BF,3405:3405,3483:3483,382A:382A,3B4D:3B4D,4E00:4E00,4E03:4E03,4E07:4E07,4E09:4E09,4E24:4E24,4E5D:4E5D,4E8C:4E8C,4E94:4E94,4E96:4E96,4EAC:4EAC,4EBF:4EC0,4EDF:4EDF,4EE8:4EE8,4F0D:4F0D,4F70:4F70,4FE9:4FE9,5006:5006,5104:5104,5146:5146,5169:5169,516B:516B,516D:516D,5341:5341,5343:5345,534C:534C,53C1:53C4,56DB:56DB,58F1:58F1,58F9:58F9,5E7A:5E7A,5EFE:5EFF,5F0C:5F0E,5F10:5F10,62D0:62D0,62FE:62FE,634C:634C,67D2:67D2,6D1E:6D1E,6F06:6F06,7396:7396,767E:767E,7695:7695,79ED:79ED,8086:8086,842C:842C,8CAE:8CAE,8CB3:8CB3,8D30:8D30,920E:920E,94A9:94A9,9621:9621,9646:9646,964C:964C,9678:9678,96F6:96F6,A620:A629,A6E6:A6EF,A830:A835,A8D0:A8D9,A900:A909,A9D0:A9D9,A9F0:A9F9,AA50:AA59,ABF0:ABF9,F96B:F96B,F973:F973,F978:F978,F9B2:F9B2,F9D1:F9D1,F9D3:F9D3,F9FD:F9FD,FF10:FF19,10107:10133,10140:10178,1018A:1018B,102E1:102FB,10320:10323,10341:10341,1034A:1034A,103D1:103D5,104A0:104A9,10858:1085F,10879:1087F,108A7:108AF,108FB:108FF,10916:1091B,109BC:109BD,109C0:109CF,109D2:109FF,10A40:10A48,10A7D:10A7E,10A9D:10A9F,10AEB:10AEF,10B58:10B5F,10B78:10B7F,10BA9:10BAF,10CFA:10CFF,10D30:10D39,10D40:10D49,10E60:10E7E,10F1D:10F26,10F51:10F54,10FC5:10FCB,11052:1106F,110F0:110F9,11136:1113F,111D0:111D9,111E1:111F4,112F0:112F9,11450:11459,114D0:114D9,11650:11659,116C0:116C9,116D0:116E3,11730:1173B,118E0:118F2,11950:11959,11BF0:11BF9,11C50:11C6C,11D50:11D59,11DA0:11DA9,11F50:11F59,11FC0:11FD4,12400:1246E,16130:16139,16A60:16A69,16AC0:16AC9,16B50:16B59,16B5B:16B61,16D70:16D79,16E80:16E96,1CCF0:1CCF9,1D2C0:1D2D3,1D2E0:1D2F3,1D360:1D378,1D7CE:1D7FF,1E140:1E149,1E2F0:1E2F9,1E4F0:1E4F9,1E5F1:1E5FA,1E8C7:1E8CF,1E950:1E959,1EC71:1ECAB,1ECAD:1ECAF,1ECB1:1ECB4,1ED01:1ED2D,1ED2F:1ED3D,1F100:1F10C,1FBF0:1FBF9,20001:20001,20064:20064,200E2:200E2,20121:20121,2092A:2092A,20983:20983,2098C:2098C,2099C:2099C,20AEA:20AEA,20AFD:20AFD,20B19:20B19,22390:22390,22998:22998,23B1B:23B1B,2626D:2626D,2F890:2F890 +isspace 9:D,1C:20,85:85,A0:A0,1680:1680,2000:200A,2028:2029,202F:202F,205F:205F,3000:3000 +isprintable 20:7E,A1:AC,AE:377,37A:37F,384:38A,38C:38C,38E:3A1,3A3:52F,531:556,559:58A,58D:58F,591:5C7,5D0:5EA,5EF:5F4,606:61B,61D:6DC,6DE:70D,710:74A,74D:7B1,7C0:7FA,7FD:82D,830:83E,840:85B,85E:85E,860:86A,870:88E,897:8E1,8E3:983,985:98C,98F:990,993:9A8,9AA:9B0,9B2:9B2,9B6:9B9,9BC:9C4,9C7:9C8,9CB:9CE,9D7:9D7,9DC:9DD,9DF:9E3,9E6:9FE,A01:A03,A05:A0A,A0F:A10,A13:A28,A2A:A30,A32:A33,A35:A36,A38:A39,A3C:A3C,A3E:A42,A47:A48,A4B:A4D,A51:A51,A59:A5C,A5E:A5E,A66:A76,A81:A83,A85:A8D,A8F:A91,A93:AA8,AAA:AB0,AB2:AB3,AB5:AB9,ABC:AC5,AC7:AC9,ACB:ACD,AD0:AD0,AE0:AE3,AE6:AF1,AF9:AFF,B01:B03,B05:B0C,B0F:B10,B13:B28,B2A:B30,B32:B33,B35:B39,B3C:B44,B47:B48,B4B:B4D,B55:B57,B5C:B5D,B5F:B63,B66:B77,B82:B83,B85:B8A,B8E:B90,B92:B95,B99:B9A,B9C:B9C,B9E:B9F,BA3:BA4,BA8:BAA,BAE:BB9,BBE:BC2,BC6:BC8,BCA:BCD,BD0:BD0,BD7:BD7,BE6:BFA,C00:C0C,C0E:C10,C12:C28,C2A:C39,C3C:C44,C46:C48,C4A:C4D,C55:C56,C58:C5A,C5D:C5D,C60:C63,C66:C6F,C77:C8C,C8E:C90,C92:CA8,CAA:CB3,CB5:CB9,CBC:CC4,CC6:CC8,CCA:CCD,CD5:CD6,CDD:CDE,CE0:CE3,CE6:CEF,CF1:CF3,D00:D0C,D0E:D10,D12:D44,D46:D48,D4A:D4F,D54:D63,D66:D7F,D81:D83,D85:D96,D9A:DB1,DB3:DBB,DBD:DBD,DC0:DC6,DCA:DCA,DCF:DD4,DD6:DD6,DD8:DDF,DE6:DEF,DF2:DF4,E01:E3A,E3F:E5B,E81:E82,E84:E84,E86:E8A,E8C:EA3,EA5:EA5,EA7:EBD,EC0:EC4,EC6:EC6,EC8:ECE,ED0:ED9,EDC:EDF,F00:F47,F49:F6C,F71:F97,F99:FBC,FBE:FCC,FCE:FDA,1000:10C5,10C7:10C7,10CD:10CD,10D0:1248,124A:124D,1250:1256,1258:1258,125A:125D,1260:1288,128A:128D,1290:12B0,12B2:12B5,12B8:12BE,12C0:12C0,12C2:12C5,12C8:12D6,12D8:1310,1312:1315,1318:135A,135D:137C,1380:1399,13A0:13F5,13F8:13FD,1400:167F,1681:169C,16A0:16F8,1700:1715,171F:1736,1740:1753,1760:176C,176E:1770,1772:1773,1780:17DD,17E0:17E9,17F0:17F9,1800:180D,180F:1819,1820:1878,1880:18AA,18B0:18F5,1900:191E,1920:192B,1930:193B,1940:1940,1944:196D,1970:1974,1980:19AB,19B0:19C9,19D0:19DA,19DE:1A1B,1A1E:1A5E,1A60:1A7C,1A7F:1A89,1A90:1A99,1AA0:1AAD,1AB0:1ACE,1B00:1B4C,1B4E:1BF3,1BFC:1C37,1C3B:1C49,1C4D:1C8A,1C90:1CBA,1CBD:1CC7,1CD0:1CFA,1D00:1F15,1F18:1F1D,1F20:1F45,1F48:1F4D,1F50:1F57,1F59:1F59,1F5B:1F5B,1F5D:1F5D,1F5F:1F7D,1F80:1FB4,1FB6:1FC4,1FC6:1FD3,1FD6:1FDB,1FDD:1FEF,1FF2:1FF4,1FF6:1FFE,2010:2027,2030:205E,2070:2071,2074:208E,2090:209C,20A0:20C0,20D0:20F0,2100:218B,2190:2429,2440:244A,2460:2B73,2B76:2B95,2B97:2CF3,2CF9:2D25,2D27:2D27,2D2D:2D2D,2D30:2D67,2D6F:2D70,2D7F:2D96,2DA0:2DA6,2DA8:2DAE,2DB0:2DB6,2DB8:2DBE,2DC0:2DC6,2DC8:2DCE,2DD0:2DD6,2DD8:2DDE,2DE0:2E5D,2E80:2E99,2E9B:2EF3,2F00:2FD5,2FF0:2FFF,3001:303F,3041:3096,3099:30FF,3105:312F,3131:318E,3190:31E5,31EF:321E,3220:A48C,A490:A4C6,A4D0:A62B,A640:A6F7,A700:A7CD,A7D0:A7D1,A7D3:A7D3,A7D5:A7DC,A7F2:A82C,A830:A839,A840:A877,A880:A8C5,A8CE:A8D9,A8E0:A953,A95F:A97C,A980:A9CD,A9CF:A9D9,A9DE:A9FE,AA00:AA36,AA40:AA4D,AA50:AA59,AA5C:AAC2,AADB:AAF6,AB01:AB06,AB09:AB0E,AB11:AB16,AB20:AB26,AB28:AB2E,AB30:AB6B,AB70:ABED,ABF0:ABF9,AC00:D7A3,D7B0:D7C6,D7CB:D7FB,F900:FA6D,FA70:FAD9,FB00:FB06,FB13:FB17,FB1D:FB36,FB38:FB3C,FB3E:FB3E,FB40:FB41,FB43:FB44,FB46:FBC2,FBD3:FD8F,FD92:FDC7,FDCF:FDCF,FDF0:FE19,FE20:FE52,FE54:FE66,FE68:FE6B,FE70:FE74,FE76:FEFC,FF01:FFBE,FFC2:FFC7,FFCA:FFCF,FFD2:FFD7,FFDA:FFDC,FFE0:FFE6,FFE8:FFEE,FFFC:FFFD,10000:1000B,1000D:10026,10028:1003A,1003C:1003D,1003F:1004D,10050:1005D,10080:100FA,10100:10102,10107:10133,10137:1018E,10190:1019C,101A0:101A0,101D0:101FD,10280:1029C,102A0:102D0,102E0:102FB,10300:10323,1032D:1034A,10350:1037A,10380:1039D,1039F:103C3,103C8:103D5,10400:1049D,104A0:104A9,104B0:104D3,104D8:104FB,10500:10527,10530:10563,1056F:1057A,1057C:1058A,1058C:10592,10594:10595,10597:105A1,105A3:105B1,105B3:105B9,105BB:105BC,105C0:105F3,10600:10736,10740:10755,10760:10767,10780:10785,10787:107B0,107B2:107BA,10800:10805,10808:10808,1080A:10835,10837:10838,1083C:1083C,1083F:10855,10857:1089E,108A7:108AF,108E0:108F2,108F4:108F5,108FB:1091B,1091F:10939,1093F:1093F,10980:109B7,109BC:109CF,109D2:10A03,10A05:10A06,10A0C:10A13,10A15:10A17,10A19:10A35,10A38:10A3A,10A3F:10A48,10A50:10A58,10A60:10A9F,10AC0:10AE6,10AEB:10AF6,10B00:10B35,10B39:10B55,10B58:10B72,10B78:10B91,10B99:10B9C,10BA9:10BAF,10C00:10C48,10C80:10CB2,10CC0:10CF2,10CFA:10D27,10D30:10D39,10D40:10D65,10D69:10D85,10D8E:10D8F,10E60:10E7E,10E80:10EA9,10EAB:10EAD,10EB0:10EB1,10EC2:10EC4,10EFC:10F27,10F30:10F59,10F70:10F89,10FB0:10FCB,10FE0:10FF6,11000:1104D,11052:11075,1107F:110BC,110BE:110C2,110D0:110E8,110F0:110F9,11100:11134,11136:11147,11150:11176,11180:111DF,111E1:111F4,11200:11211,11213:11241,11280:11286,11288:11288,1128A:1128D,1128F:1129D,1129F:112A9,112B0:112EA,112F0:112F9,11300:11303,11305:1130C,1130F:11310,11313:11328,1132A:11330,11332:11333,11335:11339,1133B:11344,11347:11348,1134B:1134D,11350:11350,11357:11357,1135D:11363,11366:1136C,11370:11374,11380:11389,1138B:1138B,1138E:1138E,11390:113B5,113B7:113C0,113C2:113C2,113C5:113C5,113C7:113CA,113CC:113D5,113D7:113D8,113E1:113E2,11400:1145B,1145D:11461,11480:114C7,114D0:114D9,11580:115B5,115B8:115DD,11600:11644,11650:11659,11660:1166C,11680:116B9,116C0:116C9,116D0:116E3,11700:1171A,1171D:1172B,11730:11746,11800:1183B,118A0:118F2,118FF:11906,11909:11909,1190C:11913,11915:11916,11918:11935,11937:11938,1193B:11946,11950:11959,119A0:119A7,119AA:119D7,119DA:119E4,11A00:11A47,11A50:11AA2,11AB0:11AF8,11B00:11B09,11BC0:11BE1,11BF0:11BF9,11C00:11C08,11C0A:11C36,11C38:11C45,11C50:11C6C,11C70:11C8F,11C92:11CA7,11CA9:11CB6,11D00:11D06,11D08:11D09,11D0B:11D36,11D3A:11D3A,11D3C:11D3D,11D3F:11D47,11D50:11D59,11D60:11D65,11D67:11D68,11D6A:11D8E,11D90:11D91,11D93:11D98,11DA0:11DA9,11EE0:11EF8,11F00:11F10,11F12:11F3A,11F3E:11F5A,11FB0:11FB0,11FC0:11FF1,11FFF:12399,12400:1246E,12470:12474,12480:12543,12F90:12FF2,13000:1342F,13440:13455,13460:143FA,14400:14646,16100:16139,16800:16A38,16A40:16A5E,16A60:16A69,16A6E:16ABE,16AC0:16AC9,16AD0:16AED,16AF0:16AF5,16B00:16B45,16B50:16B59,16B5B:16B61,16B63:16B77,16B7D:16B8F,16D40:16D79,16E40:16E9A,16F00:16F4A,16F4F:16F87,16F8F:16F9F,16FE0:16FE4,16FF0:16FF1,17000:187F7,18800:18CD5,18CFF:18D08,1AFF0:1AFF3,1AFF5:1AFFB,1AFFD:1AFFE,1B000:1B122,1B132:1B132,1B150:1B152,1B155:1B155,1B164:1B167,1B170:1B2FB,1BC00:1BC6A,1BC70:1BC7C,1BC80:1BC88,1BC90:1BC99,1BC9C:1BC9F,1CC00:1CCF9,1CD00:1CEB3,1CF00:1CF2D,1CF30:1CF46,1CF50:1CFC3,1D000:1D0F5,1D100:1D126,1D129:1D172,1D17B:1D1EA,1D200:1D245,1D2C0:1D2D3,1D2E0:1D2F3,1D300:1D356,1D360:1D378,1D400:1D454,1D456:1D49C,1D49E:1D49F,1D4A2:1D4A2,1D4A5:1D4A6,1D4A9:1D4AC,1D4AE:1D4B9,1D4BB:1D4BB,1D4BD:1D4C3,1D4C5:1D505,1D507:1D50A,1D50D:1D514,1D516:1D51C,1D51E:1D539,1D53B:1D53E,1D540:1D544,1D546:1D546,1D54A:1D550,1D552:1D6A5,1D6A8:1D7CB,1D7CE:1DA8B,1DA9B:1DA9F,1DAA1:1DAAF,1DF00:1DF1E,1DF25:1DF2A,1E000:1E006,1E008:1E018,1E01B:1E021,1E023:1E024,1E026:1E02A,1E030:1E06D,1E08F:1E08F,1E100:1E12C,1E130:1E13D,1E140:1E149,1E14E:1E14F,1E290:1E2AE,1E2C0:1E2F9,1E2FF:1E2FF,1E4D0:1E4F9,1E5D0:1E5FA,1E5FF:1E5FF,1E7E0:1E7E6,1E7E8:1E7EB,1E7ED:1E7EE,1E7F0:1E7FE,1E800:1E8C4,1E8C7:1E8D6,1E900:1E94B,1E950:1E959,1E95E:1E95F,1EC71:1ECB4,1ED01:1ED3D,1EE00:1EE03,1EE05:1EE1F,1EE21:1EE22,1EE24:1EE24,1EE27:1EE27,1EE29:1EE32,1EE34:1EE37,1EE39:1EE39,1EE3B:1EE3B,1EE42:1EE42,1EE47:1EE47,1EE49:1EE49,1EE4B:1EE4B,1EE4D:1EE4F,1EE51:1EE52,1EE54:1EE54,1EE57:1EE57,1EE59:1EE59,1EE5B:1EE5B,1EE5D:1EE5D,1EE5F:1EE5F,1EE61:1EE62,1EE64:1EE64,1EE67:1EE6A,1EE6C:1EE72,1EE74:1EE77,1EE79:1EE7C,1EE7E:1EE7E,1EE80:1EE89,1EE8B:1EE9B,1EEA1:1EEA3,1EEA5:1EEA9,1EEAB:1EEBB,1EEF0:1EEF1,1F000:1F02B,1F030:1F093,1F0A0:1F0AE,1F0B1:1F0BF,1F0C1:1F0CF,1F0D1:1F0F5,1F100:1F1AD,1F1E6:1F202,1F210:1F23B,1F240:1F248,1F250:1F251,1F260:1F265,1F300:1F6D7,1F6DC:1F6EC,1F6F0:1F6FC,1F700:1F776,1F77B:1F7D9,1F7E0:1F7EB,1F7F0:1F7F0,1F800:1F80B,1F810:1F847,1F850:1F859,1F860:1F887,1F890:1F8AD,1F8B0:1F8BB,1F8C0:1F8C1,1F900:1FA53,1FA60:1FA6D,1FA70:1FA7C,1FA80:1FA89,1FA8F:1FAC6,1FACE:1FADC,1FADF:1FAE9,1FAF0:1FAF8,1FB00:1FB92,1FB94:1FBF9,20000:2A6DF,2A700:2B739,2B740:2B81D,2B820:2CEA1,2CEB0:2EBE0,2EBF0:2EE5D,2F800:2FA1D,30000:3134A,31350:323AF,E0100:E01EF +isidentifier 41:5A,5F:5F,61:7A,AA:AA,B5:B5,BA:BA,C0:D6,D8:F6,F8:2C1,2C6:2D1,2E0:2E4,2EC:2EC,2EE:2EE,370:374,376:377,37B:37D,37F:37F,386:386,388:38A,38C:38C,38E:3A1,3A3:3F5,3F7:481,48A:52F,531:556,559:559,560:588,5D0:5EA,5EF:5F2,620:64A,66E:66F,671:6D3,6D5:6D5,6E5:6E6,6EE:6EF,6FA:6FC,6FF:6FF,710:710,712:72F,74D:7A5,7B1:7B1,7CA:7EA,7F4:7F5,7FA:7FA,800:815,81A:81A,824:824,828:828,840:858,860:86A,870:887,889:88E,8A0:8C9,904:939,93D:93D,950:950,958:961,971:980,985:98C,98F:990,993:9A8,9AA:9B0,9B2:9B2,9B6:9B9,9BD:9BD,9CE:9CE,9DC:9DD,9DF:9E1,9F0:9F1,9FC:9FC,A05:A0A,A0F:A10,A13:A28,A2A:A30,A32:A33,A35:A36,A38:A39,A59:A5C,A5E:A5E,A72:A74,A85:A8D,A8F:A91,A93:AA8,AAA:AB0,AB2:AB3,AB5:AB9,ABD:ABD,AD0:AD0,AE0:AE1,AF9:AF9,B05:B0C,B0F:B10,B13:B28,B2A:B30,B32:B33,B35:B39,B3D:B3D,B5C:B5D,B5F:B61,B71:B71,B83:B83,B85:B8A,B8E:B90,B92:B95,B99:B9A,B9C:B9C,B9E:B9F,BA3:BA4,BA8:BAA,BAE:BB9,BD0:BD0,C05:C0C,C0E:C10,C12:C28,C2A:C39,C3D:C3D,C58:C5A,C5D:C5D,C60:C61,C80:C80,C85:C8C,C8E:C90,C92:CA8,CAA:CB3,CB5:CB9,CBD:CBD,CDD:CDE,CE0:CE1,CF1:CF2,D04:D0C,D0E:D10,D12:D3A,D3D:D3D,D4E:D4E,D54:D56,D5F:D61,D7A:D7F,D85:D96,D9A:DB1,DB3:DBB,DBD:DBD,DC0:DC6,E01:E30,E32:E32,E40:E46,E81:E82,E84:E84,E86:E8A,E8C:EA3,EA5:EA5,EA7:EB0,EB2:EB2,EBD:EBD,EC0:EC4,EC6:EC6,EDC:EDF,F00:F00,F40:F47,F49:F6C,F88:F8C,1000:102A,103F:103F,1050:1055,105A:105D,1061:1061,1065:1066,106E:1070,1075:1081,108E:108E,10A0:10C5,10C7:10C7,10CD:10CD,10D0:10FA,10FC:1248,124A:124D,1250:1256,1258:1258,125A:125D,1260:1288,128A:128D,1290:12B0,12B2:12B5,12B8:12BE,12C0:12C0,12C2:12C5,12C8:12D6,12D8:1310,1312:1315,1318:135A,1380:138F,13A0:13F5,13F8:13FD,1401:166C,166F:167F,1681:169A,16A0:16EA,16EE:16F8,1700:1711,171F:1731,1740:1751,1760:176C,176E:1770,1780:17B3,17D7:17D7,17DC:17DC,1820:1878,1880:18A8,18AA:18AA,18B0:18F5,1900:191E,1950:196D,1970:1974,1980:19AB,19B0:19C9,1A00:1A16,1A20:1A54,1AA7:1AA7,1B05:1B33,1B45:1B4C,1B83:1BA0,1BAE:1BAF,1BBA:1BE5,1C00:1C23,1C4D:1C4F,1C5A:1C7D,1C80:1C8A,1C90:1CBA,1CBD:1CBF,1CE9:1CEC,1CEE:1CF3,1CF5:1CF6,1CFA:1CFA,1D00:1DBF,1E00:1F15,1F18:1F1D,1F20:1F45,1F48:1F4D,1F50:1F57,1F59:1F59,1F5B:1F5B,1F5D:1F5D,1F5F:1F7D,1F80:1FB4,1FB6:1FBC,1FBE:1FBE,1FC2:1FC4,1FC6:1FCC,1FD0:1FD3,1FD6:1FDB,1FE0:1FEC,1FF2:1FF4,1FF6:1FFC,2071:2071,207F:207F,2090:209C,2102:2102,2107:2107,210A:2113,2115:2115,2118:211D,2124:2124,2126:2126,2128:2128,212A:2139,213C:213F,2145:2149,214E:214E,2160:2188,2C00:2CE4,2CEB:2CEE,2CF2:2CF3,2D00:2D25,2D27:2D27,2D2D:2D2D,2D30:2D67,2D6F:2D6F,2D80:2D96,2DA0:2DA6,2DA8:2DAE,2DB0:2DB6,2DB8:2DBE,2DC0:2DC6,2DC8:2DCE,2DD0:2DD6,2DD8:2DDE,3005:3007,3021:3029,3031:3035,3038:303C,3041:3096,309D:309F,30A1:30FA,30FC:30FF,3105:312F,3131:318E,31A0:31BF,31F0:31FF,3400:4DBF,4E00:A48C,A4D0:A4FD,A500:A60C,A610:A61F,A62A:A62B,A640:A66E,A67F:A69D,A6A0:A6EF,A717:A71F,A722:A788,A78B:A7CD,A7D0:A7D1,A7D3:A7D3,A7D5:A7DC,A7F2:A801,A803:A805,A807:A80A,A80C:A822,A840:A873,A882:A8B3,A8F2:A8F7,A8FB:A8FB,A8FD:A8FE,A90A:A925,A930:A946,A960:A97C,A984:A9B2,A9CF:A9CF,A9E0:A9E4,A9E6:A9EF,A9FA:A9FE,AA00:AA28,AA40:AA42,AA44:AA4B,AA60:AA76,AA7A:AA7A,AA7E:AAAF,AAB1:AAB1,AAB5:AAB6,AAB9:AABD,AAC0:AAC0,AAC2:AAC2,AADB:AADD,AAE0:AAEA,AAF2:AAF4,AB01:AB06,AB09:AB0E,AB11:AB16,AB20:AB26,AB28:AB2E,AB30:AB5A,AB5C:AB69,AB70:ABE2,AC00:D7A3,D7B0:D7C6,D7CB:D7FB,F900:FA6D,FA70:FAD9,FB00:FB06,FB13:FB17,FB1D:FB1D,FB1F:FB28,FB2A:FB36,FB38:FB3C,FB3E:FB3E,FB40:FB41,FB43:FB44,FB46:FBB1,FBD3:FC5D,FC64:FD3D,FD50:FD8F,FD92:FDC7,FDF0:FDF9,FE71:FE71,FE73:FE73,FE77:FE77,FE79:FE79,FE7B:FE7B,FE7D:FE7D,FE7F:FEFC,FF21:FF3A,FF41:FF5A,FF66:FF9D,FFA0:FFBE,FFC2:FFC7,FFCA:FFCF,FFD2:FFD7,FFDA:FFDC,10000:1000B,1000D:10026,10028:1003A,1003C:1003D,1003F:1004D,10050:1005D,10080:100FA,10140:10174,10280:1029C,102A0:102D0,10300:1031F,1032D:1034A,10350:10375,10380:1039D,103A0:103C3,103C8:103CF,103D1:103D5,10400:1049D,104B0:104D3,104D8:104FB,10500:10527,10530:10563,10570:1057A,1057C:1058A,1058C:10592,10594:10595,10597:105A1,105A3:105B1,105B3:105B9,105BB:105BC,105C0:105F3,10600:10736,10740:10755,10760:10767,10780:10785,10787:107B0,107B2:107BA,10800:10805,10808:10808,1080A:10835,10837:10838,1083C:1083C,1083F:10855,10860:10876,10880:1089E,108E0:108F2,108F4:108F5,10900:10915,10920:10939,10980:109B7,109BE:109BF,10A00:10A00,10A10:10A13,10A15:10A17,10A19:10A35,10A60:10A7C,10A80:10A9C,10AC0:10AC7,10AC9:10AE4,10B00:10B35,10B40:10B55,10B60:10B72,10B80:10B91,10C00:10C48,10C80:10CB2,10CC0:10CF2,10D00:10D23,10D4A:10D65,10D6F:10D85,10E80:10EA9,10EB0:10EB1,10EC2:10EC4,10F00:10F1C,10F27:10F27,10F30:10F45,10F70:10F81,10FB0:10FC4,10FE0:10FF6,11003:11037,11071:11072,11075:11075,11083:110AF,110D0:110E8,11103:11126,11144:11144,11147:11147,11150:11172,11176:11176,11183:111B2,111C1:111C4,111DA:111DA,111DC:111DC,11200:11211,11213:1122B,1123F:11240,11280:11286,11288:11288,1128A:1128D,1128F:1129D,1129F:112A8,112B0:112DE,11305:1130C,1130F:11310,11313:11328,1132A:11330,11332:11333,11335:11339,1133D:1133D,11350:11350,1135D:11361,11380:11389,1138B:1138B,1138E:1138E,11390:113B5,113B7:113B7,113D1:113D1,113D3:113D3,11400:11434,11447:1144A,1145F:11461,11480:114AF,114C4:114C5,114C7:114C7,11580:115AE,115D8:115DB,11600:1162F,11644:11644,11680:116AA,116B8:116B8,11700:1171A,11740:11746,11800:1182B,118A0:118DF,118FF:11906,11909:11909,1190C:11913,11915:11916,11918:1192F,1193F:1193F,11941:11941,119A0:119A7,119AA:119D0,119E1:119E1,119E3:119E3,11A00:11A00,11A0B:11A32,11A3A:11A3A,11A50:11A50,11A5C:11A89,11A9D:11A9D,11AB0:11AF8,11BC0:11BE0,11C00:11C08,11C0A:11C2E,11C40:11C40,11C72:11C8F,11D00:11D06,11D08:11D09,11D0B:11D30,11D46:11D46,11D60:11D65,11D67:11D68,11D6A:11D89,11D98:11D98,11EE0:11EF2,11F02:11F02,11F04:11F10,11F12:11F33,11FB0:11FB0,12000:12399,12400:1246E,12480:12543,12F90:12FF0,13000:1342F,13441:13446,13460:143FA,14400:14646,16100:1611D,16800:16A38,16A40:16A5E,16A70:16ABE,16AD0:16AED,16B00:16B2F,16B40:16B43,16B63:16B77,16B7D:16B8F,16D40:16D6C,16E40:16E7F,16F00:16F4A,16F50:16F50,16F93:16F9F,16FE0:16FE1,16FE3:16FE3,17000:187F7,18800:18CD5,18CFF:18D08,1AFF0:1AFF3,1AFF5:1AFFB,1AFFD:1AFFE,1B000:1B122,1B132:1B132,1B150:1B152,1B155:1B155,1B164:1B167,1B170:1B2FB,1BC00:1BC6A,1BC70:1BC7C,1BC80:1BC88,1BC90:1BC99,1D400:1D454,1D456:1D49C,1D49E:1D49F,1D4A2:1D4A2,1D4A5:1D4A6,1D4A9:1D4AC,1D4AE:1D4B9,1D4BB:1D4BB,1D4BD:1D4C3,1D4C5:1D505,1D507:1D50A,1D50D:1D514,1D516:1D51C,1D51E:1D539,1D53B:1D53E,1D540:1D544,1D546:1D546,1D54A:1D550,1D552:1D6A5,1D6A8:1D6C0,1D6C2:1D6DA,1D6DC:1D6FA,1D6FC:1D714,1D716:1D734,1D736:1D74E,1D750:1D76E,1D770:1D788,1D78A:1D7A8,1D7AA:1D7C2,1D7C4:1D7CB,1DF00:1DF1E,1DF25:1DF2A,1E030:1E06D,1E100:1E12C,1E137:1E13D,1E14E:1E14E,1E290:1E2AD,1E2C0:1E2EB,1E4D0:1E4EB,1E5D0:1E5ED,1E5F0:1E5F0,1E7E0:1E7E6,1E7E8:1E7EB,1E7ED:1E7EE,1E7F0:1E7FE,1E800:1E8C4,1E900:1E943,1E94B:1E94B,1EE00:1EE03,1EE05:1EE1F,1EE21:1EE22,1EE24:1EE24,1EE27:1EE27,1EE29:1EE32,1EE34:1EE37,1EE39:1EE39,1EE3B:1EE3B,1EE42:1EE42,1EE47:1EE47,1EE49:1EE49,1EE4B:1EE4B,1EE4D:1EE4F,1EE51:1EE52,1EE54:1EE54,1EE57:1EE57,1EE59:1EE59,1EE5B:1EE5B,1EE5D:1EE5D,1EE5F:1EE5F,1EE61:1EE62,1EE64:1EE64,1EE67:1EE6A,1EE6C:1EE72,1EE74:1EE77,1EE79:1EE7C,1EE7E:1EE7E,1EE80:1EE89,1EE8B:1EE9B,1EEA1:1EEA3,1EEA5:1EEA9,1EEAB:1EEBB,20000:2A6DF,2A700:2B739,2B740:2B81D,2B820:2CEA1,2CEB0:2EBE0,2EBF0:2EE5D,2F800:2FA1D,30000:3134A,31350:323AF diff --git a/crates/unicode/tests/data/version_skew_cpython3.14.txt b/crates/unicode/tests/data/version_skew_cpython3.14.txt new file mode 100644 index 00000000000..acdb26089ba --- /dev/null +++ b/crates/unicode/tests/data/version_skew_cpython3.14.txt @@ -0,0 +1,11 @@ +# Code points whose classification differs between CPython 3.14 (Unicode 16.0.0) +# and the Rust std / icu4x build used here (a later Unicode release assigns them). +# Regenerate with RUSTPYTHON_UNICODE_REGEN_SKEW=1. +# Format: `predicate start:end,...` with inclusive hex ranges. +isalnum 88F:88F,C5C:C5C,CDC:CDC,A7CE:A7CF,A7D2:A7D2,A7D4:A7D4,A7F1:A7F1,10940:10959,10EC5:10EC7,11DB0:11DDB,11DE0:11DE9,16EA0:16EB8,16EBB:16ED3,16FF2:16FF6,187F8:187FF,18D09:18D1E,18D80:18DF2,1E6C0:1E6DE,1E6E0:1E6E2,1E6E4:1E6E5,1E6E7:1E6ED,1E6F0:1E6F4,1E6FE:1E6FF,2B73A:2B73F,2CEA2:2CEAD,323B0:33479 +isalpha 88F:88F,C5C:C5C,CDC:CDC,A7CE:A7CF,A7D2:A7D2,A7D4:A7D4,A7F1:A7F1,10940:10959,10EC5:10EC7,11DB0:11DDB,16EA0:16EB8,16EBB:16ED3,16FF2:16FF3,187F8:187FF,18D09:18D1E,18D80:18DF2,1E6C0:1E6DE,1E6E0:1E6E2,1E6E4:1E6E5,1E6E7:1E6ED,1E6F0:1E6F4,1E6FE:1E6FF,2B73A:2B73F,2CEA2:2CEAD,323B0:33479 +isdecimal 11DE0:11DE9 +isdigit 11DE0:11DE9 +isidentifier 88F:88F,C5C:C5C,CDC:CDC,A7CE:A7CF,A7D2:A7D2,A7D4:A7D4,A7F1:A7F1,10940:10959,10EC5:10EC7,11DB0:11DDB,16EA0:16EB8,16EBB:16ED3,16FF2:16FF6,187F8:187FF,18D09:18D1E,18D80:18DF2,1E6C0:1E6DE,1E6E0:1E6E2,1E6E4:1E6E5,1E6E7:1E6ED,1E6F0:1E6F4,1E6FE:1E6FF,2B73A:2B73F,2CEA2:2CEAD,323B0:33479 +isnumeric 11DE0:11DE9,12038:12039,12079:12079,12226:12226,1222B:1222B,1230B:1230B,1230D:1230D,12399:12399,16FF4:16FF6 +isprintable 88F:88F,C5C:C5C,CDC:CDC,1ACF:1ADD,1AE0:1AEB,20C1:20C1,2B96:2B96,A7CE:A7CF,A7D2:A7D2,A7D4:A7D4,A7F1:A7F1,FBC3:FBD2,FD90:FD91,FDC8:FDCE,10940:10959,10EC5:10EC7,10ED0:10ED8,10EFA:10EFB,11B60:11B67,11DB0:11DDB,11DE0:11DE9,16EA0:16EB8,16EBB:16ED3,16FF2:16FF6,187F8:187FF,18D09:18D1E,18D80:18DF2,1CCFA:1CCFC,1CEBA:1CED0,1CEE0:1CEF0,1E6C0:1E6DE,1E6E0:1E6F5,1E6FE:1E6FF,1F6D8:1F6D8,1F777:1F77A,1F8D0:1F8D8,1FA54:1FA57,1FA8A:1FA8A,1FA8E:1FA8E,1FAC8:1FAC8,1FACD:1FACD,1FAEA:1FAEA,1FAEF:1FAEF,1FBFA:1FBFA,2B73A:2B73F,2CEA2:2CEAD,323B0:33479 diff --git a/crates/unicode/tests/differential.rs b/crates/unicode/tests/differential.rs new file mode 100644 index 00000000000..fef3451fdb9 --- /dev/null +++ b/crates/unicode/tests/differential.rs @@ -0,0 +1,235 @@ +//! Differential sweep of the classification predicates over the full scalar +//! range `0..0x110000` against a committed CPython reference dataset. +//! +//! CPython 3.14 ships Unicode 16.0.0 while the Rust standard library / icu4x +//! build used here may be a later release. Code points whose classification +//! changed between those Unicode versions are expected to differ; those are +//! recorded in `data/version_skew_cpython3.14.txt` as an explicit allow-list. +//! Any divergence outside that list fails the test — a real regression, not a +//! version bump. +//! +//! Both data files use the same run-length format: one `predicate` line per +//! str method, followed by comma-separated hex `start:end` inclusive ranges. + +#[cfg(test)] +mod tests { + extern crate alloc; + + use alloc::collections::{BTreeMap, BTreeSet}; + + use rustpython_unicode::classify; + + const MAX: u32 = 0x110000; + const REFERENCE: &str = include_str!("data/cpython3.14_predicates.txt"); + const VERSION_SKEW: &str = include_str!("data/version_skew_cpython3.14.txt"); + + fn crate_predicate(name: &str, cp: u32) -> bool { + let Some(c) = char::from_u32(cp) else { + // Lone surrogates are not scalars; every str predicate is false. + return false; + }; + match name { + "isalpha" => classify::is_alpha(c), + "isalnum" => classify::is_alnum(c), + "isdecimal" => classify::is_decimal(c), + "isdigit" => classify::is_digit(c), + "isnumeric" => classify::is_numeric(c), + "isspace" => classify::is_space(c), + "isprintable" => classify::is_printable(c), + "isidentifier" => { + // str.isidentifier is a whole-string predicate; for a single char it + // is "may start an identifier". + classify_is_identifier_char(c) + } + other => panic!("unknown predicate {other}"), + } + } + + fn classify_is_identifier_char(c: char) -> bool { + rustpython_unicode::identifier::is_start(c) + } + + /// Parse a `name -> sorted set of code points` map from a run-length file. + /// + /// Each non-comment line is `predicate start:end,start:end,...` with inclusive + /// hex ranges; a predicate with no members is a bare `predicate`. + fn parse_ranges(text: &str) -> BTreeMap> { + let mut map = BTreeMap::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (name, packed) = match line.split_once(' ') { + Some((name, packed)) => (name, packed.trim()), + None => (line, ""), + }; + let mut set = BTreeSet::new(); + if !packed.is_empty() { + for run in packed.split(',') { + let (s, e) = run.split_once(':').expect("run is start:end"); + let start = u32::from_str_radix(s, 16).unwrap(); + let end = u32::from_str_radix(e, 16).unwrap(); + for cp in start..=end { + set.insert(cp); + } + } + } + map.insert(name.to_string(), set); + } + map + } + + /// Collapse a sorted code-point set into inclusive `start:end` runs. + fn encode_ranges(set: &BTreeSet) -> String { + let mut runs = Vec::new(); + let mut iter = set.iter().copied(); + if let Some(first) = iter.next() { + let (mut start, mut end) = (first, first); + for cp in iter { + if cp == end + 1 { + end = cp; + } else { + runs.push((start, end)); + start = cp; + end = cp; + } + } + runs.push((start, end)); + } + runs.iter() + .map(|(s, e)| format!("{s:X}:{e:X}")) + .collect::>() + .join(",") + } + + /// Recompute the full divergence set. Every entry is a `(predicate, code + /// point)` where the crate and the CPython reference disagree. + fn all_divergences(reference: &BTreeMap>) -> Vec<(String, u32, bool)> { + let mut out = Vec::new(); + for (name, truth) in reference { + for cp in 0..MAX { + let expected = truth.contains(&cp); + let actual = crate_predicate(name, cp); + if expected != actual { + out.push((name.clone(), cp, expected)); + } + } + } + out + } + + /// Regenerate `data/version_skew_cpython3.14.txt` from the current toolchain. + /// + /// Run with `RUSTPYTHON_UNICODE_REGEN_SKEW=1 cargo test -p rustpython-unicode + /// --test differential` after bumping the Rust/icu toolchain. All divergences + /// must be one-directional (crate=true, cpython=false) — newly-assigned code + /// points from a later Unicode release. A `cpython=true, crate=false` entry + /// means a code point lost a property, which is a real regression, so this + /// refuses to record it. + #[test] + fn regen_version_skew() { + if std::env::var_os("RUSTPYTHON_UNICODE_REGEN_SKEW").is_none() { + return; + } + let reference = parse_ranges(REFERENCE); + let divergences = all_divergences(&reference); + + let regressions: Vec<_> = divergences + .iter() + .filter(|(_, _, expected)| *expected) + .collect(); + assert!( + regressions.is_empty(), + "refusing to record {} cpython=true/crate=false divergence(s) — these are \ + regressions, not version skew: {:?}", + regressions.len(), + ®ressions[..regressions.len().min(20)] + ); + + let mut by_predicate: BTreeMap> = BTreeMap::new(); + for (name, cp, _) in &divergences { + by_predicate.entry(name.clone()).or_default().insert(*cp); + } + + let mut body = String::from( + "# Code points whose classification differs between CPython 3.14 (Unicode 16.0.0)\n\ + # and the Rust std / icu4x build used here (a later Unicode release assigns them).\n\ + # Regenerate with RUSTPYTHON_UNICODE_REGEN_SKEW=1.\n\ + # Format: `predicate start:end,...` with inclusive hex ranges.\n", + ); + for (name, set) in &by_predicate { + body.push_str(&format!("{name} {}\n", encode_ranges(set))); + } + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/data/version_skew_cpython3.14.txt" + ); + std::fs::write(path, body).unwrap(); + eprintln!( + "wrote {} skew code points across {} predicates to {path}", + divergences.len(), + by_predicate.len() + ); + } + + #[test] + fn predicates_match_cpython_except_documented_version_skew() { + let reference = parse_ranges(REFERENCE); + let skew = parse_ranges(VERSION_SKEW); + + let allowed = |name: &str, cp: u32| skew.get(name).is_some_and(|set| set.contains(&cp)); + + let mut unexpected: Vec<(String, u32, bool, bool)> = Vec::new(); + + for (name, truth) in &reference { + for cp in 0..MAX { + let expected = truth.contains(&cp); + let actual = crate_predicate(name, cp); + if expected != actual && !allowed(name, cp) { + unexpected.push((name.clone(), cp, expected, actual)); + } + } + } + + // Also flag stale allow-list entries: code points that no longer diverge. + let mut stale: Vec<(String, u32)> = Vec::new(); + for (name, set) in &skew { + let Some(truth) = reference.get(name) else { + continue; + }; + for &cp in set { + let expected = truth.contains(&cp); + let actual = crate_predicate(name, cp); + if expected == actual { + stale.push((name.clone(), cp)); + } + } + } + + if !unexpected.is_empty() || !stale.is_empty() { + let mut msg = String::new(); + if !unexpected.is_empty() { + msg.push_str(&format!( + "{} undocumented divergence(s) from CPython:\n", + unexpected.len() + )); + for (name, cp, expected, actual) in unexpected.iter().take(50) { + msg.push_str(&format!( + " {name} U+{cp:04X}: cpython={expected} crate={actual}\n" + )); + } + } + if !stale.is_empty() { + msg.push_str(&format!( + "{} stale version_skew_cpython3.14.txt entries that now agree:\n", + stale.len() + )); + for (name, cp) in stale.iter().take(50) { + msg.push_str(&format!(" {name} U+{cp:04X}\n")); + } + } + panic!("{msg}"); + } + } +} diff --git a/crates/unicode/tests/generate_reference.py b/crates/unicode/tests/generate_reference.py new file mode 100644 index 00000000000..c7b21e66fc4 --- /dev/null +++ b/crates/unicode/tests/generate_reference.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3.14 +"""Generate the CPython reference dataset for the differential Unicode sweep. + +Run with a CPython interpreter whose ``unicodedata.unidata_version`` matches the +Unicode release this crate targets (16.0.0 for CPython 3.14). The output is a +compact run-length encoding of every predicate's true-set over the full scalar +range, consumed by ``tests/differential.rs``. + +Usage: + python3.14 crates/unicode/tests/generate_reference.py + +Writes ``tests/data/cpython3.14_predicates.txt``. Commit the result. +""" + +from __future__ import annotations + +import pathlib +import sys +import unicodedata + +MAX = 0x110000 + +# str predicates: name -> single-char method. +STR_PREDICATES = { + "isalpha": str.isalpha, + "isalnum": str.isalnum, + "isdecimal": str.isdecimal, + "isdigit": str.isdigit, + "isnumeric": str.isnumeric, + "isspace": str.isspace, + "isprintable": str.isprintable, + "isidentifier": str.isidentifier, +} + + +def encode_ranges(is_true) -> list[tuple[int, int]]: + """Collapse the true-set of ``is_true`` into inclusive ``[start, end]`` runs.""" + ranges: list[tuple[int, int]] = [] + start: int | None = None + for cp in range(MAX): + if is_true(cp): + if start is None: + start = cp + elif start is not None: + ranges.append((start, cp - 1)) + start = None + if start is not None: + ranges.append((start, MAX - 1)) + return ranges + + +def main() -> int: + if unicodedata.unidata_version != "16.0.0": + sys.stderr.write( + f"warning: unidata_version is {unicodedata.unidata_version}, " + "expected 16.0.0 (CPython 3.14); regenerating anyway\n" + ) + + out = pathlib.Path(__file__).parent / "data" / "cpython3.14_predicates.txt" + out.parent.mkdir(parents=True, exist_ok=True) + + lines = [f"# unidata_version {unicodedata.unidata_version}"] + for name, method in STR_PREDICATES.items(): + ranges = encode_ranges(lambda cp, m=method: m(chr(cp))) + packed = ",".join(f"{s:X}:{e:X}" for s, e in ranges) + lines.append(f"{name} {packed}") + + out.write_text("\n".join(lines) + "\n") + print(f"wrote {out} ({out.stat().st_size} bytes)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/crates/stdlib/unicode/README.md b/crates/unicode/unicode/README.md similarity index 100% rename from crates/stdlib/unicode/README.md rename to crates/unicode/unicode/README.md diff --git a/crates/stdlib/unicode/latest/DerivedNumericValues.txt b/crates/unicode/unicode/latest/DerivedNumericValues.txt similarity index 100% rename from crates/stdlib/unicode/latest/DerivedNumericValues.txt rename to crates/unicode/unicode/latest/DerivedNumericValues.txt diff --git a/crates/stdlib/unicode/latest/NormalizationCorrections.txt b/crates/unicode/unicode/latest/NormalizationCorrections.txt similarity index 100% rename from crates/stdlib/unicode/latest/NormalizationCorrections.txt rename to crates/unicode/unicode/latest/NormalizationCorrections.txt diff --git a/crates/stdlib/unicode/latest/UnicodeData.txt b/crates/unicode/unicode/latest/UnicodeData.txt similarity index 100% rename from crates/stdlib/unicode/latest/UnicodeData.txt rename to crates/unicode/unicode/latest/UnicodeData.txt diff --git a/crates/stdlib/unicode/ucd32/DerivedBidiClass-3.2.0.txt b/crates/unicode/unicode/ucd32/DerivedBidiClass-3.2.0.txt similarity index 100% rename from crates/stdlib/unicode/ucd32/DerivedBidiClass-3.2.0.txt rename to crates/unicode/unicode/ucd32/DerivedBidiClass-3.2.0.txt diff --git a/crates/stdlib/unicode/ucd32/DerivedBinaryProperties-3.2.0.txt b/crates/unicode/unicode/ucd32/DerivedBinaryProperties-3.2.0.txt similarity index 100% rename from crates/stdlib/unicode/ucd32/DerivedBinaryProperties-3.2.0.txt rename to crates/unicode/unicode/ucd32/DerivedBinaryProperties-3.2.0.txt diff --git a/crates/stdlib/unicode/ucd32/DerivedCombiningClass-3.2.0.txt b/crates/unicode/unicode/ucd32/DerivedCombiningClass-3.2.0.txt similarity index 100% rename from crates/stdlib/unicode/ucd32/DerivedCombiningClass-3.2.0.txt rename to crates/unicode/unicode/ucd32/DerivedCombiningClass-3.2.0.txt diff --git a/crates/stdlib/unicode/ucd32/DerivedEastAsianWidth-3.2.0.txt b/crates/unicode/unicode/ucd32/DerivedEastAsianWidth-3.2.0.txt similarity index 100% rename from crates/stdlib/unicode/ucd32/DerivedEastAsianWidth-3.2.0.txt rename to crates/unicode/unicode/ucd32/DerivedEastAsianWidth-3.2.0.txt diff --git a/crates/stdlib/unicode/ucd32/DerivedGeneralCategory-3.2.0.txt b/crates/unicode/unicode/ucd32/DerivedGeneralCategory-3.2.0.txt similarity index 100% rename from crates/stdlib/unicode/ucd32/DerivedGeneralCategory-3.2.0.txt rename to crates/unicode/unicode/ucd32/DerivedGeneralCategory-3.2.0.txt diff --git a/crates/stdlib/unicode/ucd32/DerivedNumericType-3.2.0.txt b/crates/unicode/unicode/ucd32/DerivedNumericType-3.2.0.txt similarity index 100% rename from crates/stdlib/unicode/ucd32/DerivedNumericType-3.2.0.txt rename to crates/unicode/unicode/ucd32/DerivedNumericType-3.2.0.txt diff --git a/crates/stdlib/unicode/ucd32/DerivedNumericValues-3.2.0.txt b/crates/unicode/unicode/ucd32/DerivedNumericValues-3.2.0.txt similarity index 100% rename from crates/stdlib/unicode/ucd32/DerivedNumericValues-3.2.0.txt rename to crates/unicode/unicode/ucd32/DerivedNumericValues-3.2.0.txt diff --git a/crates/vm/Cargo.toml b/crates/vm/Cargo.toml index b3479e017a1..f4e59e6acc4 100644 --- a/crates/vm/Cargo.toml +++ b/crates/vm/Cargo.toml @@ -42,6 +42,7 @@ ruff_text_size = { workspace = true, optional = true } rustpython-compiler-core = { workspace = true } rustpython-literal = { workspace = true } rustpython-sre_engine = { workspace = true } +rustpython-unicode = { workspace = true } ascii = { workspace = true } bitflags = { workspace = true } diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index 69109c943e8..a72a272679b 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -48,12 +48,13 @@ use rustpython_common::{ wtf8::{CodePoint, Wtf8, Wtf8Buf, Wtf8Concat}, }; -use icu_casemap::{CaseMapper, TitlecaseMapper}; +use icu_casemap::TitlecaseMapper; use icu_locale::LanguageIdentifier; use icu_properties::props::{ - BidiClass, BinaryProperty, CaseIgnorable, Cased, EnumeratedProperty, GeneralCategory, - GeneralCategoryGroup, Lowercase, NumericType, Uppercase, XidContinue, XidStart, + BinaryProperty, CaseIgnorable, Cased, EnumeratedProperty, GeneralCategory, + GeneralCategoryGroup, Lowercase, Uppercase, }; +use rustpython_unicode as unicode; use writeable::Writeable; impl<'a> TryFromBorrowedObject<'a> for String { @@ -752,22 +753,8 @@ impl PyStr { fn casefold(&self) -> Self { match self.as_str_kind() { PyKindStr::Ascii(s) => s.to_ascii_lowercase().into(), - PyKindStr::Utf8(s) => CaseMapper::new().fold_string(s).to_string().into(), - PyKindStr::Wtf8(w) => { - let mut out = VecFmtWriter(Vec::with_capacity(w.len())); - let mapper = CaseMapper::new(); - for chunk in w.as_bytes().utf8_chunks() { - mapper - .fold(chunk.valid()) - .write_to(&mut out) - .expect("Writing to an in-memory buffer cannot fail."); - out.0.extend(chunk.invalid()); - } - // SAFETY: - // * CaseMapper only produces valid UTF-8 - // * Surrogates are appended as-is - unsafe { Wtf8Buf::from_bytes_unchecked(out.0) }.into() - } + PyKindStr::Utf8(s) => unicode::case::casefold_str(s).into(), + PyKindStr::Wtf8(w) => unicode::case::casefold_wtf8(w).into(), } } @@ -1011,41 +998,22 @@ impl PyStr { #[pymethod] fn isalnum(&self) -> bool { - !self.data.is_empty() - && self.char_all(|c| { - GeneralCategoryGroup::Letter - .union(GeneralCategoryGroup::Number) - .contains(GeneralCategory::for_char(c)) - }) + !self.data.is_empty() && self.char_all(unicode::classify::is_alnum) } #[pymethod] fn isnumeric(&self) -> bool { - !self.data.is_empty() - && self.char_all(|c| { - [ - NumericType::Decimal, - NumericType::Digit, - NumericType::Numeric, - ] - .contains(&NumericType::for_char(c)) - }) + !self.data.is_empty() && self.char_all(unicode::classify::is_numeric) } #[pymethod] fn isdigit(&self) -> bool { - !self.data.is_empty() - && self.char_all(|c| { - [NumericType::Digit, NumericType::Decimal].contains(&NumericType::for_char(c)) - }) + !self.data.is_empty() && self.char_all(unicode::classify::is_digit) } #[pymethod] fn isdecimal(&self) -> bool { - !self.data.is_empty() - && self.char_all(|c| { - matches!(GeneralCategory::for_char(c), GeneralCategory::DecimalNumber) - }) + !self.data.is_empty() && self.char_all(unicode::classify::is_decimal) } fn __mod__(&self, values: PyObjectRef, vm: &VirtualMachine) -> PyResult { @@ -1143,9 +1111,7 @@ impl PyStr { #[pymethod] fn isalpha(&self) -> bool { - !self.data.is_empty() - && self - .char_all(|c| GeneralCategoryGroup::Letter.contains(GeneralCategory::for_char(c))) + !self.data.is_empty() && self.char_all(unicode::classify::is_alpha) } #[pymethod] @@ -1175,23 +1141,12 @@ impl PyStr { #[pymethod] fn isprintable(&self) -> bool { - self.char_all(|c| c == '\u{0020}' || rustpython_literal::char::is_printable(c)) + self.char_all(unicode::classify::is_printable) } #[pymethod] fn isspace(&self) -> bool { - !self.data.is_empty() - && self.char_all(|c| { - matches!( - GeneralCategory::for_char(c), - GeneralCategory::SpaceSeparator - ) || matches!( - BidiClass::for_char(c), - BidiClass::WhiteSpace - | BidiClass::ParagraphSeparator - | BidiClass::SegmentSeparator - ) - }) + !self.data.is_empty() && self.char_all(unicode::classify::is_space) } // Return true if all cased characters in the string are lowercase and there is at least one cased character, false otherwise. @@ -1452,12 +1407,10 @@ impl PyStr { let Some(s) = self.to_str() else { return false }; let mut chars = s.chars(); - let is_identifier_start = chars - .next() - .is_some_and(|c| c == '_' || XidStart::for_char(c)); + let is_identifier_start = chars.next().is_some_and(unicode::identifier::is_start); // a string is not an identifier if it has whitespace or starts with a number - is_identifier_start && chars.all(XidContinue::for_char) + is_identifier_start && chars.all(unicode::identifier::is_continue) } // https://docs.python.org/3/library/stdtypes.html#str.translate diff --git a/crates/wtf8/Cargo.toml b/crates/wtf8/Cargo.toml index 110b54ad0ca..20bf824898a 100644 --- a/crates/wtf8/Cargo.toml +++ b/crates/wtf8/Cargo.toml @@ -9,7 +9,7 @@ repository.workspace = true license.workspace = true [dependencies] -ascii = { workspace = true } -bstr = { workspace = true } +ascii = { workspace = true, features = ["alloc"] } +bstr = { workspace = true, features = ["alloc"] } itertools = { workspace = true } memchr = { workspace = true } diff --git a/crates/wtf8/src/lib.rs b/crates/wtf8/src/lib.rs index 772a2879944..b31ed1cf09c 100644 --- a/crates/wtf8/src/lib.rs +++ b/crates/wtf8/src/lib.rs @@ -31,7 +31,7 @@ //! to match CPython's behavior. //! //! [WTF-8]: https://simonsapin.github.io/wtf-8 -//! [`OsStr`]: std::ffi::OsStr +//! [`OsStr`]: https://doc.rust-lang.org/std/ffi/struct.OsStr.html #![no_std] #![allow(clippy::precedence, clippy::match_overlapping_arm)] diff --git a/extra_tests/snippets/stdlib_unicode_shared.py b/extra_tests/snippets/stdlib_unicode_shared.py new file mode 100644 index 00000000000..ff8bc0533e0 --- /dev/null +++ b/extra_tests/snippets/stdlib_unicode_shared.py @@ -0,0 +1,93 @@ +# Exercises the Unicode semantics routed through the shared rustpython-unicode +# crate: str predicates, casefold, identifier rules, unicodedata queries, +# normalization, \N{} escapes, and re character classes. + +import re +import unicodedata + +# --- str classification predicates --------------------------------------- + +# Numeric_Type chain: isdecimal ⊂ isdigit ⊂ isnumeric +assert "5".isdecimal() and "5".isdigit() and "5".isnumeric() +assert not "²".isdecimal() # SUPERSCRIPT TWO: digit but not decimal +assert "²".isdigit() and "²".isnumeric() +assert not "⅓".isdigit() # VULGAR FRACTION ONE THIRD: numeric only +assert "⅓".isnumeric() + +assert "abc".isalpha() +assert "abc123".isalnum() +assert not "abc123".isalpha() +assert "あ".isalpha() # HIRAGANA LETTER A + +assert " \t\n".isspace() +assert " ".isspace() # IDEOGRAPHIC SPACE +assert "hello world".isprintable() +assert not "\x00".isprintable() +assert " ".isprintable() # ASCII space is printable + +# identifier rules (XID_Start / XID_Continue, plus leading underscore) +assert "_var".isidentifier() +assert "유니코드".isidentifier() # Hangul identifier +assert not "1abc".isidentifier() +assert not "a b".isidentifier() + +# --- case mapping / casefold --------------------------------------------- + +assert "ABC".lower() == "abc" +assert "abc".upper() == "ABC" +# casefold uses full mappings, unlike lower() +assert "ß".casefold() == "ss" # LATIN SMALL LETTER SHARP S +assert "Σ".casefold() == "σ" # GREEK CAPITAL SIGMA -> small sigma +assert "Straße".casefold() == "strasse" + +# lone-surrogate safety: casefold must not panic on surrogates +surrogate = "\ud800" +assert surrogate.casefold() == surrogate + +# --- unicodedata ---------------------------------------------------------- + +assert unicodedata.category("A") == "Lu" +assert unicodedata.category("1") == "Nd" +assert unicodedata.bidirectional("A") == "L" +assert unicodedata.decimal("٥") == 5 # ARABIC-INDIC DIGIT FIVE +assert unicodedata.digit("²") == 2 +assert abs(unicodedata.numeric("⅓") - (1 / 3)) < 1e-6 +assert unicodedata.name("☃") == "SNOWMAN" +assert unicodedata.lookup("SNOWMAN") == "☃" +assert unicodedata.combining("́") == 230 # COMBINING ACUTE ACCENT +assert unicodedata.mirrored("(") == 1 +assert unicodedata.east_asian_width("あ") == "W" + +# ucd_3_2_0 legacy view (used by stringprep) +assert unicodedata.ucd_3_2_0.unidata_version == "3.2.0" + +# --- normalization -------------------------------------------------------- + +composed = "é" # é +decomposed = "é" +assert unicodedata.normalize("NFC", decomposed) == composed +assert unicodedata.normalize("NFD", composed) == decomposed +assert unicodedata.is_normalized("NFC", composed) +assert not unicodedata.is_normalized("NFD", composed) + +# --- \N{} escapes (compiler) --------------------------------------------- + +assert "\N{SNOWMAN}" == "☃" +assert "\N{GREEK SMALL LETTER ALPHA}" == "α" + +# --- re character classes ------------------------------------------------- + +assert re.fullmatch(r"\w+", "abc_123") is not None +assert re.fullmatch(r"\w+", "유니코드") is not None # \w is Unicode-aware +assert re.fullmatch(r"\d+", "123") is not None +# \d matches Unicode decimal digits (category Nd), not just ASCII +assert re.fullmatch(r"\d", "٥") is not None # ARABIC-INDIC DIGIT FIVE +assert re.fullmatch(r"\d", "५") is not None # DEVANAGARI DIGIT FIVE +assert re.fullmatch(r"\d", "²") is None # SUPERSCRIPT TWO (No), not decimal +assert re.fullmatch(r"\s+", " \t\n") is not None +# ASCII flag restricts \w to ASCII +assert re.fullmatch(r"\w+", "유", re.ASCII) is None +# case-insensitive matching routes through the shared case helpers +assert re.fullmatch(r"straße", "STRAßE", re.IGNORECASE) is not None + +print("stdlib_unicode_shared: OK") From f7666c60628315160acc20fe3b1f4b2688b2ede4 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:29:52 +0900 Subject: [PATCH 084/351] Fix decompose_float subnormals, float format grouping/empty type; add HashSecret::from_keys (#8232) * common: fix decompose_float for subnormal inputs Subnormals carry a biased exponent of 0 and no implicit leading mantissa bit, so the bit-twiddling frexp misread them: hash(5e-324) returned 8404992 instead of CPython's 16777216. Scale subnormals into the normal range by 2**54 (exact) before decomposing and fold the shift back into the returned exponent. Adds a frexp-contract unit test over normals, subnormals, and boundary values. Assisted-by: Claude * common: add HashSecret::from_keys HashSecret's fields are private and the only constructor derives k0/k1 from a u32 seed, so an embedder cannot reproduce a fixed keying (e.g. a deterministic PYTHONHASHSEED=0-style run). Add a const constructor taking explicit SipHash keys; the seeded path is unchanged. Assisted-by: Claude * common: fix float format grouping and empty presentation type Thousands grouping split the magnitude only on '.', so exponent and percent tails received separators (format(1e20, ',e') gave '1e+,20'-class corruption). Group only the leading integer digits for interval-3 output; hex/octal/binary keep whole-magnitude grouping. Empty presentation type: with a precision, clamp it to at least 1 and restore a trailing '.0' on integer-looking results (Py_DTSF_ADD_DOT_0); without one, alternate form now inserts the forced decimal point before an exponent instead of appending it. Verified byte-identical to CPython 3.14 over a hash/format differential battery; test_format, test_float, test_fstring, test_hash pass. Assisted-by: Claude * common: satisfy rustfmt and clippy in new hash/float tests - float_ops: use core::f64::consts::PI in the frexp-contract battery (clippy::approx_constant) - hash: rewrite the keyed_hash test via BuildHasher::hash_one (clippy::manual_hash_one) - rustfmt the new float_ops/hash/format test modules Assisted-by: Claude * common: add DTSF to format.rs spell-checker ignores Assisted-by: Claude --- crates/common/src/float_ops.rs | 136 +++++++++++++++++++++++-- crates/common/src/format.rs | 180 +++++++++++++++++++++++++++++---- crates/common/src/hash.rs | 39 +++++++ 3 files changed, 330 insertions(+), 25 deletions(-) diff --git a/crates/common/src/float_ops.rs b/crates/common/src/float_ops.rs index f643961534a..ca1c716a9bd 100644 --- a/crates/common/src/float_ops.rs +++ b/crates/common/src/float_ops.rs @@ -5,13 +5,21 @@ use num_traits::{Signed, ToPrimitive}; #[must_use] pub const fn decompose_float(value: f64) -> (f64, i32) { if value == 0.0 { - (0.0, 0) - } else { - let bits = value.to_bits(); - let exponent: i32 = ((bits >> 52) & 0x7ff) as i32 - 1022; - let mantissa_bits = bits & (0x000f_ffff_ffff_ffff) | (1022 << 52); - (f64::from_bits(mantissa_bits), exponent) + return (0.0, 0); } + let bits = value.to_bits(); + // Subnormals carry a biased exponent of 0 and no implicit leading mantissa + // bit, so the normal decomposition below would misread them. Scale them up + // into the normal range first (exact, since it is a power-of-two shift) and + // fold the scale back into the returned exponent. + let (bits, exponent_adjust) = if (bits >> 52) & 0x7ff == 0 { + ((value * (1u64 << 54) as f64).to_bits(), -54) + } else { + (bits, 0) + }; + let exponent: i32 = ((bits >> 52) & 0x7ff) as i32 - 1022 + exponent_adjust; + let mantissa_bits = bits & (0x000f_ffff_ffff_ffff) | (1022 << 52); + (f64::from_bits(mantissa_bits), exponent) } /// Equate an integer to a float. @@ -270,3 +278,119 @@ pub fn round_float_digits(x: f64, ndigits: i32) -> Option { } Some(result) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::hash::hash_float; + + /// Exact `2**e` for `e` in `[-1074, 1023]`, built from bits so extreme + /// exponents don't overflow through an intermediate `2**|e|`. + fn pow2(e: i32) -> f64 { + if e >= -1022 { + f64::from_bits(((e + 1023) as u64) << 52) + } else { + f64::from_bits(1u64 << (e + 1074)) + } + } + + /// `decompose_float` is a frexp returning the *magnitude* mantissa: for a + /// nonzero `value`, `m` lies in `[0.5, 1)` and `m * 2**e == value.abs()`, + /// including for subnormals which have no implicit leading mantissa bit. + /// (Its sole caller reintroduces the sign via `value.signum()`.) + #[test] + fn decompose_float_frexp_contract() { + let mut values = alloc::vec![ + 0.0, + f64::from_bits(1), // smallest subnormal + f64::from_bits(2), + f64::from_bits(0x000f_ffff_ffff_ffff), // largest subnormal + f64::MIN_POSITIVE, // DBL_MIN, smallest normal + f64::from_bits(f64::MIN_POSITIVE.to_bits() - 1), // predecessor + 1.0, + 1.5, + 0.1, + core::f64::consts::PI, + ]; + for e in -1074..=1023 { + values.push(pow2(e)); + values.push(-pow2(e)); + } + for &v in &values { + let (m, e) = decompose_float(v); + if v == 0.0 { + assert_eq!((m, e), (0.0, 0)); + continue; + } + assert!( + (0.5..1.0).contains(&m), + "mantissa {m} out of [0.5, 1) for value {v:e}" + ); + // Reconstruct: m * 2**e must round-trip to the magnitude. Fold one + // power of two into the mantissa so `e` stays within `pow2`'s range + // (frexp yields e up to 1024 for 2**1023). + let reconstructed = (m * 2.0) * pow2(e - 1); + assert_eq!( + reconstructed.to_bits(), + v.abs().to_bits(), + "reconstruction failed for {v:e}: m={m}, e={e}" + ); + } + } + + /// Subnormal frexp regression: hash of the smallest positive subnormal. + #[test] + fn hash_float_smallest_subnormal() { + // hash(5e-324) == 16777216 (CPython 3.14 ground truth). The pre-fix + // bit-twiddling frexp returned 8404992 here. + assert_eq!(hash_float(f64::from_bits(1)), Some(16777216)); + } + + /// Differential float-hash table captured from CPython 3.14.5, spanning + /// subnormal boundaries, powers of two across the whole exponent range, and + /// a spread of normals. + #[test] + fn hash_float_matches_cpython() { + const HASH_CASES: &[(u64, i64)] = &[ + (0x0000000000000001, 16777216), // smallest subnormal 5e-324 + (0x0000000000000002, 33554432), // subnormal + (0x00000000deadbeef, 62678480394911744), // subnormal midrange + (0x0008000000000000, 16384), // subnormal high bit + (0x000fffffffffffff, 2305843009196949503), // largest subnormal + (0x0010000000000000, 32768), // DBL_MIN smallest normal + (0x8000000000000001, -16777216), // negative smallest subnormal + (0x0020000000000000, 65536), // 2**-1021 + (0x0170000000000000, 137438953472), // 2**-1000 + (0x39b0000000000000, 4194304), // 2**-100 + (0x3f50000000000000, 2251799813685248), // 2**-10 + (0x3fe0000000000000, 1152921504606846976), // 2**-1 + (0x3ff0000000000000, 1), // 2**0 + (0x4000000000000000, 2), // 2**1 + (0x4090000000000000, 1024), // 2**10 + (0x4630000000000000, 549755813888), // 2**100 + (0x7e70000000000000, 16777216), // 2**1000 + (0x7fe0000000000000, 140737488355328), // 2**1023 + (0xffe0000000000000, -140737488355328), // -2**1023 + (0x3ff8000000000000, 1152921504606846977), // 1.5 + (0x400921fb54442d18, 326490430436040707), // 3.141592653589793 + (0x7e37e43c8800759c, 1224995262755759164), // 1e+300 + (0x01a56e1fc2f8f359, 482449582752280463), // 1e-300 + (0x40c81cd6c8b43958, 1563361560246628409), // 12345.678 + (0x3fb999999999999a, 230584300921369408), // 0.1 + (0x4005666666666666, 1556444031219243010), // 2.675 + (0x4132d68700000000, 1234567), // 1234567.0 + (0x44dfe154f457ea13, 1428027733287631914), // 6.022e+23 + (0x3c07a42f549647fb, 851769299698974080), // 1.602e-19 + (0xbff0000000000000, -2), // -1.0 + (0xbfb999999999999a, -230584300921369408), // -0.1 + ]; + for &(bits, expected) in HASH_CASES { + let v = f64::from_bits(bits); + assert_eq!( + hash_float(v), + Some(expected), + "hash mismatch for {v:e} (bits {bits:#018x})" + ); + } + } +} diff --git a/crates/common/src/format.rs b/crates/common/src/format.rs index 5510dce41d0..1f43335b73e 100644 --- a/crates/common/src/format.rs +++ b/crates/common/src/format.rs @@ -1,4 +1,4 @@ -// spell-checker:ignore ddfe +// spell-checker:ignore ddfe DTSF use core::ops::Deref; use core::{cmp, str::FromStr}; use itertools::{Itertools, PeekingNext}; @@ -380,15 +380,31 @@ impl FormatSpec { sep: char, disp_digit_cnt: i32, ) -> String { - // Don't add separators to the floating decimal point of numbers - let mut parts = magnitude_str.splitn(2, '.'); - let magnitude_int_str = parts.next().unwrap().to_string(); + // Group only the leading integer digits; the trailing remainder must + // never receive separators. For decimal and float output (interval 3) + // that remainder is the decimal point and fraction, an exponent + // (`e+NN`), or a trailing percent sign. Hex/octal/binary output + // (interval 4) has no such tail and its `a`-`f`/`e`/`E` are digits, so + // the whole magnitude is groupable. + let int_len = if inter == 4 { + magnitude_str.len() + } else { + magnitude_str + .bytes() + .position(|b| !b.is_ascii_digit()) + .unwrap_or(magnitude_str.len()) + }; + // No leading integer digits (e.g. "inf"/"nan") means nothing to group; + // leave any width padding to the fill/align step. + if int_len == 0 { + return magnitude_str; + } + let magnitude_int_str = magnitude_str[..int_len].to_string(); + let remainder = &magnitude_str[int_len..]; let dec_digit_cnt = magnitude_str.len() as i32 - magnitude_int_str.len() as i32; let int_digit_cnt = disp_digit_cnt - dec_digit_cnt; let mut result = Self::separate_integer(magnitude_int_str, inter, sep, int_digit_cnt); - if let Some(part) = parts.next() { - result.push_str(&format!(".{part}")) - } + result.push_str(remainder); result } @@ -786,14 +802,40 @@ impl FormatSpec { magnitude if magnitude.is_nan() => Ok("nan".to_owned()), magnitude if magnitude.is_infinite() => Ok("inf".to_owned()), _ => match self.precision { - Some(precision) => Ok(float::format_general( - precision, - magnitude, - Case::Lower, - self.alternate_form, - true, - )), - None => Ok(float::to_string(magnitude)), + Some(precision) => { + // Empty presentation type with a precision behaves like + // `g` but repr-like: precision is clamped to at least 1, + // and an integer-looking result keeps a trailing `.0` + // (Py_DTSF_ADD_DOT_0). + let precision = if precision == 0 { 1 } else { precision }; + let s = float::format_general( + precision, + magnitude, + Case::Lower, + self.alternate_form, + true, + ); + Ok(if s.bytes().any(|b| matches!(b, b'.' | b'e' | b'E')) { + s + } else { + format!("{s}.0") + }) + } + None => { + let s = float::to_string(magnitude); + // Alternate form forces a decimal point into the + // repr-like output. Only exponent-form values lack one + // (`1e+16` -> `1.e+16`); fixed-form repr already carries + // a `.`. + Ok(if self.alternate_form && !s.contains('.') { + match s.find(['e', 'E']) { + Some(pos) => format!("{}.{}", &s[..pos], &s[pos..]), + None => format!("{s}."), + } + } else { + s + }) + } }, }, }; @@ -857,10 +899,11 @@ impl FormatSpec { Err(FormatSpecError::UnknownFormatCode('N', "int")) } Some(FormatType::String) => Err(FormatSpecError::UnknownFormatCode('s', "int")), - Some(FormatType::Character) => match (self.sign, self.alternate_form) { - (Some(_), _) => Err(FormatSpecError::NotAllowed("Sign")), - (_, true) => Err(FormatSpecError::NotAllowed("Alternate form (#)")), - (_, _) => match num.to_u32() { + Some(FormatType::Character) => match (self.precision, self.sign, self.alternate_form) { + (Some(_), _, _) => Err(FormatSpecError::PrecisionNotAllowed), + (_, Some(_), _) => Err(FormatSpecError::NotAllowed("Sign")), + (_, _, true) => Err(FormatSpecError::NotAllowed("Alternate form (#)")), + (_, _, _) => match num.to_u32() { Some(n) if n <= 0x10ffff => Ok(core::char::from_u32(n).unwrap().to_string()), Some(_) | None => Err(FormatSpecError::CodeNotInRange), }, @@ -1664,6 +1707,105 @@ mod tests { assert_eq!(result, "000001,234"); } + fn fmt_float(spec: &str, value: f64) -> String { + FormatSpec::parse(spec) + .unwrap() + .format_float(value) + .unwrap() + } + + #[test] + fn format_float_grouping_never_touches_exponent() { + // Grouping must group only the mantissa's integer digits, never the + // exponent digits (was "1e,+20") or a trailing percent sign. + assert_eq!(fmt_float(",g", 1e20), "1e+20"); + assert_eq!(fmt_float("_g", 1e-10), "1e-10"); + assert_eq!(fmt_float(",e", 1e20), "1.000000e+20"); + assert_eq!(fmt_float(",", 1e16), "1e+16"); + assert_eq!(fmt_float(",.0%", 1.0), "100%"); + assert_eq!(fmt_float(",.2%", 12345.0), "1,234,500.00%"); + // Fixed-form grouping still groups the integer part. + assert_eq!(fmt_float(",", 1234567.0), "1,234,567.0"); + } + + #[test] + fn format_float_grouping_inf_nan() { + // No integer digits to group; width padding is left to fill/align, so + // separators never land inside "inf"/"nan". + assert_eq!(fmt_float(",", f64::INFINITY), "inf"); + assert_eq!(fmt_float("06,", f64::INFINITY), "000inf"); + assert_eq!(fmt_float("06,", f64::NAN), "000nan"); + assert_eq!(fmt_float("06,%", f64::INFINITY), "00inf%"); + } + + #[test] + fn format_float_empty_type_with_precision() { + // Empty presentation type with a precision is repr-like: precision is + // clamped to at least 1 and integer-looking output keeps a `.0`. + assert_eq!(fmt_float(".2", 1.0), "1.0"); + assert_eq!(fmt_float(".6", 100.0), "100.0"); + assert_eq!(fmt_float(".17", 1234567.0), "1234567.0"); + assert_eq!(fmt_float(".0", 0.5), "0.5"); + assert_eq!(fmt_float(".0", 0.0001), "0.0001"); + assert_eq!(fmt_float(".2", 0.0), "0.0"); + assert_eq!(fmt_float(".0", 0.0), "0e+00"); + assert_eq!(fmt_float(".2", 100.0), "1e+02"); + } + + #[test] + fn format_float_alternate_form_forces_point() { + // Alternate form injects a decimal point into exponent-form repr. + assert_eq!(fmt_float("#", 1e16), "1.e+16"); + assert_eq!(fmt_float("#", 1e-5), "1.e-05"); + // Fixed-form repr already has a point, so it is unchanged. + assert_eq!(fmt_float("#", 100.0), "100.0"); + assert_eq!(fmt_float("#", 1.5), "1.5"); + } + + #[test] + fn format_int_hex_grouping_preserved() { + // Underscore grouping of hex/octal groups every 4 digits, including the + // `a`-`f` letters. + assert_eq!( + FormatSpec::parse("_x") + .unwrap() + .format_int(&BigInt::from(1000000)) + .unwrap(), + "f_4240" + ); + assert_eq!( + FormatSpec::parse("_X") + .unwrap() + .format_int(&BigInt::from(0xABCDEFu32)) + .unwrap(), + "AB_CDEF" + ); + } + + #[test] + fn format_int_character_rejects_precision() { + // 'c' rejects precision, and precision is checked before sign/alt form. + assert_eq!( + FormatSpec::parse(".2c") + .unwrap() + .format_int(&BigInt::from(65)), + Err(FormatSpecError::PrecisionNotAllowed) + ); + assert_eq!( + FormatSpec::parse("+.2c") + .unwrap() + .format_int(&BigInt::from(65)), + Err(FormatSpecError::PrecisionNotAllowed) + ); + // Without precision, 'c' still renders the code point. + assert_eq!( + FormatSpec::parse("c") + .unwrap() + .format_int(&BigInt::from(65)), + Ok("A".to_owned()) + ); + } + #[test] fn format_parse() { let expected = Ok(FormatString { diff --git a/crates/common/src/hash.rs b/crates/common/src/hash.rs index 56e6e000676..1c58abc20f7 100644 --- a/crates/common/src/hash.rs +++ b/crates/common/src/hash.rs @@ -50,6 +50,14 @@ impl HashSecret { Self { k0, k1 } } + /// Build a secret from explicit SipHash keys, bypassing seed derivation. + /// Lets an embedder reproduce a fixed keying (e.g. a deterministic run) that + /// [`new`](Self::new) cannot express through its `u32` seed. + #[must_use] + pub const fn from_keys(k0: u64, k1: u64) -> Self { + Self { k0, k1 } + } + pub fn hash_value(&self, data: &T) -> PyHash { fix_sentinel(mod_int(self.hash_one(data) as _)) } @@ -296,3 +304,34 @@ impl FrozenSetHash { hash as PyHash } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_keys_is_stable_and_seed_independent() { + const K0: u64 = 0x0706_0504_0302_0100; + const K1: u64 = 0x0f0e_0d0c_0b0a_0908; + const LOCKED_DIGEST: PyHash = -1862661396243998188; + + // Two secrets built from the same explicit keys hash identically, and + // the digest does not depend on the seed-derivation path. + let a = HashSecret::from_keys(K0, K1); + let b = HashSecret::from_keys(K0, K1); + assert_eq!(a.hash_str("hello"), b.hash_str("hello")); + assert_eq!( + a.hash_bytes(b"a fixed message"), + b.hash_bytes(b"a fixed message") + ); + + // Explicit keys drive the SipHasher-2-4 directly. `keyed_hash` pins + // k1 = 0, so a secret built with the same k0 and k1 = 0 must reproduce + // its raw digest. + let zero_k1 = HashSecret::from_keys(K0, 0); + assert_eq!(keyed_hash(K0, b"payload"), zero_k1.hash_one(b"payload")); + + // Locked digest so an accidental keying change is caught. + assert_eq!(a.hash_str("determinism"), LOCKED_DIGEST); + } +} From 510966039b8e49127861c1a8d1ac543ed3ffc64c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:30:13 +0900 Subject: [PATCH 085/351] Bump libz-rs-sys from 0.6.4 to 0.6.5 (#8227) Bumps [libz-rs-sys](https://github.com/trifectatechfoundation/zlib-rs) from 0.6.4 to 0.6.5. - [Release notes](https://github.com/trifectatechfoundation/zlib-rs/releases) - [Changelog](https://github.com/trifectatechfoundation/zlib-rs/blob/main/docs/release.md) - [Commits](https://github.com/trifectatechfoundation/zlib-rs/compare/v0.6.4...v0.6.5) --- updated-dependencies: - dependency-name: libz-rs-sys dependency-version: 0.6.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 31086945509..6a37af43c6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2042,9 +2042,9 @@ dependencies = [ [[package]] name = "libz-rs-sys" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3deaad727e11899800b9a177af14682bfa5d5d96fa525a6c9bd13e650c019f40" +checksum = "5c12cd6e7e66c601f22d849241e2257b38b4685a34b41401f4aefdd9c431c1c6" dependencies = [ "zlib-rs", ] @@ -5141,9 +5141,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" +checksum = "5431d5661c32445236631278f27946e444ddafe4684cac70b185272d4f9c52d5" [[package]] name = "zmij" From c174058e2f4b0e97b9edfa813a6640c9bdca93db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:33:17 +0900 Subject: [PATCH 086/351] Bump rand from 0.10.1 to 0.10.2 in the random group across 1 directory (#8224) Bumps the random group with 1 update in the / directory: [rand](https://github.com/rust-random/rand). Updates `rand` from 0.10.1 to 0.10.2 - [Release notes](https://github.com/rust-random/rand/releases) - [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md) - [Commits](https://github.com/rust-random/rand/compare/0.10.1...0.10.2) --- updated-dependencies: - dependency-name: rand dependency-version: 0.10.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: random ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6a37af43c6e..17f4ddb8169 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2926,9 +2926,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -3455,7 +3455,7 @@ dependencies = [ "is-macro", "lexical-parse-float", "num-traits", - "rand 0.10.1", + "rand 0.10.2", "rustpython-unicode", "rustpython-wtf8", ] @@ -3596,7 +3596,7 @@ dependencies = [ "phf 0.14.0", "pkcs8", "pymath", - "rand 0.10.1", + "rand 0.10.2", "rapidhash", "rustls", "rustls-native-certs", @@ -4190,7 +4190,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", From 984cb5089ebb04ce22c37c467c0fc001af2a2f36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:33:27 +0900 Subject: [PATCH 087/351] Bump actions/cache/save from 5.0.5 to 6.1.0 (#8221) Bumps [actions/cache/save](https://github.com/actions/cache) from 5.0.5 to 6.1.0. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/27d5ce7f107fe9357f9df03efb73ab90386fccae...55cc8345863c7cc4c66a329aec7e433d2d1c52a9) --- updated-dependencies: - dependency-name: actions/cache/save dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- .github/workflows/update-caches.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e42882bd806..a029776912d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -581,7 +581,7 @@ jobs: - name: save prek cache if: ${{ github.ref == 'refs/heads/main' }} # only save on main - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: prek-${{ hashFiles('.pre-commit-config.yaml') }} path: ~/.cache/prek @@ -744,7 +744,7 @@ jobs: - name: Save npm cache # Save only on main or release if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/release' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.npm-cache-dir.outputs.dir }} key: node-${{ runner.os }}-wasm-demo-${{ hashFiles('wasm/demo/package-lock.json') }} diff --git a/.github/workflows/update-caches.yml b/.github/workflows/update-caches.yml index fd3dddb7ae2..de94f3f50e0 100644 --- a/.github/workflows/update-caches.yml +++ b/.github/workflows/update-caches.yml @@ -67,7 +67,7 @@ jobs: run: cargo build --profile release ${{ env.CARGO_ARGS }} - name: Save cache - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/bin/ From 3f330734e0edca72deac72f559c8674bdb3516c3 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:14:45 +0900 Subject: [PATCH 088/351] more hostenv isolation (#7932) * Move which crate dependency from vm to host_env Add host_env::fs::which wrapper and update getpath.rs call site. * Move readline module from vm to host_env Move crates/vm/src/readline.rs to crates/host_env/src/readline.rs and re-export it from rustpython_vm::readline. Move rustyline dependency from vm to host_env. Drop the host_env feature gates on history I/O inside the moved module. * Move gethostname and mac_address dependencies from stdlib to host_env Add host_env::socket::hostname and host_env::socket::mac_address wrappers. Update _socket.gethostname and _uuid.get_node_id call sites. * Re-export libc constants and types through host_env Move direct libc constant and type references in stdlib (faulthandler, fcntl, multiprocessing, posixshmem, select, syslog) to use host_env re-exports. Replace libc::c_int and libc::c_uint in stdlib with core::ffi equivalents. * Re-export mmap libc constants through host_env Move direct libc MADV_*, MAP_*, PROT_*, EOVERFLOW references in stdlib::mmap to host_env::mmap re-exports. Replace libc::c_int with core::ffi::c_int. * Re-export locale and termios libc constants through host_env Move direct libc LC_*, ABDAY_*, ABMON_*, TIOC*, FIO* and related constants in stdlib::locale and stdlib::termios to host_env re-exports. Replace libc::c_char in stdlib::locale and stdlib::openssl::cert with core::ffi::c_char. * Replace libc c-types and FILE wrappers in openssl with host_env Replace libc::c_int, c_long, c_uchar, c_uint, c_ulong, c_void, c_char in stdlib::openssl with core::ffi equivalents. Replace libc::ENOENT with host_env::errno::errors::ENOENT. Add host_env::fileutils::{CFile, fclose} and route the load_dh_params fclose call and PEM_read_DHparams FILE pointer type through them. * Fix Windows build: expose EOVERFLOW, SIGSEGV, SIGFPE on Windows host_mmap::EOVERFLOW and host_faulthandler::{SIGSEGV, SIGFPE} re-exports were gated to cfg(unix) but stdlib uses them from Windows and platform-agnostic code paths. * Re-export remaining libc types and constants through host_env Move direct libc references in stdlib (resource, posixsubprocess, grp, socket) to host_env re-exports. Replace libc::c_long, libc::c_longlong, libc::c_char in stdlib::socket with core::ffi equivalents. Add resource::{RLIMIT_*, RUSAGE_*, c_long, rlim_t, rlimit, timeval}, posix::{c_char, pid_t}, grp::gid_t, and socket::{AF_*, SOCK_STREAM, sa_family_t, socklen_t, sockaddr_*} re-exports. * Move socket2 and dns-lookup dependencies from stdlib to host_env Re-export socket2 as host_env::socket::raw and dns-lookup as host_env::socket::dns. Update _socket call sites to route through host_env aliases. * Move system-configuration dependency from stdlib to host_env Re-export the macOS system-configuration crate as host_env::system_configuration and update _scproxy call sites. * Restrict CAN/ALG sockaddr structs to target_os linux libc on Android (bionic) does not define sockaddr_can, so importing it for target_os = "android" fails to compile. Keep AF_ALG/AF_CAN for both linux and android, and gate sockaddr_alg/sockaddr_can to linux only. Assisted-by: Claude --- Cargo.lock | 501 ++++++++++++++---------- crates/host_env/Cargo.toml | 13 + crates/host_env/src/faulthandler.rs | 4 + crates/host_env/src/fcntl.rs | 26 ++ crates/host_env/src/fileutils.rs | 14 +- crates/host_env/src/fs.rs | 10 +- crates/host_env/src/grp.rs | 2 + crates/host_env/src/lib.rs | 5 + crates/host_env/src/locale.rs | 17 + crates/host_env/src/mmap.rs | 1 - crates/host_env/src/multiprocessing.rs | 2 +- crates/host_env/src/posix.rs | 2 + crates/{vm => host_env}/src/readline.rs | 30 +- crates/host_env/src/resource.rs | 34 ++ crates/host_env/src/select.rs | 10 + crates/host_env/src/shm.rs | 2 + crates/host_env/src/socket.rs | 37 ++ crates/host_env/src/syslog.rs | 10 + crates/host_env/src/termios.rs | 50 +++ crates/stdlib/Cargo.toml | 7 - crates/stdlib/src/faulthandler.rs | 15 +- crates/stdlib/src/fcntl.rs | 18 +- crates/stdlib/src/grp.rs | 2 +- crates/stdlib/src/locale.rs | 12 +- crates/stdlib/src/multiprocessing.rs | 6 +- crates/stdlib/src/openssl.rs | 268 +++++++------ crates/stdlib/src/openssl/cert.rs | 2 +- crates/stdlib/src/posixshmem.rs | 8 +- crates/stdlib/src/posixsubprocess.rs | 12 +- crates/stdlib/src/resource.rs | 63 +-- crates/stdlib/src/scproxy.rs | 4 +- crates/stdlib/src/select.rs | 15 +- crates/stdlib/src/socket.rs | 90 +++-- crates/stdlib/src/syslog.rs | 4 +- crates/stdlib/src/termios.rs | 46 +-- crates/stdlib/src/uuid.rs | 9 +- crates/vm/Cargo.toml | 2 - crates/vm/src/getpath.rs | 2 +- crates/vm/src/lib.rs | 2 +- 39 files changed, 837 insertions(+), 520 deletions(-) rename crates/{vm => host_env}/src/readline.rs (86%) diff --git a/Cargo.lock b/Cargo.lock index 17f4ddb8169..5eb95bea29c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,17 +14,41 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "aes" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" dependencies = [ "cipher", "cpubits", "cpufeatures 0.3.0", ] +[[package]] +name = "aes-gcm" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -116,9 +140,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "approx" @@ -131,9 +155,9 @@ dependencies = [ [[package]] name = "ar_archive_writer" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" dependencies = [ "object", ] @@ -152,9 +176,9 @@ checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" [[package]] name = "asn1-rs" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ "asn1-rs-derive", "asn1-rs-impl", @@ -230,29 +254,30 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-fips-sys" -version = "0.13.14" +version = "0.13.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d619165468401dec3caa3366ebffbcb83f2f31883e5b3932f8e2dec2ddc568" +checksum = "6c0e6249c249b8916c98ebae7bc06216c8dcab3002f32872b4abe642d17063b1" dependencies = [ "bindgen 0.72.1", "cc", "cmake", "dunce", "fs_extra", + "pkg-config", "regex", ] [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-fips-sys", "aws-lc-sys", @@ -261,14 +286,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -299,7 +325,7 @@ dependencies = [ "quote", "regex", "rustc-hash", - "shlex", + "shlex 1.3.0", "syn", ] @@ -319,7 +345,7 @@ dependencies = [ "quote", "regex", "rustc-hash", - "shlex", + "shlex 1.3.0", "syn", ] @@ -367,9 +393,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -385,20 +411,20 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" dependencies = [ "allocator-api2", ] @@ -411,9 +437,9 @@ checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "bzip2" @@ -450,14 +476,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.61" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -483,9 +509,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -538,7 +564,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "crypto-common 0.2.2", "inout", ] @@ -627,9 +653,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -641,9 +667,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", @@ -730,9 +756,9 @@ dependencies = [ [[package]] name = "cranelift" -version = "0.132.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5c702984722ad27d12c532df8467890f922bf5b1932286906dd2bb6779982c" +checksum = "69c8702ad42c0aac8d585f1c3ffe8039bcd996898615c8948a1943e0c9661232" dependencies = [ "cranelift-codegen", "cranelift-frontend", @@ -741,27 +767,27 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.132.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77c4ebb31662e2051dcc49b7342d222405a99e951720756cc4b93315972abd67" +checksum = "3d521bdbc6098937af83ef4ab6d5c07398126bc71878f7ef4ea9499977978ef5" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.132.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "106dfc2ec96ec1c3a8a250602e936712e00a381df032f7a8ad175c8f768c03bb" +checksum = "3dde0b83164d4a497860af4236271178bc640512b067101e0853e4f74eaa4df5" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.132.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5694aa8a2eb2571a15b3feee38d16ccaf2712200e7b5c9ae0479069bdfb46949" +checksum = "0111d110b72b4efad69a372e29e21628652fd0bcab66967e5c8350ab679affd5" dependencies = [ "cranelift-entity", "wasmtime-internal-core", @@ -769,18 +795,18 @@ dependencies = [ [[package]] name = "cranelift-bitset" -version = "0.132.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93ab349d30a5fad9699440ee7ccb435374e8a8735dcca26696a4245bcefcc47e" +checksum = "cf01ecc92fc5499789d79c3b817299d5a8ddd828d31dcf0f9cc3cc66f38dbb36" dependencies = [ "wasmtime-internal-core", ] [[package]] name = "cranelift-codegen" -version = "0.132.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e95970bdb51d145c828a114a1084cb8b63e65569a51600ad398cb49fa78b062" +checksum = "6cd2563bead0090c3879a7ff7327f7550c9d59bb5d05ecc8cd7886977aa3125a" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -792,7 +818,7 @@ dependencies = [ "cranelift-entity", "cranelift-isle", "gimli", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "libm", "log", "regalloc2", @@ -805,9 +831,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.132.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8414b8ecc81f89f8a3f2c5cbc785b9ed690200cd6f9d780e96a92f88879704" +checksum = "9640d250d26f9381a73dc7f9862b27928d4809119aec73be0e556612e67b6a99" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -817,24 +843,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.132.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "631c4e5db42e6a0f9e7a68f18f7faba3692862114870c7c598aee7e0e5677e59" +checksum = "a07f156b90efc94371ddb3536f76e4ab671ad2e093bfa5511e10198cadbb0c47" [[package]] name = "cranelift-control" -version = "0.132.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09c6e92c825abfbb739a4beaa5db3988f98a96a68d6ea656f562098efc142976" +checksum = "d75a76fd9dd37dcbc3d2d2e00abe3281a7e88adc035fba3b8114cc69981576ec" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.132.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55e57cd185782abada9ab2606bfe88d0abc0d42d83a7d432dcf69991e17fe76e" +checksum = "2eea22522144ba08c7e7ef94bfc25d474ef016c2771974b8ab1986734ac85965" dependencies = [ "cranelift-bitset", "wasmtime-internal-core", @@ -842,9 +868,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.132.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0f17e48d15e29552e2f264d302c31a661a830196d879bbdaf4d7b2bf2f7011" +checksum = "efa2826c80dff1d93b19b3cfbcaf9fae44c78d739a98d146dd5bd89c51e14367" dependencies = [ "cranelift-codegen", "log", @@ -854,15 +880,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.132.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "407b80b46934c9dce9a6581f0e80d079b7a69e11372fb03d7dce4c7ba3fee4e3" +checksum = "e238a69b95c5415456f22189494a12db94f313bb72b6ec9cc88ddf2f1056e28e" [[package]] name = "cranelift-jit" -version = "0.132.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea72887b62db0e7c387f8c0b7af600772fd9190c6f7205fd2182b0ce10159c80" +checksum = "a7def01f2b14f97421558d1db33e396b97b97ab2ed40dedc8165ba00d13088e6" dependencies = [ "anyhow", "cranelift-codegen", @@ -881,9 +907,9 @@ dependencies = [ [[package]] name = "cranelift-module" -version = "0.132.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd3b5369ba0f409b9b218e2356a71285c6e2815e187329a59db09228fd927c4" +checksum = "985df7eceefc91cb75bf7f51b32b7f1739d0fc0e25ddf023b1de407cb3c93d77" dependencies = [ "anyhow", "cranelift-codegen", @@ -892,9 +918,9 @@ dependencies = [ [[package]] name = "cranelift-native" -version = "0.132.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a40e056e421d9a6c757983f2184f765ae1c28a51555d3877e98afc97ce5705b" +checksum = "eceb0ebd8d6aef6bb287e8d532a164b0db1c0062961211e9c22a91f67521c098" dependencies = [ "cranelift-codegen", "libc", @@ -903,9 +929,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.132.1" +version = "0.132.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cdbadda21e49798825a1ec795dab30bcb03235891f662b5ccf23fa45b39682f" +checksum = "004643f39a7bec553de5263d650db30e5b9caec1d5cbe065fd732b7ec9ae40d0" [[package]] name = "crc32fast" @@ -953,9 +979,9 @@ dependencies = [ [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -963,18 +989,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -1010,6 +1036,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -1025,6 +1060,37 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + [[package]] name = "der" version = "0.7.10" @@ -1079,9 +1145,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derive-where" @@ -1110,7 +1173,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", @@ -1139,9 +1202,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -1174,9 +1237,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "encode_unicode" @@ -1192,9 +1255,9 @@ checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" [[package]] name = "env_filter" -version = "1.0.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ "log", "regex", @@ -1202,9 +1265,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.10" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", @@ -1452,6 +1515,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", +] + [[package]] name = "gimli" version = "0.33.0" @@ -1502,9 +1574,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "foldhash", ] @@ -1553,9 +1625,9 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] @@ -1723,7 +1795,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", ] [[package]] @@ -1807,10 +1879,11 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.24" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" dependencies = [ + "defmt", "jiff-static", "log", "portable-atomic", @@ -1820,9 +1893,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.24" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" dependencies = [ "proc-macro2", "quote", @@ -1880,23 +1953,22 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -1965,9 +2037,9 @@ checksum = "803ec87c9cfb29b9d2633f20cba1f488db3fd53f2158b1024cbefb47ba05d413" [[package]] name = "libbz2-rs-sys" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" @@ -2022,9 +2094,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -2072,9 +2144,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lz4_flex" @@ -2203,9 +2275,9 @@ dependencies = [ [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -2291,9 +2363,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2310,9 +2382,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -2432,9 +2504,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" -version = "300.6.0+3.6.2" +version = "300.6.1+3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" dependencies = [ "cc", ] @@ -2496,7 +2568,7 @@ dependencies = [ [[package]] name = "parking_lot_core" version = "0.9.12" -source = "git+https://github.com/youknowone/parking_lot?branch=rustpython#4392edbe879acc9c0dd94eda53d2205d3ab912c9" +source = "git+https://github.com/youknowone/parking_lot?branch=rustpython#f4ee53a7b803354a8f0a6de2f28e93fc141240bf" dependencies = [ "cfg-if", "libc", @@ -2628,11 +2700,12 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkcs5" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "279a91971a1d8eb1260a30938eae3be9cb67b472dffecb222fbbbe2fd2dc1453" +checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" dependencies = [ "aes", + "aes-gcm", "cbc", "der 0.8.0", "pbkdf2", @@ -2699,6 +2772,17 @@ dependencies = [ "syn", ] +[[package]] +name = "polyval" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2853,9 +2937,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -2962,9 +3046,9 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rapidhash" -version = "4.4.1" +version = "4.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" dependencies = [ "rustversion", ] @@ -3043,7 +3127,7 @@ checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186" dependencies = [ "allocator-api2", "bumpalo", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "log", "rustc-hash", "smallvec", @@ -3051,9 +3135,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -3074,9 +3158,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "region" @@ -3127,9 +3211,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -3164,9 +3248,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "once_cell", @@ -3209,9 +3293,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "zeroize", ] @@ -3412,12 +3496,15 @@ name = "rustpython-host_env" version = "0.5.0" dependencies = [ "bitflags 2.13.0", + "dns-lookup", + "gethostname", "getrandom 0.4.3", "junction", "libc", "libffi", "libloading 0.9.0", - "memmap2 0.9.10", + "mac_address", + "memmap2 0.9.11", "nix 0.31.3", "num-traits", "num_cpus", @@ -3425,8 +3512,12 @@ dependencies = [ "paste", "rustix", "rustpython-wtf8", + "rustyline", "schannel", + "socket2", + "system-configuration", "termios", + "which", "widestring", "windows-sys 0.61.2", ] @@ -3563,12 +3654,10 @@ dependencies = [ "csv-core", "der 0.8.0", "digest 0.11.3", - "dns-lookup", "dyn-clone", "flame", "flate2", "foreign-types-shared", - "gethostname", "hex", "hmac", "indexmap", @@ -3577,7 +3666,6 @@ dependencies = [ "libc", "libsqlite3-sys", "libz-rs-sys", - "mac_address", "malachite-bigint", "md-5", "memchr", @@ -3616,8 +3704,6 @@ dependencies = [ "sha2", "sha3", "shake", - "socket2", - "system-configuration", "tcl-sys", "tk-sys", "uuid", @@ -3695,7 +3781,6 @@ dependencies = [ "rustpython-ruff_text_size", "rustpython-sre_engine", "rustpython-unicode", - "rustyline", "scopeguard", "serde_core", "static_assertions", @@ -3705,7 +3790,6 @@ dependencies = [ "thiserror", "timsort", "wasm-bindgen", - "which", "widestring", "writeable", ] @@ -3740,9 +3824,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rustyline" @@ -3898,9 +3982,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -3987,6 +4071,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signature" version = "2.2.0" @@ -4038,9 +4128,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" @@ -4116,9 +4206,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -4250,12 +4340,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -4265,15 +4354,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -4398,9 +4487,9 @@ checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" @@ -4419,9 +4508,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -4471,6 +4560,16 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -4497,9 +4596,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.3" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ "atomic", "js-sys", @@ -4536,18 +4635,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -4558,9 +4657,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.70" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -4568,9 +4667,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4578,9 +4677,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -4591,28 +4690,28 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] [[package]] name = "wasmtime-internal-core" -version = "45.0.1" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "110bf85122cd451d3b9ff67f8911d428ec9b729208abe950a0333c3244660e88" +checksum = "3073c03f97f871fe6400e68621c863b93ba79296f8e570285231952d23fcc804" dependencies = [ - "hashbrown 0.17.0", + "hashbrown 0.17.1", "libm", ] [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "45.0.1" +version = "45.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa6818a4864719772680694f4e4649a8600bb5efcf71111ebaf7419b266463e8" +checksum = "37dee05e8c35759826f6b926cd949c51f771b6a58619d8e60c63c3f3d3e5e59b" dependencies = [ "cfg-if", "libc", @@ -4622,9 +4721,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.97" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -4632,9 +4731,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] @@ -4659,9 +4758,9 @@ dependencies = [ [[package]] name = "wide" -version = "1.3.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9479f84a757f819cfab37295955906479181395de83add28f74975fde083141" +checksum = "dfdfe6a32973f2d1b268b8895845a8a96cac2f0191e72c27cc929036060dbf89" dependencies = [ "bytemuck", "safe_arch", @@ -4921,9 +5020,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" [[package]] name = "winresource" @@ -5022,9 +5121,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5045,18 +5144,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" dependencies = [ "proc-macro2", "quote", @@ -5065,9 +5164,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -5086,18 +5185,18 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", diff --git a/crates/host_env/Cargo.toml b/crates/host_env/Cargo.toml index 91a56ea91df..3beda890bb4 100644 --- a/crates/host_env/Cargo.toml +++ b/crates/host_env/Cargo.toml @@ -27,6 +27,16 @@ rustix = { workspace = true } [target.'cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))'.dependencies] num_cpus = "1.17.0" +[target.'cfg(not(any(target_os = "ios", target_os = "android", target_os = "windows", target_arch = "wasm32", target_os = "redox")))'.dependencies] +mac_address = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +dns-lookup = { workspace = true } +gethostname = { workspace = true } +rustyline = { workspace = true } +socket2 = { workspace = true, features = ["all"] } +which = { workspace = true } + [target.'cfg(all(unix, not(target_os = "ios"), not(target_os = "redox")))'.dependencies] termios = { workspace = true } @@ -37,6 +47,9 @@ libloading = "0.9" [target.'cfg(all(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "android"), not(any(target_env = "musl", target_env = "sgx"))))'.dependencies] libffi = { workspace = true, features = ["system"] } +[target.'cfg(target_os = "macos")'.dependencies] +system-configuration = { workspace = true } + [target.'cfg(windows)'.dependencies] junction = { workspace = true } schannel = { workspace = true } diff --git a/crates/host_env/src/faulthandler.rs b/crates/host_env/src/faulthandler.rs index 3afbdebb42b..5b19359167b 100644 --- a/crates/host_env/src/faulthandler.rs +++ b/crates/host_env/src/faulthandler.rs @@ -12,6 +12,10 @@ use alloc::vec::Vec; #[cfg(unix)] use parking_lot::Mutex; + +#[cfg(unix)] +pub use libc::{SA_NODEFER, c_int}; +pub use libc::{SIGFPE, SIGSEGV}; #[cfg(windows)] use windows_sys::Win32::System::{ Diagnostics::Debug::{ diff --git a/crates/host_env/src/fcntl.rs b/crates/host_env/src/fcntl.rs index 2467a8727bc..6fb974ba514 100644 --- a/crates/host_env/src/fcntl.rs +++ b/crates/host_env/src/fcntl.rs @@ -5,6 +5,32 @@ use std::os::fd::BorrowedFd; use crate::os::CheckLibcResult; +pub use libc::{F_GETFD, F_GETFL, F_SETFD, F_SETFL, FD_CLOEXEC}; + +#[cfg(not(target_os = "wasi"))] +pub use libc::{F_DUPFD, F_DUPFD_CLOEXEC, F_GETLK, F_SETLK, F_SETLKW}; + +#[cfg(not(any(target_os = "wasi", target_os = "redox")))] +pub use libc::{F_GETOWN, F_RDLCK, F_SETOWN, F_UNLCK, F_WRLCK, LOCK_EX, LOCK_NB, LOCK_SH, LOCK_UN}; + +#[cfg(target_vendor = "apple")] +pub use libc::{F_FULLFSYNC, F_NOCACHE}; + +#[cfg(target_os = "freebsd")] +pub use libc::{F_DUP2FD, F_DUP2FD_CLOEXEC}; + +#[cfg(any(target_os = "android", target_os = "linux"))] +pub use libc::{F_OFD_GETLK, F_OFD_SETLK, F_OFD_SETLKW}; + +#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] +pub use libc::{ + F_ADD_SEALS, F_GET_SEALS, F_GETLEASE, F_GETPIPE_SZ, F_NOTIFY, F_SEAL_GROW, F_SEAL_SEAL, + F_SEAL_SHRINK, F_SEAL_WRITE, F_SETLEASE, F_SETPIPE_SZ, +}; + +#[cfg(any(target_os = "dragonfly", target_os = "netbsd", target_vendor = "apple"))] +pub use libc::F_GETPATH; + pub fn normalize_ioctl_request(request: i64) -> libc::c_ulong { (request as u32) as libc::c_ulong } diff --git a/crates/host_env/src/fileutils.rs b/crates/host_env/src/fileutils.rs index c9e366e3dd5..5cfe3f1d757 100644 --- a/crates/host_env/src/fileutils.rs +++ b/crates/host_env/src/fileutils.rs @@ -442,10 +442,22 @@ pub mod windows { } } +/// C `FILE *` handle as returned by [`fopen`] and consumed by [`fclose`]. +pub type CFile = libc::FILE; + +/// Close a file opened with [`fopen`]. +/// +/// # Safety +/// `fp` must be a non-null pointer returned by [`fopen`] and must not have been +/// closed already. +pub unsafe fn fclose(fp: *mut CFile) -> core::ffi::c_int { + unsafe { libc::fclose(fp) } +} + // _Py_fopen_obj in cpython (Python/fileutils.c:1757-1835) // Open a file using std::fs::File and convert to FILE* // Automatically handles path encoding and EINTR retries -pub fn fopen(path: &std::path::Path, mode: &str) -> std::io::Result<*mut libc::FILE> { +pub fn fopen(path: &std::path::Path, mode: &str) -> std::io::Result<*mut CFile> { use alloc::ffi::CString; use std::fs::File; diff --git a/crates/host_env/src/fs.rs b/crates/host_env/src/fs.rs index 911bd4575b9..01040e73ed5 100644 --- a/crates/host_env/src/fs.rs +++ b/crates/host_env/src/fs.rs @@ -1,7 +1,7 @@ use std::{ fs::{self, File, Metadata, ReadDir}, io, - path::Path, + path::{Path, PathBuf}, }; pub fn open(path: impl AsRef) -> io::Result { @@ -44,10 +44,16 @@ pub fn open_write(path: impl AsRef) -> io::Result { fs::OpenOptions::new().write(true).open(path) } -pub fn canonicalize(path: impl AsRef) -> io::Result { +pub fn canonicalize(path: impl AsRef) -> io::Result { fs::canonicalize(path) } +/// Resolve `binary_name` to an absolute path by searching `PATH` (and `PATHEXT` on Windows). +#[cfg(not(target_arch = "wasm32"))] +pub fn which>(binary_name: T) -> Option { + ::which::which(binary_name).ok() +} + #[cfg(windows)] pub fn open_write_with_custom_flags(path: impl AsRef, flags: u32) -> io::Result { use std::os::windows::fs::OpenOptionsExt; diff --git a/crates/host_env/src/grp.rs b/crates/host_env/src/grp.rs index 131369ce949..54afe8aa54d 100644 --- a/crates/host_env/src/grp.rs +++ b/crates/host_env/src/grp.rs @@ -1,5 +1,7 @@ use std::io; +pub use libc::gid_t; + pub struct Group { pub name: String, pub passwd: String, diff --git a/crates/host_env/src/lib.rs b/crates/host_env/src/lib.rs index 15f816ea8f4..4d123e7e0db 100644 --- a/crates/host_env/src/lib.rs +++ b/crates/host_env/src/lib.rs @@ -30,6 +30,7 @@ pub mod fileutils; pub mod fs; #[cfg(any(unix, windows))] pub mod locale; +pub mod readline; #[cfg(windows)] pub mod windows; @@ -67,6 +68,10 @@ pub mod time; #[cfg(windows)] pub mod cert_store; +#[cfg(target_os = "macos")] +pub mod system_configuration { + pub use ::system_configuration::*; +} #[cfg(any(unix, windows))] pub mod faulthandler; #[cfg(any(unix, windows))] diff --git a/crates/host_env/src/locale.rs b/crates/host_env/src/locale.rs index 52fa7904421..6363515081c 100644 --- a/crates/host_env/src/locale.rs +++ b/crates/host_env/src/locale.rs @@ -1,6 +1,23 @@ use alloc::vec::Vec; use core::{ffi::CStr, ptr}; +pub use libc::{LC_ALL, LC_COLLATE, LC_CTYPE, LC_MONETARY, LC_NUMERIC, LC_TIME}; + +#[cfg(all(unix, not(any(target_os = "ios", target_os = "redox"))))] +pub use libc::LC_MESSAGES; + +#[cfg(all( + unix, + not(any(target_os = "ios", target_os = "android", target_os = "redox")) +))] +pub use libc::{ + ABDAY_1, ABDAY_2, ABDAY_3, ABDAY_4, ABDAY_5, ABDAY_6, ABDAY_7, ABMON_1, ABMON_2, ABMON_3, + ABMON_4, ABMON_5, ABMON_6, ABMON_7, ABMON_8, ABMON_9, ABMON_10, ABMON_11, ABMON_12, ALT_DIGITS, + AM_STR, CODESET, CRNCYSTR, D_FMT, D_T_FMT, DAY_1, DAY_2, DAY_3, DAY_4, DAY_5, DAY_6, DAY_7, + ERA, ERA_D_FMT, ERA_D_T_FMT, ERA_T_FMT, MON_1, MON_2, MON_3, MON_4, MON_5, MON_6, MON_7, MON_8, + MON_9, MON_10, MON_11, MON_12, NOEXPR, PM_STR, RADIXCHAR, T_FMT, T_FMT_AMPM, THOUSEP, YESEXPR, +}; + #[cfg(windows)] #[repr(C)] struct RawLconv { diff --git a/crates/host_env/src/mmap.rs b/crates/host_env/src/mmap.rs index ce5575061e3..c1071a716e4 100644 --- a/crates/host_env/src/mmap.rs +++ b/crates/host_env/src/mmap.rs @@ -59,7 +59,6 @@ pub use libc::MAP_STACK; #[cfg(target_os = "freebsd")] pub use libc::{MADV_AUTOSYNC, MADV_CORE, MADV_NOCORE, MADV_NOSYNC, MADV_PROTECT}; -#[cfg(windows)] pub use libc::EOVERFLOW; #[cfg(windows)] diff --git a/crates/host_env/src/multiprocessing.rs b/crates/host_env/src/multiprocessing.rs index 4e79b2573cb..0030245dbb4 100644 --- a/crates/host_env/src/multiprocessing.rs +++ b/crates/host_env/src/multiprocessing.rs @@ -13,7 +13,7 @@ use alloc::ffi::CString; use std::io; #[cfg(unix)] -use libc::sem_t; +pub use libc::{sem_t, timespec}; #[cfg(unix)] use nix::errno::Errno; diff --git a/crates/host_env/src/posix.rs b/crates/host_env/src/posix.rs index 1239d3526c4..f15dc576d47 100644 --- a/crates/host_env/src/posix.rs +++ b/crates/host_env/src/posix.rs @@ -12,6 +12,8 @@ use std::path::Path; use crate::crt_fd; +pub use libc::{c_char, pid_t}; + pub struct UnameInfo { pub sysname: String, pub nodename: String, diff --git a/crates/vm/src/readline.rs b/crates/host_env/src/readline.rs similarity index 86% rename from crates/vm/src/readline.rs rename to crates/host_env/src/readline.rs index 53475358ce6..1015ec093aa 100644 --- a/crates/vm/src/readline.rs +++ b/crates/host_env/src/readline.rs @@ -106,34 +106,18 @@ pub mod rustyline_readline { } pub fn load_history(&mut self, path: &Path) -> OtherResult<()> { - #[cfg(not(feature = "host_env"))] - { - let _ = path; - Err(io::Error::other("history requires the `host_env` feature").into()) - } - #[cfg(feature = "host_env")] - { - self.repl.load_history(path)?; - Ok(()) - } + self.repl.load_history(path)?; + Ok(()) } pub fn save_history(&mut self, path: &Path) -> OtherResult<()> { - #[cfg(not(feature = "host_env"))] - { - let _ = path; - Err(io::Error::other("history requires the `host_env` feature").into()) - } - #[cfg(feature = "host_env")] + if !path.exists() + && let Some(parent) = path.parent() { - if !path.exists() - && let Some(parent) = path.parent() - { - crate::host_env::fs::create_dir_all(parent)?; - } - self.repl.save_history(path)?; - Ok(()) + crate::fs::create_dir_all(parent)?; } + self.repl.save_history(path)?; + Ok(()) } pub fn add_history_entry(&mut self, entry: &str) -> OtherResult<()> { diff --git a/crates/host_env/src/resource.rs b/crates/host_env/src/resource.rs index 587428fe9b0..a0ba79b1b6b 100644 --- a/crates/host_env/src/resource.rs +++ b/crates/host_env/src/resource.rs @@ -2,6 +2,40 @@ use std::io; use crate::os::CheckLibcResult; +pub use libc::{ + RLIM_INFINITY, RLIMIT_AS, RLIMIT_CORE, RLIMIT_CPU, RLIMIT_DATA, RLIMIT_FSIZE, RLIMIT_MEMLOCK, + RLIMIT_NOFILE, RLIMIT_NPROC, RLIMIT_RSS, RLIMIT_STACK, c_long, rlim_t, rlimit, timeval, +}; + +#[cfg(target_os = "android")] +pub use libc::RLIM_NLIMITS; + +#[cfg(any(target_os = "linux", target_os = "android", target_os = "emscripten"))] +pub use libc::{RLIMIT_MSGQUEUE, RLIMIT_NICE, RLIMIT_RTPRIO, RLIMIT_SIGPENDING}; + +#[cfg(target_os = "linux")] +pub use libc::RLIMIT_RTTIME; + +#[cfg(any( + target_os = "freebsd", + target_os = "netbsd", + target_os = "solaris", + target_os = "illumos" +))] +pub use libc::RLIMIT_SBSIZE; + +#[cfg(any(target_os = "freebsd", target_os = "solaris", target_os = "illumos"))] +pub use libc::{RLIMIT_NPTS, RLIMIT_SWAP}; + +#[cfg(any(target_os = "solaris", target_os = "illumos"))] +pub use libc::RLIMIT_VMEM; + +#[cfg(any(target_os = "linux", target_os = "emscripten", target_os = "freebsd"))] +pub use libc::RUSAGE_THREAD; + +#[cfg(not(any(target_os = "windows", target_os = "redox")))] +pub use libc::{RUSAGE_CHILDREN, RUSAGE_SELF}; + #[derive(Debug, Clone, Copy)] pub struct RUsage { pub ru_utime: libc::timeval, diff --git a/crates/host_env/src/select.rs b/crates/host_env/src/select.rs index 385a6a110b2..3191f8d97c0 100644 --- a/crates/host_env/src/select.rs +++ b/crates/host_env/src/select.rs @@ -1,6 +1,16 @@ use core::mem::MaybeUninit; use std::io; +#[cfg(unix)] +pub use libc::{EINTR, FD_SETSIZE, POLLERR, POLLHUP, POLLIN, POLLNVAL, POLLOUT, POLLPRI}; + +#[cfg(any(target_os = "linux", target_os = "android", target_os = "redox"))] +pub use libc::{ + EPOLL_CLOEXEC, EPOLLERR, EPOLLET, EPOLLEXCLUSIVE, EPOLLHUP, EPOLLIN, EPOLLMSG, EPOLLONESHOT, + EPOLLOUT, EPOLLPRI, EPOLLRDBAND, EPOLLRDHUP, EPOLLRDNORM, EPOLLWAKEUP, EPOLLWRBAND, + EPOLLWRNORM, +}; + #[cfg(unix)] pub mod platform { pub use libc::pollfd; diff --git a/crates/host_env/src/shm.rs b/crates/host_env/src/shm.rs index 78e7d3921bc..b50e460b79a 100644 --- a/crates/host_env/src/shm.rs +++ b/crates/host_env/src/shm.rs @@ -3,6 +3,8 @@ use std::io; use crate::os::CheckLibcResult; +pub use libc::mode_t; + pub fn shm_open(name: &CStr, flags: libc::c_int, mode: libc::c_uint) -> io::Result { #[cfg(target_os = "freebsd")] let mode = mode.try_into().unwrap(); diff --git a/crates/host_env/src/socket.rs b/crates/host_env/src/socket.rs index c409132a725..af4af2717cd 100644 --- a/crates/host_env/src/socket.rs +++ b/crates/host_env/src/socket.rs @@ -7,6 +7,43 @@ use std::os::fd::AsRawFd; #[cfg(unix)] use std::{io, os::fd::BorrowedFd}; +/// Returns the system's hostname. +#[cfg(not(target_arch = "wasm32"))] +pub fn hostname() -> std::ffi::OsString { + gethostname::gethostname() +} + +#[cfg(not(target_arch = "wasm32"))] +pub use ::dns_lookup as dns; +#[cfg(not(target_arch = "wasm32"))] +pub use ::socket2 as raw; + +/// Returns the first non-loopback MAC address as 6 bytes, or `None` when no +/// MAC address is available or the lookup fails. +#[cfg(not(any( + target_os = "ios", + target_os = "android", + target_os = "windows", + target_arch = "wasm32", + target_os = "redox" +)))] +pub fn mac_address() -> Option<[u8; 6]> { + mac_address::get_mac_address() + .ok() + .flatten() + .map(|m| m.bytes()) +} + +#[cfg(unix)] +pub use libc::{AF_UNIX, SOCK_STREAM, sa_family_t, sockaddr_storage, socklen_t}; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub use libc::{AF_ALG, AF_CAN}; + +// bionic (Android) does not define the CAN/ALG sockaddr structs. +#[cfg(target_os = "linux")] +pub use libc::{sockaddr_alg, sockaddr_can}; + #[cfg(all(unix, not(target_os = "redox")))] pub fn sethostname(hostname: &str) -> io::Result<()> { nix::unistd::sethostname(hostname).map_err(io::Error::from) diff --git a/crates/host_env/src/syslog.rs b/crates/host_env/src/syslog.rs index 8820b8f1c5d..2ba38377326 100644 --- a/crates/host_env/src/syslog.rs +++ b/crates/host_env/src/syslog.rs @@ -3,6 +3,16 @@ use core::ffi::CStr; use parking_lot::RwLock; use std::{os::raw::c_char, sync::OnceLock}; +pub use libc::{ + LOG_ALERT, LOG_AUTH, LOG_CONS, LOG_CRIT, LOG_DAEMON, LOG_DEBUG, LOG_EMERG, LOG_ERR, LOG_INFO, + LOG_KERN, LOG_LOCAL0, LOG_LOCAL1, LOG_LOCAL2, LOG_LOCAL3, LOG_LOCAL4, LOG_LOCAL5, LOG_LOCAL6, + LOG_LOCAL7, LOG_LPR, LOG_MAIL, LOG_NDELAY, LOG_NEWS, LOG_NOTICE, LOG_NOWAIT, LOG_ODELAY, + LOG_PID, LOG_SYSLOG, LOG_USER, LOG_UUCP, LOG_WARNING, +}; + +#[cfg(not(target_os = "redox"))] +pub use libc::{LOG_AUTHPRIV, LOG_CRON, LOG_PERROR}; + #[derive(Debug)] enum GlobalIdent { Explicit(Box), diff --git a/crates/host_env/src/termios.rs b/crates/host_env/src/termios.rs index 074d03a455b..0a96078be94 100644 --- a/crates/host_env/src/termios.rs +++ b/crates/host_env/src/termios.rs @@ -1,5 +1,55 @@ pub type Termios = ::termios::Termios; +#[cfg(any(target_os = "illumos", target_os = "solaris"))] +pub use libc::{CSTART, CSTOP, CSWTCH}; + +#[cfg(any( + target_os = "dragonfly", + target_os = "freebsd", + target_os = "macos", + target_os = "netbsd", + target_os = "openbsd" +))] +pub use libc::{FIOASYNC, TIOCGETD, TIOCSETD}; + +pub use libc::{FIOCLEX, FIONBIO, TIOCGWINSZ, TIOCSWINSZ}; + +#[cfg(any( + target_os = "android", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "linux", + target_os = "macos", + target_os = "netbsd", + target_os = "openbsd" +))] +pub use libc::{ + FIONCLEX, FIONREAD, TIOCEXCL, TIOCM_CAR, TIOCM_CD, TIOCM_CTS, TIOCM_DSR, TIOCM_DTR, TIOCM_LE, + TIOCM_RI, TIOCM_RNG, TIOCM_RTS, TIOCM_SR, TIOCM_ST, TIOCMBIC, TIOCMBIS, TIOCMGET, TIOCMSET, + TIOCNXCL, TIOCSCTTY, +}; + +#[cfg(any(target_os = "android", target_os = "linux"))] +pub use libc::{ + IBSHIFT, TCFLSH, TCGETA, TCGETS, TCSBRK, TCSETA, TCSETAF, TCSETAW, TCSETS, TCSETSF, TCSETSW, + TCXONC, TIOCGSERIAL, TIOCGSOFTCAR, TIOCINQ, TIOCLINUX, TIOCSSOFTCAR, XTABS, +}; + +#[cfg(any( + target_os = "android", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "linux", + target_os = "macos" +))] +pub use libc::{TIOCCONS, TIOCGPGRP, TIOCOUTQ, TIOCSPGRP, TIOCSTI}; + +#[cfg(any(target_os = "dragonfly", target_os = "freebsd", target_os = "macos"))] +pub use libc::{ + TIOCNOTTY, TIOCPKT, TIOCPKT_DATA, TIOCPKT_DOSTOP, TIOCPKT_FLUSHREAD, TIOCPKT_FLUSHWRITE, + TIOCPKT_NOSTOP, TIOCPKT_START, TIOCPKT_STOP, +}; + #[cfg(any( target_os = "android", target_os = "freebsd", diff --git a/crates/stdlib/Cargo.toml b/crates/stdlib/Cargo.toml index 1ba6148e8cf..1e071869549 100644 --- a/crates/stdlib/Cargo.toml +++ b/crates/stdlib/Cargo.toml @@ -92,14 +92,10 @@ chrono.workspace = true # uuid [target.'cfg(not(any(target_os = "ios", target_os = "android", target_os = "windows", target_arch = "wasm32", target_os = "redox")))'.dependencies] -mac_address = { workspace = true } uuid = { workspace = true, features = ["v1"] } # mmap + socket dependencies [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -gethostname = { workspace = true } -socket2 = { workspace = true, features = ["all"] } -dns-lookup = { workspace = true } # OpenSSL dependencies (optional, for ssl-openssl feature) openssl = { workspace = true, optional = true } @@ -129,9 +125,6 @@ xz-sys = { workspace = true } paste = { workspace = true } widestring = { workspace = true } -[target.'cfg(target_os = "macos")'.dependencies] -system-configuration = { workspace = true } - [dev-dependencies] insta = { workspace = true } rustpython-pylib = { workspace = true, features = [ "freeze-stdlib" ] } diff --git a/crates/stdlib/src/faulthandler.rs b/crates/stdlib/src/faulthandler.rs index e890cecfac4..3717c18b78f 100644 --- a/crates/stdlib/src/faulthandler.rs +++ b/crates/stdlib/src/faulthandler.rs @@ -373,7 +373,7 @@ mod decl { // faulthandler_fatal_error #[cfg(unix)] - extern "C" fn faulthandler_fatal_error(signum: libc::c_int) { + extern "C" fn faulthandler_fatal_error(signum: core::ffi::c_int) { let save_errno = get_errno(); if !FATAL_ERROR.enabled.load(Ordering::Relaxed) { @@ -410,7 +410,7 @@ mod decl { // faulthandler_fatal_error for Windows #[cfg(windows)] - extern "C" fn faulthandler_fatal_error(signum: libc::c_int) { + extern "C" fn faulthandler_fatal_error(signum: core::ffi::c_int) { let save_errno = get_errno(); if !FATAL_ERROR.enabled.load(Ordering::Relaxed) { @@ -479,7 +479,7 @@ mod decl { // Disable SIGSEGV handler for access violations to avoid double output if host_faulthandler::is_access_violation(code) { - host_faulthandler::disable_fatal_signal(libc::SIGSEGV); + host_faulthandler::disable_fatal_signal(host_faulthandler::SIGSEGV); } let all_threads = FATAL_ERROR.all_threads.load(Ordering::Relaxed); @@ -495,7 +495,10 @@ mod decl { return true; } - if !host_faulthandler::enable_fatal_handlers(faulthandler_fatal_error, libc::SA_NODEFER) { + if !host_faulthandler::enable_fatal_handlers( + faulthandler_fatal_error, + host_faulthandler::SA_NODEFER, + ) { return false; } @@ -769,7 +772,7 @@ mod decl { } #[cfg(unix)] - extern "C" fn faulthandler_user_signal(signum: libc::c_int) { + extern "C" fn faulthandler_user_signal(signum: core::ffi::c_int) { let save_errno = get_errno(); let user = match host_faulthandler::get_user_signal(signum as usize) { @@ -900,7 +903,7 @@ mod decl { #[cfg(not(target_arch = "wasm32"))] { suppress_crash_report(); - host_faulthandler::raise_signal(libc::SIGFPE); + host_faulthandler::raise_signal(host_faulthandler::SIGFPE); } } diff --git a/crates/stdlib/src/fcntl.rs b/crates/stdlib/src/fcntl.rs index 5081c9e9c14..8e24f2b6e4a 100644 --- a/crates/stdlib/src/fcntl.rs +++ b/crates/stdlib/src/fcntl.rs @@ -25,38 +25,40 @@ mod fcntl { // I_LINK, I_UNLINK, I_PLINK, I_PUNLINK #[pyattr] - use libc::{F_GETFD, F_GETFL, F_SETFD, F_SETFL, FD_CLOEXEC}; + use host_fcntl::{F_GETFD, F_GETFL, F_SETFD, F_SETFL, FD_CLOEXEC}; #[cfg(not(target_os = "wasi"))] #[pyattr] - use libc::{F_DUPFD, F_DUPFD_CLOEXEC, F_GETLK, F_SETLK, F_SETLKW}; + use host_fcntl::{F_DUPFD, F_DUPFD_CLOEXEC, F_GETLK, F_SETLK, F_SETLKW}; #[cfg(not(any(target_os = "wasi", target_os = "redox")))] #[pyattr] - use libc::{F_GETOWN, F_RDLCK, F_SETOWN, F_UNLCK, F_WRLCK, LOCK_EX, LOCK_NB, LOCK_SH, LOCK_UN}; + use host_fcntl::{ + F_GETOWN, F_RDLCK, F_SETOWN, F_UNLCK, F_WRLCK, LOCK_EX, LOCK_NB, LOCK_SH, LOCK_UN, + }; #[cfg(target_vendor = "apple")] #[pyattr] - use libc::{F_FULLFSYNC, F_NOCACHE}; + use host_fcntl::{F_FULLFSYNC, F_NOCACHE}; #[cfg(target_os = "freebsd")] #[pyattr] - use libc::{F_DUP2FD, F_DUP2FD_CLOEXEC}; + use host_fcntl::{F_DUP2FD, F_DUP2FD_CLOEXEC}; #[cfg(any(target_os = "android", target_os = "linux"))] #[pyattr] - use libc::{F_OFD_GETLK, F_OFD_SETLK, F_OFD_SETLKW}; + use host_fcntl::{F_OFD_GETLK, F_OFD_SETLK, F_OFD_SETLKW}; #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] #[pyattr] - use libc::{ + use host_fcntl::{ F_ADD_SEALS, F_GET_SEALS, F_GETLEASE, F_GETPIPE_SZ, F_NOTIFY, F_SEAL_GROW, F_SEAL_SEAL, F_SEAL_SHRINK, F_SEAL_WRITE, F_SETLEASE, F_SETPIPE_SZ, }; #[cfg(any(target_os = "dragonfly", target_os = "netbsd", target_vendor = "apple"))] #[pyattr] - use libc::F_GETPATH; + use host_fcntl::F_GETPATH; #[pyfunction] fn fcntl( diff --git a/crates/stdlib/src/grp.rs b/crates/stdlib/src/grp.rs index 34e9929d2c4..c2a231bef27 100644 --- a/crates/stdlib/src/grp.rs +++ b/crates/stdlib/src/grp.rs @@ -43,7 +43,7 @@ mod grp { #[pyfunction] fn getgrgid(gid: PyIntRef, vm: &VirtualMachine) -> PyResult { let gr_gid = gid.as_bigint(); - let gid = libc::gid_t::try_from(gr_gid).ok(); + let gid = host_grp::gid_t::try_from(gr_gid).ok(); let group = gid .map(host_grp::getgrgid) .transpose() diff --git a/crates/stdlib/src/locale.rs b/crates/stdlib/src/locale.rs index 74e9053fbfb..2f929e8b57f 100644 --- a/crates/stdlib/src/locale.rs +++ b/crates/stdlib/src/locale.rs @@ -18,7 +18,7 @@ mod _locale { not(any(target_os = "ios", target_os = "android", target_os = "redox")) ))] #[pyattr] - use libc::{ + use rustpython_host_env::locale::{ ABDAY_1, ABDAY_2, ABDAY_3, ABDAY_4, ABDAY_5, ABDAY_6, ABDAY_7, ABMON_1, ABMON_2, ABMON_3, ABMON_4, ABMON_5, ABMON_6, ABMON_7, ABMON_8, ABMON_9, ABMON_10, ABMON_11, ABMON_12, ALT_DIGITS, AM_STR, CODESET, CRNCYSTR, D_FMT, D_T_FMT, DAY_1, DAY_2, DAY_3, DAY_4, DAY_5, @@ -29,17 +29,19 @@ mod _locale { #[cfg(all(unix, not(any(target_os = "ios", target_os = "redox"))))] #[pyattr] - use libc::LC_MESSAGES; + use rustpython_host_env::locale::LC_MESSAGES; #[pyattr] - use libc::{LC_ALL, LC_COLLATE, LC_CTYPE, LC_MONETARY, LC_NUMERIC, LC_TIME}; + use rustpython_host_env::locale::{ + LC_ALL, LC_COLLATE, LC_CTYPE, LC_MONETARY, LC_NUMERIC, LC_TIME, + }; #[pyattr(name = "CHAR_MAX")] fn char_max(vm: &VirtualMachine) -> PyIntRef { - vm.ctx.new_int(libc::c_char::MAX) + vm.ctx.new_int(core::ffi::c_char::MAX) } - fn copy_grouping(group: &[libc::c_char], vm: &VirtualMachine) -> PyListRef { + fn copy_grouping(group: &[core::ffi::c_char], vm: &VirtualMachine) -> PyListRef { let mut group_vec: Vec = Vec::new(); for &value in group { let val = vm.ctx.new_int(value); diff --git a/crates/stdlib/src/multiprocessing.rs b/crates/stdlib/src/multiprocessing.rs index 78debdc7813..1eee2ec04e4 100644 --- a/crates/stdlib/src/multiprocessing.rs +++ b/crates/stdlib/src/multiprocessing.rs @@ -333,7 +333,7 @@ mod _multiprocessing { } #[pyfunction] - fn send(socket: usize, buf: ArgBytesLike, vm: &VirtualMachine) -> PyResult { + fn send(socket: usize, buf: ArgBytesLike, vm: &VirtualMachine) -> PyResult { buf.with_ref(|b| { host_multiprocessing::send_socket(socket as host_multiprocessing::RawSocket, b) }) @@ -355,7 +355,7 @@ mod _multiprocessing { }; use core::sync::atomic::{AtomicI32, AtomicU64, Ordering}; #[cfg(target_vendor = "apple")] - use libc::sem_t; + use rustpython_host_env::multiprocessing::sem_t; use rustpython_host_env::multiprocessing::{ self as host_multiprocessing, SemError, TryAcquireStatus, WaitStatus, }; @@ -373,7 +373,7 @@ mod _multiprocessing { #[cfg(target_vendor = "apple")] fn sem_timedwait_polled( sem: *mut sem_t, - deadline: &libc::timespec, + deadline: &host_multiprocessing::timespec, vm: &VirtualMachine, ) -> Result<(), SemWaitError> { let mut delay: u64 = 0; diff --git a/crates/stdlib/src/openssl.rs b/crates/stdlib/src/openssl.rs index 76309d3c21d..3397fff8ef0 100644 --- a/crates/stdlib/src/openssl.rs +++ b/crates/stdlib/src/openssl.rs @@ -154,63 +154,63 @@ mod _ssl { // SSL Alert Descriptions (RFC 5246 and extensions) // Hybrid approach: use openssl_sys constants where available, hardcode others #[pyattr] - const ALERT_DESCRIPTION_CLOSE_NOTIFY: libc::c_int = 0; + const ALERT_DESCRIPTION_CLOSE_NOTIFY: core::ffi::c_int = 0; #[pyattr] - const ALERT_DESCRIPTION_UNEXPECTED_MESSAGE: libc::c_int = 10; + const ALERT_DESCRIPTION_UNEXPECTED_MESSAGE: core::ffi::c_int = 10; #[pyattr] - const ALERT_DESCRIPTION_BAD_RECORD_MAC: libc::c_int = 20; + const ALERT_DESCRIPTION_BAD_RECORD_MAC: core::ffi::c_int = 20; #[pyattr] - const ALERT_DESCRIPTION_RECORD_OVERFLOW: libc::c_int = 22; + const ALERT_DESCRIPTION_RECORD_OVERFLOW: core::ffi::c_int = 22; #[pyattr] - const ALERT_DESCRIPTION_DECOMPRESSION_FAILURE: libc::c_int = 30; + const ALERT_DESCRIPTION_DECOMPRESSION_FAILURE: core::ffi::c_int = 30; #[pyattr] - const ALERT_DESCRIPTION_HANDSHAKE_FAILURE: libc::c_int = 40; + const ALERT_DESCRIPTION_HANDSHAKE_FAILURE: core::ffi::c_int = 40; #[pyattr] - const ALERT_DESCRIPTION_BAD_CERTIFICATE: libc::c_int = 42; + const ALERT_DESCRIPTION_BAD_CERTIFICATE: core::ffi::c_int = 42; #[pyattr] - const ALERT_DESCRIPTION_UNSUPPORTED_CERTIFICATE: libc::c_int = 43; + const ALERT_DESCRIPTION_UNSUPPORTED_CERTIFICATE: core::ffi::c_int = 43; #[pyattr] - const ALERT_DESCRIPTION_CERTIFICATE_REVOKED: libc::c_int = 44; + const ALERT_DESCRIPTION_CERTIFICATE_REVOKED: core::ffi::c_int = 44; #[pyattr] - const ALERT_DESCRIPTION_CERTIFICATE_EXPIRED: libc::c_int = 45; + const ALERT_DESCRIPTION_CERTIFICATE_EXPIRED: core::ffi::c_int = 45; #[pyattr] - const ALERT_DESCRIPTION_CERTIFICATE_UNKNOWN: libc::c_int = 46; + const ALERT_DESCRIPTION_CERTIFICATE_UNKNOWN: core::ffi::c_int = 46; #[pyattr] - const ALERT_DESCRIPTION_ILLEGAL_PARAMETER: libc::c_int = SSL_AD_ILLEGAL_PARAMETER; + const ALERT_DESCRIPTION_ILLEGAL_PARAMETER: core::ffi::c_int = SSL_AD_ILLEGAL_PARAMETER; #[pyattr] - const ALERT_DESCRIPTION_UNKNOWN_CA: libc::c_int = 48; + const ALERT_DESCRIPTION_UNKNOWN_CA: core::ffi::c_int = 48; #[pyattr] - const ALERT_DESCRIPTION_ACCESS_DENIED: libc::c_int = 49; + const ALERT_DESCRIPTION_ACCESS_DENIED: core::ffi::c_int = 49; #[pyattr] - const ALERT_DESCRIPTION_DECODE_ERROR: libc::c_int = SSL_AD_DECODE_ERROR; + const ALERT_DESCRIPTION_DECODE_ERROR: core::ffi::c_int = SSL_AD_DECODE_ERROR; #[pyattr] - const ALERT_DESCRIPTION_DECRYPT_ERROR: libc::c_int = 51; + const ALERT_DESCRIPTION_DECRYPT_ERROR: core::ffi::c_int = 51; #[pyattr] - const ALERT_DESCRIPTION_PROTOCOL_VERSION: libc::c_int = 70; + const ALERT_DESCRIPTION_PROTOCOL_VERSION: core::ffi::c_int = 70; #[pyattr] - const ALERT_DESCRIPTION_INSUFFICIENT_SECURITY: libc::c_int = 71; + const ALERT_DESCRIPTION_INSUFFICIENT_SECURITY: core::ffi::c_int = 71; #[pyattr] - const ALERT_DESCRIPTION_INTERNAL_ERROR: libc::c_int = 80; + const ALERT_DESCRIPTION_INTERNAL_ERROR: core::ffi::c_int = 80; #[pyattr] - const ALERT_DESCRIPTION_USER_CANCELLED: libc::c_int = 90; + const ALERT_DESCRIPTION_USER_CANCELLED: core::ffi::c_int = 90; #[pyattr] - const ALERT_DESCRIPTION_NO_RENEGOTIATION: libc::c_int = 100; + const ALERT_DESCRIPTION_NO_RENEGOTIATION: core::ffi::c_int = 100; #[pyattr] - const ALERT_DESCRIPTION_UNSUPPORTED_EXTENSION: libc::c_int = 110; + const ALERT_DESCRIPTION_UNSUPPORTED_EXTENSION: core::ffi::c_int = 110; #[pyattr] - const ALERT_DESCRIPTION_CERTIFICATE_UNOBTAINABLE: libc::c_int = 111; + const ALERT_DESCRIPTION_CERTIFICATE_UNOBTAINABLE: core::ffi::c_int = 111; #[pyattr] - const ALERT_DESCRIPTION_UNRECOGNIZED_NAME: libc::c_int = SSL_AD_UNRECOGNIZED_NAME; + const ALERT_DESCRIPTION_UNRECOGNIZED_NAME: core::ffi::c_int = SSL_AD_UNRECOGNIZED_NAME; #[pyattr] - const ALERT_DESCRIPTION_BAD_CERTIFICATE_STATUS_RESPONSE: libc::c_int = 113; + const ALERT_DESCRIPTION_BAD_CERTIFICATE_STATUS_RESPONSE: core::ffi::c_int = 113; #[pyattr] - const ALERT_DESCRIPTION_BAD_CERTIFICATE_HASH_VALUE: libc::c_int = 114; + const ALERT_DESCRIPTION_BAD_CERTIFICATE_HASH_VALUE: core::ffi::c_int = 114; #[pyattr] - const ALERT_DESCRIPTION_UNKNOWN_PSK_IDENTITY: libc::c_int = 115; + const ALERT_DESCRIPTION_UNKNOWN_PSK_IDENTITY: core::ffi::c_int = 115; // CRL verification constants #[pyattr] - const VERIFY_CRL_CHECK_CHAIN: libc::c_ulong = + const VERIFY_CRL_CHECK_CHAIN: core::ffi::c_ulong = sys::X509_V_FLAG_CRL_CHECK | sys::X509_V_FLAG_CRL_CHECK_ALL; // taken from CPython, should probably be kept up to date with their version if it ever changes @@ -248,7 +248,8 @@ mod _ssl { #[pyattr] const PROTO_MAXIMUM_SUPPORTED: i32 = ProtoVersion::MaxSupported as i32; #[pyattr] - const OP_ALL: libc::c_ulong = (sys::SSL_OP_ALL & !sys::SSL_OP_DONT_INSERT_EMPTY_FRAGMENTS) as _; + const OP_ALL: core::ffi::c_ulong = + (sys::SSL_OP_ALL & !sys::SSL_OP_DONT_INSERT_EMPTY_FRAGMENTS) as _; #[pyattr] const HAS_TLS_UNIQUE: bool = true; #[pyattr] @@ -298,7 +299,7 @@ mod _ssl { // SSL_VERIFY constants for post-handshake authentication #[cfg(ossl111)] - const SSL_VERIFY_POST_HANDSHAKE: libc::c_int = 0x20; + const SSL_VERIFY_POST_HANDSHAKE: core::ffi::c_int = 0x20; // the openssl version from the API headers @@ -393,7 +394,7 @@ mod _ssl { unsafe { ptr2obj(sys::OBJ_nid2obj(nid.as_raw())) } } - type PyNid = (libc::c_int, String, String, Option); + type PyNid = (core::ffi::c_int, String, String, Option); fn obj2py(obj: &Asn1ObjectRef, vm: &VirtualMachine) -> PyResult { let nid = obj.nid(); let short_name = nid @@ -428,7 +429,7 @@ mod _ssl { } #[pyfunction] - fn nid2obj(nid: libc::c_int, vm: &VirtualMachine) -> PyResult { + fn nid2obj(nid: core::ffi::c_int, vm: &VirtualMachine) -> PyResult { _nid2obj(Nid::from_raw(nid)) .as_deref() .ok_or_else(|| vm.new_value_error(format!("unknown NID {nid}"))) @@ -508,7 +509,7 @@ mod _ssl { #[pyfunction(name = "RAND_add")] fn rand_add(string: ArgStrOrBytesLike, entropy: f64) { let f = |b: &[u8]| { - for buf in b.chunks(libc::c_int::MAX as usize) { + for buf in b.chunks(core::ffi::c_int::MAX as usize) { unsafe { sys::RAND_add(buf.as_ptr() as *const _, buf.len() as _, entropy) } } }; @@ -573,9 +574,9 @@ mod _ssl { } // Get or create an ex_data index for SNI callback data - fn get_sni_ex_data_index() -> libc::c_int { + fn get_sni_ex_data_index() -> core::ffi::c_int { use rustpython_common::lock::LazyLock; - static SNI_EX_DATA_IDX: LazyLock = LazyLock::new(|| unsafe { + static SNI_EX_DATA_IDX: LazyLock = LazyLock::new(|| unsafe { sys::SSL_get_ex_new_index( 0, core::ptr::null_mut(), @@ -591,12 +592,12 @@ mod _ssl { // NOTE: We don't free the data here because it's managed manually in do_handshake // to avoid use-after-free when the SSL object is dropped after timeout unsafe extern "C" fn sni_callback_data_free( - _parent: *mut libc::c_void, - _ptr: *mut libc::c_void, + _parent: *mut core::ffi::c_void, + _ptr: *mut core::ffi::c_void, _ad: *mut sys::CRYPTO_EX_DATA, - _idx: libc::c_int, - _argl: libc::c_long, - _argp: *mut libc::c_void, + _idx: core::ffi::c_int, + _argl: core::ffi::c_long, + _argp: *mut core::ffi::c_void, ) { // Intentionally empty - data is freed in cleanup_sni_ex_data() } @@ -617,9 +618,9 @@ mod _ssl { } // Get or create an ex_data index for msg_callback data - fn get_msg_callback_ex_data_index() -> libc::c_int { + fn get_msg_callback_ex_data_index() -> core::ffi::c_int { use rustpython_common::lock::LazyLock; - static MSG_CB_EX_DATA_IDX: LazyLock = LazyLock::new(|| unsafe { + static MSG_CB_EX_DATA_IDX: LazyLock = LazyLock::new(|| unsafe { sys::SSL_get_ex_new_index( 0, core::ptr::null_mut(), @@ -633,12 +634,12 @@ mod _ssl { // Free function for msg_callback data - called by OpenSSL when SSL is freed unsafe extern "C" fn msg_callback_data_free( - _parent: *mut libc::c_void, - ptr: *mut libc::c_void, + _parent: *mut core::ffi::c_void, + ptr: *mut core::ffi::c_void, _ad: *mut sys::CRYPTO_EX_DATA, - _idx: libc::c_int, - _argl: libc::c_long, - _argp: *mut libc::c_void, + _idx: core::ffi::c_int, + _argl: core::ffi::c_long, + _argp: *mut core::ffi::c_void, ) { if !ptr.is_null() { unsafe { @@ -652,13 +653,13 @@ mod _ssl { // SNI callback function called by OpenSSL unsafe extern "C" fn _servername_callback( ssl_ptr: *mut sys::SSL, - al: *mut libc::c_int, - arg: *mut libc::c_void, - ) -> libc::c_int { - const SSL_TLSEXT_ERR_OK: libc::c_int = 0; - const SSL_TLSEXT_ERR_ALERT_FATAL: libc::c_int = 2; - const SSL_AD_INTERNAL_ERROR: libc::c_int = 80; - const TLSEXT_NAMETYPE_host_name: libc::c_int = 0; + al: *mut core::ffi::c_int, + arg: *mut core::ffi::c_void, + ) -> core::ffi::c_int { + const SSL_TLSEXT_ERR_OK: core::ffi::c_int = 0; + const SSL_TLSEXT_ERR_ALERT_FATAL: core::ffi::c_int = 2; + const SSL_AD_INTERNAL_ERROR: core::ffi::c_int = 80; + const TLSEXT_NAMETYPE_host_name: core::ffi::c_int = 0; if arg.is_null() { return SSL_TLSEXT_ERR_OK; @@ -766,13 +767,13 @@ mod _ssl { // Called during SSL operations to report protocol messages. // debughelpers.c:_PySSL_msg_callback unsafe extern "C" fn _msg_callback( - write_p: libc::c_int, - mut version: libc::c_int, - content_type: libc::c_int, - buf: *const libc::c_void, + write_p: core::ffi::c_int, + mut version: core::ffi::c_int, + content_type: core::ffi::c_int, + buf: *const core::ffi::c_void, len: usize, ssl_ptr: *mut sys::SSL, - _arg: *mut libc::c_void, + _arg: *mut core::ffi::c_void, ) { if ssl_ptr.is_null() { return; @@ -1122,7 +1123,7 @@ mod _ssl { } #[pygetset] - fn options(&self) -> libc::c_ulong { + fn options(&self) -> core::ffi::c_ulong { self.ctx.read().options().bits() as _ } #[pygetset(setter)] @@ -1130,10 +1131,10 @@ mod _ssl { if new_opts < 0 { return Err(vm.new_value_error("invalid options value")); } - let new_opts = new_opts as libc::c_ulong; + let new_opts = new_opts as core::ffi::c_ulong; let mut ctx = self.builder(); // Get current options - let current = ctx.options().bits() as libc::c_ulong; + let current = ctx.options().bits() as core::ffi::c_ulong; // Calculate options to clear and set let clear = current & !new_opts; @@ -1190,7 +1191,7 @@ mod _ssl { Ok(()) } #[pygetset] - fn verify_flags(&self) -> libc::c_ulong { + fn verify_flags(&self) -> core::ffi::c_ulong { unsafe { let ctx_ptr = self.ctx().as_ptr(); let param = sys::SSL_CTX_get0_param(ctx_ptr); @@ -1198,7 +1199,11 @@ mod _ssl { } } #[pygetset(setter)] - fn set_verify_flags(&self, new_flags: libc::c_ulong, vm: &VirtualMachine) -> PyResult<()> { + fn set_verify_flags( + &self, + new_flags: core::ffi::c_ulong, + vm: &VirtualMachine, + ) -> PyResult<()> { unsafe { let ctx_ptr = self.ctx().as_ptr(); let param = sys::SSL_CTX_get0_param(ctx_ptr); @@ -1359,10 +1364,10 @@ mod _ssl { { let mut ctx = self.builder(); let server = protos.with_ref(|pbuf| { - if pbuf.len() > libc::c_uint::MAX as usize { + if pbuf.len() > core::ffi::c_uint::MAX as usize { return Err(vm.new_overflow_error(format!( "protocols longer than {} bytes", - libc::c_uint::MAX + core::ffi::c_uint::MAX ))); } ctx.set_alpn_protos(pbuf) @@ -1523,7 +1528,7 @@ mod _ssl { return Err(vm .new_os_subtype_error( vm.ctx.exceptions.file_not_found_error.to_owned(), - Some(libc::ENOENT), + Some(rustpython_host_env::errno::errors::ENOENT), format!("No such file or directory: '{}'", path.display()), ) .upcast()); @@ -1534,7 +1539,7 @@ mod _ssl { return Err(vm .new_os_subtype_error( vm.ctx.exceptions.file_not_found_error.to_owned(), - Some(libc::ENOENT), + Some(rustpython_host_env::errno::errors::ENOENT), format!("No such file or directory: '{}'", path.display()), ) .upcast()); @@ -1667,7 +1672,7 @@ mod _ssl { std::io::ErrorKind::NotFound => vm .new_os_subtype_error( vm.ctx.exceptions.file_not_found_error.to_owned(), - Some(libc::ENOENT), + Some(rustpython_host_env::errno::errors::ENOENT), e.to_string(), ) .upcast(), @@ -1684,7 +1689,7 @@ mod _ssl { ) }; unsafe { - libc::fclose(fp); + rustpython_host_env::fileutils::fclose(fp); } if dh.is_null() { @@ -1842,7 +1847,7 @@ mod _ssl { return Err(vm .new_os_subtype_error( vm.ctx.exceptions.file_not_found_error.to_owned(), - Some(libc::ENOENT), + Some(rustpython_host_env::errno::errors::ENOENT), format!("No such file or directory: '{}'", cert_path.display()), ) .upcast()); @@ -1853,7 +1858,7 @@ mod _ssl { return Err(vm .new_os_subtype_error( vm.ctx.exceptions.file_not_found_error.to_owned(), - Some(libc::ENOENT), + Some(rustpython_host_env::errno::errors::ENOENT), format!("No such file or directory: '{}'", kp.display()), ) .upcast()); @@ -2013,7 +2018,7 @@ mod _ssl { let ret = SSL_set_session_id_context( ssl.as_ptr(), SID_CTX.as_ptr(), - SID_CTX.len() as libc::c_uint, + SID_CTX.len() as core::ffi::c_uint, ); if ret == 0 { return Err(convert_openssl_error(vm, ErrorStack::get())); @@ -2061,7 +2066,7 @@ mod _ssl { // Server socket: add SSL_VERIFY_POST_HANDSHAKE flag // Only in combination with SSL_VERIFY_PEER let mode = sys::SSL_get_verify_mode(ssl.as_ptr()); - if (mode & sys::SSL_VERIFY_PEER as libc::c_int) != 0 { + if (mode & sys::SSL_VERIFY_PEER as core::ffi::c_int) != 0 { sys::SSL_set_verify( ssl.as_ptr(), mode | SSL_VERIFY_POST_HANDSHAKE, @@ -2553,7 +2558,7 @@ mod _ssl { unsafe { let ssl_ctx = sys::SSL_get_SSL_CTX(stream.ssl().as_ptr()); let verify_mode = sys::SSL_CTX_get_verify_mode(ssl_ctx); - if (verify_mode & sys::SSL_VERIFY_PEER as libc::c_int) == 0 { + if (verify_mode & sys::SSL_VERIFY_PEER as core::ffi::c_int) == 0 { // Return empty dict when SSL_VERIFY_PEER is not set Ok(Some(vm.ctx.new_dict().into())) } else { @@ -2723,8 +2728,8 @@ mod _ssl { // Use thread-local SSL pointer during handshake to avoid deadlock let ssl_ptr = get_ssl_ptr_for_context_change(&self.connection); unsafe { - let mut out: *const libc::c_uchar = core::ptr::null(); - let mut outlen: libc::c_uint = 0; + let mut out: *const core::ffi::c_uchar = core::ptr::null(); + let mut outlen: core::ffi::c_uint = 0; sys::SSL_get0_alpn_selected(ssl_ptr, &mut out, &mut outlen); @@ -3308,8 +3313,8 @@ mod _ssl { return Ok(PyComparisonValue::NotImplemented); } let mut eq = unsafe { - let mut self_len: libc::c_uint = 0; - let mut other_len: libc::c_uint = 0; + let mut self_len: core::ffi::c_uint = 0; + let mut other_len: core::ffi::c_uint = 0; let self_id = sys::SSL_SESSION_get_id(zelf.session, &mut self_len); let other_id = sys::SSL_SESSION_get_id(other.session, &mut other_len); @@ -3359,7 +3364,7 @@ mod _ssl { unsafe extern "C" { // X509_check_ca returns 1 for CA certificates, 0 otherwise - fn X509_check_ca(x: *const sys::X509) -> libc::c_int; + fn X509_check_ca(x: *const sys::X509) -> core::ffi::c_int; } unsafe extern "C" { @@ -3373,13 +3378,13 @@ mod _ssl { #[cfg(ossl111)] unsafe extern "C" { - fn SSL_verify_client_post_handshake(ssl: *const sys::SSL) -> libc::c_int; - fn SSL_set_post_handshake_auth(ssl: *mut sys::SSL, val: libc::c_int); + fn SSL_verify_client_post_handshake(ssl: *const sys::SSL) -> core::ffi::c_int; + fn SSL_set_post_handshake_auth(ssl: *mut sys::SSL, val: core::ffi::c_int); } #[cfg(ossl110)] unsafe extern "C" { - fn SSL_CTX_get_security_level(ctx: *const sys::SSL_CTX) -> libc::c_int; + fn SSL_CTX_get_security_level(ctx: *const sys::SSL_CTX) -> core::ffi::c_int; } unsafe extern "C" { @@ -3390,13 +3395,13 @@ mod _ssl { #[allow(non_camel_case_types)] type SSL_CTX_msg_callback = Option< unsafe extern "C" fn( - write_p: libc::c_int, - version: libc::c_int, - content_type: libc::c_int, - buf: *const libc::c_void, + write_p: core::ffi::c_int, + version: core::ffi::c_int, + content_type: core::ffi::c_int, + buf: *const core::ffi::c_void, len: usize, ssl: *mut sys::SSL, - arg: *mut libc::c_void, + arg: *mut core::ffi::c_void, ), >; @@ -3406,40 +3411,42 @@ mod _ssl { #[cfg(ossl110)] unsafe extern "C" { - fn SSL_SESSION_has_ticket(session: *const sys::SSL_SESSION) -> libc::c_int; - fn SSL_SESSION_get_ticket_lifetime_hint(session: *const sys::SSL_SESSION) -> libc::c_ulong; + fn SSL_SESSION_has_ticket(session: *const sys::SSL_SESSION) -> core::ffi::c_int; + fn SSL_SESSION_get_ticket_lifetime_hint( + session: *const sys::SSL_SESSION, + ) -> core::ffi::c_ulong; } // X509 object types - const X509_LU_X509: libc::c_int = 1; - const X509_LU_CRL: libc::c_int = 2; + const X509_LU_X509: core::ffi::c_int = 1; + const X509_LU_CRL: core::ffi::c_int = 2; unsafe extern "C" { - fn X509_OBJECT_get_type(obj: *const sys::X509_OBJECT) -> libc::c_int; + fn X509_OBJECT_get_type(obj: *const sys::X509_OBJECT) -> core::ffi::c_int; fn SSL_set_session_id_context( ssl: *mut sys::SSL, - sid_ctx: *const libc::c_uchar, - sid_ctx_len: libc::c_uint, - ) -> libc::c_int; + sid_ctx: *const core::ffi::c_uchar, + sid_ctx_len: core::ffi::c_uint, + ) -> core::ffi::c_int; fn SSL_get1_session(ssl: *const sys::SSL) -> *mut sys::SSL_SESSION; } // SSL session statistics constants (used with SSL_CTX_ctrl) - const SSL_CTRL_SESS_NUMBER: libc::c_int = 20; - const SSL_CTRL_SESS_CONNECT: libc::c_int = 21; - const SSL_CTRL_SESS_CONNECT_GOOD: libc::c_int = 22; - const SSL_CTRL_SESS_CONNECT_RENEGOTIATE: libc::c_int = 23; - const SSL_CTRL_SESS_ACCEPT: libc::c_int = 24; - const SSL_CTRL_SESS_ACCEPT_GOOD: libc::c_int = 25; - const SSL_CTRL_SESS_ACCEPT_RENEGOTIATE: libc::c_int = 26; - const SSL_CTRL_SESS_HIT: libc::c_int = 27; - const SSL_CTRL_SESS_MISSES: libc::c_int = 29; - const SSL_CTRL_SESS_TIMEOUTS: libc::c_int = 30; - const SSL_CTRL_SESS_CACHE_FULL: libc::c_int = 31; + const SSL_CTRL_SESS_NUMBER: core::ffi::c_int = 20; + const SSL_CTRL_SESS_CONNECT: core::ffi::c_int = 21; + const SSL_CTRL_SESS_CONNECT_GOOD: core::ffi::c_int = 22; + const SSL_CTRL_SESS_CONNECT_RENEGOTIATE: core::ffi::c_int = 23; + const SSL_CTRL_SESS_ACCEPT: core::ffi::c_int = 24; + const SSL_CTRL_SESS_ACCEPT_GOOD: core::ffi::c_int = 25; + const SSL_CTRL_SESS_ACCEPT_RENEGOTIATE: core::ffi::c_int = 26; + const SSL_CTRL_SESS_HIT: core::ffi::c_int = 27; + const SSL_CTRL_SESS_MISSES: core::ffi::c_int = 29; + const SSL_CTRL_SESS_TIMEOUTS: core::ffi::c_int = 30; + const SSL_CTRL_SESS_CACHE_FULL: core::ffi::c_int = 31; // SSL session statistics functions (implemented as macros in OpenSSL) #[allow(non_snake_case)] - unsafe fn SSL_CTX_sess_number(ctx: *const sys::SSL_CTX) -> libc::c_long { + unsafe fn SSL_CTX_sess_number(ctx: *const sys::SSL_CTX) -> core::ffi::c_long { unsafe { sys::SSL_CTX_ctrl( ctx as *mut _, @@ -3451,7 +3458,7 @@ mod _ssl { } #[allow(non_snake_case)] - unsafe fn SSL_CTX_sess_connect(ctx: *const sys::SSL_CTX) -> libc::c_long { + unsafe fn SSL_CTX_sess_connect(ctx: *const sys::SSL_CTX) -> core::ffi::c_long { unsafe { sys::SSL_CTX_ctrl( ctx as *mut _, @@ -3463,7 +3470,7 @@ mod _ssl { } #[allow(non_snake_case)] - unsafe fn SSL_CTX_sess_connect_good(ctx: *const sys::SSL_CTX) -> libc::c_long { + unsafe fn SSL_CTX_sess_connect_good(ctx: *const sys::SSL_CTX) -> core::ffi::c_long { unsafe { sys::SSL_CTX_ctrl( ctx as *mut _, @@ -3475,7 +3482,7 @@ mod _ssl { } #[allow(non_snake_case)] - unsafe fn SSL_CTX_sess_connect_renegotiate(ctx: *const sys::SSL_CTX) -> libc::c_long { + unsafe fn SSL_CTX_sess_connect_renegotiate(ctx: *const sys::SSL_CTX) -> core::ffi::c_long { unsafe { sys::SSL_CTX_ctrl( ctx as *mut _, @@ -3487,7 +3494,7 @@ mod _ssl { } #[allow(non_snake_case)] - unsafe fn SSL_CTX_sess_accept(ctx: *const sys::SSL_CTX) -> libc::c_long { + unsafe fn SSL_CTX_sess_accept(ctx: *const sys::SSL_CTX) -> core::ffi::c_long { unsafe { sys::SSL_CTX_ctrl( ctx as *mut _, @@ -3499,7 +3506,7 @@ mod _ssl { } #[allow(non_snake_case)] - unsafe fn SSL_CTX_sess_accept_good(ctx: *const sys::SSL_CTX) -> libc::c_long { + unsafe fn SSL_CTX_sess_accept_good(ctx: *const sys::SSL_CTX) -> core::ffi::c_long { unsafe { sys::SSL_CTX_ctrl( ctx as *mut _, @@ -3511,7 +3518,7 @@ mod _ssl { } #[allow(non_snake_case)] - unsafe fn SSL_CTX_sess_accept_renegotiate(ctx: *const sys::SSL_CTX) -> libc::c_long { + unsafe fn SSL_CTX_sess_accept_renegotiate(ctx: *const sys::SSL_CTX) -> core::ffi::c_long { unsafe { sys::SSL_CTX_ctrl( ctx as *mut _, @@ -3523,12 +3530,12 @@ mod _ssl { } #[allow(non_snake_case)] - unsafe fn SSL_CTX_sess_hits(ctx: *const sys::SSL_CTX) -> libc::c_long { + unsafe fn SSL_CTX_sess_hits(ctx: *const sys::SSL_CTX) -> core::ffi::c_long { unsafe { sys::SSL_CTX_ctrl(ctx as *mut _, SSL_CTRL_SESS_HIT, 0, core::ptr::null_mut()) } } #[allow(non_snake_case)] - unsafe fn SSL_CTX_sess_misses(ctx: *const sys::SSL_CTX) -> libc::c_long { + unsafe fn SSL_CTX_sess_misses(ctx: *const sys::SSL_CTX) -> core::ffi::c_long { unsafe { sys::SSL_CTX_ctrl( ctx as *mut _, @@ -3540,7 +3547,7 @@ mod _ssl { } #[allow(non_snake_case)] - unsafe fn SSL_CTX_sess_timeouts(ctx: *const sys::SSL_CTX) -> libc::c_long { + unsafe fn SSL_CTX_sess_timeouts(ctx: *const sys::SSL_CTX) -> core::ffi::c_long { unsafe { sys::SSL_CTX_ctrl( ctx as *mut _, @@ -3552,7 +3559,7 @@ mod _ssl { } #[allow(non_snake_case)] - unsafe fn SSL_CTX_sess_cache_full(ctx: *const sys::SSL_CTX) -> libc::c_long { + unsafe fn SSL_CTX_sess_cache_full(ctx: *const sys::SSL_CTX) -> core::ffi::c_long { unsafe { sys::SSL_CTX_ctrl( ctx as *mut _, @@ -3566,17 +3573,17 @@ mod _ssl { // DH parameters functions unsafe extern "C" { fn PEM_read_DHparams( - fp: *mut libc::FILE, + fp: *mut rustpython_host_env::fileutils::CFile, x: *mut *mut sys::DH, - cb: *mut libc::c_void, - u: *mut libc::c_void, + cb: *mut core::ffi::c_void, + u: *mut core::ffi::c_void, ) -> *mut sys::DH; } // OpenSSL BIO helper functions // These are typically macros in OpenSSL, implemented via BIO_ctrl - const BIO_CTRL_PENDING: libc::c_int = 10; - const BIO_CTRL_SET_EOF: libc::c_int = 2; + const BIO_CTRL_PENDING: core::ffi::c_int = 10; + const BIO_CTRL_SET_EOF: core::ffi::c_int = 2; #[allow(non_snake_case)] unsafe fn BIO_ctrl_pending(bio: *mut sys::BIO) -> usize { @@ -3584,14 +3591,17 @@ mod _ssl { } #[allow(non_snake_case)] - unsafe fn BIO_set_mem_eof_return(bio: *mut sys::BIO, eof: libc::c_int) -> libc::c_int { + unsafe fn BIO_set_mem_eof_return( + bio: *mut sys::BIO, + eof: core::ffi::c_int, + ) -> core::ffi::c_int { unsafe { sys::BIO_ctrl( bio, BIO_CTRL_SET_EOF, - eof as libc::c_long, + eof as core::ffi::c_long, core::ptr::null_mut(), - ) as libc::c_int + ) as core::ffi::c_int } } @@ -3721,7 +3731,7 @@ mod _ssl { #[pygetset] fn id(&self, vm: &VirtualMachine) -> PyBytesRef { unsafe { - let mut len: libc::c_uint = 0; + let mut len: core::ffi::c_uint = 0; let id_ptr = sys::SSL_SESSION_get_id(self.session, &mut len); let id_slice = core::slice::from_raw_parts(id_ptr, len as usize); vm.ctx.new_bytes(id_slice.to_vec()) @@ -3976,7 +3986,7 @@ mod _ssl { let mut buf = vec![0u8; 256]; let result = sys::SSL_CIPHER_description( cipher, - buf.as_mut_ptr() as *mut libc::c_char, + buf.as_mut_ptr() as *mut core::ffi::c_char, buf.len() as i32, ); if result.is_null() { @@ -4142,7 +4152,7 @@ mod windows { mod bio { //! based off rust-openssl's private `bio` module - use libc::c_int; + use core::ffi::c_int; use openssl::error::ErrorStack; use openssl_sys as sys; use std::marker::PhantomData; diff --git a/crates/stdlib/src/openssl/cert.rs b/crates/stdlib/src/openssl/cert.rs index e18e7feb9f0..7704e51f0a2 100644 --- a/crates/stdlib/src/openssl/cert.rs +++ b/crates/stdlib/src/openssl/cert.rs @@ -39,7 +39,7 @@ pub(crate) mod ssl_cert { let buflen = buflen as usize; let mut buf = Vec::::with_capacity(buflen + 1); let ret = sys::OBJ_obj2txt( - buf.as_mut_ptr() as *mut libc::c_char, + buf.as_mut_ptr() as *mut core::ffi::c_char, buf.capacity() as _, ptr, no_name, diff --git a/crates/stdlib/src/posixshmem.rs b/crates/stdlib/src/posixshmem.rs index 91fdf4aafbc..6bc1c006513 100644 --- a/crates/stdlib/src/posixshmem.rs +++ b/crates/stdlib/src/posixshmem.rs @@ -16,15 +16,15 @@ mod _posixshmem { #[pyarg(any)] name: PyUtf8StrRef, #[pyarg(any)] - flags: libc::c_int, + flags: core::ffi::c_int, #[pyarg(any, default = 0o600)] - mode: libc::mode_t, + mode: shm::mode_t, } #[pyfunction] - fn shm_open(args: ShmOpenArgs, vm: &VirtualMachine) -> PyResult { + fn shm_open(args: ShmOpenArgs, vm: &VirtualMachine) -> PyResult { let name = CString::new(args.name.as_str()).map_err(|e| e.into_pyexception(vm))?; - let mode: libc::c_uint = args.mode as _; + let mode: core::ffi::c_uint = args.mode as _; shm::shm_open(name.as_c_str(), args.flags, mode).map_err(|e| e.into_pyexception(vm)) } diff --git a/crates/stdlib/src/posixsubprocess.rs b/crates/stdlib/src/posixsubprocess.rs index 0371d12e3c2..080c0646845 100644 --- a/crates/stdlib/src/posixsubprocess.rs +++ b/crates/stdlib/src/posixsubprocess.rs @@ -26,7 +26,7 @@ mod _posixsubprocess { use crate::vm::{PyResult, VirtualMachine, convert::IntoPyException}; #[pyfunction] - fn fork_exec(args: ForkExecArgs<'_>, vm: &VirtualMachine) -> PyResult { + fn fork_exec(args: ForkExecArgs<'_>, vm: &VirtualMachine) -> PyResult { // Check for interpreter shutdown when preexec_fn is used if args.preexec_fn.is_some() && vm @@ -85,7 +85,7 @@ impl AsRef for CStrPathLike { #[derive(Default)] struct CharPtrVec<'a> { - vec: Vec<*const libc::c_char>, + vec: Vec<*const host_posix::c_char>, marker: PhantomData>, } @@ -107,7 +107,7 @@ impl<'a> Deref for CharPtrVec<'a> { type Target = CharPtrSlice<'a>; fn deref(&self) -> &Self::Target { unsafe { - &*(self.vec.as_slice() as *const [*const libc::c_char] as *const CharPtrSlice<'a>) + &*(self.vec.as_slice() as *const [*const host_posix::c_char] as *const CharPtrSlice<'a>) } } } @@ -115,11 +115,11 @@ impl<'a> Deref for CharPtrVec<'a> { #[repr(transparent)] struct CharPtrSlice<'a> { marker: PhantomData<[&'a CStr]>, - slice: [*const libc::c_char], + slice: [*const host_posix::c_char], } impl CharPtrSlice<'_> { - const fn as_ptr(&self) -> *const *const libc::c_char { + const fn as_ptr(&self) -> *const *const host_posix::c_char { self.slice.as_ptr() } } @@ -254,7 +254,7 @@ gen_args! { errpipe_write: Fd, restore_signals: bool, call_setsid: bool, - pgid_to_set: libc::pid_t, + pgid_to_set: host_posix::pid_t, gid: Option, groups_list: Option, uid: Option, diff --git a/crates/stdlib/src/resource.rs b/crates/stdlib/src/resource.rs index bac708435c9..cf7fe23d8cc 100644 --- a/crates/stdlib/src/resource.rs +++ b/crates/stdlib/src/resource.rs @@ -16,7 +16,7 @@ mod resource { #[cfg_attr(target_os = "android", expect(deprecated))] const RLIM_NLIMITS: i32 = cfg_select! { target_os = "android" => { - libc::RLIM_NLIMITS + host_resource::RLIM_NLIMITS } _ => { // This constant isn't abi-stable across os versions, so we just @@ -28,18 +28,18 @@ mod resource { // TODO: RLIMIT_OFILE, #[pyattr] - use libc::{ + use host_resource::{ RLIM_INFINITY, RLIMIT_AS, RLIMIT_CORE, RLIMIT_CPU, RLIMIT_DATA, RLIMIT_FSIZE, RLIMIT_MEMLOCK, RLIMIT_NOFILE, RLIMIT_NPROC, RLIMIT_RSS, RLIMIT_STACK, }; #[cfg(any(target_os = "linux", target_os = "android", target_os = "emscripten"))] #[pyattr] - use libc::{RLIMIT_MSGQUEUE, RLIMIT_NICE, RLIMIT_RTPRIO, RLIMIT_SIGPENDING}; + use host_resource::{RLIMIT_MSGQUEUE, RLIMIT_NICE, RLIMIT_RTPRIO, RLIMIT_SIGPENDING}; // TODO: I think this is supposed to be defined for all linux_like? #[cfg(target_os = "linux")] #[pyattr] - use libc::RLIMIT_RTTIME; + use host_resource::RLIMIT_RTTIME; #[cfg(any( target_os = "freebsd", @@ -48,41 +48,41 @@ mod resource { target_os = "illumos" ))] #[pyattr] - use libc::RLIMIT_SBSIZE; + use host_resource::RLIMIT_SBSIZE; #[cfg(any(target_os = "freebsd", target_os = "solaris", target_os = "illumos"))] #[pyattr] - use libc::{RLIMIT_NPTS, RLIMIT_SWAP}; + use host_resource::{RLIMIT_NPTS, RLIMIT_SWAP}; #[cfg(any(target_os = "solaris", target_os = "illumos"))] #[pyattr] - use libc::RLIMIT_VMEM; + use host_resource::RLIMIT_VMEM; #[cfg(any(target_os = "linux", target_os = "emscripten", target_os = "freebsd"))] #[pyattr] - use libc::RUSAGE_THREAD; + use host_resource::RUSAGE_THREAD; #[cfg(not(any(target_os = "windows", target_os = "redox")))] #[pyattr] - use libc::{RUSAGE_CHILDREN, RUSAGE_SELF}; + use host_resource::{RUSAGE_CHILDREN, RUSAGE_SELF}; #[pystruct_sequence_data] struct RUsageData { ru_utime: f64, ru_stime: f64, - ru_maxrss: libc::c_long, - ru_ixrss: libc::c_long, - ru_idrss: libc::c_long, - ru_isrss: libc::c_long, - ru_minflt: libc::c_long, - ru_majflt: libc::c_long, - ru_nswap: libc::c_long, - ru_inblock: libc::c_long, - ru_oublock: libc::c_long, - ru_msgsnd: libc::c_long, - ru_msgrcv: libc::c_long, - ru_nsignals: libc::c_long, - ru_nvcsw: libc::c_long, - ru_nivcsw: libc::c_long, + ru_maxrss: host_resource::c_long, + ru_ixrss: host_resource::c_long, + ru_idrss: host_resource::c_long, + ru_isrss: host_resource::c_long, + ru_minflt: host_resource::c_long, + ru_majflt: host_resource::c_long, + ru_nswap: host_resource::c_long, + ru_inblock: host_resource::c_long, + ru_oublock: host_resource::c_long, + ru_msgsnd: host_resource::c_long, + ru_msgrcv: host_resource::c_long, + ru_nsignals: host_resource::c_long, + ru_nvcsw: host_resource::c_long, + ru_nivcsw: host_resource::c_long, } #[pyattr] @@ -94,7 +94,8 @@ mod resource { impl From for RUsageData { fn from(rusage: host_resource::RUsage) -> Self { - let tv = |tv: libc::timeval| tv.tv_sec as f64 + (tv.tv_usec as f64 / 1_000_000.0); + let tv = + |tv: host_resource::timeval| tv.tv_sec as f64 + (tv.tv_usec as f64 / 1_000_000.0); Self { ru_utime: tv(rusage.ru_utime), ru_stime: tv(rusage.ru_stime), @@ -128,13 +129,13 @@ mod resource { }) } - struct Limits(libc::rlimit); + struct Limits(host_resource::rlimit); impl<'a> TryFromBorrowedObject<'a> for Limits { fn try_from_borrowed_object(vm: &VirtualMachine, obj: &'a PyObject) -> PyResult { - let seq: Vec = obj.try_to_value(vm)?; + let seq: Vec = obj.try_to_value(vm)?; match *seq { - [cur, max] => Ok(Self(libc::rlimit { + [cur, max] => Ok(Self(host_resource::rlimit { rlim_cur: cur & RLIM_INFINITY, rlim_max: max & RLIM_INFINITY, })), @@ -149,14 +150,14 @@ mod resource { } } - fn py2rlim(obj: PyIntRef, vm: &VirtualMachine) -> PyResult { + fn py2rlim(obj: PyIntRef, vm: &VirtualMachine) -> PyResult { let value = obj.try_to_primitive::(vm)?; if value.is_negative() { return Err(vm.new_value_error("Cannot convert negative int")); } - libc::rlim_t::try_from(value) + host_resource::rlim_t::try_from(value) .map_err(|_| vm.new_overflow_error("Python int too large to convert to C rlim_t")) } @@ -164,7 +165,7 @@ mod resource { fn getrlimit(resource: PyIntRef, vm: &VirtualMachine) -> PyResult { let resource = py2rlim(resource, vm)?; - if resource >= RLIM_NLIMITS as libc::rlim_t { + if resource >= RLIM_NLIMITS as host_resource::rlim_t { return Err(vm.new_value_error("invalid resource specified")); } @@ -176,7 +177,7 @@ mod resource { fn setrlimit(resource: PyIntRef, limits: Limits, vm: &VirtualMachine) -> PyResult<()> { let resource = py2rlim(resource, vm)?; - if resource >= RLIM_NLIMITS as libc::rlim_t { + if resource >= RLIM_NLIMITS as host_resource::rlim_t { return Err(vm.new_value_error("invalid resource specified")); } diff --git a/crates/stdlib/src/scproxy.rs b/crates/stdlib/src/scproxy.rs index 09e7cdc6046..f31432cbb51 100644 --- a/crates/stdlib/src/scproxy.rs +++ b/crates/stdlib/src/scproxy.rs @@ -9,14 +9,14 @@ mod _scproxy { builtins::{PyDict, PyDictRef, PyStr}, convert::ToPyObject, }; - use system_configuration::core_foundation::{ + use rustpython_host_env::system_configuration::core_foundation::{ array::CFArray, base::{CFType, FromVoid, TCFType}, dictionary::CFDictionary, number::CFNumber, string::{CFString, CFStringRef}, }; - use system_configuration::sys::{ + use rustpython_host_env::system_configuration::sys::{ dynamic_store_copy_specific::SCDynamicStoreCopyProxies, schema_definitions::*, }; diff --git a/crates/stdlib/src/select.rs b/crates/stdlib/src/select.rs index f8125ea375f..c1f10f3ecc2 100644 --- a/crates/stdlib/src/select.rs +++ b/crates/stdlib/src/select.rs @@ -170,7 +170,7 @@ mod decl { #[cfg(unix)] #[pyattr] - use libc::{POLLERR, POLLHUP, POLLIN, POLLNVAL, POLLOUT, POLLPRI}; + use host_select::{POLLERR, POLLHUP, POLLIN, POLLNVAL, POLLOUT, POLLPRI}; #[cfg(unix)] pub(super) mod poll { @@ -261,7 +261,8 @@ mod decl { } } - const DEFAULT_EVENTS: i16 = libc::POLLIN | libc::POLLPRI | libc::POLLOUT; + const DEFAULT_EVENTS: i16 = + host_select::POLLIN | host_select::POLLPRI | host_select::POLLOUT; #[pyclass] impl PyPoll { @@ -315,7 +316,7 @@ mod decl { loop { match vm.allow_threads(|| host_select::poll_fds(&mut fds, poll_timeout)) { Ok(_) => break, - Err(err) if err.raw_os_error() == Some(libc::EINTR) => { + Err(err) if err.raw_os_error() == Some(host_select::EINTR) => { vm.check_signals()? } Err(err) => return Err(err.into_pyexception(vm)), @@ -346,14 +347,14 @@ mod decl { #[cfg(any(target_os = "linux", target_os = "android", target_os = "redox"))] #[pyattr] - use libc::{ + use host_select::{ EPOLL_CLOEXEC, EPOLLERR, EPOLLEXCLUSIVE, EPOLLHUP, EPOLLIN, EPOLLMSG, EPOLLONESHOT, EPOLLOUT, EPOLLPRI, EPOLLRDBAND, EPOLLRDHUP, EPOLLRDNORM, EPOLLWAKEUP, EPOLLWRBAND, EPOLLWRNORM, }; #[cfg(any(target_os = "linux", target_os = "android", target_os = "redox"))] #[pyattr] - const EPOLLET: u32 = libc::EPOLLET as u32; + const EPOLLET: u32 = host_select::EPOLLET as u32; #[cfg(any(target_os = "linux", target_os = "android", target_os = "redox"))] pub(super) mod epoll { @@ -392,7 +393,7 @@ mod decl { if let ..=-2 | 0 = args.sizehint { return Err(vm.new_value_error("negative sizehint")); } - if !matches!(args.flags, 0 | libc::EPOLL_CLOEXEC) { + if !matches!(args.flags, 0 | host_select::EPOLL_CLOEXEC) { return Err(vm.new_os_error("invalid flags")); } Self::new().map_err(|e| e.into_pyexception(vm)) @@ -497,7 +498,7 @@ mod decl { "maxevents must be greater than 0, got {maxevents}" ))); } - -1 => libc::FD_SETSIZE - 1, + -1 => host_select::FD_SETSIZE - 1, _ => maxevents as usize, }; diff --git a/crates/stdlib/src/socket.rs b/crates/stdlib/src/socket.rs index e3ce52b943a..e7ee6c907db 100644 --- a/crates/stdlib/src/socket.rs +++ b/crates/stdlib/src/socket.rs @@ -45,8 +45,8 @@ mod _socket { time::Duration, }; use crossbeam_utils::atomic::AtomicCell; + use host_socket::raw::Socket; use num_traits::ToPrimitive; - use socket2::Socket; use std::{ ffi, io::{self, Read, Write}, @@ -885,9 +885,9 @@ mod _socket { fn get_raw_sock(obj: PyObjectRef, vm: &VirtualMachine) -> PyResult { #[cfg(unix)] - type CastFrom = libc::c_long; + type CastFrom = core::ffi::c_long; #[cfg(windows)] - type CastFrom = libc::c_longlong; + type CastFrom = core::ffi::c_longlong; // should really just be to_index() but test_socket tests the error messages explicitly if obj.fast_isinstance(vm.ctx.types.float_type) { @@ -1109,7 +1109,7 @@ mod _socket { addr: PyObjectRef, caller: &str, vm: &VirtualMachine, - ) -> Result { + ) -> Result { let family = self.family.load(); match family { #[cfg(unix)] @@ -1122,7 +1122,7 @@ mod _socket { ArgStrOrBytesLike::Buf(_) => ffi::OsStr::from_bytes(bytes).into(), ArgStrOrBytesLike::Str(s) => vm.fsencode(s)?, }; - socket2::SockAddr::unix(path) + host_socket::raw::SockAddr::unix(path) .map_err(|_| vm.new_os_error("AF_UNIX path too long").into()) } c::AF_INET => { @@ -1214,19 +1214,18 @@ mod _socket { }; // Create sockaddr_can - let mut storage: libc::sockaddr_storage = unsafe { core::mem::zeroed() }; - let can_addr = - &mut storage as *mut libc::sockaddr_storage as *mut libc::sockaddr_can; + let mut storage: c::sockaddr_storage = unsafe { core::mem::zeroed() }; + let can_addr = &mut storage as *mut c::sockaddr_storage as *mut c::sockaddr_can; unsafe { - (*can_addr).can_family = libc::AF_CAN as libc::sa_family_t; + (*can_addr).can_family = c::AF_CAN as c::sa_family_t; (*can_addr).can_ifindex = ifindex; } - let storage: socket2::SockAddrStorage = + let storage: host_socket::raw::SockAddrStorage = unsafe { core::mem::transmute(storage) }; Ok(unsafe { - socket2::SockAddr::new( + host_socket::raw::SockAddr::new( storage, - core::mem::size_of::() as libc::socklen_t, + core::mem::size_of::() as c::socklen_t, ) }) } @@ -1273,11 +1272,10 @@ mod _socket { } // Create sockaddr_alg - let mut storage: libc::sockaddr_storage = unsafe { core::mem::zeroed() }; - let alg_addr = - &mut storage as *mut libc::sockaddr_storage as *mut libc::sockaddr_alg; + let mut storage: c::sockaddr_storage = unsafe { core::mem::zeroed() }; + let alg_addr = &mut storage as *mut c::sockaddr_storage as *mut c::sockaddr_alg; unsafe { - (*alg_addr).salg_family = libc::AF_ALG as libc::sa_family_t; + (*alg_addr).salg_family = c::AF_ALG as c::sa_family_t; // Copy type string for (i, b) in type_str.bytes().enumerate() { (*alg_addr).salg_type[i] = b; @@ -1287,12 +1285,12 @@ mod _socket { (*alg_addr).salg_name[i] = b; } } - let storage: socket2::SockAddrStorage = + let storage: host_socket::raw::SockAddrStorage = unsafe { core::mem::transmute(storage) }; Ok(unsafe { - socket2::SockAddr::new( + host_socket::raw::SockAddr::new( storage, - core::mem::size_of::() as libc::socklen_t, + core::mem::size_of::() as c::socklen_t, ) }) } @@ -1773,7 +1771,7 @@ mod _socket { vm: &VirtualMachine, ) -> PyResult { let flags = flags.unwrap_or(0); - let mut msg = socket2::MsgHdr::new(); + let mut msg = host_socket::raw::MsgHdr::new(); let sockaddr; if let Some(addr) = addr.flatten() { @@ -1896,9 +1894,9 @@ mod _socket { // Build address tuple let address = if let Some(address) = msg.address { - let storage: socket2::SockAddrStorage = + let storage: host_socket::raw::SockAddrStorage = unsafe { core::mem::transmute(address.storage) }; - let addr = unsafe { socket2::SockAddr::new(storage, address.len as _) }; + let addr = unsafe { host_socket::raw::SockAddr::new(storage, address.len as _) }; get_addr_tuple(&addr, vm) } else { vm.ctx.none() @@ -2260,7 +2258,7 @@ mod _socket { } } - fn get_addr_tuple(addr: &socket2::SockAddr, vm: &VirtualMachine) -> PyObjectRef { + fn get_addr_tuple(addr: &host_socket::raw::SockAddr, vm: &VirtualMachine) -> PyObjectRef { if let Some(addr) = addr.as_socket() { return get_ip_addr_tuple(&addr, vm); } @@ -2280,9 +2278,9 @@ mod _socket { #[cfg(target_os = "linux")] { let family = addr.family(); - if family == libc::AF_CAN as libc::sa_family_t { + if family == c::AF_CAN as c::sa_family_t { // AF_CAN address: (interface_name,) or (interface_name, can_id) - let can_addr = unsafe { &*(addr.as_ptr() as *const libc::sockaddr_can) }; + let can_addr = unsafe { &*(addr.as_ptr() as *const c::sockaddr_can) }; let ifindex = can_addr.can_ifindex; let ifname = if ifindex == 0 { String::new() @@ -2291,9 +2289,9 @@ mod _socket { }; return vm.ctx.new_tuple(vec![vm.ctx.new_str(ifname).into()]).into(); } - if family == libc::AF_ALG as libc::sa_family_t { + if family == c::AF_ALG as c::sa_family_t { // AF_ALG address: (type, name) - let alg_addr = unsafe { &*(addr.as_ptr() as *const libc::sockaddr_alg) }; + let alg_addr = unsafe { &*(addr.as_ptr() as *const c::sockaddr_alg) }; let type_bytes = &alg_addr.salg_type; let name_bytes = &alg_addr.salg_name; let type_nul = memchr::memchr(b'\0', type_bytes).unwrap_or(type_bytes.len()); @@ -2319,7 +2317,7 @@ mod _socket { audit.call((vm.ctx.new_str("socket.gethostname"),), vm)?; } - gethostname::gethostname() + rustpython_host_env::socket::hostname() .into_string() .map(|hostname| vm.ctx.new_str(hostname)) .map_err(|err| vm.new_os_error(err.into_string().unwrap())) @@ -2348,7 +2346,7 @@ mod _socket { Ok(vm.ctx.new_str(Ipv4Addr::from(*packed_ip).to_string())) } - fn cstr_opt_as_ptr(x: &OptionalArg) -> *const libc::c_char { + fn cstr_opt_as_ptr(x: &OptionalArg) -> *const core::ffi::c_char { x.as_ref().map_or_else(core::ptr::null, |s| s.as_ptr()) } @@ -2593,7 +2591,7 @@ mod _socket { opts: GAIOptions, vm: &VirtualMachine, ) -> Result, IoOrPyException> { - let hints = dns_lookup::AddrInfoHints { + let hints = host_socket::dns::AddrInfoHints { socktype: opts.ty, protocol: opts.proto, address: opts.family, @@ -2646,7 +2644,7 @@ mod _socket { }; let port = port_encoded.as_deref(); - let addrs = dns_lookup::getaddrinfo(host, port, Some(hints)) + let addrs = host_socket::dns::getaddrinfo(host, port, Some(hints)) .map_err(|err| convert_socket_error(vm, err, SocketError::GaiError))?; let list = addrs @@ -2672,7 +2670,7 @@ mod _socket { vm: &VirtualMachine, ) -> Result<(String, PyListRef, PyListRef), IoOrPyException> { let addr = get_addr(vm, addr, c::AF_UNSPEC)?; - let (hostname, _) = dns_lookup::getnameinfo(&addr, 0) + let (hostname, _) = host_socket::dns::getnameinfo(&addr, 0) .map_err(|e| convert_socket_error(vm, e, SocketError::HError))?; Ok(( hostname, @@ -2697,7 +2695,7 @@ mod _socket { vm: &VirtualMachine, ) -> Result<(String, PyListRef, PyListRef), IoOrPyException> { let addr = get_addr(vm, name, c::AF_INET)?; - let (hostname, _) = dns_lookup::getnameinfo(&addr, 0) + let (hostname, _) = host_socket::dns::getnameinfo(&addr, 0) .map_err(|e| convert_socket_error(vm, e, SocketError::HError))?; Ok(( hostname, @@ -2772,7 +2770,7 @@ mod _socket { } } let (addr, flowinfo, scopeid) = Address::from_tuple_ipv6(&address, vm)?; - let hints = dns_lookup::AddrInfoHints { + let hints = host_socket::dns::AddrInfoHints { address: c::AF_UNSPEC, socktype: c::SOCK_DGRAM, flags: c::AI_NUMERICHOST, @@ -2780,7 +2778,7 @@ mod _socket { }; let service = addr.port.to_string(); let host_str = addr.host.as_str(); - let mut res = dns_lookup::getaddrinfo(Some(host_str), Some(&service), Some(hints)) + let mut res = host_socket::dns::getaddrinfo(Some(host_str), Some(&service), Some(hints)) .map_err(|e| convert_socket_error(vm, e, SocketError::GaiError))? .filter_map(Result::ok); let mut ainfo = res.next().unwrap(); @@ -2800,7 +2798,7 @@ mod _socket { addr.set_scope_id(scopeid); } } - dns_lookup::getnameinfo(&ainfo.sockaddr, flags) + host_socket::dns::getnameinfo(&ainfo.sockaddr, flags) .map_err(|e| convert_socket_error(vm, e, SocketError::GaiError)) } @@ -2811,8 +2809,8 @@ mod _socket { socket_kind: OptionalArg, proto: OptionalArg, ) -> Result<(PySocket, PySocket), IoOrPyException> { - let family = family.unwrap_or(libc::AF_UNIX); - let socket_kind = socket_kind.unwrap_or(libc::SOCK_STREAM); + let family = family.unwrap_or(c::AF_UNIX); + let socket_kind = socket_kind.unwrap_or(c::SOCK_STREAM); let proto = proto.unwrap_or(0); let (a, b) = Socket::pair(family.into(), socket_kind.into(), Some(proto.into()))?; let py_a = PySocket::default(); @@ -2839,7 +2837,7 @@ mod _socket { { let name = name.to_cstring(vm)?; // in case 'if_nametoindex' does not set errno - rustpython_host_env::os::set_errno(libc::ENODEV); + rustpython_host_env::os::set_errno(c::ENODEV); let ret = unsafe { c::if_nametoindex(name.as_ptr() as _) }; if ret == 0 { Err(vm.new_last_errno_error()) @@ -2860,7 +2858,7 @@ mod _socket { { let mut buf = [0; c::IF_NAMESIZE + 1]; // in case 'if_indextoname' does not set errno - rustpython_host_env::os::set_errno(libc::ENXIO); + rustpython_host_env::os::set_errno(c::ENXIO); let ret = unsafe { c::if_indextoname(index, buf.as_mut_ptr()) }; if ret.is_null() { Err(vm.new_last_errno_error()) @@ -2912,13 +2910,13 @@ mod _socket { ) -> Result { let name = pyname.as_str(); if name.is_empty() { - let hints = dns_lookup::AddrInfoHints { + let hints = host_socket::dns::AddrInfoHints { address: af, socktype: c::SOCK_DGRAM, flags: c::AI_PASSIVE, protocol: 0, }; - let mut res = dns_lookup::getaddrinfo(None, Some("0"), Some(hints)) + let mut res = host_socket::dns::getaddrinfo(None, Some("0"), Some(hints)) .map_err(|e| convert_socket_error(vm, e, SocketError::GaiError))?; let ainfo = res.next().unwrap()?; if res.next().is_some() { @@ -2951,7 +2949,7 @@ mod _socket { { return Ok(SocketAddr::V6(net::SocketAddrV6::new(addr, 0, 0, 0))); } - let hints = dns_lookup::AddrInfoHints { + let hints = host_socket::dns::AddrInfoHints { address: af, ..Default::default() }; @@ -2961,7 +2959,7 @@ mod _socket { .encode_text(pyname.into_wtf8(), "idna", None, vm)?; let name = core::str::from_utf8(name.as_bytes()) .map_err(|_| vm.new_runtime_error("idna output is not utf8"))?; - let mut res = dns_lookup::getaddrinfo(Some(name), None, Some(hints)) + let mut res = host_socket::dns::getaddrinfo(Some(name), None, Some(hints)) .map_err(|e| convert_socket_error(vm, e, SocketError::GaiError))?; Ok(res.next().unwrap().map(|ainfo| ainfo.sockaddr)?) } @@ -3027,10 +3025,10 @@ mod _socket { fn convert_socket_error( vm: &VirtualMachine, - err: dns_lookup::LookupError, + err: host_socket::dns::LookupError, err_kind: SocketError, ) -> IoOrPyException { - if let dns_lookup::LookupErrorKind::System = err.kind() { + if let host_socket::dns::LookupErrorKind::System = err.kind() { return io::Error::from(err).into(); } let strerr = { diff --git a/crates/stdlib/src/syslog.rs b/crates/stdlib/src/syslog.rs index 52424972a0a..910175ec6ec 100644 --- a/crates/stdlib/src/syslog.rs +++ b/crates/stdlib/src/syslog.rs @@ -13,7 +13,7 @@ mod syslog { use rustpython_host_env::syslog as host_syslog; #[pyattr] - use libc::{ + use host_syslog::{ LOG_ALERT, LOG_AUTH, LOG_CONS, LOG_CRIT, LOG_DAEMON, LOG_DEBUG, LOG_EMERG, LOG_ERR, LOG_INFO, LOG_KERN, LOG_LOCAL0, LOG_LOCAL1, LOG_LOCAL2, LOG_LOCAL3, LOG_LOCAL4, LOG_LOCAL5, LOG_LOCAL6, LOG_LOCAL7, LOG_LPR, LOG_MAIL, LOG_NDELAY, LOG_NEWS, LOG_NOTICE, LOG_NOWAIT, @@ -22,7 +22,7 @@ mod syslog { #[cfg(not(target_os = "redox"))] #[pyattr] - use libc::{LOG_AUTHPRIV, LOG_CRON, LOG_PERROR}; + use host_syslog::{LOG_AUTHPRIV, LOG_CRON, LOG_PERROR}; fn get_argv(vm: &VirtualMachine) -> Option { if let Some(argv) = vm.state.config.settings.argv.first() diff --git a/crates/stdlib/src/termios.rs b/crates/stdlib/src/termios.rs index 7a2c2472443..2207276f81e 100644 --- a/crates/stdlib/src/termios.rs +++ b/crates/stdlib/src/termios.rs @@ -99,25 +99,9 @@ mod termios { ))] #[pyattr] use host_termios::{CBAUD, CIBAUD, IUCLC, OLCUC, XCASE}; - #[cfg(any( - target_os = "android", - target_os = "freebsd", - target_os = "illumos", - target_os = "linux", - target_os = "macos", - target_os = "solaris" - ))] - #[pyattr] - use host_termios::{TAB0, TABDLY}; - #[cfg(any(target_os = "android", target_os = "linux"))] - #[pyattr] - use host_termios::{VSWTC, VSWTC as VSWTCH}; #[cfg(any(target_os = "illumos", target_os = "solaris"))] #[pyattr] - use host_termios::{VSWTCH, VSWTCH as VSWTC}; - #[cfg(any(target_os = "illumos", target_os = "solaris"))] - #[pyattr] - use libc::{CSTART, CSTOP, CSWTCH}; + use host_termios::{CSTART, CSTOP, CSWTCH}; #[cfg(any( target_os = "dragonfly", target_os = "freebsd", @@ -126,9 +110,9 @@ mod termios { target_os = "openbsd" ))] #[pyattr] - use libc::{FIOASYNC, TIOCGETD, TIOCSETD}; + use host_termios::{FIOASYNC, TIOCGETD, TIOCSETD}; #[pyattr] - use libc::{FIOCLEX, FIONBIO, TIOCGWINSZ, TIOCSWINSZ}; + use host_termios::{FIOCLEX, FIONBIO, TIOCGWINSZ, TIOCSWINSZ}; #[cfg(any( target_os = "android", target_os = "dragonfly", @@ -139,17 +123,27 @@ mod termios { target_os = "openbsd" ))] #[pyattr] - use libc::{ + use host_termios::{ FIONCLEX, FIONREAD, TIOCEXCL, TIOCM_CAR, TIOCM_CD, TIOCM_CTS, TIOCM_DSR, TIOCM_DTR, TIOCM_LE, TIOCM_RI, TIOCM_RNG, TIOCM_RTS, TIOCM_SR, TIOCM_ST, TIOCMBIC, TIOCMBIS, TIOCMGET, TIOCMSET, TIOCNXCL, TIOCSCTTY, }; #[cfg(any(target_os = "android", target_os = "linux"))] #[pyattr] - use libc::{ + use host_termios::{ IBSHIFT, TCFLSH, TCGETA, TCGETS, TCSBRK, TCSETA, TCSETAF, TCSETAW, TCSETS, TCSETSF, TCSETSW, TCXONC, TIOCGSERIAL, TIOCGSOFTCAR, TIOCINQ, TIOCLINUX, TIOCSSOFTCAR, XTABS, }; + #[cfg(any( + target_os = "android", + target_os = "freebsd", + target_os = "illumos", + target_os = "linux", + target_os = "macos", + target_os = "solaris" + ))] + #[pyattr] + use host_termios::{TAB0, TABDLY}; #[cfg(any( target_os = "android", target_os = "dragonfly", @@ -158,13 +152,19 @@ mod termios { target_os = "macos" ))] #[pyattr] - use libc::{TIOCCONS, TIOCGPGRP, TIOCOUTQ, TIOCSPGRP, TIOCSTI}; + use host_termios::{TIOCCONS, TIOCGPGRP, TIOCOUTQ, TIOCSPGRP, TIOCSTI}; #[cfg(any(target_os = "dragonfly", target_os = "freebsd", target_os = "macos"))] #[pyattr] - use libc::{ + use host_termios::{ TIOCNOTTY, TIOCPKT, TIOCPKT_DATA, TIOCPKT_DOSTOP, TIOCPKT_FLUSHREAD, TIOCPKT_FLUSHWRITE, TIOCPKT_NOSTOP, TIOCPKT_START, TIOCPKT_STOP, }; + #[cfg(any(target_os = "android", target_os = "linux"))] + #[pyattr] + use host_termios::{VSWTC, VSWTC as VSWTCH}; + #[cfg(any(target_os = "illumos", target_os = "solaris"))] + #[pyattr] + use host_termios::{VSWTCH, VSWTCH as VSWTC}; #[pyfunction] fn tcgetattr(fd: PyObjectRef, vm: &VirtualMachine) -> PyResult> { diff --git a/crates/stdlib/src/uuid.rs b/crates/stdlib/src/uuid.rs index 44121683628..10dc9541755 100644 --- a/crates/stdlib/src/uuid.rs +++ b/crates/stdlib/src/uuid.rs @@ -3,16 +3,13 @@ pub(crate) use _uuid::module_def; #[pymodule] mod _uuid { use crate::{builtins::PyNone, vm::VirtualMachine}; - use mac_address::get_mac_address; use std::sync::OnceLock; use uuid::{ContextV1, Uuid, timestamp::Timestamp}; fn get_node_id() -> [u8; 6] { - match get_mac_address() { - Ok(Some(_ma)) => get_mac_address().unwrap().unwrap().bytes(), - // os_random is expensive, but this is only ever called once - _ => rustpython_common::rand::os_random::<6>(), - } + // os_random is expensive, but this is only ever called once + rustpython_host_env::socket::mac_address() + .unwrap_or_else(rustpython_common::rand::os_random::<6>) } #[pyfunction] diff --git a/crates/vm/Cargo.toml b/crates/vm/Cargo.toml index f4e59e6acc4..0b2feed50a3 100644 --- a/crates/vm/Cargo.toml +++ b/crates/vm/Cargo.toml @@ -91,8 +91,6 @@ writeable = { workspace = true } exitcode = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -rustyline = { workspace = true } -which = { workspace = true } widestring = { workspace = true } [target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] diff --git a/crates/vm/src/getpath.rs b/crates/vm/src/getpath.rs index 63454720178..437db37835b 100644 --- a/crates/vm/src/getpath.rs +++ b/crates/vm/src/getpath.rs @@ -359,7 +359,7 @@ fn get_executable_path() -> Option { #[cfg(not(target_arch = "wasm32"))] { let exec_arg = env::args_os().next()?; - which::which(exec_arg).ok() + crate::host_env::fs::which(exec_arg) } #[cfg(target_arch = "wasm32")] { diff --git a/crates/vm/src/lib.rs b/crates/vm/src/lib.rs index cca5b43457c..c353f8dfc46 100644 --- a/crates/vm/src/lib.rs +++ b/crates/vm/src/lib.rs @@ -86,7 +86,7 @@ pub mod py_io; pub mod py_serde; pub mod gc_state; -pub mod readline; +pub use rustpython_host_env::readline; pub mod recursion; pub mod scope; pub mod sequence; From 0e2c8488810f00ed14d42d601b28154594ce1cb3 Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:26:16 +0200 Subject: [PATCH 089/351] Move type c-api functions (#8233) --- crates/capi/src/object.rs | 92 ++----------------------------- crates/capi/src/object/pytype.rs | 95 ++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 86 deletions(-) create mode 100644 crates/capi/src/object/pytype.rs diff --git a/crates/capi/src/object.rs b/crates/capi/src/object.rs index 9a5d2682ee2..27417a6ad33 100644 --- a/crates/capi/src/object.rs +++ b/crates/capi/src/object.rs @@ -1,14 +1,15 @@ use crate::PyObject; use crate::pystate::with_vm; -use core::ffi::{CStr, c_char, c_int, c_uint, c_ulong, c_void}; +use core::ffi::{CStr, c_char, c_int, c_uint, c_void}; use core::ptr::NonNull; -use rustpython_vm::builtins::{PyStr, PyType, object_generic_set_dict, object_get_dict}; +pub use pytype::*; +use rustpython_vm::builtins::{PyStr, object_generic_set_dict, object_get_dict}; use rustpython_vm::bytecode::ComparisonOperator; use rustpython_vm::function::PySetterValue; use rustpython_vm::types::{PyComparisonOp, hash_not_implemented}; -use rustpython_vm::{AsObject, Py, PyPayload, PyResult, VirtualMachine}; +use rustpython_vm::{AsObject, PyPayload, PyResult, VirtualMachine}; -pub type PyTypeObject = Py; +mod pytype; macro_rules! define_py_check { (fn $name:ident, $($ctx_path:ident).+) => { @@ -37,69 +38,6 @@ macro_rules! define_py_check { } pub(crate) use define_py_check; -define_py_check!(fn PyType_Check, types.type_type); -define_py_check!(exact fn PyType_CheckExact, types.type_type); - -#[unsafe(no_mangle)] -pub unsafe extern "C" fn Py_TYPE(op: *mut PyObject) -> *const PyTypeObject { - unsafe { (*op).class() } -} - -#[unsafe(no_mangle)] -pub unsafe extern "C" fn Py_IS_TYPE(op: *mut PyObject, ty: *mut PyTypeObject) -> c_int { - with_vm(|_vm| { - let obj = unsafe { &*op }; - let ty = unsafe { &*ty }; - obj.class().is(ty) - }) -} - -#[unsafe(no_mangle)] -pub unsafe extern "C" fn PyType_GetFlags(ptr: *const PyTypeObject) -> c_ulong { - let ty = unsafe { &*ptr }; - ty.slots.flags.bits() as u32 as c_ulong -} - -#[unsafe(no_mangle)] -pub unsafe extern "C" fn PyType_IsSubtype(a: *const PyTypeObject, b: *const PyTypeObject) -> c_int { - with_vm(move |_vm| { - let a = unsafe { &*a }; - let b = unsafe { &*b }; - Ok(a.is_subtype(b)) - }) -} - -#[unsafe(no_mangle)] -pub unsafe extern "C" fn PyType_GetName(ptr: *const PyTypeObject) -> *mut PyObject { - with_vm(|vm| unsafe { &*ptr }.__name__(vm)) -} - -#[unsafe(no_mangle)] -pub unsafe extern "C" fn PyType_GetQualName(ptr: *const PyTypeObject) -> *mut PyObject { - with_vm(|vm| unsafe { &*ptr }.__qualname__(vm)) -} - -#[unsafe(no_mangle)] -pub unsafe extern "C" fn PyType_GetModuleName(ptr: *const PyTypeObject) -> *mut PyObject { - with_vm(|vm| unsafe { &*ptr }.__module__(vm)) -} - -#[unsafe(no_mangle)] -pub unsafe extern "C" fn PyType_GetFullyQualifiedName(ptr: *const PyTypeObject) -> *mut PyObject { - with_vm(|vm| { - let ty = unsafe { &*ptr }; - let qualname = ty.__qualname__(vm).try_downcast::(vm)?; - let module = ty.__module__(vm); - - if let Some(module) = module.downcast_ref::() - && module.as_wtf8() != "builtins" - { - Ok(vm.ctx.new_str(format!("{module}.{qualname}"))) - } else { - Ok(qualname) - } - }) -} #[inline] fn get_constant(vm: &VirtualMachine, constant_id: c_uint) -> PyResult<&PyObject> { @@ -545,7 +483,7 @@ pub unsafe extern "C" fn PyObject_GenericSetDict( mod tests { use pyo3::class::basic::CompareOp; use pyo3::prelude::*; - use pyo3::types::{PyBool, PyDict, PyInt, PyString, PyTypeMethods}; + use pyo3::types::{PyBool, PyDict, PyInt}; #[test] fn is_truthy() { @@ -569,14 +507,6 @@ mod tests { }) } - #[test] - fn type_name() { - Python::attach(|py| { - let string = PyString::new(py, "Hello, World!"); - assert_eq!(string.get_type().name().unwrap().to_str().unwrap(), "str"); - }) - } - #[test] fn repr() { Python::attach(|py| { @@ -654,16 +584,6 @@ mod tests { }) } - #[test] - fn type_get_module_name() { - Python::attach(|py| { - assert_eq!( - py.get_type::().module().unwrap().to_str().unwrap(), - "builtins" - ); - }) - } - #[test] fn generic_get_dict() { Python::attach(|py| { diff --git a/crates/capi/src/object/pytype.rs b/crates/capi/src/object/pytype.rs new file mode 100644 index 00000000000..daad7b3133b --- /dev/null +++ b/crates/capi/src/object/pytype.rs @@ -0,0 +1,95 @@ +use crate::object::define_py_check; +use crate::pystate::with_vm; +use core::ffi::{c_int, c_ulong}; +use rustpython_vm::builtins::{PyStr, PyType}; +use rustpython_vm::{AsObject, Py, PyObject}; + +pub type PyTypeObject = Py; + +define_py_check!(fn PyType_Check, types.type_type); +define_py_check!(exact fn PyType_CheckExact, types.type_type); + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Py_TYPE(op: *mut PyObject) -> *const PyTypeObject { + unsafe { (*op).class() } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Py_IS_TYPE(op: *mut PyObject, ty: *mut PyTypeObject) -> c_int { + with_vm(|_vm| { + let obj = unsafe { &*op }; + let ty = unsafe { &*ty }; + obj.class().is(ty) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyType_GetFlags(ptr: *const PyTypeObject) -> c_ulong { + let ty = unsafe { &*ptr }; + ty.slots.flags.bits() as u32 as c_ulong +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyType_IsSubtype(a: *const PyTypeObject, b: *const PyTypeObject) -> c_int { + with_vm(move |_vm| { + let a = unsafe { &*a }; + let b = unsafe { &*b }; + Ok(a.is_subtype(b)) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyType_GetName(ptr: *const PyTypeObject) -> *mut PyObject { + with_vm(|vm| unsafe { &*ptr }.__name__(vm)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyType_GetQualName(ptr: *const PyTypeObject) -> *mut PyObject { + with_vm(|vm| unsafe { &*ptr }.__qualname__(vm)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyType_GetModuleName(ptr: *const PyTypeObject) -> *mut PyObject { + with_vm(|vm| unsafe { &*ptr }.__module__(vm)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyType_GetFullyQualifiedName(ptr: *const PyTypeObject) -> *mut PyObject { + with_vm(|vm| { + let ty = unsafe { &*ptr }; + let qualname = ty.__qualname__(vm).try_downcast::(vm)?; + let module = ty.__module__(vm); + + if let Some(module) = module.downcast_ref::() + && module.as_wtf8() != "builtins" + { + Ok(vm.ctx.new_str(format!("{module}.{qualname}"))) + } else { + Ok(qualname) + } + }) +} + +#[cfg(test)] +mod tests { + use pyo3::prelude::*; + use pyo3::types::{PyInt, PyString, PyTypeMethods}; + + #[test] + fn type_name() { + Python::attach(|py| { + let string = PyString::new(py, "Hello, World!"); + assert_eq!(string.get_type().name().unwrap().to_str().unwrap(), "str"); + }) + } + + #[test] + fn type_get_module_name() { + Python::attach(|py| { + assert_eq!( + py.get_type::().module().unwrap().to_str().unwrap(), + "builtins" + ); + }) + } +} From a5eecb5ebfd48dc62fc05913d16a556d6b0ccd3a Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:03:29 +0900 Subject: [PATCH 090/351] Implement round-half-even float.fromhex in common float_ops (#8234) * Add round-half-even hex float parser to common float_ops Add float_ops::from_hex returning Result, which parses the coefficient and exponent, rounds round-half-even over the full exponent range, and detects overflow. Route float.fromhex through it: raise OverflowError for values too large to represent and ValueError for invalid input. Remove the expectedFailure on test_from_hex. Assisted-by: Claude * Address review: Option-returning byte helpers, const fns, drop test_ prefix Assisted-by: Claude * Whitelist fdigits and inity in cspell Assisted-by: Claude * Home is_py_ascii_whitespace in rustpython-wtf8; share with common and sre_engine Assisted-by: Claude * Use shared wtf8 is_py_ascii_whitespace in bytes_inner Remove the private copy of is_py_ascii_whitespace in bytes_inner.rs and import the shared rustpython_common::wtf8::is_py_ascii_whitespace. Assisted-by: Claude --- .cspell.json | 2 + Lib/test/test_float.py | 1 - crates/common/src/float_ops.rs | 408 ++++++++++++++++++++++++++++++++ crates/sre_engine/src/string.rs | 6 +- crates/vm/src/builtins/float.rs | 12 +- crates/vm/src/bytes_inner.rs | 5 +- crates/wtf8/src/lib.rs | 7 + 7 files changed, 429 insertions(+), 12 deletions(-) diff --git a/.cspell.json b/.cspell.json index af2f1401d95..f173874d4cb 100644 --- a/.cspell.json +++ b/.cspell.json @@ -66,11 +66,13 @@ "deoptimize", "emscripten", "excs", + "fdigits", "flufl", "fnfe", "fsdefault", "ifexp", "implicits", + "inity", "interps", "jitted", "jitting", diff --git a/Lib/test/test_float.py b/Lib/test/test_float.py index 0938c89cbcb..a514111c1b8 100644 --- a/Lib/test/test_float.py +++ b/Lib/test/test_float.py @@ -1262,7 +1262,6 @@ def test_whitespace(self): self.identical(got, expected) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: invalid hexadecimal floating-point string def test_from_hex(self): MIN = self.MIN MAX = self.MAX diff --git a/crates/common/src/float_ops.rs b/crates/common/src/float_ops.rs index ca1c716a9bd..4f117e80c46 100644 --- a/crates/common/src/float_ops.rs +++ b/crates/common/src/float_ops.rs @@ -279,6 +279,414 @@ pub fn round_float_digits(x: f64, ndigits: i32) -> Option { Some(result) } +/// Error from [`from_hex`], mapping to the exception the caller should raise. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HexFloatError { + /// ValueError "invalid hexadecimal floating-point string" + Invalid, + /// ValueError "hexadecimal string too long to convert" + TooLong, + /// OverflowError "hexadecimal value too large to represent as a float" + Overflow, +} + +const DBL_MANT_DIG: i64 = 53; +const DBL_MIN_EXP: i64 = -1021; +const DBL_MAX_EXP: i64 = 1024; + +/// Read byte at `i`, returning `None` past the end so that digit/sign/space +/// scans stop at the string boundary. +#[inline] +fn byte_at(bytes: &[u8], i: usize) -> Option { + bytes.get(i).copied() +} + +/// '0'-'9' -> 0..9, 'a'-'f'/'A'-'F' -> 10..15, else `None`. +#[inline] +const fn hex_from_char(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + b'A'..=b'F' => Some(c - b'A' + 10), + _ => None, + } +} + +/// Peek at byte `i` and decode it as a hex digit, or `None` if it is out of +/// range or not a hex digit. +#[inline] +fn hex_digit_at(bytes: &[u8], i: usize) -> Option { + byte_at(bytes, i).and_then(hex_from_char) +} + +/// `t` must be an ASCII-lowercase literal. Returns true if every byte of `t` +/// matched case-insensitively starting at `s`. +fn case_insensitive_match(bytes: &[u8], s: usize, t: &[u8]) -> bool { + let mut si = s; + let mut ti = 0; + while ti < t.len() && byte_at(bytes, si).is_some_and(|b| b.to_ascii_lowercase() == t[ti]) { + si += 1; + ti += 1; + } + ti == t.len() +} + +/// Returns `Some((value, endptr))` when the text at `p` parses as inf/nan, +/// otherwise `None`. +fn parse_inf_or_nan(bytes: &[u8], p: usize) -> Option<(f64, usize)> { + let mut s = p; + let mut negate = false; + if byte_at(bytes, s) == Some(b'-') { + negate = true; + s += 1; + } else if byte_at(bytes, s) == Some(b'+') { + s += 1; + } + if case_insensitive_match(bytes, s, b"inf") { + s += 3; + if case_insensitive_match(bytes, s, b"inity") { + s += 5; + } + let value = if negate { + f64::NEG_INFINITY + } else { + f64::INFINITY + }; + Some((value, s)) + } else if case_insensitive_match(bytes, s, b"nan") { + s += 3; + let value = if negate { + f64::from_bits(0xfff8_0000_0000_0000) + } else { + f64::from_bits(0x7ff8_0000_0000_0000) + }; + Some((value, s)) + } else { + None + } +} + +/// Correctly-rounded scalbn. Every call site scales an already-representable +/// value, so the result is exact. +const fn ldexp(x: f64, mut n: i32) -> f64 { + let x1p1023 = f64::from_bits(0x7fe0000000000000); + let x1p53 = f64::from_bits(0x4340000000000000); + let x1p_1022 = f64::from_bits(0x0010000000000000); + let mut y = x; + if n > 1023 { + y *= x1p1023; + n -= 1023; + if n > 1023 { + y *= x1p1023; + n -= 1023; + if n > 1023 { + n = 1023; + } + } + } else if n < -1022 { + y *= x1p_1022 * x1p53; + n += 1022 - 53; + if n < -1022 { + y *= x1p_1022 * x1p53; + n += 1022 - 53; + if n < -1022 { + n = -1022; + } + } + } + y * f64::from_bits(((0x3ff + n) as u64) << 52) +} + +/// Parse the already-validated `[+-]?[0-9]+` slice `bytes[start..end]` as base-10 +/// signed, saturating to i64::MIN/MAX on overflow like strtol. +fn strtol_saturating(bytes: &[u8], start: usize, end: usize) -> i64 { + let mut i = start; + let mut neg = false; + if i < end && (bytes[i] == b'+' || bytes[i] == b'-') { + neg = bytes[i] == b'-'; + i += 1; + } + let mut val: i64 = 0; + let mut overflowed = false; + while i < end { + let d = (bytes[i] - b'0') as i64; + match val.checked_mul(10).and_then(|v| v.checked_add(d)) { + Some(v) => val = v, + None => { + overflowed = true; + break; + } + } + i += 1; + } + if overflowed { + if neg { i64::MIN } else { i64::MAX } + } else if neg { + -val + } else { + val + } +} + +/// Parse a hexadecimal floating-point string (the `float.fromhex` grammar). +/// +/// The raw string is consumed as-is: leading and trailing whitespace are handled +/// internally using the ASCII space set, so callers must not trim first. +pub fn from_hex(s: &str) -> Result { + let bytes = s.as_bytes(); + let s_end = bytes.len(); + + let mut negate = false; + let mut idx = 0usize; + let mut x; + + // leading whitespace + while byte_at(bytes, idx).is_some_and(rustpython_wtf8::is_py_ascii_whitespace) { + idx += 1; + } + + // infinities and nans + if let Some((value, end)) = parse_inf_or_nan(bytes, idx) { + idx = end; + return finish_hex(bytes, s_end, idx, negate, value); + } + + // optional sign + if byte_at(bytes, idx) == Some(b'-') { + idx += 1; + negate = true; + } else if byte_at(bytes, idx) == Some(b'+') { + idx += 1; + } + + // [0x] + let s_store = idx; + if byte_at(bytes, idx) == Some(b'0') { + idx += 1; + if matches!(byte_at(bytes, idx), Some(b'x' | b'X')) { + idx += 1; + } else { + idx = s_store; + } + } + + // coefficient: [. ] + let coeff_start = idx; + while hex_digit_at(bytes, idx).is_some() { + idx += 1; + } + let s_store = idx; + let coeff_end = if byte_at(bytes, idx) == Some(b'.') { + idx += 1; + while hex_digit_at(bytes, idx).is_some() { + idx += 1; + } + idx - 1 + } else { + idx + }; + + // ndigits = total # of hex digits; fdigits = # after point + let ndigits_total = (coeff_end - coeff_start) as i64; + let fdigits = (coeff_end - s_store) as i64; + if ndigits_total == 0 { + return Err(HexFloatError::Invalid); + } + let insane_bound = core::cmp::min( + DBL_MIN_EXP - DBL_MANT_DIG - i64::MIN / 2, + i64::MAX / 2 + 1 - DBL_MAX_EXP, + ) / 4; + if ndigits_total > insane_bound { + return Err(HexFloatError::TooLong); + } + + // [p ] + let exp = if matches!(byte_at(bytes, idx), Some(b'p' | b'P')) { + idx += 1; + let exp_start = idx; + if matches!(byte_at(bytes, idx), Some(b'-' | b'+')) { + idx += 1; + } + if !matches!(byte_at(bytes, idx), Some(b'0'..=b'9')) { + return Err(HexFloatError::Invalid); + } + idx += 1; + while matches!(byte_at(bytes, idx), Some(b'0'..=b'9')) { + idx += 1; + } + strtol_saturating(bytes, exp_start, idx) + } else { + 0 + }; + + // HEX_DIGIT(j): jth hex digit counting from the least significant. + let hex_digit = |j: i64| -> i32 { + let byte_idx = if j < fdigits { + coeff_end as i64 - j + } else { + coeff_end as i64 - 1 - j + }; + hex_digit_at(bytes, byte_idx as usize).expect("hex digit within coefficient") as i32 + }; + + // Discard leading zeros, and catch extreme overflow and underflow. + let mut ndigits = ndigits_total; + while ndigits > 0 && hex_digit(ndigits - 1) == 0 { + ndigits -= 1; + } + if ndigits == 0 || exp < i64::MIN / 2 { + x = 0.0; + return finish_hex(bytes, s_end, idx, negate, x); + } + if exp > i64::MAX / 2 { + return Err(HexFloatError::Overflow); + } + + // Adjust exponent for fractional part. + let exp = exp - 4 * fdigits; + + // top_exp = 1 more than exponent of most significant bit of coefficient. + let mut top_exp = exp + 4 * (ndigits - 1); + let mut digit = hex_digit(ndigits - 1); + while digit != 0 { + top_exp += 1; + digit /= 2; + } + + // catch almost all nonextreme cases of overflow and underflow here + if top_exp < DBL_MIN_EXP - DBL_MANT_DIG { + x = 0.0; + return finish_hex(bytes, s_end, idx, negate, x); + } + if top_exp > DBL_MAX_EXP { + return Err(HexFloatError::Overflow); + } + + // lsb = exponent of least significant bit of the rounded value. + let lsb = core::cmp::max(top_exp, DBL_MIN_EXP) - DBL_MANT_DIG; + + x = 0.0; + if exp >= lsb { + // no rounding required + let mut i = ndigits - 1; + while i >= 0 { + x = 16.0 * x + hex_digit(i) as f64; + i -= 1; + } + x = ldexp(x, exp as i32); + return finish_hex(bytes, s_end, idx, negate, x); + } + + // rounding required. key_digit is the index of the hex digit + // containing the first bit to be rounded away. + let half_eps: i32 = 1 << ((lsb - exp - 1) % 4) as i32; + let key_digit = (lsb - exp - 1) / 4; + let mut i = ndigits - 1; + while i > key_digit { + x = 16.0 * x + hex_digit(i) as f64; + i -= 1; + } + let digit = hex_digit(key_digit); + x = 16.0 * x + (digit & (16 - 2 * half_eps)) as f64; + + // round-half-even + if (digit & half_eps) != 0 { + let round_up = if (digit & (3 * half_eps - 1)) != 0 + || (half_eps == 8 && key_digit + 1 < ndigits && (hex_digit(key_digit + 1) & 1) != 0) + { + true + } else { + let mut r = false; + let mut i = key_digit - 1; + while i >= 0 { + if hex_digit(i) != 0 { + r = true; + break; + } + i -= 1; + } + r + }; + if round_up { + x += (2 * half_eps) as f64; + if top_exp == DBL_MAX_EXP && x == ldexp((2 * half_eps) as f64, DBL_MANT_DIG as i32) { + // overflow corner case + return Err(HexFloatError::Overflow); + } + } + } + x = ldexp(x, (exp + 4 * key_digit) as i32); + + finish_hex(bytes, s_end, idx, negate, x) +} + +/// Skip trailing whitespace, require the whole string was consumed, and apply +/// the sign. +fn finish_hex( + bytes: &[u8], + s_end: usize, + mut idx: usize, + negate: bool, + x: f64, +) -> Result { + while byte_at(bytes, idx).is_some_and(rustpython_wtf8::is_py_ascii_whitespace) { + idx += 1; + } + if idx != s_end { + return Err(HexFloatError::Invalid); + } + Ok(if negate { -x } else { x }) +} + +#[cfg(test)] +mod from_hex_tests { + use super::{HexFloatError, from_hex}; + + fn bits(s: &str) -> u64 { + from_hex(s).unwrap().to_bits() + } + + #[test] + fn from_hex_exact_bits() { + assert_eq!(bits("0x1p-1074"), 0x0000000000000001); + assert_eq!(bits("0x1.fffffffffffffp+1023"), 0x7fefffffffffffff); + // round-half-even ties + assert_eq!(bits("0x1.00000000000008p0"), 0x3ff0000000000000); + assert_eq!(bits("0x1.00000000000018p0"), 0x3ff0000000000002); + assert_eq!(bits("-0x1p0"), 0xbff0000000000000); + assert_eq!(bits("0x0p0"), 0x0000000000000000); + assert_eq!(bits("-0x0p0"), 0x8000000000000000); + } + + #[test] + fn from_hex_inf_nan() { + assert_eq!(bits("inf"), 0x7ff0000000000000); + assert_eq!(bits("-inf"), 0xfff0000000000000); + assert_eq!(bits("Infinity"), 0x7ff0000000000000); + + let n = from_hex("nan").unwrap(); + assert!(n.is_nan()); + assert_eq!(n.to_bits(), 0x7ff8000000000000); + let neg = from_hex("-nan").unwrap(); + assert!(neg.is_nan()); + assert_eq!(neg.to_bits(), 0xfff8000000000000); + } + + #[test] + fn from_hex_whitespace() { + assert_eq!(bits(" 0x1p0 "), 0x3ff0000000000000); + assert_eq!(bits("\t0x1p0\n"), 0x3ff0000000000000); + } + + #[test] + fn from_hex_errors() { + assert_eq!(from_hex("0x1p1024"), Err(HexFloatError::Overflow)); + assert_eq!(from_hex("0x1z"), Err(HexFloatError::Invalid)); + assert_eq!(from_hex(""), Err(HexFloatError::Invalid)); + assert_eq!(from_hex("0x1 p0"), Err(HexFloatError::Invalid)); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/sre_engine/src/string.rs b/crates/sre_engine/src/string.rs index 6468c6d0cfd..5deeab67eb6 100644 --- a/crates/sre_engine/src/string.rs +++ b/crates/sre_engine/src/string.rs @@ -341,10 +341,6 @@ const CONT_MASK: u8 = 0b0011_1111; const UNDERSCORE: u32 = '_' as u32; -const fn is_py_ascii_whitespace(b: u8) -> bool { - matches!(b, b'\t' | b'\n' | b'\x0C' | b'\r' | b' ' | b'\x0B') -} - #[inline] pub(crate) fn is_word(ch: u32) -> bool { ch == UNDERSCORE || u8::try_from(ch).is_ok_and(|x| x.is_ascii_alphanumeric()) @@ -352,7 +348,7 @@ pub(crate) fn is_word(ch: u32) -> bool { #[inline] pub(crate) fn is_space(ch: u32) -> bool { - u8::try_from(ch).is_ok_and(is_py_ascii_whitespace) + u8::try_from(ch).is_ok_and(rustpython_wtf8::is_py_ascii_whitespace) } #[inline] diff --git a/crates/vm/src/builtins/float.rs b/crates/vm/src/builtins/float.rs index 36846640e59..1c861b14fc6 100644 --- a/crates/vm/src/builtins/float.rs +++ b/crates/vm/src/builtins/float.rs @@ -399,8 +399,16 @@ impl PyFloat { #[pyclassmethod] fn fromhex(cls: PyTypeRef, string: PyUtf8StrRef, vm: &VirtualMachine) -> PyResult { - let result = crate::literal::float::from_hex(string.as_str().trim()) - .ok_or_else(|| vm.new_value_error("invalid hexadecimal floating-point string"))?; + use float_ops::HexFloatError; + let result = float_ops::from_hex(string.as_str()).map_err(|e| match e { + HexFloatError::Overflow => { + vm.new_overflow_error("hexadecimal value too large to represent as a float") + } + HexFloatError::TooLong => vm.new_value_error("hexadecimal string too long to convert"), + HexFloatError::Invalid => { + vm.new_value_error("invalid hexadecimal floating-point string") + } + })?; PyType::call(&cls, vec![vm.ctx.new_float(result).into()].into(), vm) } diff --git a/crates/vm/src/bytes_inner.rs b/crates/vm/src/bytes_inner.rs index 08518deb1c4..9cfe4d7609f 100644 --- a/crates/vm/src/bytes_inner.rs +++ b/crates/vm/src/bytes_inner.rs @@ -9,6 +9,7 @@ use crate::{ byte::bytes_from_object, cformat::cformat_bytes, common::hash, + common::wtf8::is_py_ascii_whitespace, function::{ArgIterable, Either, OptionalArg, OptionalOption, PyComparisonValue}, literal::escape::Escape, protocol::PyBuffer, @@ -1207,10 +1208,6 @@ pub(crate) fn bytes_to_hex( } } -pub(crate) const fn is_py_ascii_whitespace(b: u8) -> bool { - matches!(b, b'\t' | b'\n' | b'\x0C' | b'\r' | b' ' | b'\x0B') -} - /// ASCII-only title casing. /// /// This is purposely naive as is CPython's implementation. diff --git a/crates/wtf8/src/lib.rs b/crates/wtf8/src/lib.rs index b31ed1cf09c..2167b6b04c6 100644 --- a/crates/wtf8/src/lib.rs +++ b/crates/wtf8/src/lib.rs @@ -1347,6 +1347,13 @@ pub fn slice_error_fail(s: &Wtf8, begin: usize, end: usize) -> ! { panic!("index {begin} and/or {end} in `{s:?}` do not lie on character boundary"); } +/// True for the ASCII bytes Python treats as whitespace in numeric parsing +/// (`\t \n \x0b \x0c \r` and space). +#[must_use] +pub const fn is_py_ascii_whitespace(b: u8) -> bool { + matches!(b, b'\t' | b'\n' | b'\x0b' | b'\x0c' | b'\r' | b' ') +} + /// Iterator for the code points of a WTF-8 string. /// /// Created with the method `.code_points()`. From be384a36f79f6bb1a28f0e07b2252ab03b559359 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:42:10 +0900 Subject: [PATCH 091/351] ctypes: unify the foreign-call path on a single host_env `call()` API (#8235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * host_env: add unified high-level `call` foreign-call API Add a libffi-hiding foreign-call entry point that takes ctypes type codes and recursive layouts plus raw buffers instead of libffi `Type`/`Arg`, to become the single call path for the VM `_ctypes` (a later change) and other consumers. Also bring in the `callproc_simple` helper so this host_env copy stays byte-identical to the pyre-dev copy during the migration. - `CTypeLayout` (Simple/Pointer/Struct/Union/Array/Opaque) with `size()` and an internal libffi-type lowering built on `ffi_type_for_layout`. - `CallArg` (Typed/Int/Double/Pointer/Aggregate), `CallRet` (Void/Code/Pointer/Aggregate), `CallOptions` (use_errno/use_last_error), `CallValue` (Void/Scalar/Pointer/Aggregate), and `CallError`. - `call(addr, &[CallArg], CallRet, CallOptions) -> Result`, built on the existing `Cif`/`ffi_*` primitives: the errno / last-error swap wraps only the raw call, and by-value aggregate arguments and returns go through `Cif::call_return_into`. - 16 ABI unit tests over local `extern "C"` functions: scalar parity, by-value struct / nested / array-in-struct / {f32,f32} / large-struct arguments, small / odd / large struct returns, union size, the errno window, and the null-pointer / unknown-code / short-buffer errors. `callproc` and `callproc_simple` are left in place; both are removed once the migration onto `call` completes. Assisted-by: Claude * _ctypes: route the VM call path through host_env call() Replace the VM's direct libffi `Type`/`Arg` marshalling with the unified host_env `call` entry point. Behavior is preserved on the tested paths: structs and unions still pass by pointer and struct returns still use the register-size approximation (passing/returning aggregates by value is a later change). - base.rs: `FfiArgValue` → `CArgValue` (Typed{code,bytes}/Int/Double/Pointer) with `as_call_arg()`; the paramfunc producers emit `CArgValue`, snapshotting buffer bytes at conversion time so no lock is held across the call. - _ctypes.rs: `CArgObject` carries `value: CArgValue` plus a `keep` slot for the from_param z/Z/P null-terminated-copy keepalive that no longer rides in the value; `PyCArg` repr reconstructs the scalar for identical output; byref updated. - simple.rs: `to_ffi_value` → `to_carg_value`; from_param emits `CArgValue` and its keepalive. - function.rs: `Argument{value, keep}`; `ArgumentType` yields a `CArgValue` (with the "unsupported argument type" check moved up front); `CallInfo`'s return fields collapse to `RetSpec` (Void/Pointer/Code) reproducing the exact return type and result dispatch; `ctypes_callproc` builds `CallArg`/`CallRet`/ `CallOptions` and invokes `host_env::ctypes::call`; the errno/last-error swap moves into `CallOptions` (the `with_swapped_*` wrappers are removed); `convert_raw_result` consumes `CallValue`. - extra_tests/snippets/stdlib_ctypes_calls.py: libc calls over the live FFI path (abs/strlen/sqrt, c_char_p/c_void_p returns, a use_errno round-trip); output matches CPython. Two intentional deltas: a NULL function pointer now raises ValueError instead of a debug assertion, and a c_char passed positionally without argtypes now zero-extends via its type code rather than sign-extends via the old tag path, which also makes it agree with the with-argtypes path. The old host_env `callproc`/`ffi_*` and `StgInfo`'s libffi field types remain for now; they are removed when by-value aggregates land. Assisted-by: Claude * _ctypes: pass and return structs and unions by value Flip aggregate arguments and returns from the pointer / register-size approximation to true by-value passing through the host_env `call` entry point, and carry the driving layouts as host_env `CTypeLayout`. - base.rs: `StgInfo.ffi_field_types: Vec` becomes `field_layouts: Vec`, still built incrementally from the base class so struct inheritance is reflected. `StgInfo::to_ffi_type()` is replaced by a `type_layout(ty, &stg, vm)` helper that reads a type's own layout (aggregates from `field_layouts`, arrays recurse into the element type, simple types from their `_type_` code); it takes the already-borrowed `StgInfo` so it never re-locks the type. `CArgValue` gains an `Aggregate { layout, bytes }` variant lowering to `CallArg::Aggregate`, and `struct_union_paramfunc` snapshots the instance bytes into it (tag 'V'). - structure.rs / union.rs: collect each field's `type_layout` into `field_layouts` and store it on the finalized `StgInfo`. - function.rs: `convert_object` passes a struct/union argument by value (snapshot bytes + argtype layout); a `byref()` result is still a pointer. `RetSpec` gains `Aggregate(CTypeLayout)`; `compute_ret_spec` returns it for a struct/union restype; `ctypes_callproc` lowers it to `CallRet::Aggregate`. `convert_raw_result` now drops the restype `StgInfo` read guard before constructing the result instance: instance construction write-locks the type's `StgInfo` to finalize it, which otherwise self-deadlocks against the held read guard (latent since the register-size return path, unreachable until a struct was actually returned by value). - _ctypes.rs: a 'V' cparam now reprs as `` (the object-address default), matching PyCArg_repr; the aggregate `CArgValue` routes through that arm. - extra_tests/snippets/stdlib_ctypes_byvalue.py: div/imaxdiv struct returns (8- and 16-byte), inet_ntoa struct and union arguments, with and without argtypes; output matches CPython. Also sorts the imports in the sibling stdlib_ctypes_calls.py snippet (ruff isort). `_pack_` / `_swappedbytes_` layouts stay approximate — `CTypeLayout` carries no explicit field offsets, as the previous libffi field-type path did not either. host_env is untouched, so both `ctypes.rs` copies stay byte-identical. test_ctypes is unchanged at run=322 skipped=58. Assisted-by: Claude * host_env: remove the pre-`call` foreign-call scaffolding With the VM `_ctypes` and pyre both routed through `call`, delete the foreign-call paths and libffi-type builders that no longer have a consumer: - `callproc` (the low-level libffi `Vec` / `&[Arg]` entry) and its `CallResult` return type plus `call_result_bytes`. - `callproc_simple` and its `SimpleArg` / `SimpleRestype` / `SimpleCallError` types (the pointer-only slice added for pyre's first cut), and the `lookup_function_symbol_addr_str` helper, along with their tests. - `ffi_type_for_layout` + `CTypeParamKind`, `ffi_type_from_format`, `ffi_type_from_tag`, and `ffi_type_for_return_size` — the aggregate/simple libffi-type builders the old marshalling used. `call` and `CTypeLayout` build their own libffi types from `ffi_type_from_code` and the small `ffi_{pointer,byte_struct,repeat,void,i32,f64}_type` helpers, which stay. `simple_type_chars` stays (still used by pyre's simple-type validation). The `call` doc no longer references `callproc_simple`. A stale comment in the VM's result decoder that named `call_result_bytes` is reworded. host_env drops from 4051 to 3535 lines; the `call` ABI unit tests remain (24 tests). test_ctypes is unchanged at run=322 skipped=58. Assisted-by: Claude * host_env: read `call` scalar returns as a full register, use `add_i32` `call` read `Code` returns through `low::ffi_arg`. The `libffi_sys` binding types `ffi_arg` as `c_ulong`, which is 4 bytes under LLP64 (Windows x64), so `low::call` truncated 8-byte returns (`q`/`Q`/`d`) to the low 4 bytes. `calls_f64_scalar` and `passes_large_struct_by_value` failed on windows-2025. Read a full register (`u64`) instead; `decode_type_code` still slices the leading bytes each type code needs. No change on LP64, where `ffi_arg` is already 8 bytes. Add `typed_two_scalar_args`, which calls the previously-unused `add_i32` ABI helper (two-argument scalar path); the helper tripped `-D warnings` dead-code on clippy. Assisted-by: Claude * _ctypes: skip the by-value snippet on Windows `stdlib_ctypes_byvalue.py` reaches libc through `CDLL(None)` and calls `div`/`imaxdiv`/`inet_ntoa`, none of which resolve that way on Windows, so the snippet aborted on windows-2025. Guard it like `stdlib_ctypes_calls.py`: print "OK" and exit before touching `CDLL`. The by-value path is covered on Windows by test_ctypes. Assisted-by: Claude * _ctypes: keep the converted argument alive for by-value aggregates The by-value struct/union argument path snapshotted the instance bytes and returned `None` for the keep-alive slot, dropping the `from_param` result before the foreign call. When the snapshot embeds pointers into buffers owned by that object's keep-alive set, the call could read freed memory. Return the converted instance as the keep-alive owner. Assisted-by: Claude * host_env: avoid indexing an empty by-value aggregate buffer in `call` A zero-sized `Structure`/`Union` argument reaches the aggregate lowering with `layout.size() == 0` and an empty buffer, which passed the `buffer.len() < expected` check and then panicked on `&buffer[0]`. Use `buffer.first().unwrap_or(&0u8)`; libffi reads nothing for a zero-size type. Assisted-by: Claude --- crates/host_env/src/ctypes.rs | 1269 ++++++++++++++--- crates/vm/src/stdlib/_ctypes.rs | 91 +- crates/vm/src/stdlib/_ctypes/base.rs | 187 ++- crates/vm/src/stdlib/_ctypes/function.rs | 530 +++---- crates/vm/src/stdlib/_ctypes/simple.rs | 41 +- crates/vm/src/stdlib/_ctypes/structure.rs | 14 +- crates/vm/src/stdlib/_ctypes/union.rs | 14 +- extra_tests/snippets/stdlib_ctypes_byvalue.py | 106 ++ extra_tests/snippets/stdlib_ctypes_calls.py | 64 + 9 files changed, 1693 insertions(+), 623 deletions(-) create mode 100644 extra_tests/snippets/stdlib_ctypes_byvalue.py create mode 100644 extra_tests/snippets/stdlib_ctypes_calls.py diff --git a/crates/host_env/src/ctypes.rs b/crates/host_env/src/ctypes.rs index 9bfd41c2818..2dfc8cbe29d 100644 --- a/crates/host_env/src/ctypes.rs +++ b/crates/host_env/src/ctypes.rs @@ -24,7 +24,7 @@ use libffi::middle::Type; ))] use libffi::{ low, - middle::{Arg, Cif, Closure, CodePtr}, + middle::{Arg, Cif, Closure, CodePtr, Ret}, }; #[cfg(any(unix, windows))] use libloading::Library; @@ -652,21 +652,6 @@ pub enum FfiValue { Pointer(usize), } -#[cfg(all( - any( - target_os = "linux", - target_os = "macos", - target_os = "windows", - target_os = "android" - ), - not(any(target_env = "musl", target_env = "sgx")) -))] -pub enum CallResult { - Void, - Pointer(usize), - Value(low::ffi_arg), -} - #[cfg(all( any( target_os = "linux", @@ -1537,78 +1522,6 @@ pub fn ffi_type_from_code(ty: &str) -> Option { } } -#[cfg(all( - any( - target_os = "linux", - target_os = "macos", - target_os = "windows", - target_os = "android" - ), - not(any(target_env = "musl", target_env = "sgx")) -))] -pub fn ffi_type_from_tag(tag: u8) -> Type { - match tag { - b'c' | b'b' => Type::i8(), - b'B' | b'?' => Type::u8(), - b'h' | b'v' => Type::i16(), - b'H' => Type::u16(), - b'i' => Type::i32(), - b'I' => Type::u32(), - b'l' => { - if core::mem::size_of::() == 8 { - Type::i64() - } else { - Type::i32() - } - } - b'L' => { - if core::mem::size_of::() == 8 { - Type::u64() - } else { - Type::u32() - } - } - b'q' => Type::i64(), - b'Q' => Type::u64(), - b'f' => Type::f32(), - b'd' | b'g' => Type::f64(), - b'u' => { - if core::mem::size_of::() == 2 { - Type::u16() - } else { - Type::u32() - } - } - _ => Type::pointer(), - } -} - -#[cfg(all( - any( - target_os = "linux", - target_os = "macos", - target_os = "windows", - target_os = "android" - ), - not(any(target_env = "musl", target_env = "sgx")) -))] -pub fn ffi_type_from_format(fmt: &str) -> Type { - match fmt.trim_start_matches(['<', '>', '!', '@', '=']) { - "b" => Type::i8(), - "B" => Type::u8(), - "h" => Type::i16(), - "H" => Type::u16(), - "i" | "l" => Type::i32(), - "I" | "L" => Type::u32(), - "q" => Type::i64(), - "Q" => Type::u64(), - "f" => Type::f32(), - "d" => Type::f64(), - "P" | "z" | "Z" | "O" => Type::pointer(), - _ => Type::u8(), - } -} - #[cfg(all( any( target_os = "linux", @@ -1687,119 +1600,6 @@ pub fn ffi_void_type() -> Type { Type::void() } -#[cfg(all( - any( - target_os = "linux", - target_os = "macos", - target_os = "windows", - target_os = "android" - ), - not(any(target_env = "musl", target_env = "sgx")) -))] -pub fn ffi_type_for_return_size(size: usize) -> Type { - if size <= 4 { - Type::i32() - } else if size <= 8 { - Type::i64() - } else { - Type::pointer() - } -} - -#[cfg(all( - any( - target_os = "linux", - target_os = "macos", - target_os = "windows", - target_os = "android" - ), - not(any(target_env = "musl", target_env = "sgx")) -))] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CTypeParamKind { - Structure, - Union, - Array, - Pointer, - Simple, -} - -#[cfg(all( - any( - target_os = "linux", - target_os = "macos", - target_os = "windows", - target_os = "android" - ), - not(any(target_env = "musl", target_env = "sgx")) -))] -pub fn ffi_type_for_layout( - kind: CTypeParamKind, - ffi_field_types: &[Type], - size: usize, - length: usize, - format: Option<&str>, -) -> Type { - const MAX_FFI_STRUCT_SIZE: usize = 1024 * 1024; - - match kind { - CTypeParamKind::Structure | CTypeParamKind::Union => { - if !ffi_field_types.is_empty() { - Type::structure(ffi_field_types.iter().cloned()) - } else if size <= MAX_FFI_STRUCT_SIZE { - ffi_byte_struct(size) - } else { - ffi_pointer_type() - } - } - CTypeParamKind::Array => { - if size > MAX_FFI_STRUCT_SIZE || length > MAX_FFI_STRUCT_SIZE { - ffi_pointer_type() - } else if let Some(fmt) = format { - ffi_repeat_type(ffi_type_from_format(fmt), length) - } else { - ffi_byte_struct(size) - } - } - CTypeParamKind::Pointer => ffi_pointer_type(), - CTypeParamKind::Simple => { - if let Some(fmt) = format { - ffi_type_from_format(fmt) - } else { - Type::u8() - } - } - } -} - -#[cfg(all( - any( - target_os = "linux", - target_os = "macos", - target_os = "windows", - target_os = "android" - ), - not(any(target_env = "musl", target_env = "sgx")) -))] -pub fn callproc( - code_ptr: CodePtr, - ffi_arg_types: Vec, - ffi_return_type: Type, - ffi_args: &[Arg<'_>], - restype_is_none: bool, - is_pointer_return: bool, -) -> CallResult { - let cif = Cif::new(ffi_arg_types, ffi_return_type); - if restype_is_none { - unsafe { cif.call::<()>(code_ptr, ffi_args) }; - CallResult::Void - } else if is_pointer_return { - CallResult::Pointer(unsafe { cif.call::(code_ptr, ffi_args) }) - } else { - CallResult::Value(unsafe { cif.call::(code_ptr, ffi_args) }) - } -} - #[cfg(all( any( target_os = "linux", @@ -2052,6 +1852,230 @@ impl Drop for CallbackThunk { } } +/// Type codes whose value is a pointer (drives pointer-return decoding and +/// TYPEFLAG_ISPOINTER). +pub fn simple_type_is_pointer(code: &str) -> bool { + matches!(code, "z" | "Z" | "P" | "s" | "X" | "O") +} + +/// All valid ctypes simple type codes on this platform. +// +// TODO: the vm's `SIMPLE_TYPE_CHARS` const (crates/vm/src/stdlib/_ctypes/simple.rs) +// should adopt this as the single source of truth. +pub fn simple_type_chars() -> &'static str { + #[cfg(windows)] + { + // spell-checker: disable-next-line + "cbBhHiIlLdfuzZqQPXOv?g" + } + #[cfg(not(windows))] + { + // spell-checker: disable-next-line + "cbBhHiIlLdfuzZqQPOv?g" + } +} + +/// Recursive layout of a ctypes type: memory shape independent of any object +/// model, used to lower by-value aggregate arguments and aggregate returns for +/// [`call`]. +#[cfg(all( + any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "android" + ), + not(any(target_env = "musl", target_env = "sgx")) +))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CTypeLayout { + /// Simple type identified by its ctypes code ('i', 'd', 'P', 'u', ...). + Simple(char), + /// Any pointer-kind field (`POINTER(T)`, `c_void_p`/`z`/`Z`, function pointer). + Pointer, + /// Struct with per-field layouts in declaration order; `size` is the total + /// size including trailing padding. + Struct { fields: Vec, size: usize }, + /// Union, lowered to a size-matched byte struct (libffi has no union kind, so + /// register classification of float-only unions is approximate). + Union { fields: Vec, size: usize }, + /// Fixed-length array; only meaningful nested inside an aggregate. + Array { + element: Box, + length: usize, + size: usize, + }, + /// No field information available: a size-matched byte struct fallback. + Opaque { size: usize }, +} + +#[cfg(all( + any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "android" + ), + not(any(target_env = "musl", target_env = "sgx")) +))] +impl CTypeLayout { + /// Total size in bytes. + pub fn size(&self) -> usize { + match self { + Self::Simple(code) => { + let mut buf = [0u8; 4]; + simple_type_size(code.encode_utf8(&mut buf)).unwrap_or(0) + } + Self::Pointer => POINTER_SIZE, + Self::Struct { size, .. } + | Self::Union { size, .. } + | Self::Array { size, .. } + | Self::Opaque { size } => *size, + } + } + + /// Lower to a libffi type. `Err` if a simple code is unrecognized. + fn to_ffi_type(&self) -> Result { + match self { + Self::Simple(code) => { + let mut buf = [0u8; 4]; + let code = code.encode_utf8(&mut buf); + ffi_type_from_code(code).ok_or_else(|| CallError::UnknownTypeCode(code.to_string())) + } + Self::Pointer => Ok(ffi_pointer_type()), + Self::Struct { fields, .. } => { + let mut ffi_fields = Vec::with_capacity(fields.len()); + for field in fields { + ffi_fields.push(field.to_ffi_type()?); + } + Ok(Type::structure(ffi_fields)) + } + Self::Array { + element, length, .. + } => Ok(ffi_repeat_type(element.to_ffi_type()?, *length)), + Self::Union { size, .. } | Self::Opaque { size } => Ok(ffi_byte_struct(*size)), + } + } +} + +/// One argument of a foreign call. Buffers are borrowed and must outlive the +/// call; keeping their owners alive is the caller's responsibility. +#[cfg(all( + any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "android" + ), + not(any(target_env = "musl", target_env = "sgx")) +))] +#[derive(Debug, Clone, Copy)] +pub enum CallArg<'a> { + /// A value typed by a ctypes simple type code, as its raw native-endian + /// buffer (at least `simple_type_size(code)` bytes relevant). + Typed { code: &'a str, buffer: &'a [u8] }, + /// Untyped Python int (ConvParam default: C int). + Int(i32), + /// Untyped Python float (ConvParam default: C double). + Double(f64), + /// Address-valued argument (pointer decay, byref, bytes/str copies, NULL = 0). + Pointer(usize), + /// By-value aggregate: layout plus its raw bytes (`buffer.len() >= layout.size()`). + Aggregate { + layout: &'a CTypeLayout, + buffer: &'a [u8], + }, +} + +/// Return-type selector for [`call`]. +#[cfg(all( + any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "android" + ), + not(any(target_env = "musl", target_env = "sgx")) +))] +#[derive(Debug, Clone, Copy)] +pub enum CallRet<'a> { + /// restype is None: the call returns void. + Void, + /// A ctypes simple type code. Pointer-kind codes (`simple_type_is_pointer`) + /// yield [`CallValue::Pointer`]; everything else [`CallValue::Scalar`]. + Code(&'a str), + /// A pointer-typed return without a driving code (`POINTER(T)`, function + /// pointer). + Pointer, + /// A by-value aggregate return. + Aggregate(&'a CTypeLayout), +} + +/// Per-call error-swapping options. +#[cfg(all( + any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "android" + ), + not(any(target_env = "musl", target_env = "sgx")) +))] +#[derive(Debug, Clone, Copy, Default)] +pub struct CallOptions { + /// Swap the ctypes-local errno around the raw call (unix; ignored on windows). + pub use_errno: bool, + /// Swap the ctypes-local last error around the raw call (windows; ignored + /// elsewhere). + pub use_last_error: bool, +} + +/// Result of a foreign call. +#[cfg(all( + any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "android" + ), + not(any(target_env = "musl", target_env = "sgx")) +))] +#[derive(Debug)] +pub enum CallValue { + /// Void return. + Void, + /// Raw return-register image, native endian (register-sized: 8 bytes on + /// 64-bit). Decode with [`decode_type_code`]. + Scalar(Vec), + /// Pointer-valued return. + Pointer(usize), + /// Exactly `layout.size()` bytes of a returned aggregate. + Aggregate(Vec), +} + +/// Errors from [`call`]. +#[cfg(all( + any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "android" + ), + not(any(target_env = "musl", target_env = "sgx")) +))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CallError { + NullFunctionPointer, + UnknownTypeCode(String), + /// An aggregate argument's buffer was shorter than its layout size. + BufferTooSmall { + expected: usize, + got: usize, + }, +} + +/// Perform a foreign call: handles scalar, pointer, and by-value aggregate +/// arguments, and void / scalar / pointer / aggregate returns. #[cfg(all( any( target_os = "linux", @@ -2061,18 +2085,156 @@ impl Drop for CallbackThunk { ), not(any(target_env = "musl", target_env = "sgx")) ))] -pub fn call_result_bytes(raw_result: &CallResult) -> Option<(Vec, usize)> { - match raw_result { - CallResult::Void => None, - CallResult::Pointer(ptr) => { - let bytes = ptr.to_ne_bytes(); - Some((bytes.to_vec(), core::mem::size_of::())) +pub fn call( + addr: usize, + args: &[CallArg<'_>], + ret: CallRet<'_>, + options: CallOptions, +) -> Result { + enum Lowered<'a> { + Scalar(FfiValue), + Aggregate(&'a [u8]), + } + + let code_ptr = code_ptr_from_addr(addr).ok_or(CallError::NullFunctionPointer)?; + + // Pass 1: argument types + owned scalar values / borrowed aggregate buffers. + let mut ffi_arg_types: Vec = Vec::with_capacity(args.len()); + let mut lowered: Vec> = Vec::with_capacity(args.len()); + for arg in args { + match arg { + CallArg::Typed { code, buffer } => { + let ty = ffi_type_from_code(code) + .ok_or_else(|| CallError::UnknownTypeCode((*code).to_string()))?; + ffi_arg_types.push(ty); + lowered.push(Lowered::Scalar(ffi_value_from_type_code(code, buffer))); + } + CallArg::Int(value) => { + ffi_arg_types.push(ffi_i32_type()); + lowered.push(Lowered::Scalar(FfiValue::I32(*value))); + } + CallArg::Double(value) => { + ffi_arg_types.push(ffi_f64_type()); + lowered.push(Lowered::Scalar(FfiValue::F64(*value))); + } + CallArg::Pointer(value) => { + ffi_arg_types.push(ffi_pointer_type()); + lowered.push(Lowered::Scalar(FfiValue::Pointer(*value))); + } + CallArg::Aggregate { layout, buffer } => { + let expected = layout.size(); + if buffer.len() < expected { + return Err(CallError::BufferTooSmall { + expected, + got: buffer.len(), + }); + } + ffi_arg_types.push(layout.to_ffi_type()?); + lowered.push(Lowered::Aggregate(buffer)); + } } - CallResult::Value(val) => { - let bytes = val.to_ne_bytes(); - Some((bytes.to_vec(), core::mem::size_of_val(val))) + } + + let ffi_return_type = match ret { + CallRet::Void => ffi_void_type(), + CallRet::Code(code) => { + ffi_type_from_code(code).ok_or_else(|| CallError::UnknownTypeCode(code.to_string()))? } + CallRet::Pointer => ffi_pointer_type(), + CallRet::Aggregate(layout) => layout.to_ffi_type()?, + }; + + // Pass 2: borrow the completed `lowered` as libffi Args. No reallocation can + // now invalidate the scalar borrows; aggregate Args point at caller buffers. + let ffi_args: Vec> = lowered + .iter() + .map(|arg| match arg { + Lowered::Scalar(value) => ffi_arg_from_value(value), + // `.first()` avoids indexing an empty buffer (a zero-sized by-value + // aggregate); libffi reads nothing for a zero-size type. + Lowered::Aggregate(buffer) => Arg::new(buffer.first().unwrap_or(&0u8)), + }) + .collect(); + + let cif = Cif::new(ffi_arg_types, ffi_return_type); + + // Allocate the aggregate return buffer outside the error-swap window so no + // allocation runs between the raw call and the errno/last-error capture. + // libffi requires this buffer be at least `ffi_arg`-sized and suitably + // aligned; a `u64` slice guarantees both. + let mut aggregate_buffer: Vec = match ret { + CallRet::Aggregate(layout) => vec![0u64; core::cmp::max(layout.size(), 8).div_ceil(8)], + _ => Vec::new(), + }; + + enum RawResult { + Void, + Pointer(usize), + Scalar(u64), + Aggregate, } + + let mut invoke = || -> RawResult { + match ret { + CallRet::Void => { + unsafe { cif.call::<()>(code_ptr, &ffi_args) }; + RawResult::Void + } + CallRet::Code(code) if simple_type_is_pointer(code) => { + RawResult::Pointer(unsafe { cif.call::(code_ptr, &ffi_args) }) + } + CallRet::Code(_) => { + // Capture a full register (`u64`), not `low::ffi_arg`: the + // `libffi_sys` binding types `ffi_arg` as `c_ulong`, which is + // 4 bytes under LLP64 (Windows x64) and would truncate 8-byte + // returns (`q`/`Q`/`d`). `decode_type_code` reads the leading + // bytes the type code needs. + RawResult::Scalar(unsafe { cif.call::(code_ptr, &ffi_args) }) + } + CallRet::Pointer => { + RawResult::Pointer(unsafe { cif.call::(code_ptr, &ffi_args) }) + } + CallRet::Aggregate(_) => { + unsafe { + cif.call_return_into(code_ptr, &ffi_args, Ret::new(&mut aggregate_buffer[..])); + } + RawResult::Aggregate + } + } + }; + + #[cfg(not(windows))] + let raw = if options.use_errno { + with_swapped_errno(invoke) + } else { + invoke() + }; + + #[cfg(windows)] + let raw = if options.use_last_error { + with_swapped_last_error(invoke) + } else { + invoke() + }; + + let result = match raw { + RawResult::Void => CallValue::Void, + RawResult::Pointer(ptr) => CallValue::Pointer(ptr), + RawResult::Scalar(value) => CallValue::Scalar(value.to_ne_bytes().to_vec()), + RawResult::Aggregate => { + let size = match ret { + CallRet::Aggregate(layout) => layout.size(), + _ => 0, + }; + let bytes: Vec = aggregate_buffer + .iter() + .flat_map(|word| word.to_ne_bytes()) + .collect(); + CallValue::Aggregate(bytes[..size].to_vec()) + } + }; + + Ok(result) } /// # Safety @@ -2718,3 +2880,690 @@ pub fn dlsym_checked(_handle: usize, symbol_name: &CStr) -> Result<*mut c_void, symbol_name.to_string_lossy() )) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn simple_type_is_pointer_classifies_codes() { + assert!(simple_type_is_pointer("z")); + assert!(simple_type_is_pointer("Z")); + assert!(simple_type_is_pointer("P")); + assert!(simple_type_is_pointer("O")); + assert!(!simple_type_is_pointer("i")); + assert!(!simple_type_is_pointer("d")); + assert!(!simple_type_is_pointer("")); + } + + #[test] + fn simple_type_chars_contains_expected_codes() { + let chars = simple_type_chars(); + assert!(chars.contains('i')); + assert!(chars.contains('d')); + assert!(chars.contains('P')); + // junk / non-code characters are excluded + assert!(!chars.contains('@')); + assert!(!chars.contains(' ')); + assert!(!chars.contains('1')); + } + + #[cfg(all( + any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "android" + ), + not(any(target_env = "musl", target_env = "sgx")) + ))] + mod call_tests { + use super::*; + + extern "C" fn abs_i32(x: i32) -> i32 { + x.abs() + } + + extern "C" fn add_i32(a: i32, b: i32) -> i32 { + a + b + } + + extern "C" fn sqrt_f64(x: f64) -> f64 { + x.sqrt() + } + + extern "C" fn noop() {} + + #[repr(C)] + struct PairI32 { + a: i32, + b: i32, + } + extern "C" fn sum_pair(p: PairI32) -> i32 { + p.a + p.b + } + extern "C" fn ret_pair() -> PairI32 { + PairI32 { a: 10, b: 20 } + } + + #[repr(C)] + struct PairF32 { + x: f32, + y: f32, + } + extern "C" fn sum_pair_f32(p: PairF32) -> f32 { + p.x + p.y + } + + #[repr(C)] + struct Inner { + a: i32, + b: i32, + } + #[repr(C)] + struct Outer { + inner: Inner, + c: i32, + } + extern "C" fn sum_outer(o: Outer) -> i32 { + o.inner.a + o.inner.b + o.c + } + + #[repr(C)] + struct ArrStruct { + arr: [i32; 3], + tag: i32, + } + extern "C" fn sum_arr_struct(s: ArrStruct) -> i32 { + s.arr[0] + s.arr[1] + s.arr[2] + s.tag + } + + #[repr(C)] + struct Big { + a: i64, + b: i64, + c: i64, + } + extern "C" fn sum_big(v: Big) -> i64 { + v.a + v.b + v.c + } + extern "C" fn ret_big() -> Big { + Big { a: 1, b: 2, c: 3 } + } + + #[allow(dead_code)] + #[repr(C)] + struct S3 { + a: u8, + b: u8, + c: u8, + } + extern "C" fn ret_s3() -> S3 { + S3 { a: 1, b: 2, c: 3 } + } + + #[allow(dead_code)] + #[repr(C)] + struct S5 { + a: u8, + b: u8, + c: u8, + d: u8, + e: u8, + } + extern "C" fn ret_s5() -> S5 { + S5 { + a: 1, + b: 2, + c: 3, + d: 4, + e: 5, + } + } + + #[allow(dead_code)] + #[repr(C)] + struct S12 { + a: i32, + b: i32, + c: i32, + } + extern "C" fn ret_s12() -> S12 { + S12 { + a: 100, + b: 200, + c: 300, + } + } + + fn addr_of(f: extern "C" fn() -> ()) -> usize { + f as *const () as usize + } + + fn scalar_bytes(value: &CallValue) -> &[u8] { + match value { + CallValue::Scalar(bytes) => bytes, + other => panic!("expected Scalar, got {other:?}"), + } + } + + fn aggregate_bytes(value: &CallValue) -> &[u8] { + match value { + CallValue::Aggregate(bytes) => bytes, + other => panic!("expected Aggregate, got {other:?}"), + } + } + + fn i32_bytes(values: &[i32]) -> Vec { + values.iter().flat_map(|v| v.to_ne_bytes()).collect() + } + + // --- scalar parity ----------------------------------------------------- + + #[test] + fn calls_f64_scalar() { + let addr = sqrt_f64 as *const () as usize; + let result = call( + addr, + &[CallArg::Double(2.0)], + CallRet::Code("d"), + CallOptions::default(), + ) + .unwrap(); + match decode_type_code("d", scalar_bytes(&result)) { + DecodedValue::Float(v) => { + assert!((v - core::f64::consts::SQRT_2).abs() < 1e-12) + } + _ => panic!("expected Float return"), + } + } + + #[test] + fn typed_scalar_arg_from_buffer() { + let addr = abs_i32 as *const () as usize; + let buffer = (-5i32).to_ne_bytes(); + let result = call( + addr, + &[CallArg::Typed { + code: "i", + buffer: &buffer, + }], + CallRet::Code("i"), + CallOptions::default(), + ) + .unwrap(); + assert!(matches!( + decode_type_code("i", scalar_bytes(&result)), + DecodedValue::Signed(5) + )); + } + + #[test] + fn typed_two_scalar_args() { + let addr = add_i32 as *const () as usize; + let a = 2i32.to_ne_bytes(); + let b = 3i32.to_ne_bytes(); + let result = call( + addr, + &[ + CallArg::Typed { + code: "i", + buffer: &a, + }, + CallArg::Typed { + code: "i", + buffer: &b, + }, + ], + CallRet::Code("i"), + CallOptions::default(), + ) + .unwrap(); + assert!(matches!( + decode_type_code("i", scalar_bytes(&result)), + DecodedValue::Signed(5) + )); + } + + #[test] + fn void_return_is_void() { + let result = call(addr_of(noop), &[], CallRet::Void, CallOptions::default()).unwrap(); + assert!(matches!(result, CallValue::Void)); + } + + #[test] + fn every_simple_code_is_accepted() { + for code in simple_type_chars().chars() { + let code = code.to_string(); + assert!( + ffi_type_from_code(&code).is_some(), + "code {code:?} not accepted by call's arg/return lowering" + ); + } + } + + #[test] + fn scalar_lowering_helpers_agree_where_carrier_matches() { + // For codes whose ffi carrier type matches their ctypes signedness the + // two lowering helpers agree. + let buffer = 0x1122_3344_5566_7788u64.to_ne_bytes(); + for code in ["b", "B", "h", "H", "i", "I", "q", "Q", "d", "f"] { + let by_code = ffi_value_from_type_code(code, &buffer); + let by_type = + ffi_value_from_type(&buffer, ffi_type_from_code(code).unwrap()).unwrap(); + assert_eq!( + format!("{by_code:?}"), + format!("{by_type:?}"), + "code {code}" + ); + } + } + + #[test] + fn scalar_lowering_helpers_diverge_for_signed_char() { + // `ffi_value_from_type` classifies purely by libffi Type identity, while + // `ffi_value_from_type_code` carries ctypes signedness: for 'c' (u8 + // carrier, signed value) the two intentionally differ. `call` uses only + // the code-based helper. + let buffer = [200u8]; + assert!(matches!( + ffi_value_from_type_code("c", &buffer), + FfiValue::I8(-56) + )); + assert!(matches!( + ffi_value_from_type(&buffer, ffi_type_from_code("c").unwrap()), + Some(FfiValue::U8(200)) + )); + } + + // --- by-value aggregate arguments ------------------------------------- + + #[test] + fn passes_struct_by_value() { + let addr = sum_pair as *const () as usize; + let layout = CTypeLayout::Struct { + fields: vec![CTypeLayout::Simple('i'), CTypeLayout::Simple('i')], + size: core::mem::size_of::(), + }; + let buffer = i32_bytes(&[3, 4]); + let result = call( + addr, + &[CallArg::Aggregate { + layout: &layout, + buffer: &buffer, + }], + CallRet::Code("i"), + CallOptions::default(), + ) + .unwrap(); + assert!(matches!( + decode_type_code("i", scalar_bytes(&result)), + DecodedValue::Signed(7) + )); + } + + #[test] + fn passes_nested_struct_by_value() { + let addr = sum_outer as *const () as usize; + let inner = CTypeLayout::Struct { + fields: vec![CTypeLayout::Simple('i'), CTypeLayout::Simple('i')], + size: core::mem::size_of::(), + }; + let layout = CTypeLayout::Struct { + fields: vec![inner, CTypeLayout::Simple('i')], + size: core::mem::size_of::(), + }; + let buffer = i32_bytes(&[5, 6, 7]); + let result = call( + addr, + &[CallArg::Aggregate { + layout: &layout, + buffer: &buffer, + }], + CallRet::Code("i"), + CallOptions::default(), + ) + .unwrap(); + assert!(matches!( + decode_type_code("i", scalar_bytes(&result)), + DecodedValue::Signed(18) + )); + } + + #[test] + fn passes_array_in_struct_by_value() { + let addr = sum_arr_struct as *const () as usize; + let layout = CTypeLayout::Struct { + fields: vec![ + CTypeLayout::Array { + element: Box::new(CTypeLayout::Simple('i')), + length: 3, + size: 12, + }, + CTypeLayout::Simple('i'), + ], + size: core::mem::size_of::(), + }; + let buffer = i32_bytes(&[1, 2, 3, 4]); + let result = call( + addr, + &[CallArg::Aggregate { + layout: &layout, + buffer: &buffer, + }], + CallRet::Code("i"), + CallOptions::default(), + ) + .unwrap(); + assert!(matches!( + decode_type_code("i", scalar_bytes(&result)), + DecodedValue::Signed(10) + )); + } + + #[test] + fn passes_float_pair_struct_by_value() { + let addr = sum_pair_f32 as *const () as usize; + let layout = CTypeLayout::Struct { + fields: vec![CTypeLayout::Simple('f'), CTypeLayout::Simple('f')], + size: core::mem::size_of::(), + }; + let mut buffer = Vec::new(); + buffer.extend_from_slice(&1.5f32.to_ne_bytes()); + buffer.extend_from_slice(&2.25f32.to_ne_bytes()); + let result = call( + addr, + &[CallArg::Aggregate { + layout: &layout, + buffer: &buffer, + }], + CallRet::Code("f"), + CallOptions::default(), + ) + .unwrap(); + match decode_type_code("f", scalar_bytes(&result)) { + DecodedValue::Float(v) => assert!((v - 3.75).abs() < 1e-6), + _ => panic!("expected Float return"), + } + } + + #[test] + fn passes_large_struct_by_value() { + let addr = sum_big as *const () as usize; + let layout = CTypeLayout::Struct { + fields: vec![ + CTypeLayout::Simple('q'), + CTypeLayout::Simple('q'), + CTypeLayout::Simple('q'), + ], + size: core::mem::size_of::(), + }; + let mut buffer = Vec::new(); + for v in [11i64, 22, 33] { + buffer.extend_from_slice(&v.to_ne_bytes()); + } + let result = call( + addr, + &[CallArg::Aggregate { + layout: &layout, + buffer: &buffer, + }], + CallRet::Code("q"), + CallOptions::default(), + ) + .unwrap(); + assert!(matches!( + decode_type_code("q", scalar_bytes(&result)), + DecodedValue::Signed(66) + )); + } + + // --- by-value aggregate returns --------------------------------------- + + #[test] + fn returns_small_struct_by_value() { + let layout = CTypeLayout::Struct { + fields: vec![CTypeLayout::Simple('i'), CTypeLayout::Simple('i')], + size: core::mem::size_of::(), + }; + let result = call( + ret_pair as *const () as usize, + &[], + CallRet::Aggregate(&layout), + CallOptions::default(), + ) + .unwrap(); + assert_eq!(aggregate_bytes(&result), i32_bytes(&[10, 20]).as_slice()); + } + + #[test] + fn returns_odd_size_structs_by_value() { + let s3 = CTypeLayout::Struct { + fields: vec![ + CTypeLayout::Simple('B'), + CTypeLayout::Simple('B'), + CTypeLayout::Simple('B'), + ], + size: 3, + }; + let result = call( + ret_s3 as *const () as usize, + &[], + CallRet::Aggregate(&s3), + CallOptions::default(), + ) + .unwrap(); + assert_eq!(aggregate_bytes(&result), &[1u8, 2, 3]); + + let s5 = CTypeLayout::Struct { + fields: vec![CTypeLayout::Simple('B'); 5], + size: 5, + }; + let result = call( + ret_s5 as *const () as usize, + &[], + CallRet::Aggregate(&s5), + CallOptions::default(), + ) + .unwrap(); + assert_eq!(aggregate_bytes(&result), &[1u8, 2, 3, 4, 5]); + + let s12 = CTypeLayout::Struct { + fields: vec![CTypeLayout::Simple('i'); 3], + size: 12, + }; + let result = call( + ret_s12 as *const () as usize, + &[], + CallRet::Aggregate(&s12), + CallOptions::default(), + ) + .unwrap(); + assert_eq!( + aggregate_bytes(&result), + i32_bytes(&[100, 200, 300]).as_slice() + ); + } + + #[test] + fn returns_large_struct_by_value() { + let layout = CTypeLayout::Struct { + fields: vec![CTypeLayout::Simple('q'); 3], + size: core::mem::size_of::(), + }; + let result = call( + ret_big as *const () as usize, + &[], + CallRet::Aggregate(&layout), + CallOptions::default(), + ) + .unwrap(); + let mut expected = Vec::new(); + for v in [1i64, 2, 3] { + expected.extend_from_slice(&v.to_ne_bytes()); + } + assert_eq!(aggregate_bytes(&result), expected.as_slice()); + } + + // --- pointers, layout, unions, errors --------------------------------- + + #[test] + fn pointer_return_round_trips_address() { + extern "C" fn echo_ptr(p: usize) -> usize { + p + } + let addr = echo_ptr as *const () as usize; + let sentinel = 0xDEAD_BEEFusize; + let result = call( + addr, + &[CallArg::Pointer(sentinel)], + CallRet::Code("P"), + CallOptions::default(), + ) + .unwrap(); + assert!(matches!(result, CallValue::Pointer(p) if p == sentinel)); + let result = call( + addr, + &[CallArg::Pointer(sentinel)], + CallRet::Pointer, + CallOptions::default(), + ) + .unwrap(); + assert!(matches!(result, CallValue::Pointer(p) if p == sentinel)); + } + + #[test] + fn layout_size_matches_repr_c() { + assert_eq!(CTypeLayout::Simple('i').size(), core::mem::size_of::()); + assert_eq!(CTypeLayout::Pointer.size(), core::mem::size_of::()); + assert_eq!( + CTypeLayout::Struct { + fields: vec![CTypeLayout::Simple('i'), CTypeLayout::Simple('i')], + size: 8, + } + .size(), + 8 + ); + assert_eq!(CTypeLayout::Opaque { size: 5 }.size(), 5); + assert_eq!( + CTypeLayout::Array { + element: Box::new(CTypeLayout::Simple('i')), + length: 3, + size: 12, + } + .size(), + 12 + ); + } + + #[test] + fn union_layout_reports_size_and_lowers() { + let layout = CTypeLayout::Union { + fields: vec![CTypeLayout::Simple('i'), CTypeLayout::Simple('d')], + size: 8, + }; + assert_eq!(layout.size(), 8); + assert!(layout.to_ffi_type().is_ok()); + } + + #[test] + fn null_addr_is_error() { + let result = call(0, &[], CallRet::Void, CallOptions::default()); + assert_eq!(result.err(), Some(CallError::NullFunctionPointer)); + } + + #[test] + fn unknown_arg_code_is_error() { + let addr = noop as *const () as usize; + let result = call( + addr, + &[CallArg::Typed { + code: "@", + buffer: &[], + }], + CallRet::Void, + CallOptions::default(), + ); + assert_eq!( + result.err(), + Some(CallError::UnknownTypeCode("@".to_string())) + ); + } + + #[test] + fn unknown_return_code_is_error() { + let addr = noop as *const () as usize; + let result = call(addr, &[], CallRet::Code("@"), CallOptions::default()); + assert_eq!( + result.err(), + Some(CallError::UnknownTypeCode("@".to_string())) + ); + } + + #[test] + fn short_aggregate_buffer_is_error() { + let addr = sum_pair as *const () as usize; + let layout = CTypeLayout::Struct { + fields: vec![CTypeLayout::Simple('i'), CTypeLayout::Simple('i')], + size: 8, + }; + let buffer = [0u8; 4]; + let result = call( + addr, + &[CallArg::Aggregate { + layout: &layout, + buffer: &buffer, + }], + CallRet::Code("i"), + CallOptions::default(), + ); + assert_eq!( + result.err(), + Some(CallError::BufferTooSmall { + expected: 8, + got: 4, + }) + ); + } + + // EINVAL: a valid errno value on all unix targets, so it round-trips + // through crate::os::set_errno/get_errno. + #[cfg(not(windows))] + const ERRNO_MARKER: i32 = 22; + + #[cfg(not(windows))] + extern "C" fn write_errno_marker() -> i32 { + crate::os::set_errno(ERRNO_MARKER); + 7 + } + + #[cfg(not(windows))] + #[test] + fn errno_swap_window_captures_and_restores() { + // Distinguish the real platform errno from the ctypes-local one. + crate::os::set_errno(11); + super::super::CTYPES_LOCAL_ERRNO.with(|e| e.set(99)); + let result = call( + write_errno_marker as *const () as usize, + &[], + CallRet::Code("i"), + CallOptions { + use_errno: true, + use_last_error: false, + }, + ) + .unwrap(); + assert!(matches!( + decode_type_code("i", scalar_bytes(&result)), + DecodedValue::Signed(7) + )); + // The function's errno write landed in the ctypes-local slot... + assert_eq!( + super::super::CTYPES_LOCAL_ERRNO.with(|e| e.get()), + ERRNO_MARKER + ); + // ...and the real errno was restored to its pre-call value. + assert_eq!(crate::os::get_errno(), 11); + } + } +} diff --git a/crates/vm/src/stdlib/_ctypes.rs b/crates/vm/src/stdlib/_ctypes.rs index 6370bc42b3d..adf047ec750 100644 --- a/crates/vm/src/stdlib/_ctypes.rs +++ b/crates/vm/src/stdlib/_ctypes.rs @@ -16,7 +16,7 @@ use crate::{ }; pub(super) use array::PyCArray; -pub(super) use base::{FfiArgValue, PyCData, PyCField, StgInfo, StgInfoFlags}; +pub(super) use base::{CArgValue, PyCData, PyCField, StgInfo, StgInfoFlags}; pub(super) use pointer::PyCPointer; pub(super) use simple::{PyCSimple, PyCSimpleType}; pub(super) use structure::PyCStructure; @@ -107,10 +107,13 @@ pub(crate) mod _ctypes { pub(crate) struct CArgObject { /// Type tag ('P', 'V', 'i', 'd', etc.) pub tag: u8, - /// The actual FFI value (mirrors union value) - pub value: super::FfiArgValue, + /// The actual foreign-call value (mirrors union value) + pub value: super::CArgValue, /// Reference to original object (for memory safety) pub obj: PyObjectRef, + /// Owner keeping a `Pointer` value's target memory alive (e.g. a + /// null-terminated buffer copy created by `from_param`), if any. + pub keep: Option, /// Size for struct/union ('V' tag) #[allow(dead_code)] pub size: usize, @@ -126,72 +129,68 @@ pub(crate) mod _ctypes { impl Representable for CArgObject { // PyCArg_repr - use tag and value fields directly fn repr_str(zelf: &Py, _vm: &VirtualMachine) -> PyResult { - use super::base::FfiArgValue; + use rustpython_host_env::ctypes::{FfiValue, ffi_value_from_type_code}; let tag_char = zelf.tag as char; + // Reconstruct the scalar the value lowers to, so the formatting + // matches the value passed to the foreign call exactly. + let ffi_val = match &zelf.value { + super::CArgValue::Typed { code, bytes } => { + let mut buf = [0u8; 4]; + ffi_value_from_type_code(code.encode_utf8(&mut buf), bytes) + } + super::CArgValue::Int(v) => FfiValue::I32(*v), + super::CArgValue::Double(v) => FfiValue::F64(*v), + super::CArgValue::Pointer(v) => FfiValue::Pointer(*v), + // 'V' aggregates format via the object-address default arm below. + super::CArgValue::Aggregate { .. } => FfiValue::Pointer(0), + }; + // Format value based on tag match zelf.tag { b'b' | b'h' | b'i' | b'l' | b'q' => { // Signed integers - let n = match zelf.value { - FfiArgValue::Scalar(rustpython_host_env::ctypes::FfiValue::I8(v)) => { - v as i64 - } - FfiArgValue::Scalar(rustpython_host_env::ctypes::FfiValue::I16(v)) => { - v as i64 - } - FfiArgValue::Scalar(rustpython_host_env::ctypes::FfiValue::I32(v)) => { - v as i64 - } - FfiArgValue::Scalar(rustpython_host_env::ctypes::FfiValue::I64(v)) => v, + let n = match ffi_val { + FfiValue::I8(v) => v as i64, + FfiValue::I16(v) => v as i64, + FfiValue::I32(v) => v as i64, + FfiValue::I64(v) => v, _ => 0, }; Ok(format!("")) } b'B' | b'H' | b'I' | b'L' | b'Q' => { // Unsigned integers - let n = match zelf.value { - FfiArgValue::Scalar(rustpython_host_env::ctypes::FfiValue::U8(v)) => { - v as u64 - } - FfiArgValue::Scalar(rustpython_host_env::ctypes::FfiValue::U16(v)) => { - v as u64 - } - FfiArgValue::Scalar(rustpython_host_env::ctypes::FfiValue::U32(v)) => { - v as u64 - } - FfiArgValue::Scalar(rustpython_host_env::ctypes::FfiValue::U64(v)) => v, + let n = match ffi_val { + FfiValue::U8(v) => v as u64, + FfiValue::U16(v) => v as u64, + FfiValue::U32(v) => v as u64, + FfiValue::U64(v) => v, _ => 0, }; Ok(format!("")) } b'f' => { - let v = match zelf.value { - FfiArgValue::Scalar(rustpython_host_env::ctypes::FfiValue::F32(v)) => { - v as f64 - } + let v = match ffi_val { + FfiValue::F32(v) => v as f64, _ => 0.0, }; Ok(format!("")) } b'd' | b'g' => { - let v = match zelf.value { - FfiArgValue::Scalar(rustpython_host_env::ctypes::FfiValue::F64(v)) => v, - FfiArgValue::Scalar(rustpython_host_env::ctypes::FfiValue::F32(v)) => { - v as f64 - } + let v = match ffi_val { + FfiValue::F64(v) => v, + FfiValue::F32(v) => v as f64, _ => 0.0, }; Ok(format!("")) } b'c' => { // c_char - single byte - let byte = match zelf.value { - FfiArgValue::Scalar(rustpython_host_env::ctypes::FfiValue::I8(v)) => { - v as u8 - } - FfiArgValue::Scalar(rustpython_host_env::ctypes::FfiValue::U8(v)) => v, + let byte = match ffi_val { + FfiValue::I8(v) => v as u8, + FfiValue::U8(v) => v, _ => 0, }; if is_literal_char(byte) { @@ -200,11 +199,10 @@ pub(crate) mod _ctypes { Ok(format!("")) } } - b'z' | b'Z' | b'P' | b'V' => { + b'z' | b'Z' | b'P' => { // Pointer types - let ptr = match zelf.value { - FfiArgValue::Scalar(rustpython_host_env::ctypes::FfiValue::Pointer(v)) => v, - FfiArgValue::OwnedPointer(v, _) => v, + let ptr = match ffi_val { + FfiValue::Pointer(v) => v, _ => 0, }; if ptr == 0 { @@ -600,7 +598,7 @@ pub(crate) mod _ctypes { offset: OptionalArg, vm: &VirtualMachine, ) -> PyResult { - use super::FfiArgValue; + use super::CArgValue; // Check if obj is a ctypes instance if !obj.fast_isinstance(PyCData::static_type()) @@ -628,8 +626,9 @@ pub(crate) mod _ctypes { // Create CArgObject to hold the reference Ok(CArgObject { tag: b'P', - value: FfiArgValue::pointer(ptr_val), + value: CArgValue::pointer(ptr_val), obj, + keep: None, size: 0, offset: offset_val, } diff --git a/crates/vm/src/stdlib/_ctypes/base.rs b/crates/vm/src/stdlib/_ctypes/base.rs index 62856c4cef8..1cc84750cb5 100644 --- a/crates/vm/src/stdlib/_ctypes/base.rs +++ b/crates/vm/src/stdlib/_ctypes/base.rs @@ -18,8 +18,8 @@ use num_traits::{Signed, ToPrimitive}; use rustpython_common::lock::PyRwLock; use rustpython_common::wtf8::Wtf8; use rustpython_host_env::ctypes::{ - CTypeParamKind, FfiArg, FfiType, FfiValue, char_array_assignment_bytes, char_array_field_value, - ffi_arg_from_value, ffi_type_for_layout, wchar_array_field_value, write_cow_bytes_at_offset, + CTypeLayout, char_array_assignment_bytes, char_array_field_value, wchar_array_field_value, + write_cow_bytes_at_offset, }; // StgInfo - Storage information for ctypes types @@ -99,8 +99,9 @@ pub struct StgInfo { // Byte order (for _swappedbytes_) pub big_endian: bool, // true if big endian, false if little endian - // FFI field types for structure/union passing (inherited from base class) - pub ffi_field_types: Vec, + // Call layouts of the struct/union fields, in declaration order (inherited + // from base class). Drives by-value aggregate passing. + pub field_layouts: Vec, // Cached pointer type (non-inheritable via descriptor) pub pointer_type: Option, @@ -127,7 +128,7 @@ impl core::fmt::Debug for StgInfo { .field("shape", &self.shape) .field("paramfunc", &self.paramfunc) .field("big_endian", &self.big_endian) - .field("ffi_field_types", &self.ffi_field_types.len()) + .field("field_layouts", &self.field_layouts.len()) .finish() } } @@ -147,7 +148,7 @@ impl Default for StgInfo { shape: Vec::new(), paramfunc: ParamFunc::None, big_endian: cfg!(target_endian = "big"), // native endian by default - ffi_field_types: Vec::new(), + field_layouts: Vec::new(), pointer_type: None, } } @@ -168,7 +169,7 @@ impl StgInfo { shape: Vec::new(), paramfunc: ParamFunc::None, big_endian: cfg!(target_endian = "big"), // native endian by default - ffi_field_types: Vec::new(), + field_layouts: Vec::new(), pointer_type: None, } } @@ -220,30 +221,11 @@ impl StgInfo { shape, paramfunc: ParamFunc::Array, big_endian: cfg!(target_endian = "big"), // native endian by default - ffi_field_types: Vec::new(), + field_layouts: Vec::new(), pointer_type: None, } } - /// Get libffi type for this StgInfo - /// Note: For very large types, returns pointer type to avoid overflow - pub fn to_ffi_type(&self) -> FfiType { - let kind = match self.paramfunc { - ParamFunc::Structure => CTypeParamKind::Structure, - ParamFunc::Union => CTypeParamKind::Union, - ParamFunc::Array => CTypeParamKind::Array, - ParamFunc::Pointer => CTypeParamKind::Pointer, - _ => CTypeParamKind::Simple, - }; - ffi_type_for_layout( - kind, - &self.ffi_field_types, - self.size, - self.length, - self.format.as_deref(), - ) - } - /// Check if this type is finalized (cannot set _fields_ again) pub fn is_final(&self) -> bool { self.flags.contains(StgInfoFlags::DICTFLAG_FINAL) @@ -255,6 +237,44 @@ impl StgInfo { } } +/// Build the host_env call layout for a ctypes type from its already-borrowed +/// `StgInfo`. Aggregate layouts come straight from the type's `field_layouts` +/// (built incrementally from the base class, so struct inheritance is +/// reflected); array elements recurse into the element type; simple types read +/// their `_type_` code. The caller passes the borrowed `stg` so this never +/// re-locks `ty`'s own type data. +pub(super) fn type_layout(ty: &Py, stg: &StgInfo, vm: &VirtualMachine) -> CTypeLayout { + match stg.paramfunc { + ParamFunc::Structure => CTypeLayout::Struct { + fields: stg.field_layouts.clone(), + size: stg.size, + }, + ParamFunc::Union => CTypeLayout::Union { + fields: stg.field_layouts.clone(), + size: stg.size, + }, + ParamFunc::Array => { + let element = stg + .element_type + .as_ref() + .and_then(|et| et.stg_info_opt().map(|et_stg| type_layout(et, &et_stg, vm))) + .unwrap_or(CTypeLayout::Opaque { + size: stg.element_size, + }); + CTypeLayout::Array { + element: Box::new(element), + length: stg.length, + size: stg.size, + } + } + ParamFunc::Pointer => CTypeLayout::Pointer, + ParamFunc::Simple | ParamFunc::None => ty + .type_code(vm) + .and_then(|code| code.chars().next()) + .map_or(CTypeLayout::Opaque { size: stg.size }, CTypeLayout::Simple), + } +} + /// __pointer_type__ getter for ctypes metaclasses. /// Reads from StgInfo.pointer_type (non-inheritable). pub(super) fn pointer_type_get(zelf: &Py, vm: &VirtualMachine) -> PyResult { @@ -1828,12 +1848,12 @@ fn simple_paramfunc(obj: &PyObject, vm: &VirtualMachine) -> PyResult // Read value from buffer: memcpy(&parg->value, self->b_ptr, self->b_size) let buffer = simple.0.buffer.read(); - let ffi_value = buffer_to_ffi_value(&type_code, &buffer); Ok(CArgObject { tag, - value: ffi_value, + value: CArgValue::typed(tag as char, &buffer), obj: obj.to_owned(), + keep: None, size: 0, offset: 0, }) @@ -1853,8 +1873,9 @@ fn array_paramfunc(obj: &PyObject, vm: &VirtualMachine) -> PyResult Ok(CArgObject { tag: b'P', - value: FfiArgValue::pointer(ptr_val), + value: CArgValue::pointer(ptr_val), obj: obj.to_owned(), + keep: None, size: 0, offset: 0, }) @@ -1873,8 +1894,9 @@ fn pointer_paramfunc(obj: &PyObject, vm: &VirtualMachine) -> PyResult PyResult CArgObject { - // Get buffer pointer - // For large structs (> sizeof(void*)), we'd need to allocate and copy. - // For now, just point to buffer directly and keep obj reference for memory safety. - let buffer = if let Some(cdata) = obj.downcast_ref::() { - cdata.buffer.read() + // Snapshot the instance bytes and pass the aggregate by value. The layout + // is built here from the already-borrowed `stg_info` to avoid re-locking. + let (bytes, size) = if let Some(cdata) = obj.downcast_ref::() { + let buffer = cdata.buffer.read(); + (buffer.to_vec(), buffer.len()) } else { - return CArgObject { - tag: b'V', - value: FfiArgValue::pointer(0), - obj: obj.to_owned(), - size: stg_info.size, - offset: 0, - }; + (Vec::new(), stg_info.size) }; - let ptr_val = buffer.as_ptr() as usize; - let size = buffer.len(); + let layout = if matches!(stg_info.paramfunc, ParamFunc::Union) { + CTypeLayout::Union { + fields: stg_info.field_layouts.clone(), + size: stg_info.size, + } + } else { + CTypeLayout::Struct { + fields: stg_info.field_layouts.clone(), + size: stg_info.size, + } + }; CArgObject { tag: b'V', - value: FfiArgValue::pointer(ptr_val), + value: CArgValue::aggregate(layout, bytes), obj: obj.to_owned(), + keep: None, size, offset: 0, } } -// FfiArgValue - Owned FFI argument value +// CArgValue - Owned foreign-call argument value -/// Owned FFI argument value. Keeps the value alive for the duration of the FFI call. +/// A foreign-call argument in a form the unified `call` entry point accepts: a +/// simple-typed scalar (its ctypes code plus a native-endian bytes snapshot), +/// an untyped int/float, or an address. Any object whose memory an address +/// refers to is kept alive by the enclosing `Argument`/`CArgObject`, not here. #[derive(Debug, Clone)] -pub enum FfiArgValue { - Scalar(FfiValue), - /// Pointer with owned data. The PyObjectRef keeps the pointed data alive. - OwnedPointer(usize, #[allow(dead_code)] PyObjectRef), +pub enum CArgValue { + /// A value typed by its ctypes simple-type code, snapshotted as its bytes. + Typed { code: char, bytes: Vec }, + /// Untyped Python int (ConvParam default: C int). + Int(i32), + /// Untyped Python float (ConvParam default: C double). + Double(f64), + /// Address-valued argument (pointer decay, byref, buffer copies, NULL = 0). + Pointer(usize), + /// By-value aggregate: its call layout plus a snapshot of its bytes. + Aggregate { layout: CTypeLayout, bytes: Vec }, } -impl FfiArgValue { +impl CArgValue { pub fn pointer(value: usize) -> Self { - Self::Scalar(FfiValue::Pointer(value)) + Self::Pointer(value) } - /// Create an Arg reference to this owned value - pub fn as_arg(&self) -> FfiArg<'_> { - match self { - Self::Scalar(value) => ffi_arg_from_value(value), - Self::OwnedPointer(v, _) => rustpython_host_env::ctypes::ffi_arg( - rustpython_host_env::ctypes::FfiArgRef::Pointer(v), - ), + /// Snapshot a simple-typed value from its code and buffer bytes. + pub(super) fn typed(code: char, buffer: &[u8]) -> Self { + Self::Typed { + code, + bytes: buffer.to_vec(), } } -} -/// Convert buffer bytes to FfiArgValue based on type code -pub(super) fn buffer_to_ffi_value(type_code: &str, buffer: &[u8]) -> FfiArgValue { - FfiArgValue::Scalar(rustpython_host_env::ctypes::ffi_value_from_type_code( - type_code, buffer, - )) + /// Snapshot an aggregate value from its call layout and buffer bytes. + pub(super) fn aggregate(layout: CTypeLayout, bytes: Vec) -> Self { + Self::Aggregate { layout, bytes } + } + + /// Lower to a [`CallArg`], borrowing `code_buf` for the code's `&str`. + pub(super) fn as_call_arg<'a>( + &'a self, + code_buf: &'a mut [u8; 4], + ) -> rustpython_host_env::ctypes::CallArg<'a> { + use rustpython_host_env::ctypes::CallArg; + match self { + Self::Typed { code, bytes } => CallArg::Typed { + code: code.encode_utf8(code_buf), + buffer: bytes, + }, + Self::Int(value) => CallArg::Int(*value), + Self::Double(value) => CallArg::Double(*value), + Self::Pointer(value) => CallArg::Pointer(*value), + Self::Aggregate { layout, bytes } => CallArg::Aggregate { + layout, + buffer: bytes, + }, + } + } } /// Convert bytes to appropriate Python object based on ctypes type diff --git a/crates/vm/src/stdlib/_ctypes/function.rs b/crates/vm/src/stdlib/_ctypes/function.rs index 2cf3eda13e1..86b4ff59b3f 100644 --- a/crates/vm/src/stdlib/_ctypes/function.rs +++ b/crates/vm/src/stdlib/_ctypes/function.rs @@ -3,13 +3,13 @@ use super::{ _ctypes::CArgObject, - PyCArray, PyCData, PyCPointer, PyCStructure, StgInfo, - base::{CDATA_BUFFER_METHODS, FfiArgValue, ParamFunc, StgInfoFlags}, + PyCArray, PyCData, PyCPointer, PyCStructure, PyCUnion, StgInfo, + base::{CArgValue, CDATA_BUFFER_METHODS, ParamFunc, StgInfoFlags}, simple::PyCSimple, }; use crate::{ AsObject, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, - builtins::{PyBytes, PyDict, PyNone, PyStr, PyTuple, PyType, PyTypeRef}, + builtins::{PyBytes, PyDict, PyStr, PyTuple, PyType, PyTypeRef}, class::StaticType, function::FuncArgs, protocol::{BufferDescriptor, PyBuffer, PyNumberMethods}, @@ -25,11 +25,10 @@ use rustpython_common::lock::PyRwLock; #[cfg(windows)] use rustpython_host_env::ctypes::ComMethodError; use rustpython_host_env::ctypes::{ - CallResult as RawResult, FfiCif, FfiCodePtr, FfiType, FfiValue, RawMemoryView, - RawMemoryViewError, StringAtError, ffi_f64_type, ffi_i32_type, ffi_pointer_type, - ffi_type_for_return_size, ffi_type_from_code, ffi_type_from_tag, ffi_void_type, - has_pointer_width, null_code_ptr, offset_address, pointer_bytes, pointer_format, pointer_size, - write_pointer_to_buffer_at, write_prefix_limited, + CTypeLayout, CallError, CallOptions, CallRet, CallValue, FfiCif, FfiCodePtr, FfiType, + RawMemoryView, RawMemoryViewError, StringAtError, call, ffi_pointer_type, ffi_type_from_code, + ffi_void_type, has_pointer_width, offset_address, pointer_bytes, pointer_format, pointer_size, + simple_type_is_pointer, write_pointer_to_buffer_at, write_prefix_limited, }; // Internal function addresses for special ctypes functions @@ -42,7 +41,7 @@ pub(super) const INTERNAL_MEMORYVIEW_AT_ADDR: usize = 4; /// Convert any object to a pointer value for c_void_p arguments /// Follows ConvParam logic for pointer types -fn convert_to_pointer(value: &PyObject, vm: &VirtualMachine) -> PyResult { +fn convert_to_pointer(value: &PyObject, vm: &VirtualMachine) -> PyResult { // 0. CArgObject (from byref()) -> buffer address + offset if let Some(carg) = value.downcast_ref::() { // Get buffer address from the underlying object @@ -55,29 +54,29 @@ fn convert_to_pointer(value: &PyObject, vm: &VirtualMachine) -> PyResult NULL if value.is(&vm.ctx.none) { - return Ok(FfiArgValue::pointer(0)); + return Ok(CArgValue::pointer(0)); } // 2. PyCArray -> buffer address (PyCArrayType_paramfunc) if let Some(array) = value.downcast_ref::() { let addr = array.0.buffer.read().as_ptr() as usize; - return Ok(FfiArgValue::pointer(addr)); + return Ok(CArgValue::pointer(addr)); } // 3. PyCPointer -> stored pointer value if let Some(ptr) = value.downcast_ref::() { - return Ok(FfiArgValue::pointer(ptr.get_ptr_value())); + return Ok(CArgValue::pointer(ptr.get_ptr_value())); } // 4. PyCStructure -> buffer address if let Some(struct_obj) = value.downcast_ref::() { let addr = struct_obj.0.buffer.read().as_ptr() as usize; - return Ok(FfiArgValue::pointer(addr)); + return Ok(CArgValue::pointer(addr)); } // 5. PyCSimple (c_void_p, c_char_p, etc.) -> value from buffer @@ -85,14 +84,14 @@ fn convert_to_pointer(value: &PyObject, vm: &VirtualMachine) -> PyResult buffer address (PyBytes_AsString) if let Some(bytes) = value.downcast_ref::() { let addr = bytes.as_bytes().as_ptr() as usize; - return Ok(FfiArgValue::pointer(addr)); + return Ok(CArgValue::pointer(addr)); } // 7. Integer -> direct value (PyLong_AsVoidPtr behavior) @@ -101,10 +100,10 @@ fn convert_to_pointer(value: &PyObject, vm: &VirtualMachine) -> PyResult PyResult PyResult { - // 1. CArgObject (from byref() or paramfunc) -> use stored type and value + // 1. CArgObject (from byref() or paramfunc) -> use stored value if let Some(carg) = value.downcast_ref::() { - let ffi_type = ffi_type_from_tag(carg.tag); return Ok(Argument { - ffi_type, - keep: None, + keep: carg.keep.clone(), value: carg.value.clone(), }); } @@ -137,18 +134,15 @@ fn conv_param(value: &PyObject, vm: &VirtualMachine) -> PyResult { // 2. None -> NULL pointer if value.is(&vm.ctx.none) { return Ok(Argument { - ffi_type: ffi_pointer_type(), keep: None, - value: FfiArgValue::pointer(0), + value: CArgValue::pointer(0), }); } // 3. ctypes objects -> use paramfunc if let Ok(carg) = super::base::call_paramfunc(value, vm) { - let ffi_type = ffi_type_from_tag(carg.tag); return Ok(Argument { - ffi_type, - keep: None, + keep: carg.keep, value: carg.value, }); } @@ -159,9 +153,8 @@ fn conv_param(value: &PyObject, vm: &VirtualMachine) -> PyResult { let keep = vm.ctx.new_bytes(wide_bytes); let addr = keep.as_bytes().as_ptr() as usize; return Ok(Argument { - ffi_type: ffi_pointer_type(), keep: Some(keep.into()), - value: FfiArgValue::pointer(addr), + value: CArgValue::pointer(addr), }); } @@ -172,9 +165,8 @@ fn conv_param(value: &PyObject, vm: &VirtualMachine) -> PyResult { let keep = vm.ctx.new_bytes(buffer); let addr = keep.as_bytes().as_ptr() as usize; return Ok(Argument { - ffi_type: ffi_pointer_type(), keep: Some(keep.into()), - value: FfiArgValue::pointer(addr), + value: CArgValue::pointer(addr), }); } @@ -182,18 +174,16 @@ fn conv_param(value: &PyObject, vm: &VirtualMachine) -> PyResult { if let Ok(int_val) = value.try_int(vm) { let val = int_val.as_bigint().to_i32().unwrap_or(0); return Ok(Argument { - ffi_type: ffi_i32_type(), keep: None, - value: FfiArgValue::Scalar(FfiValue::I32(val)), + value: CArgValue::Int(val), }); } // 11. Python float -> f64 if let Ok(float_val) = value.try_float(vm) { return Ok(Argument { - ffi_type: ffi_f64_type(), keep: None, - value: FfiArgValue::Scalar(FfiValue::F64(float_val.to_f64())), + value: CArgValue::Double(float_val.to_f64()), }); } @@ -209,47 +199,47 @@ fn conv_param(value: &PyObject, vm: &VirtualMachine) -> PyResult { } trait ArgumentType { - fn to_ffi_type(&self, vm: &VirtualMachine) -> PyResult; - fn convert_object(&self, value: PyObjectRef, vm: &VirtualMachine) -> PyResult; + /// Convert an argument for this type into a foreign-call value plus an + /// optional owner keeping any referenced memory alive. + fn convert_object( + &self, + value: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult<(CArgValue, Option)>; } impl ArgumentType for PyTypeRef { - fn to_ffi_type(&self, vm: &VirtualMachine) -> PyResult { - use super::pointer::PyCPointer; - use super::structure::PyCStructure; - - // CArgObject (from byref()) should be treated as pointer - if self.fast_issubclass(CArgObject::static_type()) { - return Ok(ffi_pointer_type()); - } - - // Pointer types (POINTER(T)) are always pointer FFI type - // Check if type is a subclass of _Pointer (PyCPointer) - if self.fast_issubclass(PyCPointer::static_type()) { - return Ok(ffi_pointer_type()); - } - - // Structure types are passed as pointers - if self.fast_issubclass(PyCStructure::static_type()) { - return Ok(ffi_pointer_type()); - } + fn convert_object( + &self, + value: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult<(CArgValue, Option)> { + // Validate the argument type up front (mirrors the pre-conversion + // check): pointer-like ctypes types are always acceptable; a simple + // type must carry a known _type_ code; anything else is unsupported. + let type_code = if self.fast_issubclass(CArgObject::static_type()) + || self.fast_issubclass(PyCPointer::static_type()) + || self.fast_issubclass(PyCStructure::static_type()) + || self.fast_issubclass(PyCUnion::static_type()) + { + None + } else { + // Use get_attr to traverse MRO (for subclasses like MyInt(c_int)) + let typ = self + .as_object() + .get_attr(vm.ctx.intern_str("_type_"), vm) + .ok() + .ok_or_else(|| vm.new_type_error("Unsupported argument type"))?; + let typ = typ + .downcast_ref::() + .ok_or_else(|| vm.new_type_error("Unsupported argument type"))? + .to_string(); + if ffi_type_from_code(&typ).is_none() { + return Err(vm.new_type_error(format!("Unsupported argument type: {typ}"))); + } + Some(typ) + }; - // Use get_attr to traverse MRO (for subclasses like MyInt(c_int)) - let typ = self - .as_object() - .get_attr(vm.ctx.intern_str("_type_"), vm) - .ok() - .ok_or_else(|| vm.new_type_error("Unsupported argument type"))?; - let typ = typ - .downcast_ref::() - .ok_or_else(|| vm.new_type_error("Unsupported argument type"))?; - let typ = typ.to_string(); - let typ = typ.as_str(); - ffi_type_from_code(typ) - .ok_or_else(|| vm.new_type_error(format!("Unsupported argument type: {typ}"))) - } - - fn convert_object(&self, value: PyObjectRef, vm: &VirtualMachine) -> PyResult { // Call from_param first to convert the value // converter = PyTuple_GET_ITEM(argtypes, i); // v = PyObject_CallOneArg(converter, arg); @@ -259,88 +249,62 @@ impl ArgumentType for PyTypeRef { let converted = from_param.call((value,), vm)?; // Then pass the converted value to ConvParam logic - // CArgObject (from from_param) -> use stored value directly + // CArgObject (from from_param) -> use stored value and keepalive directly if let Some(carg) = converted.downcast_ref::() { - return Ok(carg.value.clone()); + return Ok((carg.value.clone(), carg.keep.clone())); } // None -> NULL pointer if vm.is_none(&converted) { - return Ok(FfiArgValue::pointer(0)); + return Ok((CArgValue::pointer(0), None)); } // For pointer types (POINTER(T)), we need to pass the pointer VALUE stored in buffer if self.fast_issubclass(PyCPointer::static_type()) { if let Some(pointer) = converted.downcast_ref::() { - return Ok(FfiArgValue::pointer(pointer.get_ptr_value())); + return Ok((CArgValue::pointer(pointer.get_ptr_value()), None)); } - return convert_to_pointer(&converted, vm); + return Ok((convert_to_pointer(&converted, vm)?, None)); } - // For structure types, convert to pointer to structure - if self.fast_issubclass(PyCStructure::static_type()) { - return convert_to_pointer(&converted, vm); + // For structure/union types, pass the aggregate by value: snapshot the + // instance bytes and build its call layout from the argtype. A byref() + // result is a CArgObject and was already handled above (stays a pointer). + if self.fast_issubclass(PyCStructure::static_type()) + || self.fast_issubclass(PyCUnion::static_type()) + { + if let Some(cdata) = converted.downcast_ref::() { + let bytes = cdata.buffer.read().to_vec(); + let layout = self.stg_info_opt().map_or_else( + || CTypeLayout::Opaque { size: bytes.len() }, + |stg| super::base::type_layout(self, &stg, vm), + ); + // Keep the converted instance alive through the call: the + // snapshot may embed pointers into buffers its keep-alive set + // owns, which must outlive the foreign call. + return Ok((CArgValue::aggregate(layout, bytes), Some(converted.clone()))); + } + return Ok((convert_to_pointer(&converted, vm)?, None)); } - // Get the type code for this argument type - let type_code = self - .as_object() - .get_attr(vm.ctx.intern_str("_type_"), vm) - .ok() - .and_then(|t| t.downcast_ref::().map(|s| s.to_string())); - // For pointer types (c_void_p, c_char_p, c_wchar_p), handle as pointer if matches!(type_code.as_deref(), Some("P" | "z" | "Z")) { - return convert_to_pointer(&converted, vm); + return Ok((convert_to_pointer(&converted, vm)?, None)); } // PyCSimple (already a ctypes instance from from_param) if let Ok(simple) = converted.downcast::() { - let typ = ArgumentType::to_ffi_type(self, vm)?; - let ffi_value = simple - .to_ffi_value(typ, vm) + let code = type_code + .as_deref() + .and_then(|s| s.chars().next()) .ok_or_else(|| vm.new_type_error("Unsupported argument type"))?; - return Ok(ffi_value); + return Ok((simple.to_carg_value(code), None)); } Err(vm.new_type_error("Unsupported argument type")) } } -trait ReturnType { - fn to_ffi_type(&self, vm: &VirtualMachine) -> Option; -} - -impl ReturnType for PyTypeRef { - fn to_ffi_type(&self, vm: &VirtualMachine) -> Option { - // Try to get _type_ attribute first (for ctypes types like c_void_p) - if let Ok(type_attr) = self.as_object().get_attr(vm.ctx.intern_str("_type_"), vm) - && let Some(s) = type_attr.downcast_ref::() - && let Some(ffi_type) = s.to_str().and_then(ffi_type_from_code) - { - return Some(ffi_type); - } - - // Check for Structure/Array types (have StgInfo but no _type_) - // _ctypes_get_ffi_type: returns appropriately sized type for struct returns - if let Some(stg_info) = self.stg_info_opt() { - let size = stg_info.size; - // Small structs can be returned in registers - // Match can_return_struct_as_int/can_return_struct_as_sint64 - return Some(ffi_type_for_return_size(size)); - } - - // Fallback to class name - ffi_type_from_code(self.name().to_string().as_str()) - } -} - -impl ReturnType for PyNone { - fn to_ffi_type(&self, _vm: &VirtualMachine) -> Option { - ffi_type_from_code("void") - } -} - // PyCFuncPtrType - Metaclass for function pointer types // PyCFuncPtrType_init @@ -676,12 +640,6 @@ impl PyCFuncPtr { rustpython_host_env::ctypes::read_pointer_from_buffer(&buffer) } - /// Get CodePtr from buffer for FFI calls - fn get_code_ptr(&self) -> Option { - let addr = self.get_func_ptr(); - rustpython_host_env::ctypes::code_ptr_from_addr(addr) - } - /// Create buffer with function pointer address fn make_ptr_buffer(addr: usize) -> Vec { pointer_bytes(addr) @@ -961,13 +919,86 @@ fn handle_internal_func(addr: usize, args: &FuncArgs, vm: &VirtualMachine) -> Op None } +/// How the foreign call's return value is retrieved (mirrors `CallRet`). +enum RetSpec { + /// restype is None: void return. + Void, + /// Pointer-valued return (a `TYPEFLAG_ISPOINTER` restype, or an oversized + /// by-value struct approximated as a pointer-sized register). + Pointer, + /// A scalar retrieved as the given ctypes simple-type code. + Code(char), + /// A by-value aggregate (struct/union) return with the given call layout. + Aggregate(CTypeLayout), +} + /// Call information extracted from PyCFuncPtr (argtypes, restype, etc.) struct CallInfo { explicit_arg_types: Option>, restype_obj: Option, + ret: RetSpec, +} + +/// Determine how to retrieve the return value from restype, reproducing the +/// prior `ffi_return_type` + `is_pointer_return` dispatch. +fn compute_ret_spec( restype_is_none: bool, - ffi_return_type: FfiType, - is_pointer_return: bool, + restype_obj: Option<&PyObjectRef>, + vm: &VirtualMachine, +) -> RetSpec { + if restype_is_none { + return RetSpec::Void; + } + let Some(restype_type) = restype_obj.and_then(|t| t.clone().downcast::().ok()) else { + return RetSpec::Code('i'); + }; + + // Pointer return via TYPEFLAG_ISPOINTER (c_void_p, c_char_p, c_wchar_p, POINTER(T)) + if restype_type + .stg_info_opt() + .is_some_and(|info| info.flags.contains(StgInfoFlags::TYPEFLAG_ISPOINTER)) + { + return RetSpec::Pointer; + } + + // Simple type via its _type_ code (traversing MRO) + if let Ok(type_attr) = restype_type + .as_object() + .get_attr(vm.ctx.intern_str("_type_"), vm) + && let Some(s) = type_attr.downcast_ref::() + && let Some(code) = s.to_str() + && ffi_type_from_code(code).is_some() + { + return if simple_type_is_pointer(code) { + RetSpec::Pointer + } else { + RetSpec::Code(code.chars().next().unwrap_or('i')) + }; + } + + // Structure/Union (StgInfo, no _type_): returned by value as an aggregate. + // The layout is built from the held guard to avoid re-locking the type. + if let Some(stg_info) = restype_type.stg_info_opt() { + return match stg_info.paramfunc { + ParamFunc::Structure | ParamFunc::Union => { + RetSpec::Aggregate(super::base::type_layout(&restype_type, &stg_info, vm)) + } + // Any other aggregate-ish StgInfo without a code: size-approximated + // register return, as before. + _ => { + let size = stg_info.size; + if size <= 4 { + RetSpec::Code('i') + } else if size <= 8 { + RetSpec::Code('q') + } else { + RetSpec::Pointer + } + } + }; + } + + RetSpec::Code('i') } /// Extract call information (argtypes, restype) from PyCFuncPtr @@ -1012,33 +1043,12 @@ fn extract_call_info(zelf: &Py, vm: &VirtualMachine) -> PyResult().ok()) - .and_then(|t| ReturnType::to_ffi_type(&t, vm)) - .unwrap_or_else(ffi_i32_type) - }; - - // Check if return type is a pointer type via TYPEFLAG_ISPOINTER - // This handles c_void_p, c_char_p, c_wchar_p, and POINTER(T) types - let is_pointer_return = restype_obj - .as_ref() - .and_then(|t| t.clone().downcast::().ok()) - .and_then(|t| { - t.stg_info_opt() - .map(|info| info.flags.contains(StgInfoFlags::TYPEFLAG_ISPOINTER)) - }) - .unwrap_or(false); + let ret = compute_ret_spec(restype_is_none, restype_obj.as_ref(), vm); Ok(CallInfo { explicit_arg_types, restype_obj, - restype_is_none, - ffi_return_type, - is_pointer_return, + ret, }) } @@ -1135,8 +1145,7 @@ fn resolve_com_method( /// Single argument for FFI call // struct argument struct Argument { - ffi_type: FfiType, - value: FfiArgValue, + value: CArgValue, #[allow(dead_code)] keep: Option, // Object to keep alive during call } @@ -1198,13 +1207,8 @@ fn build_callargs_simple( let arg_type = arg_types .get(n) .ok_or_else(|| vm.new_type_error("argument amount mismatch"))?; - let ffi_type = ArgumentType::to_ffi_type(arg_type, vm)?; - let value = arg_type.convert_object(arg.clone(), vm)?; - Ok(Argument { - ffi_type, - keep: None, - value, - }) + let (value, keep) = arg_type.convert_object(arg.clone(), vm)?; + Ok(Argument { value, keep }) }) .collect::>>()?; Ok((arguments, Vec::new())) @@ -1241,17 +1245,14 @@ fn build_callargs_with_paramflags( let is_out = (*direction & 2) != 0; // OUT flag let is_in = (*direction & 1) != 0 || *direction == 0; // IN flag or default - let ffi_type = ArgumentType::to_ffi_type(arg_type, vm)?; - if is_out && !is_in { // Pure OUT parameter: create buffer, don't consume caller arg let buffer = create_out_buffer(arg_type, vm)?; let addr = get_buffer_addr(&buffer) .ok_or_else(|| vm.new_type_error("Cannot create OUT buffer for this type"))?; arguments.push(Argument { - ffi_type, keep: None, - value: FfiArgValue::pointer(addr), + value: CArgValue::pointer(addr), }); out_buffers.push((param_idx, buffer)); } else { @@ -1269,12 +1270,8 @@ fn build_callargs_with_paramflags( // IN|OUT: track for return out_buffers.push((param_idx, arg.clone())); } - let value = arg_type.convert_object(arg, vm)?; - arguments.push(Argument { - ffi_type, - keep: None, - value, - }); + let (value, keep) = arg_type.convert_object(arg, vm)?; + arguments.push(Argument { value, keep }); } } @@ -1307,13 +1304,8 @@ fn build_callargs( let arg_type = arg_types .get(n) .ok_or_else(|| vm.new_type_error("argument amount mismatch"))?; - let ffi_type = ArgumentType::to_ffi_type(arg_type, vm)?; - let value = arg_type.convert_object(arg.clone(), vm)?; - arguments.push(Argument { - ffi_type, - keep: None, - value, - }); + let (value, keep) = arg_type.convert_object(arg.clone(), vm)?; + arguments.push(Argument { value, keep }); } Ok((arguments, Vec::new())) } else { @@ -1322,22 +1314,31 @@ fn build_callargs( } } -/// Execute FFI call +/// Execute the foreign call through the unified `call` entry point. fn ctypes_callproc( - code_ptr: FfiCodePtr, + addr: usize, arguments: &[Argument], - call_info: &CallInfo, -) -> RawResult { - let ffi_arg_types: Vec = arguments.iter().map(|a| a.ffi_type.clone()).collect(); - let ffi_args: Vec<_> = arguments.iter().map(|a| a.value.as_arg()).collect(); - rustpython_host_env::ctypes::callproc( - code_ptr, - ffi_arg_types, - call_info.ffi_return_type.clone(), - &ffi_args, - call_info.restype_is_none, - call_info.is_pointer_return, - ) + ret: &RetSpec, + options: CallOptions, +) -> Result { + // Encode each simple-type code into its own buffer so the `&str` borrowed + // by `CallArg::Typed` outlives the call. + let mut code_bufs = vec![[0u8; 4]; arguments.len()]; + let call_args: Vec<_> = arguments + .iter() + .zip(code_bufs.iter_mut()) + .map(|(arg, code_buf)| arg.value.as_call_arg(code_buf)) + .collect(); + + let mut ret_code_buf = [0u8; 4]; + let call_ret = match ret { + RetSpec::Void => CallRet::Void, + RetSpec::Pointer => CallRet::Pointer, + RetSpec::Code(code) => CallRet::Code(code.encode_utf8(&mut ret_code_buf)), + RetSpec::Aggregate(layout) => CallRet::Aggregate(layout), + }; + + call(addr, &call_args, call_ret, options) } /// Check and handle HRESULT errors (Windows) @@ -1375,26 +1376,38 @@ fn check_hresult(hresult: i32, zelf: &Py, vm: &VirtualMachine) -> Py } } -/// Convert raw FFI result to Python object +/// Convert the foreign-call result to a Python object // = GetResult fn convert_raw_result( - raw_result: &mut RawResult, + result: &CallValue, call_info: &CallInfo, vm: &VirtualMachine, ) -> Option { - // Get result as bytes for type conversion - let (result_bytes, result_size) = rustpython_host_env::ctypes::call_result_bytes(raw_result)?; + // Result register image as bytes + size (None for void): pointer/scalar + // returns are pointer/register sized. + let (result_bytes, result_size) = match result { + CallValue::Void => return None, + CallValue::Pointer(ptr) => (ptr.to_ne_bytes().to_vec(), size_of::()), + CallValue::Scalar(bytes) | CallValue::Aggregate(bytes) => (bytes.clone(), bytes.len()), + }; + + // Integer view of the return register, for the fallback branches below. + let result_word: usize = match result { + CallValue::Pointer(ptr) => *ptr, + CallValue::Scalar(bytes) | CallValue::Aggregate(bytes) => { + let mut word = [0u8; size_of::()]; + let n = bytes.len().min(word.len()); + word[..n].copy_from_slice(&bytes[..n]); + usize::from_ne_bytes(word) + } + CallValue::Void => 0, + }; // 1. No restype → return as int let restype = match &call_info.restype_obj { None => { // Default: return as int - let val = match raw_result { - RawResult::Pointer(p) => *p as isize, - RawResult::Value(v) => *v as isize, - RawResult::Void => return None, - }; - return Some(vm.ctx.new_int(val).into()); + return Some(vm.ctx.new_int(result_word as isize).into()); } Some(r) => r, }; @@ -1409,12 +1422,7 @@ fn convert_raw_result( Ok(t) => t, Err(_) => { // Not a type, call it with int result - let val = match raw_result { - RawResult::Pointer(p) => *p as isize, - RawResult::Value(v) => *v as isize, - RawResult::Void => return None, - }; - return restype.call((val,), vm).ok(); + return restype.call((result_word as isize,), vm).ok(); } }; @@ -1423,15 +1431,19 @@ fn convert_raw_result( // No StgInfo → call restype with int if stg_info.is_none() { - let val = match raw_result { - RawResult::Pointer(p) => *p as isize, - RawResult::Value(v) => *v as isize, - RawResult::Void => return None, - }; - return restype_type.as_object().call((val,), vm).ok(); + return restype_type + .as_object() + .call((result_word as isize,), vm) + .ok(); } let info = stg_info.unwrap(); + // Extract what's needed and release the read guard before constructing any + // instance below: instance construction write-locks the type's StgInfo (to + // finalize it), which would self-deadlock against a held read guard. + let is_pointer_type = info.flags.contains(StgInfoFlags::TYPEFLAG_ISPOINTER); + let has_proto = info.proto.is_some(); + drop(info); // py_object: interpret return value as PyObject* and materialize it. if let Ok(type_attr) = restype_type @@ -1440,12 +1452,7 @@ fn convert_raw_result( && let Some(type_str) = type_attr.downcast_ref::() && type_str.to_str() == Some("O") { - let ptr = match raw_result { - RawResult::Pointer(p) => *p, - RawResult::Value(v) => *v as usize, - RawResult::Void => 0, - }; - let ptr = NonNull::new(ptr as *mut PyObject).or_else(|| { + let ptr = NonNull::new(result_word as *mut PyObject).or_else(|| { vm.set_exception(Some(vm.new_value_error("PyObject is NULL"))); None })?; @@ -1469,9 +1476,9 @@ fn convert_raw_result( // This handles POINTER(T), Structure, Array, etc. // Special handling for POINTER(T) types - set pointer value directly - if info.flags.contains(StgInfoFlags::TYPEFLAG_ISPOINTER) - && info.proto.is_some() - && let RawResult::Pointer(ptr) = raw_result + if is_pointer_type + && has_proto + && let CallValue::Pointer(ptr) = result && let Ok(instance) = restype_type.as_object().call((), vm) { if let Some(pointer) = instance.downcast_ref::() { @@ -1516,7 +1523,7 @@ fn extract_out_values( /// Build final result (main function) fn build_result( - mut raw_result: RawResult, + call_result: CallValue, call_info: &CallInfo, out_buffers: OutBuffers, zelf: &Py, @@ -1525,19 +1532,22 @@ fn build_result( ) -> PyResult { // Check HRESULT on Windows #[cfg(windows)] - if let RawResult::Value(val) = raw_result { + if let CallValue::Scalar(bytes) = &call_result { let is_hresult = call_info .restype_obj .as_ref() .and_then(|t| t.clone().downcast::().ok()) .is_some_and(|t| t.name().to_string() == "HRESULT"); if is_hresult { - check_hresult(val as i32, zelf, vm)?; + let mut word = [0u8; size_of::()]; + let n = bytes.len().min(word.len()); + word[..n].copy_from_slice(&bytes[..n]); + check_hresult(usize::from_ne_bytes(word) as i32, zelf, vm)?; } } - // Convert raw result to Python object - let mut result = convert_raw_result(&mut raw_result, call_info, vm); + // Convert the foreign-call result to a Python object + let mut result = convert_raw_result(&call_result, call_info, vm); // Apply errcheck if set if let Some(errcheck) = zelf.errcheck.read().as_ref() { @@ -1583,44 +1593,34 @@ impl Callable for PyCFuncPtr { let (arguments, out_buffers) = build_callargs(&args, &call_info, paramflags.as_ref(), is_com_method, vm)?; - // 6. Get code pointer - let code_ptr = match func_ptr.or_else(|| zelf.get_code_ptr()) { - Some(cp) => cp, - None => { - debug_assert!(false, "NULL function pointer"); - // In release mode, this will crash - null_code_ptr() - } + // 6. Function address (usize); the unified `call` rejects a NULL address. + let addr = match func_ptr { + Some(cp) => cp.0 as usize, + None => zelf.get_func_ptr(), }; - // 7. Get flags to check for use_last_error/use_errno + // 7. Errno / last-error swap options from flags let flags = Self::_flags_(zelf, vm); - - // 8. Call the function (with use_last_error/use_errno handling) - #[cfg(not(windows))] - let raw_result = { - if flags & super::base::StgInfoFlags::FUNCFLAG_USE_ERRNO.bits() != 0 { - rustpython_host_env::ctypes::with_swapped_errno(|| { - ctypes_callproc(code_ptr, &arguments, &call_info) - }) - } else { - ctypes_callproc(code_ptr, &arguments, &call_info) - } + let options = CallOptions { + use_errno: flags & super::base::StgInfoFlags::FUNCFLAG_USE_ERRNO.bits() != 0, + use_last_error: flags & super::base::StgInfoFlags::FUNCFLAG_USE_LASTERROR.bits() != 0, }; - #[cfg(windows)] - let raw_result = { - if flags & super::base::StgInfoFlags::FUNCFLAG_USE_LASTERROR.bits() != 0 { - rustpython_host_env::ctypes::with_swapped_last_error(|| { - ctypes_callproc(code_ptr, &arguments, &call_info) - }) - } else { - ctypes_callproc(code_ptr, &arguments, &call_info) - } - }; + // 8. Call the function through the unified entry point. + let call_result = ctypes_callproc(addr, &arguments, &call_info.ret, options).map_err( + |err| match err { + CallError::NullFunctionPointer => vm.new_value_error("NULL function pointer"), + CallError::UnknownTypeCode(code) => { + vm.new_type_error(format!("Unsupported argument type: {code}")) + } + CallError::BufferTooSmall { expected, got } => vm.new_value_error(format!( + "argument buffer too small: expected {expected}, got {got}" + )), + }, + )?; // 9. Build result - build_result(raw_result, &call_info, out_buffers, zelf, &args, vm) + build_result(call_result, &call_info, out_buffers, zelf, &args, vm) } } diff --git a/crates/vm/src/stdlib/_ctypes/simple.rs b/crates/vm/src/stdlib/_ctypes/simple.rs index 122c23cc25c..c947e56010a 100644 --- a/crates/vm/src/stdlib/_ctypes/simple.rs +++ b/crates/vm/src/stdlib/_ctypes/simple.rs @@ -1,8 +1,7 @@ use super::_ctypes::CArgObject; use super::array::PyCArray; use super::base::{ - CDATA_BUFFER_METHODS, FfiArgValue, PyCData, StgInfo, StgInfoFlags, buffer_to_ffi_value, - bytes_to_pyobject, + CArgValue, CDATA_BUFFER_METHODS, PyCData, StgInfo, StgInfoFlags, bytes_to_pyobject, }; use super::function::PyCFuncPtr; use super::pointer::PyCPointer; @@ -263,11 +262,11 @@ impl PyCSimpleType { let simple_obj: PyObjectRef = simple.into_ref_with_type(vm, cls.clone())?.into(); // from_param returns CArgObject, not the simple type itself let tag = type_str.as_bytes().first().copied().unwrap_or(b'?'); - let ffi_value = buffer_to_ffi_value(type_str, &buffer_bytes); Ok(CArgObject { tag, - value: ffi_value, + value: CArgValue::typed(tag as char, &buffer_bytes), obj: simple_obj, + keep: None, size: 0, offset: 0, } @@ -319,8 +318,9 @@ impl PyCSimpleType { let (kept_alive, ptr) = super::base::ensure_z_null_terminated(bytes, vm); return Ok(CArgObject { tag: b'z', - value: FfiArgValue::OwnedPointer(ptr, kept_alive), + value: CArgValue::pointer(ptr), obj: value.clone(), + keep: Some(kept_alive), size: 0, offset: 0, } @@ -344,8 +344,9 @@ impl PyCSimpleType { let (holder, ptr) = super::base::str_to_wchar_bytes(s.as_wtf8(), vm); return Ok(CArgObject { tag: b'Z', - value: FfiArgValue::OwnedPointer(ptr, holder), + value: CArgValue::pointer(ptr), obj: value.clone(), + keep: Some(holder), size: 0, offset: 0, } @@ -373,8 +374,9 @@ impl PyCSimpleType { let (kept_alive, ptr) = super::base::ensure_z_null_terminated(bytes, vm); return Ok(CArgObject { tag: b'z', - value: FfiArgValue::OwnedPointer(ptr, kept_alive), + value: CArgValue::pointer(ptr), obj: value.clone(), + keep: Some(kept_alive), size: 0, offset: 0, } @@ -385,8 +387,9 @@ impl PyCSimpleType { let (holder, ptr) = super::base::str_to_wchar_bytes(s.as_wtf8(), vm); return Ok(CArgObject { tag: b'Z', - value: FfiArgValue::OwnedPointer(ptr, holder), + value: CArgValue::pointer(ptr), obj: value.clone(), + keep: Some(holder), size: 0, offset: 0, } @@ -412,8 +415,9 @@ impl PyCSimpleType { }; return Ok(CArgObject { tag: b'P', - value: FfiArgValue::pointer(ptr_val), + value: CArgValue::pointer(ptr_val), obj: value.clone(), + keep: None, size: 0, offset: 0, } @@ -429,8 +433,9 @@ impl PyCSimpleType { }; return Ok(CArgObject { tag: b'Z', - value: FfiArgValue::pointer(ptr_val), + value: CArgValue::pointer(ptr_val), obj: value.clone(), + keep: None, size: 0, offset: 0, } @@ -442,8 +447,9 @@ impl PyCSimpleType { Some("O") => { return Ok(CArgObject { tag: b'O', - value: FfiArgValue::pointer(value.get_id()), + value: CArgValue::pointer(value.get_id()), obj: value, + keep: None, size: 0, offset: 0, } @@ -1255,17 +1261,10 @@ impl PyCSimple { } impl PyCSimple { - /// Extract the value from this ctypes object as an owned FfiArgValue. - /// The value must be kept alive until after the FFI call completes. - pub(crate) fn to_ffi_value( - &self, - ty: rustpython_host_env::ctypes::FfiType, - _vm: &VirtualMachine, - ) -> Option { + /// Snapshot this object's buffer as a simple-typed foreign-call value. + pub(crate) fn to_carg_value(&self, code: char) -> CArgValue { let buffer = self.0.buffer.read(); - Some(FfiArgValue::Scalar( - rustpython_host_env::ctypes::ffi_value_from_type(&buffer, ty)?, - )) + CArgValue::typed(code, &buffer) } } diff --git a/crates/vm/src/stdlib/_ctypes/structure.rs b/crates/vm/src/stdlib/_ctypes/structure.rs index 96321cd7d55..39bb8a57413 100644 --- a/crates/vm/src/stdlib/_ctypes/structure.rs +++ b/crates/vm/src/stdlib/_ctypes/structure.rs @@ -269,14 +269,14 @@ impl PyCStructType { // Determine byte order for format string let big_endian = super::base::is_big_endian(is_swapped); - // Initialize offset, alignment, type flags, and ffi_field_types from base class + // Initialize offset, alignment, type flags, and field_layouts from base class let ( mut offset, mut max_align, mut has_pointer, mut has_union, mut has_bitfield, - mut ffi_field_types, + mut field_layouts, ) = { let bases = cls.bases.read(); if let Some(base) = bases.first() @@ -288,7 +288,7 @@ impl PyCStructType { baseinfo.flags.contains(StgInfoFlags::TYPEFLAG_HASPOINTER), baseinfo.flags.contains(StgInfoFlags::TYPEFLAG_HASUNION), baseinfo.flags.contains(StgInfoFlags::TYPEFLAG_HASBITFIELD), - baseinfo.ffi_field_types.clone(), + baseinfo.field_layouts.clone(), ) } else { (0, forced_alignment, false, false, false, Vec::new()) @@ -366,8 +366,8 @@ impl PyCStructType { if field_stg.flags.contains(StgInfoFlags::TYPEFLAG_HASBITFIELD) { has_bitfield = true; } - // Collect FFI type for this field - ffi_field_types.push(field_stg.to_ffi_type()); + // Collect the call layout for this field + field_layouts.push(super::base::type_layout(type_obj, &field_stg, vm)); } // Mark field type as finalized (using type as field finalizes it) @@ -552,8 +552,8 @@ impl PyCStructType { stg_info.paramfunc = super::base::ParamFunc::Structure; // Set byte order: swap if _swappedbytes_ is defined stg_info.big_endian = super::base::is_big_endian(is_swapped); - // Store FFI field types for structure passing - stg_info.ffi_field_types = ffi_field_types; + // Store field call layouts for by-value structure passing + stg_info.field_layouts = field_layouts; super::base::set_or_init_stginfo(cls, stg_info); // Process _anonymous_ fields diff --git a/crates/vm/src/stdlib/_ctypes/union.rs b/crates/vm/src/stdlib/_ctypes/union.rs index e0b4900cbd5..8d37178d587 100644 --- a/crates/vm/src/stdlib/_ctypes/union.rs +++ b/crates/vm/src/stdlib/_ctypes/union.rs @@ -184,9 +184,9 @@ impl PyCUnionType { let forced_alignment = super::base::get_usize_attr(cls.as_object(), "_align_", 1, vm)?.max(1); - // Initialize size, alignment, type flags, and ffi_field_types from base class + // Initialize size, alignment, type flags, and field_layouts from base class // Note: Union fields always start at offset 0, but we inherit base size/align - let (mut max_size, mut max_align, mut has_pointer, mut has_bitfield, mut ffi_field_types) = { + let (mut max_size, mut max_align, mut has_pointer, mut has_bitfield, mut field_layouts) = { let bases = cls.bases.read(); if let Some(base) = bases.first() && let Some(baseinfo) = base.stg_info_opt() @@ -196,7 +196,7 @@ impl PyCUnionType { core::cmp::max(baseinfo.align, forced_alignment), baseinfo.flags.contains(StgInfoFlags::TYPEFLAG_HASPOINTER), baseinfo.flags.contains(StgInfoFlags::TYPEFLAG_HASBITFIELD), - baseinfo.ffi_field_types.clone(), + baseinfo.field_layouts.clone(), ) } else { (0, forced_alignment, false, false, Vec::new()) @@ -256,8 +256,8 @@ impl PyCUnionType { if field_stg.flags.contains(StgInfoFlags::TYPEFLAG_HASBITFIELD) { has_bitfield = true; } - // Collect FFI type for this field - ffi_field_types.push(field_stg.to_ffi_type()); + // Collect the call layout for this field + field_layouts.push(super::base::type_layout(type_obj, &field_stg, vm)); } // Mark field type as finalized (using type as field finalizes it) @@ -345,8 +345,8 @@ impl PyCUnionType { stg_info.paramfunc = super::base::ParamFunc::Union; // Set byte order: swap if _swappedbytes_ is defined stg_info.big_endian = super::base::is_big_endian(is_swapped); - // Store FFI field types for union passing - stg_info.ffi_field_types = ffi_field_types; + // Store field call layouts for by-value union passing + stg_info.field_layouts = field_layouts; super::base::set_or_init_stginfo(cls, stg_info); // Process _anonymous_ fields diff --git a/extra_tests/snippets/stdlib_ctypes_byvalue.py b/extra_tests/snippets/stdlib_ctypes_byvalue.py new file mode 100644 index 00000000000..73d4b334506 --- /dev/null +++ b/extra_tests/snippets/stdlib_ctypes_byvalue.py @@ -0,0 +1,106 @@ +# ctypes by-value aggregate arguments and returns over the live FFI path. +# +# Exercises passing structs/unions BY VALUE to foreign functions and returning +# structs BY VALUE through the unified host_env `call` entry point: +# - div(7, 3) / div(-7, 3): return div_t{quot, rem} by value (8-byte int +# struct, register-returned on SysV/AArch64), +# - imaxdiv(7, 3): return imaxdiv_t{quot, rem} by value (16-byte two-long +# struct, two-register return on SysV), +# - inet_ntoa(struct in_addr): take a 4-byte struct by value, with argtypes, +# without argtypes (direct-instance paramfunc path), and via a Union. +# +# Runs on little-endian linux/macOS; skipped on Windows (see below). Prints +# "OK"; a failed assertion aborts with a non-zero status. + +import ctypes +import sys +from ctypes import ( + CDLL, + Structure, + Union, + c_char, + c_char_p, + c_int, + c_int64, + c_uint32, + sizeof, +) + +if sys.platform == "win32": + # The C library is not reachable as CDLL(None) on Windows; by-value + # aggregate calls are covered there by test_ctypes. Keep output identical. + print("OK") + sys.exit(0) + + +libc = CDLL(None) + + +# 1. struct RETURN by value: div(7, 3) -> div_t{quot=2, rem=1} +class div_t(Structure): + _fields_ = [("quot", c_int), ("rem", c_int)] + + +assert sizeof(div_t) == 8, sizeof(div_t) +libc.div.argtypes = [c_int, c_int] +libc.div.restype = div_t + +r = libc.div(7, 3) +assert isinstance(r, div_t) +assert (r.quot, r.rem) == (2, 1), (r.quot, r.rem) + +# C division truncates toward zero. +r = libc.div(-7, 3) +assert (r.quot, r.rem) == (-2, -1), (r.quot, r.rem) + +# struct RETURN by value with NO argtypes on the arguments (ints via ConvParam) +libc.div.argtypes = None +r = libc.div(17, 5) +assert (r.quot, r.rem) == (3, 2), (r.quot, r.rem) + + +# 2. larger struct RETURN by value: imaxdiv(7, 3) -> imaxdiv_t{quot=2, rem=1} +class imaxdiv_t(Structure): + _fields_ = [("quot", c_int64), ("rem", c_int64)] + + +assert sizeof(imaxdiv_t) == 16, sizeof(imaxdiv_t) +libc.imaxdiv.argtypes = [c_int64, c_int64] +libc.imaxdiv.restype = imaxdiv_t + +r = libc.imaxdiv(7, 3) +assert (r.quot, r.rem) == (2, 1), (r.quot, r.rem) +r = libc.imaxdiv(-9, 4) +assert (r.quot, r.rem) == (-2, -1), (r.quot, r.rem) + + +# 3. struct ARGUMENT by value: inet_ntoa(struct in_addr) -> b"1.2.3.4" +class in_addr(Structure): + _fields_ = [("s_addr", c_uint32)] + + +assert sizeof(in_addr) == 4, sizeof(in_addr) +# `s_addr` holds the four address bytes in memory (network) order; a host-endian +# int whose bytes are [1, 2, 3, 4] yields the dotted string "1.2.3.4". +addr_value = int.from_bytes(bytes([1, 2, 3, 4]), sys.byteorder) + +libc.inet_ntoa.argtypes = [in_addr] +libc.inet_ntoa.restype = c_char_p +assert libc.inet_ntoa(in_addr(addr_value)) == b"1.2.3.4" + +# struct ARGUMENT by value with NO argtypes (direct-instance paramfunc path) +libc.inet_ntoa.argtypes = None +assert libc.inet_ntoa(in_addr(addr_value)) == b"1.2.3.4" + + +# 4. union ARGUMENT by value: a union laid out like in_addr, passed by value. +class in_addr_u(Union): + _fields_ = [("s_addr", c_uint32), ("bytes", c_char * 4)] + + +assert sizeof(in_addr_u) == 4, sizeof(in_addr_u) +libc.inet_ntoa.argtypes = [in_addr_u] +libc.inet_ntoa.restype = c_char_p +assert libc.inet_ntoa(in_addr_u(addr_value)) == b"1.2.3.4" + +print("OK") diff --git a/extra_tests/snippets/stdlib_ctypes_calls.py b/extra_tests/snippets/stdlib_ctypes_calls.py new file mode 100644 index 00000000000..1de29931429 --- /dev/null +++ b/extra_tests/snippets/stdlib_ctypes_calls.py @@ -0,0 +1,64 @@ +# Exercises the migrated _ctypes foreign-call path (routed through the unified +# host_env `call` entry point): scalar int/double arguments and returns, +# pointer (c_char_p / c_void_p) returns, and a use_errno round-trip. +# +# Prints "OK" and exits 0; any failed assertion aborts. Output is identical +# under CPython and RustPython on the same platform. +import ctypes +import errno +import sys +from ctypes import ( + CDLL, + c_char_p, + c_double, + c_int, + c_long, + c_size_t, + c_void_p, + get_errno, + set_errno, +) + +if sys.platform == "win32": + # The C library is not reachable as CDLL(None) on Windows; the migrated + # path is covered there by test_ctypes. Keep output identical regardless. + print("OK") + sys.exit(0) + +libc = CDLL(None, use_errno=True) + +# 1. scalar int argument + int return: abs(-5) == 5 +libc.abs.argtypes = [c_int] +libc.abs.restype = c_int +assert libc.abs(-5) == 5, libc.abs(-5) + +# 2. pointer argument (bytes -> char*) + size_t return: strlen(b"hello") == 5 +libc.strlen.argtypes = [c_char_p] +libc.strlen.restype = c_size_t +assert libc.strlen(b"hello") == 5, libc.strlen(b"hello") + +# 3. double argument + double return: sqrt(2.0) +libc.sqrt.argtypes = [c_double] +libc.sqrt.restype = c_double +root = libc.sqrt(2.0) +assert abs(root - 2.0**0.5) < 1e-12, root + +# 4. c_char_p return: strchr(b"abcdef", 'c') -> b"cdef" +libc.strchr.argtypes = [c_char_p, c_int] +libc.strchr.restype = c_char_p +assert libc.strchr(b"abcdef", ord("c")) == b"cdef", libc.strchr(b"abcdef", ord("c")) + +# 5. c_void_p return: the same call yields a non-null integer address +libc.strchr.restype = c_void_p +addr = libc.strchr(b"abcdef", ord("c")) +assert isinstance(addr, int) and addr != 0, addr + +# 6. use_errno round-trip: strtol overflow sets errno == ERANGE, captured into +# the ctypes-private errno by the call's errno swap. +libc.strtol.argtypes = [c_char_p, c_void_p, c_int] +libc.strtol.restype = c_long +set_errno(0) +libc.strtol(b"9" * 40, None, 10) +assert get_errno() == errno.ERANGE, (get_errno(), errno.ERANGE) + +print("OK") From c41180d4b7ece1c214d6e69e794e954c5222700f Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:21:41 +0900 Subject: [PATCH 092/351] Thread-safe type operations: type lock, QSBR type-cache reclamation, GC stop-the-world, and interpreter optimizations (#7416) * type lock * Drop old PyObjectRef outside type lock to prevent deadlock Dropping values inside with_type_lock can trigger weakref callbacks, which may access attributes (LOAD_ATTR specialization) and re-acquire the non-reentrant type mutex, causing deadlock. Return old values from lock closures so they drop after lock release. * Align type lock behavior with CPython * Add PUBLISHED flag bit to RefCount state word Assisted-by: Claude * Add QSBR module for deferred memory reclamation Assisted-by: Claude * Defer memory free of cache-published objects via QSBR Assisted-by: Claude * Wire QSBR checkpoints into thread lifecycle and eval breaker Assisted-by: Claude * Publish type-cache values to QSBR and bypass cache without a VM Mark cached type-method values as QSBR-published before storing their pointer in TYPE_CACHE, so racing readers never try-incref freed memory. Debug-assert that the current thread is ATTACHED whenever a lock-free cache read happens. Delete find_name_in_mro_without_vm and its unsound direct SeqLock read outside any VM/thread registration; find_name_in_mro now falls back to the locked, uncached MRO walk when no VM is current. This also removes assign_version_tag()'s last caller, so its unlocked fallback is deleted along with it (version_for_specialization keeps the locked path). VirtualMachine::initialize() runs Python bytecode (e.g. importing codecs/encodings) before any enter_vm scope exists, which left the bootstrap thread not ATTACHED for cache reads during startup. Add thread::VmBootstrapGuard, an RAII counterpart to enter_vm usable across statements that need &mut VirtualMachine, and wrap initialize() with it. Assisted-by: Claude * Use try-incref reads and QSBR-backed swaps in specialization cache Assisted-by: Claude * Reset QSBR thread registry after fork in the child Add Qsbr::reset_after_fork, which clears the registered thread slots before draining the retire queue. Call it in py_os_after_fork_child, right after type_cache_after_fork() and before _thread::after_fork_child() re-registers the surviving thread. Dead parent threads' Arc handles live on in the child's copied memory with no destructor ever running, so without this the registry keeps them 'online' forever and future grace periods never complete. Assisted-by: Claude * Add threaded stress test for type cache mutation races Assisted-by: Claude * Restrict qsbr internals to pub(crate) and fix clippy lints Downgrade pub items in the private qsbr::threading module (QsbrSlot, Qsbr, QSBR, and its methods) to pub(crate), since they were never reachable outside the crate. Downgrade ThreadSlot::qsbr accordingly to avoid a private-interfaces warning. Import Arc/Weak from alloc instead of std to match clippy::std_instead_of_alloc. Rewrite the process() comment: goals are not strictly ordered by push order since advance() and the queue push aren't atomic together, so concurrent free_delayed calls can interleave; the drained prefix stays sound because each item individually passed poll, with reordering only affecting reclamation latency. Assisted-by: Claude * Gate per-instruction QSBR check behind a global pending flag eval_breaker_tripped() and check_signals() called thread::qsbr_break_requested() on every bytecode instruction, which does a thread-local lookup, RefCell borrow, and atomic load even when no QSBR retirement is pending. This caused a measured 10-18% slowdown on tight interpreter loops. Add Qsbr::pending, a global AtomicBool set under the queue lock while free_delayed() holds a retired allocation, and cleared under the same lock by process()/drain_all() once the queue empties. The hot path now checks Qsbr::break_pending() (a single relaxed static load) before touching thread-local state, and only pays the TLS cost while a retirement is actually in flight. Assisted-by: Claude * Merge signal and QSBR eval-breaker flags into one atomic word Replace the separate ANY_TRIGGERED AtomicBool and QSBR pending AtomicBool checks in the per-instruction eval-breaker path with a single AtomicU8 (EVAL_BREAKER) holding a bit per source. The global QSBR instance mirrors its pending state into the QSBR bit under the same queue-lock critical sections that already toggle pending, while local QSBR instances used by unit tests are left untouched. eval_breaker_tripped() and check_signals() now read/act on the merged word (eval_breaker_pending(), qsbr_bit_set()) instead of issuing two separate atomic loads per instruction. set_triggered() switches from store to fetch_or so a signal handler never clobbers the QSBR bit. Removed the now-unreferenced is_triggered() helper; break_pending() is kept for unit tests only and marked allow(dead_code) outside test builds. Assisted-by: Claude * Skip freelist reuse for published objects in default_dealloc Freelist-eligible payloads (tuple, int, list, dict, float, ...) were routed to T::freelist_push before ever reaching PyInner::dealloc, so a published object (e.g. a tuple cached as a type attribute) bypassed the is_published -> free_delayed QSBR hook entirely. That let PyRef::new_ref reuse the slot and rewrite the refcount word with a non-atomic core::ptr::write racing a reader's atomic try-incref, and let FreeList::drop call alloc::dealloc directly while a reader could still be mid-read. Gate the freelist branch in default_dealloc on RefCount::is_published(), checked before any teardown. A published object now always falls through to PyInner::dealloc, whose existing hook defers the memory free via QSBR. Document the non-unix (e.g. Windows) reclamation-latency limitation in the QSBR design doc's Lifecycle integration section (previously only noted in a code comment in vm/thread.rs). Extend the type-cache stress test so the mutator also churns a freelist-eligible published tuple class attribute (C.shape), read by readers each inner loop and deleted on the same cadence as C.m, to exercise the fixed bypass. Assisted-by: Claude * Use itoa for PyInt::to_str_radix_10 i64 fast path Format small integers with itoa::Buffer instead of i64::to_string() in PyInt::to_str_radix_10(), which backs both str(int)/repr(int) for exact int and PyObject::str()'s exact-int fast path. Adds the itoa crate as a workspace dependency. Assisted-by: Claude * Make object attribute dunders wrapper descriptors Remove the __getattribute__, __setattr__ and __delattr__ #[pymethod]s from PyBaseObject so add_operators installs PyWrapper slot wrappers for them from the #[pyslot] functions instead of method descriptors. lookup_slot_in_mro now classifies these entries as NativeSlot, so heap types without Python-level overrides keep the native getattro/setattro slot functions, which lets LOAD_ATTR/STORE_ATTR specialization apply to instances of Python-defined classes. Since __setattr__ and __delattr__ share the setattro slot, resolve both names together in update_one_slot: overriding only one of them no longer lets the other name's native resolution overwrite the dispatching wrapper. Assisted-by: Claude * Resolve TpSetattro pair on attribute deletion, not just addition update_one_slot's TpSetattro branch only ran the combined __setattr__/__delattr__ resolution (added in the previous commit) when ADD was true. On deletion (e.g. del C.__setattr__), it fell back to inherit_from_mro, which copies the base class's slot and discards the class's own remaining Python-level override of the other name. lookup_slot_in_mro reads the current attribute dicts, so the same resolution is correct for both addition and deletion; the ADD-only guard is removed and the logic now always runs. Assisted-by: Claude * Generate Opcode::cache_entries/deopt as table lookups generate_rs_opcode_metadata.py now emits a 256-entry const array for each function instead of a chained match over opcode names. Opcode::deopt() indexes an [Option; 256] table built from the existing specialization-to-base mapping, and Opcode::cache_entries() indexes a [u8; 256] table that bakes in the same deopt/to_base composition deoptimize() previously performed before its own match. Regenerated opcode_metadata.rs from the updated script; PseudoOpcode's bodies are unchanged since it has no cache/deopt entries to table. Added an exhaustive test in instruction.rs that checks every valid u8 opcode against a frozen copy of the old chained-match bodies. Assisted-by: Claude * Make Opcode/Instruction numeric conversions O(1) Opcode::as_numeric, Opcode::as_instruction and Instruction::as_opcode were each a per-variant match over ~230 arms in the define_opcodes! macro. Opcode now carries the same explicit $op_id discriminants and #[repr($typ)] as Instruction, which makes the conversions sound as: - Opcode::as_numeric: a plain `self as $typ` identity cast. - Opcode::as_instruction / Instruction::as_opcode: a mem::transmute, relying on both enums sharing one-$typ-wide layout (Instruction's payload fields are all the zero-sized Arg marker, checked by a new per-instantiation size_of assertion). try_from_numeric is left as a match: $op_id values have gaps (specialized/instrumented opcode ranges), so a range check can't replace it. Adds #[inline] to the rewritten conversions plus deoptimize, and to the small generated wrapper/table-lookup functions from the previous opcode-metadata change (as_u8/as_u16, cache_entries, deopt, to_base) via generate_rs_opcode_metadata.py, then regenerates opcode_metadata.rs. Extends the instruction.rs equivalence-test pattern with two exhaustive tests (u8 and u16 instantiations) checking the new identity-cast/transmute conversions against the untouched try_from_numeric/TryFrom paths. Assisted-by: Claude * Pop CallIsinstance arguments directly instead of collecting into Vecs The specialized handler built two heap-allocated Vecs per call (pop_multiple().collect() plus a with_capacity(2) buffer) before invoking is_instance. Pop the class and instance straight off the stack for the effective two-argument case, removing the per-call allocations. Assisted-by: Claude * Inherit tp_new instead of always installing new_wrapper update_one_slot's TpNew branch stored new_wrapper for every ADD, so all heap types had slots.new == new_wrapper and the CallAllocAndEnterInit specialization never fired. Now __new__ is resolved through the MRO dicts: an own or Python-level definition installs new_wrapper, while a builtin __new__ entry (or none) inherits slots.new from the solid base. The now-reachable CallAllocAndEnterInit path gains the missing guards: the specializer requires co_argcount == oparg + 1 (via can_specialize_call) and rejects generator-like __init__, and the handler re-checks the argcount against the class-level init cache. Without the argcount check, __init__ defaults broke re._parser (UnboundLocalError in SubPattern.__init__). Remove the now-unused is_simple_for_call_specialization. Assisted-by: Claude * Skip locals dict allocation for the init-cleanup shim frame Mark the shim code object NEWLOCALS and create the shim frame with no locals mapping so FrameLocals::lazy() is used. The shim only executes ExitInitCheck/ReturnValue and never touches locals, so this removes one dict allocation per specialized instantiation. Assisted-by: Claude * Guard CALL_ALLOC_AND_ENTER_INIT with cached function version The init specialization cache stored only the type version, so swapping __init__.__code__ (which zeroes func_version) did not deopt the warmed call site and stale code assumptions (argcount shape, no varargs/kwonly, not a generator) were applied to the new code object. Store get_version_for_current_state() in the specialization cache when caching __init__ and re-check func_version() in the handler before using the cached function, deopting to the generic call path on mismatch, the same way the getitem specialization cache does. Assisted-by: Claude * Apply rustfmt and ruff-format fixes to files from earlier commits Assisted-by: Claude * Add frame object freelist Frame.iframe becomes FrameUnsafeCell> so a dead frame can be left as an empty husk. Traverse::clear extracts all owned child references (including localsplus values via LocalsPlus::clear_into) and empties the cell. Dead frame allocations are cached in a thread-local FreeList (up to 200 entries) and reused by PyRef::new_ref. clear_generator returns early when the frame was already cleared by cycle collection; generator drops can run after tp_clear of their frame. gc: skip tp_clear for objects saved to gc.garbage by DEBUG_SAVEALL, since they remain reachable from Python. Assisted-by: Claude * Drop frame children directly in tp_clear instead of extracting Pushing the frame's ~10 child references into the clear buffer grew a Vec on every frame deallocation. Take the interpreter frame out of the cell and drop it in place; LocalsPlus::clear_into is removed. Assisted-by: Claude * Untrack objects before tp_clear and guard cross-thread f_locals The cycle collector called tp_clear on unreachable objects while they were still linked into the GC generation lists, so another thread could obtain a strong reference to an already-cleared object (e.g. a frame husk with iframe == None) via gc.get_objects() and access the cleared payload. Untrack the dead set before the clear phase, mirroring the untrack-then-clear ordering of the refcount dealloc path. Objects that gained an external reference before untracking are found by comparing each object's strong count against the references coming from within the dead set; such late-resurrected objects and everything reachable from them are re-tracked and skipped, letting a later collection retry once the external references are released. Also reject f_locals access for a frame currently executing on another thread. Reading fastlocals of such a frame races with the executing thread overwriting the slots (torn reads of dropped values). Access from the executing thread itself (locals(), trace callbacks) is still allowed via the current-frame chain check. Assisted-by: Claude * Skip localsplus heap copy for uniquely referenced dying frames release_datastack_frame now checks the frame's strong count after untracking it from the GC generation lists. When the caller holds the only reference, localsplus values are dropped in place and the data stack storage is released without the heap copy; escaped frames (traceback, sys._getframe, trace callbacks) keep the copy path. The three inline materialize+pop call sites in function.rs are routed through release_datastack_frame. Assisted-by: Claude * Stage exact-args call arguments in stack slot buffers CallPyExactArgs, CallBoundMethodExactArgs and CallAllocAndEnterInit no longer collect popped arguments into a per-call Vec (plus a second Vec for self-prepending). Arguments are popped into a fixed-size Option slot buffer (CallArgBuffer, 8 inline slots, heap fallback for larger arities) and moved into fastlocals via the new invoke_exact_args_slots / a take() iterator. - prepare_exact_args_frame now accepts an ExactSizeIterator of args - specialization_run_init_cleanup_shim takes the slot buffer and fills slot 0 with new_obj, dropping one redundant clone - LoadAttrGetattributeOverridden, LoadAttrProperty and BinaryOpSubscrGetitem pass inline slot arrays instead of vec![] Assisted-by: Claude * Reduce allocations in generic call paths - FuncArgs::prepend_arg: use reserve instead of reserve_exact so exact-capacity vectors do not realloc on every prepend. - IntoFuncArgs::into_method_args: build the final args vec once with capacity len + 1 instead of prepending into a full vector. - PyType::call: skip cloning FuncArgs when no init slot can run after slot_new (no init slot, not `type`, and slot_new is not new_wrapper). Assisted-by: Claude * Skip redundant exc_info restore on frame exit with_frame_impl saves the current exc_info slot value and restores it when the frame exits. When the slot still holds the same value, the restore rewrites the slot and recomputes the thread-exception mirror for no effect. Add restore_exception, which compares the slot against the saved value by pointer identity and skips the store when they match. The saved value is a strong reference held for the whole frame scope, so the pointed-to object cannot be freed and its address reused while the frame runs, making the pointer comparison free of ABA. Assisted-by: Claude * Apply rustfmt to gc_state.rs Assisted-by: Claude * Avoid empty FuncArgs clone in PyType::call Cloning args for the init call clones the kwargs map even when args is empty, which shows up in no-argument instantiation profiles. Prepare the init-call args before slot_new: a default FuncArgs when args is empty (indistinguishable from a clone of empty args), the existing clone otherwise. The empty check also keeps the init-slot load of the clone-elision path off the no-argument call path. Assisted-by: Claude * Push freelist husks only after tp_clear and child drops default_dealloc pushed the object onto the thread-local freelist before running clear_fn. Payloads that drop children inside clear (Frame) can run __del__ there, and a reentrant allocation could pop the husk and overwrite its payload while clear_fn still held a &mut borrow of it. Tuples had the same window through the extracted-edges drop, which ran after the push. Reorder default_dealloc to clear, drop the extracted children, and only then attempt the freelist push, so the husk becomes reusable only when no borrows into the payload remain. The tuple freelist bucketed husks by element count, which required reading the payload during push; after this reordering the elements are already cleared. PyInner is a fixed-size allocation (the elements box is dropped and replaced on reuse), so replace the per-size buckets with the shared single-list FreeList. Remove the now-unused pyinner_layout helper. Assisted-by: Claude * Resolve __set__ and __delete__ together for the descr_set slot The descr_set slot serves both __set__ and __delete__, but the TpDescrSet accessor resolved only the single modified name and fell back to MRO inheritance on delete. Deleting one of the pair could disable the surviving operation: after `del D.__set__`, an instance delete stopped calling the remaining __delete__. Resolve both names together like the TpSetattro accessor does: any Python-level definition selects the dispatching wrapper, matching native functions are stored directly, a single native function with the other name absent is stored directly, and the slot is inherited from the MRO only when neither name resolves. Assisted-by: Claude * Update type base on __bases__ assignment set_bases replaced bases and mro but never updated the stored base, so __base__ kept reporting the old value after reassignment and slot resolution (tp_new inheritance, set_new/set_alloc during init_slots) read the stale solid base. Change the base field to PyAtomicRef> so it can be swapped under the type lock while remaining lock-free for readers, and recompute it in set_bases with the same best_base validation type creation uses (BASETYPE flag, instance layout conflict among bases). The swapped-out base is parked in the frame's temporary refs; other references released inside the critical section are dropped after the lock is released. Also in set_bases: - remove this class from the old bases' subclass lists (pruning dead entries), so repeated assignment no longer accumulates duplicates - roll back bases, base, and all updated mros when the recursive mro update fails, instead of leaving the type half-reparented - fix the misformatted empty-tuple error message Remove the expectedFailure marker from test_unsubclassable_types, which now passes. Assisted-by: Claude * Fix rollback order, dead weakrefs, and lock-held drops in set_bases - Restore recorded mros in reverse on rollback so a class visited multiple times through diamond inheritance ends with its original mro instead of an intermediate one. - Skip dead weakrefs in the subclass list during the recursive mro update instead of panicking on upgrade. - Retire the replaced mros on the success path instead of dropping them while the type lock is held. Assisted-by: Claude * Reify number sub-slot wrapper once in update_one_slot The update_sub_slot! macro expanded the Python-method wrapper closure at both the own and inherited store sites. Each expansion is a distinct fn item, so a base and a non-overriding subclass stored wrappers with distinct addresses in unmerged debug builds. binary_op1 compares slot fn addresses to detect whether a subclass overrides the operator, so the inherited slot was misread as an override and C() // E() dispatched to __rfloordiv__ instead of __floordiv__. Bind the wrapper store in a single closure and call it from both branches so the fn item is reified once and the slot value stays identical across a base and its subclasses. Document the conservative-on-mismatch nature of the new_wrapper address guard in call_wrapped. Assisted-by: Claude * Restrict type_cache_after_fork visibility and guard type-cache reads type_cache_after_fork is only called from within the crate, so mark it pub(crate) to resolve the unreachable-pub warning. Add debug_assert_current_thread_attached at the has_name_in_mro lock-free type-cache read site, matching lookup_ref_and_version_interned, and gate the function on debug_assertions so it is not compiled unused in release builds where the call sites are elided. Assisted-by: Claude * Guard BinaryOpSubscrGetitem with the cached type version The specialized BINARY_OP_SUBSCR_GETITEM handler used the cached __getitem__ after checking only the function version, unlike the sibling specialized handlers which revalidate the type version tag first. Store the type version in the inline cache at specialization time and revalidate owner.class().tp_version_tag against it before using the cached function, deopting to the generic subscript path on mismatch. Assisted-by: Claude * Call __abstractmethods__ __len__ once in object.__new__ The abstract-method check invoked the user-visible __len__ twice: once via length_opt for the count and again while materializing the method names. Derive the count from the materialized list instead, so __len__ runs once. Assisted-by: Claude * Use i64 fast paths for specialized int add/sub/mul Rewrite execute_binary_op_int to box results through new_int via i64 checked arithmetic instead of raw BigInt ops with new_bigint. Add an int_mul helper and a shared int_fast_op that computes the i64 result and falls back to the BigInt operation on to_i64 or checked-op failure. Wire int_mul into both the specialized BinaryOpMultiplyInt handler and the generic execute_bin_op Multiply/InplaceMultiply path, gated on exact int operands so subclasses keep dispatching through _mul/_imul. Assisted-by: Claude * Use i64 fast paths for exact int floordiv and remainder Add floordiv_i64/mod_i64 computing i64 floor-division and divisor-signed remainder, guarding zero divisor and i64::MIN overflow by returning None. Wire them through int_floordiv/int_mod (shared int_div_fast_op boxes via new_int) into the generic execute_bin_op FloorDivide/Remainder and their Inplace variants, gated on exact int operands so subclasses and zero divisors fall through to the existing _floordiv/_mod/_ifloordiv/_imod slow path. Assisted-by: Claude * Skip trashcan and untrack for non-GC-tracked objects in dealloc default_dealloc read is_gc_tracked() only to decide untracking, and entered the trashcan recursion guard unconditionally. Non-GC objects (int, float, str, ...) own no child references that recurse during deallocation, so they need neither the trashcan nor untracking. Read is_gc_tracked() once and gate both trashcan begin/end and untrack on it, removing three thread-local accesses per non-GC object deallocation. Assisted-by: Claude * Merge trashcan depth and defer queue into one thread-local struct trashcan::end accessed two separate thread-locals (DEALLOC_DEPTH and DEALLOC_QUEUE) at the outermost deallocation. Combine both into a single `Trashcan` thread-local holding Cell-based depth and queue fields, so begin and end each reach their state through one thread-local access. The queue is set back before each deferred dealloc call so reentrant begin/end during draining never holds an outstanding borrow. Assisted-by: Claude * Check object layout compatibility on __bases__ and __class__ assignment Add a shared compatible_for_assignment helper that walks each type to the base that fixes its instance layout and rejects the assignment when the old and new layouts differ (basicsize, itemsize, member count, __dict__, __weakref__, and __slots__ names). Wire it into set_bases, which previously performed no layout check, and replace the inline check in __class__ assignment, which compared the two types directly and over-rejected a subclass that adds no layout. Enable test_descr.test_builtin_bases, which the new set_bases check passes. Assisted-by: Claude * Stop the world around GC pointer-reading phases The cycle collector reads each tracked object's interpreter state during reference subtraction, the reachability walk and the strong-reference snapshot, including the localsplus of frames other threads are executing. Those slots are written without synchronization, so the reads are a data race under threading. collect_inner now stops the world before taking the generation read locks and restarts it after the strong-reference snapshot, before the finalizer/weakref/tp_clear phases. A debug assertion checks that no frame on any thread's call stack was classified unreachable. Automatic collections from maybe_collect are deferred to the next bytecode safepoint via a new eval-breaker GC bit instead of running synchronously: a synchronous collection can hold an internal lock (e.g. the lazy frame locals cell) that another thread is blocked on with no way to reach a safepoint, deadlocking the stop. Hardens the stop-the-world machinery for these frequent, concurrent GC stops: attach_thread now honors a pending stop after re-attaching so a thread doing rapid allow_threads calls cannot run past the requester; stop_the_world completion is level-triggered on all-threads-suspended rather than an edge-triggered countdown; and the thread-start started/ready handshakes detach while waiting so the waiter is parkable. Updates the Frame and PyRwLock/PyMutex traversal SAFETY comments to state the actual invariant. Assisted-by: Claude * Add threaded GC vs executing-frame stress snippet Workers churn frame state (recursion, generators, frame cycles) while a collector loops gc.collect() and an introspector walks live frame objects. Exercises the stop-the-world barrier around GC traversal. Assisted-by: Claude * Serialize fork and GC stop-the-world requesters fork() (posix before/after-fork) and the cycle collector both drive the single StopTheWorldState. With no mutual exclusion their requester word and suspension countdown could interleave and be clobbered, so the completion check never converged and a requester waited on itself forever (reproducible parent-side hang when forking with GC enabled). Add one exclusion held for the whole stop->start span of either requester: stop_the_world acquires it before any stop bookkeeping and start_the_world/reset_after_fork release it. The acquire is park-friendly (poll try-lock and honor a pending suspend between tries) so a requester blocked behind an active stop can still be force-parked instead of deadlocking the active requester. The acquirer holds no other lock while spinning. Also correct the PyRwLock traverse SAFETY note: a failed try_read may mean a force-parked thread holds the write lock; skipping is safe because under-traversal only over-approximates liveness. Document the residual gc.collect()-under-a-non-generation-lock exposure at the collect_inner barrier. Assisted-by: Claude * Add fork under concurrent GC stop-the-world snippet Worker threads allocate cyclic garbage with GC enabled while the main thread forks repeatedly and each child collects. Exercises the fork/GC stop-the-world exclusion; the allocation rate is throttled so a collection stays cheap in unoptimized builds. Assisted-by: Claude * Detach while acquiring the import lock The global import lock is held across bytecode by the importlib bootstrap, so its holder can be parked at a safepoint mid-hold. Acquiring it while attached let another thread block attached on the lock, so a stop-the-world requester could wait forever for that attached thread to suspend while the holder stayed parked. Wrap IMP_LOCK acquisition in allow_threads in both _imp.acquire_lock and the pre-fork acquire_imp_lock_for_fork so the wait honors stop-the-world requests. Correct the acquire_exclusion comment: a spinning requester may hold IMP_LOCK (fork) or the collecting mutex (GC); safety relies on those never being acquired attached-blocking by another thread. Assisted-by: Claude * Add concurrent-import vs GC deadlock snippet Two threads re-import modules (contending the import lock) while a third storms the cycle collector and a fourth allocates cyclic garbage. Regressed as a hang before the import lock acquisition was made park-friendly. Assisted-by: Claude * Create call frames untracked and track generators explicitly Add PyPayload::NEW_REF_UNTRACKED (default false, true for Frame) so PyRef::new_ref skips auto-tracking ordinary call frames in the GC. Generator/coroutine/async-generator frames are tracked explicitly in invoke_with_locals before their generator back-reference is installed. run_frame debug-asserts that a datastack frame is untracked on entry. Assisted-by: Claude * Track escaped call frames lazily at datastack release Rewrite release_datastack_frame around the invariant that a datastack frame is never GC-tracked while it runs: strong_count() == 1 means the frame never escaped, so drop localsplus in place without touching the GC; strong_count() > 1 means it escaped, so materialize localsplus onto the heap and then track the frame. This removes the untrack and the untrack-recheck dance from the common non-escaping path. Assisted-by: Claude * Guard the tracked-frame localsplus invariant Document at Frame::traverse that references to a frame are always recorded as graph edges, so the collector reads a frame's localsplus only when the frame is itself a tracked candidate. Add debug assertions at both frame track sites (escaped datastack frames and generator frames) that a frame is heap-backed before it is tracked, so a collector never reads data-stack-resident, still-mutating storage. Assisted-by: Claude * Reword frame/dealloc invariant comments and drop needless borrow - frame.rs: replace the task codename in the tracked-frame localsplus invariant comment with a description of the collector-vs-executing-frame behavior. - object.rs: remove needless borrow on current_cls in the __class__ assignment compatibility check. - core.rs: rewrite the default_dealloc trashcan/untrack-skip comment to state the actual invariant, which now covers untracked non-escaped frames releasing at interpreter depth with bounded recursion. Assisted-by: Claude * Move type SetAttr slot rewrite inside the type lock and tighten cache reads Run update_slot inside the same with_type_lock transaction as modified_inner and the attributes dict mutation, so the version invalidation, dict change, and slot-table rewrite are published together. Load init_version and getitem_version with Acquire in the specialization cache readers to pair with the Release stores in the writers. Assisted-by: Claude * Capture the type version before reading mutable slots in specializers specialize_load_attr, specialize_store_attr, specialize_to_bool, and the CallAllocAndEnterInit path of specialize_call read the version tag with version_for_specialization before inspecting getattro, setattro, the bool/len slots, and tp_new/tp_alloc, so a concurrent install of the corresponding dunder invalidates the version the specialization is cached against. In the BinaryOpSubscr __getitem__ path, check the HEAPTYPE and eval-frame gates before lookup_ref_and_version_interned, which takes the global type lock and may allocate a version tag. Assisted-by: Claude * Use _get_method_dict for the test_type check in the test runner Replace the direct __func__.__dict__ access with the _get_method_dict helper so plain functions without __func__ do not raise AttributeError. Assisted-by: Claude * Fix lint hooks: cspell dictionary entries, spelling fix, formatting Add qsbr to the rustpython dictionary and reborrows/reparenting to the top-level word list. Rename oldto/newto locals to old_to/new_to. Fix Stabilise -> Stabilize typo in a frame.rs comment. Apply cargo fmt to object.rs, type.rs, and frame.rs, and ruff format/check fixes to two extra_tests snippet files. Assisted-by: Claude * Rebuild all slots for type and descendants on __bases__ reassignment Add PyType::update_all_slots, which invalidates version tags and iterates the full SLOT_DEFS name table calling update_slot:: for each distinct name. Unlike init_slots, which is additive and driven only by dunder names present in the current MRO, this resets a slot whose method left the MRO instead of leaving a stale dispatcher pointer. Call it from set_bases in place of the previous modified_inner + init_slots pair, so reassigning __bases__ rebuilds slots for the type and every descendant. Add extra_tests/snippets/type_bases_slot_rebuild.py covering removed-method resets on zelf and deep descendants, the wrong-target switch, __getattr__, the added-method mirror, and a swap-away-and-back round trip. Assisted-by: Claude * Derive the thread-local frame stack from the frame chain Remove the per-VM `frames` Vec. current_frame, sys._getframe, sys._getframemodulename, frame.f_back, sys.monitoring re-instrumentation, gc.get_referrers, faulthandler stack dumps and the post-fork slot rebuild now walk the signal-safe CURRENT_FRAME/`previous` chain instead. Add `Py::from_payload_ptr` to recover a frame object from a chain pointer. The cross-thread `ThreadSlot::frames` registry (sys._current_frames, cross-thread f_back, GC stop-the-world assertion) is unchanged. Assisted-by: Claude * Inherit sub-slot fallback into the field being resolved update_one_slot's number/sequence/mapping fallback inherited via the accessor's default field. Left and right binary ops (add/right_add) share one accessor but occupy distinct fields, so resolving an absent right op or deleting it reset the left op's field, dropping a still-defined __add__ dispatcher. Inherit the exact field under resolution instead. Assisted-by: Claude * Gate type_cache_after_fork to its fork caller and use Self in downcast type_cache_after_fork is only called from the unix fork path, so gate it with all(feature = "host_env", unix) to match the caller and avoid a dead-code error on non-unix targets. Replace the explicit PyType with Self in the modified_inner downcast. Assisted-by: Claude * Unmark test_attr and test_method_call_error in test_monitoring Both TestLoadSuperAttr tests now pass; remove their expectedFailure markers. Assisted-by: Claude * Run tp_new specialization __init__ without a trampoline frame CallAllocAndEnterInit ran __init__ inside a synthetic init-cleanup shim frame whose code carried the __init__ name. Since the thread frame stack is derived from the frame chain, that shim frame was visible to sys._getframe, f_back walks, traceback construction and inspect.stack / inspect.trace. Its code object has empty co_positions, so a stack walk that reached it raised StopIteration inside inspect._get_code_position, cascading into unrelated failures (test_inspect trace/stack/frame, asyncio source traceback). Call __init__ directly via run_frame, enforce the __init__() should return None contract inline, and drop the shim: the init-cleanup code object, its builder, with_frame_untraced, monitoring_disabled_for_code, and the extra-frame datastack/recursion budget. Removing the second frame per construction also cuts the specialization's per-call cost. Assisted-by: Claude * Replace per-call thread-frame mutex with an atomic top-of-stack (unix) On unix threading builds, publish each thread's top Python frame in a single relaxed AtomicPtr store from set_current_frame instead of pushing onto a parking_lot::Mutex> per call. Cross-thread readers (sys._current_frames, cross-thread f_back, faulthandler.dump_traceback, the GC unreachable debug-assert) run under stop-the-world and walk the published top frame down the Frame::previous chain; the owning thread is then parked at a safepoint, so the pointer and the frames it reaches are quiescent and alive. The faulthandler watchdog is a plain OS thread that cannot stop-the-world, so it walks the chain lock-free and best-effort. Non-unix threading builds have no stop-the-world and keep the existing mutex-guarded frame stack unchanged. Assisted-by: Claude * Skip exc_info save/restore for callees that never touch the slot with_frame saves and restores the shared exc_info slot around every Python call to contain frames that leave it unbalanced. Cache a has_exc_handling bit on PyCode at creation, set when the bytecode contains any opcode that calls vm.set_exception (PushExcInfo, PopExcept, CheckEgMatch, EndAsyncFor, InstrumentedEndAsyncFor). A callee whose code has none of these cannot mutate the slot, so the save and restore are skipped for it. Generators go through resume_gen_frame and are unaffected. Assisted-by: Claude * Return a write-through FrameLocalsProxy from frame.f_locals Optimized (function) frames now expose `f_locals` as a `FrameLocalsProxy` implementing PEP 667 semantics instead of a cached snapshot dict: - reads go live through the fast-local slots; each access mints a fresh proxy; keys that do not name a fast local are stored in a per-frame `f_extra_locals` side dict and folded into `locals()`. - writes to a fast-local key store into the slot (or its cell) in place; deleting a fast local raises ValueError; extra keys delete normally. - full mapping protocol: keys/values/items (lists), get/pop/setdefault, update (dict or FrameLocalsProxy only), __or__/__ior__/__ror__ (dict result), copy (plain dict), __reduce__ blocks pickling/copy, repr with recursion guard, mapping-pattern and Mapping ABC support. Class/module/exec frames keep returning their namespace mapping directly. Cross-thread access to a frame running on another thread still raises RuntimeError. A closed generator now keeps its frame locals when a durable frame reference escaped (f_locals proxy, sys._getframe, f_back), matching take_ownership; the escape is tracked with a per-frame flag. The snapshot-then-fold locals_to_fast/locals_dirty write-back is retired since proxy writes reach the slots directly. Assisted-by: Claude * Retain the caller frame so f_back resolves after it returns When a frame escapes its execution (referenced through a traceback, `sys._getframe`, `f_locals`, ...), capture a strong reference to its caller at release time. `f_back` consults it once the caller has left the live frame chain, so the Python-visible frame chain survives return. The retained reference is a GC-traversed edge and is cleared by `frame.clear()`, so ancestor chains stay collectable. Assisted-by: Claude * Fix debug-build native stack overflow on deep recursion Make check_c_stack_overflow one-sided (trip whenever the stack pointer is below the soft limit) so a single native frame larger than the margin cannot step past the danger band undetected. Raise the debug STACK_MARGIN_BYTES from 4096 to 16384 words so the margin exceeds a single debug interpreter frame, leaving headroom to raise RecursionError. Release margin unchanged. Clamp the soft-limit margin to half the stack so small explicit thread stacks do not get a soft limit above their stack top. Assisted-by: Claude * Allocate exception instance __dict__ lazily Exception construction went through into_ref_with_type, which eagerly allocated an empty instance dict for every HAS_DICT type. Add into_ref_with_type_lazy_dict, which builds the instance with an unallocated dict slot, and route the four exception construction sites (PyBaseException, PyOSError, OSErrorBuilder, PyBaseExceptionGroup) through it. The dict now materializes on first attribute write or __dict__ access via the existing get_or_insert path. add_note and PyImportError::slot_init now obtain the dict through object_get_dict so they materialize it instead of assuming it exists. A freshly constructed exception no longer reports an empty dict in gc.get_referents, matching the reference interpreter. Assisted-by: Claude * Allocate exception __dict__ lazily in vm.new_exception Route vm.new_exception() through into_ref_with_type_lazy_dict so internally raised exceptions (new_type_error, new_value_error, etc.) start without an instance dict, matching the slot_new path. The dict is materialized on the first attribute write or __dict__ access. Assisted-by: Claude * Validate FrameLocalsProxy.update() arguments Reject keyword arguments and require exactly one positional argument, raising TypeError with the "takes no keyword arguments" and "takes exactly one argument (N given)" messages. Assisted-by: Claude * Address review nits in frame and faulthandler - Drop stale vm.frames reference from the release_datastack_frame uniqueness argument. - Assert has_exc_handling when unwinding an Except-typed stack slot, documenting the invariant that guards the shared exc_info write. - Truncate the specialized __init__ return-type name to 200 chars, matching the unspecialized wrapper. - Reword two comments to describe behavior without prose references to CPython. Assisted-by: Claude * Use scopeguard for start_the_world in faulthandler dump_all_threads Wrap the unix stop-the-world registry walk in a scope with scopeguard::defer! so start_the_world runs on panic, matching the f_back and get_all_current_frames sites. Add scopeguard to the stdlib dependencies. Also correct the restore_exception doc comment to name with_frame after the rename. Assisted-by: Claude * Gitignore docs/superpowers Assisted-by: Claude * Rename type_bases_slot_rebuild.py to builtin_type_bases.py Match the builtin_* naming convention of extra_tests/snippets. Assisted-by: Claude * Apply rustfmt, ruff, and cspell lint fixes Reformat with rustfmt, fix import spacing in builtin_type_bases.py with ruff, and add "pointee" to the cspell word list. Assisted-by: Claude * Gate unix-only QSBR methods to their call sites `online`, `drain_all`, and `reset_after_fork` are called only from unix code (thread attach/detach, post-fork reset), and `offline` from unix code plus a unit test. Gate them with matching cfg so `-D dead_code` does not fire on non-unix targets. Assisted-by: Claude * Visit the function closure tuple as a GC edge PyFunction::traverse visited the cells inside the closure tuple instead of the tuple object itself, so the tuple's reference from the function was never subtracted during cycle collection. A closure tuple that reached back to its function (or, through a frame retained by f_back, to a Thread) was stranded as a false GC root and never collected, leaking the whole cycle. Visit the tuple itself, matching clear(). Assisted-by: Claude * Apply formatting hook fix to builtin_type_bases.py Assisted-by: Claude * Unmark asyncgen finalization-by-gc tests in test_base_events test_asyncgen_finalization_by_gc and test_asyncgen_finalization_by_gc_in_other_thread now pass; GC finalizes the async generators. Assisted-by: Claude * Unmark test_sni_callback_refcycle in test_ssl The servername-callback reference cycle is now collected by GC. Assisted-by: Claude * Widen GC stop-the-world gates from unix-only to all threading builds Change `cfg(all(unix, feature = "threading"))` to `cfg(feature = "threading")` on the stop-the-world machinery so it also compiles and runs on non-unix threading builds: - StopTheWorldState, its stats, stw_trace, and the stop_the_world field - ThreadSlot state/stop_requested/thread fields and their initializers - wait_while_suspended/attach_thread/detach_thread/suspend_if_needed/do_suspend, allow_threads, stop_requested_for_current_thread, and the enter_vm / VmBootstrapGuard / attach_current_thread / release_current_thread / cleanup attach-state wiring - eval_breaker_tripped, check_signals, run_scheduled_gc, signal GC_BIT / schedule_gc / take_gc_scheduled, and the frame.rs safepoint call - CollectStopTheWorld and its use in collect_inner - QSBR::online/offline, now called from attach/detach on all threading builds - debug_assert_current_thread_attached and its type-cache call sites maybe_collect defers auto-collection to the bytecode safepoint on every threading build instead of only unix; non-threading builds keep the inline collect. stw_trace writes to std stderr on non-unix. top_frame publishing (CURRENT_TOP_FRAME_SLOT, set_current_frame), the frame-walk debug assert in collect_inner, and the fork reinit helpers remain unix-only; non-unix keeps ThreadSlot::frames for introspection. Assisted-by: Claude * Detach current thread around blocking _winapi/_overlapped waits Wrap the blocking Windows wait calls in `vm.allow_threads` so the calling thread transitions ATTACHED -> DETACHED for the duration of the wait: - _winapi: WaitForSingleObject, WaitForMultipleObjects, BatchedWaitForMultipleObjects, ConnectNamedPipe, ReadFile, Overlapped.GetOverlappedResult - _overlapped: Overlapped.getresult These previously blocked while ATTACHED, so a stop-the-world requester could never suspend the thread and spun in its wait loop indefinitely. * Format WaitForMultipleObjects allow_threads closure * Treat concurrent sni_callback removal as no-op in invoke_sni_callback * Assert capi refcount on a fresh mortal list instead of the int type object The refcount test asserted exact incref/decref deltas on PyInt's shared type object. That object's reference count is perturbed by the other capi tests running in parallel, and is immortal under some interpreter configurations, so the deltas were not reliably +1/-1. Assert them on a freshly created, uniquely owned list whose reference count is private to the test and mortal. Assisted-by: Claude * Traverse and GC-track PyOSError instances The `#[pyexception]` struct macro now forwards a `traverse` option to the generated `#[pyclass]`, and `ExceptionItemMeta` accepts the `traverse` key. `PyOSError` is marked `traverse = "manual"`, so `HAS_TRAVERSE` is true and OSError-family instances are tracked at creation and traversed by the collector. `PyOSError::traverse` now visits the underlying `PyBaseException` (traceback, cause, context, args) instead of `PyException::try_traverse`, which was a no-op because `PyException` has `HAS_TRAVERSE = false`. * Unmark test_blockingioerror in test_io The BlockingIOError reference cycle is now collected by GC. Assisted-by: Claude --- .cspell.dict/rustpython.txt | 1 + .cspell.json | 3 + .gitignore | 3 +- Cargo.lock | 2 + Cargo.toml | 1 + Lib/test/test_asyncio/test_base_events.py | 2 - Lib/test/test_descr.py | 2 - Lib/test/test_frame.py | 10 - Lib/test/test_generators.py | 1 - Lib/test/test_inspect/test_inspect.py | 1 - Lib/test/test_io.py | 1 - Lib/test/test_monitoring.py | 2 - Lib/test/test_pdb.py | 2 +- Lib/test/test_ssl.py | 1 - Lib/test/test_traceback.py | 1 - crates/capi/src/refcount.rs | 10 +- crates/common/src/refcount.rs | 42 +- .../compiler-core/src/bytecode/instruction.rs | 271 ++- .../src/bytecode/opcode_metadata.rs | 379 +++-- crates/derive-impl/src/pyclass.rs | 9 +- crates/derive-impl/src/util.rs | 11 +- crates/stdlib/Cargo.toml | 1 + crates/stdlib/src/faulthandler.rs | 111 +- crates/stdlib/src/overlapped.rs | 5 +- crates/stdlib/src/ssl.rs | 14 +- crates/vm/Cargo.toml | 1 + crates/vm/src/builtins/code.rs | 19 + crates/vm/src/builtins/frame.rs | 86 +- crates/vm/src/builtins/frame_locals_proxy.rs | 326 ++++ crates/vm/src/builtins/function.rs | 147 +- crates/vm/src/builtins/int.rs | 2 +- crates/vm/src/builtins/mod.rs | 2 + crates/vm/src/builtins/object.rs | 103 +- crates/vm/src/builtins/tuple.rs | 63 +- crates/vm/src/builtins/type.rs | 951 +++++++---- crates/vm/src/coroutine.rs | 28 +- crates/vm/src/exception_group.rs | 2 +- crates/vm/src/exceptions.rs | 25 +- crates/vm/src/frame.rs | 1452 ++++++++++++----- crates/vm/src/function/argument.rs | 10 +- crates/vm/src/gc_state.rs | 227 ++- crates/vm/src/object/core.rs | 161 +- crates/vm/src/object/ext.rs | 29 + crates/vm/src/object/mod.rs | 1 + crates/vm/src/object/payload.rs | 50 +- crates/vm/src/object/qsbr.rs | 333 ++++ crates/vm/src/object/traverse.rs | 24 +- crates/vm/src/signal.rs | 61 +- crates/vm/src/stdlib/_imp.rs | 19 +- crates/vm/src/stdlib/_thread.rs | 97 +- crates/vm/src/stdlib/_winapi.rs | 34 +- crates/vm/src/stdlib/gc.rs | 14 +- crates/vm/src/stdlib/posix.rs | 14 +- crates/vm/src/stdlib/sys.rs | 28 +- crates/vm/src/stdlib/sys/monitoring.rs | 6 +- crates/vm/src/types/slot.rs | 146 +- crates/vm/src/types/slot_defs.rs | 2 +- crates/vm/src/types/zoo.rs | 11 +- crates/vm/src/vm/context.rs | 49 - crates/vm/src/vm/interpreter.rs | 21 +- crates/vm/src/vm/mod.rs | 285 +++- crates/vm/src/vm/thread.rs | 244 ++- crates/vm/src/vm/vm_new.rs | 10 +- extra_tests/custom_text_test_runner.py | 8 +- extra_tests/snippets/builtin_type_bases.py | 293 ++++ .../snippets/stdlib_threading_gc_fork.py | 62 + .../stdlib_threading_gc_frame_race.py | 101 ++ .../snippets/stdlib_threading_gc_import.py | 58 + .../snippets/stdlib_threading_type_cache.py | 69 + .../generate_rs_opcode_metadata.py | 85 +- 70 files changed, 5140 insertions(+), 1505 deletions(-) create mode 100644 crates/vm/src/builtins/frame_locals_proxy.rs create mode 100644 crates/vm/src/object/qsbr.rs create mode 100644 extra_tests/snippets/builtin_type_bases.py create mode 100644 extra_tests/snippets/stdlib_threading_gc_fork.py create mode 100644 extra_tests/snippets/stdlib_threading_gc_frame_race.py create mode 100644 extra_tests/snippets/stdlib_threading_gc_import.py create mode 100644 extra_tests/snippets/stdlib_threading_type_cache.py diff --git a/.cspell.dict/rustpython.txt b/.cspell.dict/rustpython.txt index 8cd08358019..07099bbb171 100644 --- a/.cspell.dict/rustpython.txt +++ b/.cspell.dict/rustpython.txt @@ -27,6 +27,7 @@ pystr pystruct pystructseq pytype +qsbr rustix struc zelf diff --git a/.cspell.json b/.cspell.json index f173874d4cb..ab3d229cf74 100644 --- a/.cspell.json +++ b/.cspell.json @@ -81,8 +81,11 @@ "mcache", "oparg", "opargs", + "pointee", "pyc", "reborrow", + "reborrows", + "reparenting", "reraises", "reraising", "significand", diff --git a/.gitignore b/.gitignore index b5887be53b5..09e1b97b9f8 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,5 @@ Lib/site-packages/* Lib/test/data/* !Lib/test/data/README cpython/ -.claude/scheduled_tasks.lock \ No newline at end of file +.claude/scheduled_tasks.lock +docs/superpowers/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 5eb95bea29c..73afeda8a18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3700,6 +3700,7 @@ dependencies = [ "rustpython-ruff_text_size", "rustpython-unicode", "rustpython-vm", + "scopeguard", "sha1 0.11.0", "sha2", "sha3", @@ -3754,6 +3755,7 @@ dependencies = [ "indexmap", "is-macro", "itertools 0.15.0", + "itoa", "libc", "log", "malachite-bigint", diff --git a/Cargo.toml b/Cargo.toml index d8081f50166..540324a4aaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -229,6 +229,7 @@ hmac = "0.13" indexmap = { version = "2.14.0", features = ["std"] } insta = "1.47" itertools = { version = "0.15.0", default-features = false, features = ["use_alloc"] } +itoa = "1" is-macro = "0.3.7" js-sys = "0.3" junction = "2.0.0" diff --git a/Lib/test/test_asyncio/test_base_events.py b/Lib/test/test_asyncio/test_base_events.py index 92895bbb420..1b727f3b1fe 100644 --- a/Lib/test/test_asyncio/test_base_events.py +++ b/Lib/test/test_asyncio/test_base_events.py @@ -1019,7 +1019,6 @@ async def iter_one(): asyncio.create_task(iter_one()) return status - @unittest.expectedFailure # TODO: RUSTPYTHON; - GC doesn't finalize async generators def test_asyncgen_finalization_by_gc(self): # Async generators should be finalized when garbage collected. self.loop._process_events = mock.Mock() @@ -1035,7 +1034,6 @@ def test_asyncgen_finalization_by_gc(self): test_utils.run_briefly(self.loop) self.assertTrue(status['finalized']) - @unittest.expectedFailure # TODO: RUSTPYTHON; - GC doesn't finalize async generators def test_asyncgen_finalization_by_gc_in_other_thread(self): # Python issue 34769: If garbage collector runs in another # thread, async generators will not finalize in debug diff --git a/Lib/test/test_descr.py b/Lib/test/test_descr.py index 92bf7998d75..0b19496ec4b 100644 --- a/Lib/test/test_descr.py +++ b/Lib/test/test_descr.py @@ -4154,7 +4154,6 @@ class E(D): else: self.fail("shouldn't be able to create inheritance cycles") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_builtin_bases(self): # Make sure all the builtin types can have their base queried without # segfaulting. See issue #5787. @@ -4199,7 +4198,6 @@ class D(C): else: self.fail("best_base calculation found wanting") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_unsubclassable_types(self): with self.assertRaises(TypeError): class X(type(None)): diff --git a/Lib/test/test_frame.py b/Lib/test/test_frame.py index ae02e2a59f9..53d42a595b7 100644 --- a/Lib/test/test_frame.py +++ b/Lib/test/test_frame.py @@ -315,7 +315,6 @@ def inner(): % (file_repr, offset + 5)) class TestFrameLocals(unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON def test_scope(self): class A: x = 1 @@ -333,7 +332,6 @@ def f(): self.assertEqual(locals()['y'], 2) f() - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 1 != 2 def test_closure(self): x = 1 y = 2 @@ -356,7 +354,6 @@ def test_closure_with_inline_comprehension(self): lst = [locals() for k in [0]] self.assertEqual(lst[0]['k'], 0) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 3 != 4 def test_as_dict(self): x = 1 y = 2 @@ -414,7 +411,6 @@ def test_non_string_key(self): d[1] = 2 self.assertEqual(d[1], 2) - @unittest.expectedFailure # TODO: RUSTPYTHON; UnboundLocalError: local variable 'b' referenced before assignment def test_write_with_hidden(self): def f(): f_locals = [sys._getframe().f_locals for b in [0]][0] @@ -426,7 +422,6 @@ def f(): c = 0 f() - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: != 'a.b.c' def test_local_objects(self): o = object() k = '.'.join(['a', 'b', 'c']) @@ -457,7 +452,6 @@ def test_repr(self): frame = sys._getframe() self.assertEqual(repr(frame.f_locals), repr(dict(frame.f_locals))) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: ValueError not raised def test_delete(self): x = 1 d = sys._getframe().f_locals @@ -501,7 +495,6 @@ def test_sizeof(self): proxy = sys._getframe().f_locals support.check_sizeof(self, proxy, support.calcobjsize("P")) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: TypeError not raised def test_unsupport(self): x = 1 d = sys._getframe().f_locals @@ -536,7 +529,6 @@ def __eq__(self, other): return StringSubclass('x'), ImpostorX(), 'x' - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: dict_keys(['obj', 'x']) != ['obj', 'x', 'proxy'] def test_proxy_key_stringlikes_overwrite(self): def f(obj): x = 1 @@ -559,7 +551,6 @@ def f(obj): self.assertEqual(keys_snapshot, expected_keys) self.assertEqual(proxy_snapshot, expected_dict) - @unittest.expectedFailure # TODO: RUSTPYTHON; UnboundLocalError: local variable 'b' referenced before assignment def test_proxy_key_stringlikes_ftrst_write(self): def f(obj): proxy = sys._getframe().f_locals @@ -587,7 +578,6 @@ class ObjectSubclass: with self.assertRaises(TypeError): proxy[obj] = 0 - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 'dict' != 'FrameLocalsProxy' def test_constructor(self): FrameLocalsProxy = type([sys._getframe().f_locals for x in range(1)][0]) diff --git a/Lib/test/test_generators.py b/Lib/test/test_generators.py index 8ede6e22fab..b3826f4229d 100644 --- a/Lib/test/test_generators.py +++ b/Lib/test/test_generators.py @@ -762,7 +762,6 @@ def get_frame(index): self.assertIn('a', frame_locals) self.assertEqual(frame_locals['a'], 42) - @unittest.expectedFailure # TODO: RUSTPYTHON; frame locals don't survive generator deallocation def test_frame_locals_outlive_generator(self): frame_locals1 = None diff --git a/Lib/test/test_inspect/test_inspect.py b/Lib/test/test_inspect/test_inspect.py index f7a7c0cc825..74126751835 100644 --- a/Lib/test/test_inspect/test_inspect.py +++ b/Lib/test/test_inspect/test_inspect.py @@ -588,7 +588,6 @@ def test_frame(self): self.assertEqual(inspect.formatargvalues(args, varargs, varkw, locals), '(x=11, y=14)') - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'NoneType' object has no attribute 'f_code' def test_previous_frame(self): args, varargs, varkw, locals = inspect.getargvalues(mod.fr.f_back) self.assertEqual(args, ['a', 'b', 'c', 'd', 'e', 'f']) diff --git a/Lib/test/test_io.py b/Lib/test/test_io.py index c51b547f31a..99ab6c7ba90 100644 --- a/Lib/test/test_io.py +++ b/Lib/test/test_io.py @@ -4473,7 +4473,6 @@ def test_io_after_close(self): self.assertRaises(ValueError, f.writelines, []) self.assertRaises(ValueError, next, f) - @unittest.expectedFailure # TODO: RUSTPYTHON; cyclic gc def test_blockingioerror(self): # Various BlockingIOError issues class C(str): diff --git a/Lib/test/test_monitoring.py b/Lib/test/test_monitoring.py index 1f72b552c6c..30eee65dc12 100644 --- a/Lib/test/test_monitoring.py +++ b/Lib/test/test_monitoring.py @@ -1984,7 +1984,6 @@ def f(): ] return d["f"], expected - @unittest.expectedFailure # TODO: RUSTPYTHON; line number differences in multi-line super() calls def test_method_call_error(self): nonopt_func, nonopt_expected = self._super_method_call_error(optimized=False) opt_func, opt_expected = self._super_method_call_error(optimized=True) @@ -2022,7 +2021,6 @@ def f(): ] return d["f"], expected - @unittest.expectedFailure # TODO: RUSTPYTHON; line number differences in multi-line super() calls def test_attr(self): nonopt_func, nonopt_expected = self._super_attr(optimized=False) opt_func, opt_expected = self._super_attr(optimized=True) diff --git a/Lib/test/test_pdb.py b/Lib/test/test_pdb.py index 97f084088c5..cd1e88f5475 100644 --- a/Lib/test/test_pdb.py +++ b/Lib/test/test_pdb.py @@ -3122,7 +3122,7 @@ def test_pdb_issue_gh_101673(): ... a = 1 ... import pdb; pdb.Pdb(nosigint=True, readrc=False).set_trace() - >>> with PdbTestInput([ # TODO: RUSTPYTHON # doctest: +NORMALIZE_WHITESPACE +EXPECTED_FAILURE + >>> with PdbTestInput([ # doctest: +NORMALIZE_WHITESPACE ... '!a = 2', ... 'll', ... 'p a', diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 10fb5c80b9b..4808da82f20 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -1526,7 +1526,6 @@ def sni_callback(sock, servername, ctx): pass self.assertIn(libssl_error_reason, str(cm.exception)) self.assertEqual(cm.exception.errno, ssl.SSL_ERROR_SSL) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: is not None def test_sni_callback_refcycle(self): # Reference cycles through the servername callback are detected # and cleared. diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index ec56f26a735..42f066a6239 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -3141,7 +3141,6 @@ def last_returns_frame4(self): def last_returns_frame5(self): return self.last_returns_frame4() - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 1 not greater than 5 def test_extract_stack(self): frame = self.last_returns_frame5() def extract(**kwargs): diff --git a/crates/capi/src/refcount.rs b/crates/capi/src/refcount.rs index 849b130e292..48c7132b0f1 100644 --- a/crates/capi/src/refcount.rs +++ b/crates/capi/src/refcount.rs @@ -26,14 +26,18 @@ pub unsafe extern "C" fn Py_REFCNT(op: *mut PyObject) -> isize { #[cfg(test)] mod tests { + use pyo3::ffi; use pyo3::prelude::*; - use pyo3::types::PyInt; - use pyo3::{PyTypeInfo, ffi}; + use pyo3::types::PyList; #[test] fn refcount() { Python::attach(|py| unsafe { - let obj = PyInt::type_object(py); + // A freshly created, non-empty list is uniquely owned here: its + // reference count is private to this test (so parallel tests cannot + // perturb it) and it is mortal (not interned), so incref then decref + // must move the count by exactly one and back. + let obj = PyList::new(py, [1, 2, 3]).unwrap(); let ref_count = ffi::Py_REFCNT(obj.as_ptr()); let obj_clone = obj.clone(); assert_eq!(ffi::Py_REFCNT(obj.as_ptr()), ref_count + 1); diff --git a/crates/common/src/refcount.rs b/crates/common/src/refcount.rs index c589ead40f6..4d52e1382e6 100644 --- a/crates/common/src/refcount.rs +++ b/crates/common/src/refcount.rs @@ -1,10 +1,14 @@ use crate::atomic::{Ordering, PyAtomic, Radium}; // State layout (usize): -// [1 bit: destructed] [1 bit: reserved] [1 bit: leaked] [N bits: weak_count] [M bits: strong_count] +// [1 bit: destructed] [1 bit: published] [1 bit: leaked] [N bits: weak_count] [M bits: strong_count] // 64-bit: N=30, M=31. 32-bit: N=14, M=15. const FLAG_BITS: u32 = 3; const DESTRUCTED: usize = 1 << (usize::BITS - 1); +/// Object was published to a lock-free cache; memory reclamation is +/// deferred through QSBR so concurrent try-incref readers never touch +/// freed memory. Sticky once set. +const PUBLISHED: usize = 1 << (usize::BITS - 2); const LEAKED: usize = 1 << (usize::BITS - 3); const TOTAL_COUNT_WIDTH: u32 = usize::BITS - FLAG_BITS; const WEAK_WIDTH: u32 = TOTAL_COUNT_WIDTH / 2; @@ -72,8 +76,8 @@ impl State { /// Reference count using state layout with LEAKED support. /// /// State layout (usize): -/// 64-bit: [1 bit: destructed] [1 bit: reserved] [1 bit: leaked] [30 bits: weak_count] [31 bits: strong_count] -/// 32-bit: [1 bit: destructed] [1 bit: reserved] [1 bit: leaked] [14 bits: weak_count] [15 bits: strong_count] +/// 64-bit: [1 bit: destructed] [1 bit: published] [1 bit: leaked] [30 bits: weak_count] [31 bits: strong_count] +/// 32-bit: [1 bit: destructed] [1 bit: published] [1 bit: leaked] [14 bits: weak_count] [15 bits: strong_count] pub struct RefCount { state: PyAtomic, } @@ -187,6 +191,17 @@ impl RefCount { pub fn is_leaked(&self) -> bool { State::from_raw(self.state.load(Ordering::Acquire)).leaked() } + + /// Mark the object as published to a lock-free cache (sticky). + #[inline] + pub fn mark_published(&self) { + self.state.fetch_or(PUBLISHED, Ordering::Release); + } + + #[inline] + pub fn is_published(&self) -> bool { + (self.state.load(Ordering::Acquire) & PUBLISHED) != 0 + } } // Deferred Drop Infrastructure @@ -279,3 +294,24 @@ pub fn flush_deferred_drops() { } }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn published_bit_survives_refcount_traffic() { + let rc = RefCount::new(); // strong = 1 + assert!(!rc.is_published()); + rc.mark_published(); + assert!(rc.is_published()); + rc.inc(); // strong = 2 + assert!(rc.is_published()); + assert!(!rc.dec()); // strong = 1 + assert!(rc.is_published()); + assert!(rc.safe_inc()); // strong = 2 + assert!(!rc.dec()); // strong = 1 + assert!(rc.dec()); // strong = 0 -> true + assert!(rc.is_published()); + } +} diff --git a/crates/compiler-core/src/bytecode/instruction.rs b/crates/compiler-core/src/bytecode/instruction.rs index 76871b2c97e..63e29222570 100644 --- a/crates/compiler-core/src/bytecode/instruction.rs +++ b/crates/compiler-core/src/bytecode/instruction.rs @@ -18,23 +18,29 @@ macro_rules! define_opcodes { } ) => { #[derive(Clone, Copy, Debug, Eq, PartialEq)] + #[repr($typ)] $opcode_vis enum $opcode_name { - $($op_name),* + $($op_name = $op_id),* } impl $opcode_name { #[doc = concat!("Converts this opcode to [`", stringify!($instr_name), "`].")] #[must_use] + #[inline] $opcode_vis const fn as_instruction(&self) -> $instr_name { - match self { - $( - Self::$op_name => $instr_name::$op_name $({ $arg_name: Arg::marker() })?, - )* - } + // SAFETY: `$opcode_name` and `$instr_name` are both `#[repr($typ)]` + // enums sharing identical explicit discriminants, and every + // `$instr_name` payload field is the zero-sized `Arg` marker + // (see the `size_of` assertion near its definition), so both + // enums have the same one-`$typ`-wide representation: just the + // discriminant. Converting a live `$opcode_name` value therefore + // yields the `$instr_name` variant with the matching discriminant. + unsafe { core::mem::transmute(*self) } } /// Map a specialized or instrumented opcode back to its adaptive (base) variant. #[must_use] + #[inline] $opcode_vis const fn deoptimize(self) -> Self { match self.deopt() { Some(v) => v, @@ -49,6 +55,9 @@ macro_rules! define_opcodes { } // NOTE: Keep private. Will be exposed under `try_from_u8/try_from_u16`. + // Kept as a match rather than a range check + transmute: `$op_id` + // values are not contiguous (specialized/instrumented opcodes leave + // gaps), so validity can't be expressed as a simple bound. pub(super) const fn try_from_numeric(value: $typ) -> Result { match value { $($op_id => Ok(Self::$op_name),)* @@ -58,10 +67,11 @@ macro_rules! define_opcodes { // NOTE: Keep private. Will be exposed under `as_u8/as_u16`. #[must_use] + #[inline] pub(super) const fn as_numeric(self) -> $typ { - match self { - $(Self::$op_name => $op_id,)* - } + // `$opcode_name` is `#[repr($typ)]` with an explicit `$op_id` + // discriminant on every variant, so this is a plain identity cast. + self as $typ } } @@ -95,15 +105,24 @@ macro_rules! define_opcodes { ),* } + // Every `$instr_name` payload field is the zero-sized `Arg` marker, so + // (combined with the `#[repr($typ)]` above) each variant's representation + // is exactly its `$typ` discriminant with no padding. `as_opcode` and + // `$opcode_name::as_instruction` rely on this to convert via + // `mem::transmute` instead of a per-variant match. + const _: () = assert!(core::mem::size_of::<$instr_name>() == core::mem::size_of::<$typ>()); + impl $instr_name { #[doc = concat!("Get the corresponding [`", stringify!($opcode_name), "`].")] #[must_use] + #[inline] $instr_vis const fn as_opcode(&self) -> $opcode_name { - match self { - $( - Self::$op_name $({ $arg_name: _ })? => $opcode_name::$op_name, - )* - } + // SAFETY: symmetric to `$opcode_name::as_instruction` above: + // `*self`'s representation is exactly its `$typ` discriminant + // (checked by the `size_of` assertion above this impl), and that + // discriminant is always a valid `$opcode_name` discriminant + // because both enums share the same explicit `$op_id` list. + unsafe { core::mem::transmute(*self) } } #[must_use] @@ -1450,4 +1469,228 @@ mod tests { assert!(AnyInstruction::from(PseudoOpcode::Jump).is_no_fallthrough()); } + + /// Snapshot of the chained `match` implementations that `Opcode::deopt` + /// and `Opcode::cache_entries` used before they were rewritten as table + /// lookups. Exists only to pin the observable behavior of the table + /// lookups against the logic they replaced. + mod reference { + use super::Opcode; + + pub(super) const fn deopt(op: Opcode) -> Option { + Some(match op { + Opcode::ResumeCheck => Opcode::Resume, + Opcode::LoadConstMortal | Opcode::LoadConstImmortal => Opcode::LoadConst, + Opcode::ToBoolAlwaysTrue + | Opcode::ToBoolBool + | Opcode::ToBoolInt + | Opcode::ToBoolList + | Opcode::ToBoolNone + | Opcode::ToBoolStr => Opcode::ToBool, + Opcode::BinaryOpMultiplyInt + | Opcode::BinaryOpAddInt + | Opcode::BinaryOpSubtractInt + | Opcode::BinaryOpMultiplyFloat + | Opcode::BinaryOpAddFloat + | Opcode::BinaryOpSubtractFloat + | Opcode::BinaryOpAddUnicode + | Opcode::BinaryOpSubscrListInt + | Opcode::BinaryOpSubscrListSlice + | Opcode::BinaryOpSubscrTupleInt + | Opcode::BinaryOpSubscrStrInt + | Opcode::BinaryOpSubscrDict + | Opcode::BinaryOpSubscrGetitem + | Opcode::BinaryOpExtend + | Opcode::BinaryOpInplaceAddUnicode => Opcode::BinaryOp, + Opcode::StoreSubscrDict | Opcode::StoreSubscrListInt => Opcode::StoreSubscr, + Opcode::SendGen => Opcode::Send, + Opcode::UnpackSequenceTwoTuple + | Opcode::UnpackSequenceTuple + | Opcode::UnpackSequenceList => Opcode::UnpackSequence, + Opcode::StoreAttrInstanceValue + | Opcode::StoreAttrSlot + | Opcode::StoreAttrWithHint => Opcode::StoreAttr, + Opcode::LoadGlobalModule | Opcode::LoadGlobalBuiltin => Opcode::LoadGlobal, + Opcode::LoadSuperAttrAttr | Opcode::LoadSuperAttrMethod => Opcode::LoadSuperAttr, + Opcode::LoadAttrInstanceValue + | Opcode::LoadAttrModule + | Opcode::LoadAttrWithHint + | Opcode::LoadAttrSlot + | Opcode::LoadAttrClass + | Opcode::LoadAttrClassWithMetaclassCheck + | Opcode::LoadAttrProperty + | Opcode::LoadAttrGetattributeOverridden + | Opcode::LoadAttrMethodWithValues + | Opcode::LoadAttrMethodNoDict + | Opcode::LoadAttrMethodLazyDict + | Opcode::LoadAttrNondescriptorWithValues + | Opcode::LoadAttrNondescriptorNoDict => Opcode::LoadAttr, + Opcode::CompareOpFloat | Opcode::CompareOpInt | Opcode::CompareOpStr => { + Opcode::CompareOp + } + Opcode::ContainsOpSet | Opcode::ContainsOpDict => Opcode::ContainsOp, + Opcode::JumpBackwardNoJit | Opcode::JumpBackwardJit => Opcode::JumpBackward, + Opcode::ForIterList + | Opcode::ForIterTuple + | Opcode::ForIterRange + | Opcode::ForIterGen => Opcode::ForIter, + Opcode::CallBoundMethodExactArgs + | Opcode::CallPyExactArgs + | Opcode::CallType1 + | Opcode::CallStr1 + | Opcode::CallTuple1 + | Opcode::CallBuiltinClass + | Opcode::CallBuiltinO + | Opcode::CallBuiltinFast + | Opcode::CallBuiltinFastWithKeywords + | Opcode::CallLen + | Opcode::CallIsinstance + | Opcode::CallListAppend + | Opcode::CallMethodDescriptorO + | Opcode::CallMethodDescriptorFastWithKeywords + | Opcode::CallMethodDescriptorNoargs + | Opcode::CallMethodDescriptorFast + | Opcode::CallAllocAndEnterInit + | Opcode::CallPyGeneral + | Opcode::CallBoundMethodGeneral + | Opcode::CallNonPyGeneral => Opcode::Call, + Opcode::CallKwBoundMethod | Opcode::CallKwPy | Opcode::CallKwNonPy => { + Opcode::CallKw + } + _ => return None, + }) + } + + pub(super) const fn deoptimize(op: Opcode) -> Opcode { + match deopt(op) { + Some(v) => v, + None => match op.to_base() { + Some(v) => v, + None => op, + }, + } + } + + pub(super) const fn cache_entries(op: Opcode) -> usize { + match deoptimize(op) { + Opcode::StoreSubscr => 1, + Opcode::ToBool => 3, + Opcode::BinaryOp => 5, + Opcode::Call => 3, + Opcode::CallKw => 3, + Opcode::CompareOp => 1, + Opcode::ContainsOp => 1, + Opcode::ForIter => 1, + Opcode::JumpBackward => 1, + Opcode::LoadAttr => 9, + Opcode::LoadGlobal => 4, + Opcode::LoadSuperAttr => 1, + Opcode::PopJumpIfFalse => 1, + Opcode::PopJumpIfNone => 1, + Opcode::PopJumpIfNotNone => 1, + Opcode::PopJumpIfTrue => 1, + Opcode::Send => 1, + Opcode::StoreAttr => 4, + Opcode::UnpackSequence => 1, + _ => 0, + } + } + } + + #[test] + fn cache_entries_and_deopt_tables_match_reference_impl() { + let mut checked = 0; + for byte in 0u8..=255 { + let Ok(op) = Opcode::try_from_u8(byte) else { + continue; + }; + + assert_eq!( + op.deopt(), + reference::deopt(op), + "deopt() mismatch for {op:?}" + ); + assert_eq!( + op.cache_entries(), + reference::cache_entries(op), + "cache_entries() mismatch for {op:?}" + ); + checked += 1; + } + + // Sanity check that the loop actually exercised opcodes rather than + // silently skipping all of them. + assert!(checked > 200); + } + + /// `Opcode::as_numeric`, `Opcode::as_instruction` and + /// `Instruction::as_opcode` used to be per-variant matches; they are now + /// an identity cast and two `mem::transmute`s respectively. `byte` (an + /// input independent of any of those three functions) together with the + /// untouched `try_from_u8`/`TryFrom` conversions serve as the + /// reference: every opcode reachable from a byte must convert back to + /// that exact byte and round-trip through `Instruction`. + #[test] + fn opcode_instruction_numeric_conversions_match_try_from_numeric() { + let mut checked = 0; + for byte in 0u8..=255 { + let Ok(op) = Opcode::try_from_u8(byte) else { + continue; + }; + + assert_eq!(op.as_numeric(), byte, "as_numeric() mismatch for {op:?}"); + + let instr = op.as_instruction(); + assert_eq!( + instr.as_opcode(), + op, + "as_instruction()/as_opcode() round trip mismatch for {op:?}" + ); + + let instr_via_try_from = Instruction::try_from(byte).unwrap(); + assert_eq!( + instr_via_try_from.as_opcode(), + op, + "Instruction::try_from({byte}) mismatch" + ); + + checked += 1; + } + + assert!(checked > 200); + } + + /// Same as [`opcode_instruction_numeric_conversions_match_try_from_numeric`] + /// but for the `u16`-discriminant pseudo-opcode instantiation of + /// `define_opcodes!`. + #[test] + fn pseudo_opcode_instruction_numeric_conversions_match_try_from_numeric() { + let mut checked = 0; + for value in 0u16..=u16::MAX { + let Ok(op) = PseudoOpcode::try_from_u16(value) else { + continue; + }; + + assert_eq!(op.as_numeric(), value, "as_numeric() mismatch for {op:?}"); + + let instr = op.as_instruction(); + assert_eq!( + instr.as_opcode(), + op, + "as_instruction()/as_opcode() round trip mismatch for {op:?}" + ); + + let instr_via_try_from = PseudoInstruction::try_from(value).unwrap(); + assert_eq!( + instr_via_try_from.as_opcode(), + op, + "PseudoInstruction::try_from({value}) mismatch" + ); + + checked += 1; + } + + // All 11 `PseudoInstruction` variants should have been exercised. + assert_eq!(checked, 11); + } } diff --git a/crates/compiler-core/src/bytecode/opcode_metadata.rs b/crates/compiler-core/src/bytecode/opcode_metadata.rs index 64c8d3c5330..16db49bea0d 100644 --- a/crates/compiler-core/src/bytecode/opcode_metadata.rs +++ b/crates/compiler-core/src/bytecode/opcode_metadata.rs @@ -6,114 +6,292 @@ use crate::{bytecode::instruction::StackEffect, marshal::MarshalError}; impl super::Opcode { /// Returns [`Self`] as [`u8`]. #[must_use] + #[inline] pub const fn as_u8(self) -> u8 { self.as_numeric() } #[must_use] + #[inline] pub const fn cache_entries(self) -> usize { - match self.deoptimize() { - Self::StoreSubscr => 1, - Self::ToBool => 3, - Self::BinaryOp => 5, - Self::Call => 3, - Self::CallKw => 3, - Self::CompareOp => 1, - Self::ContainsOp => 1, - Self::ForIter => 1, - Self::JumpBackward => 1, - Self::LoadAttr => 9, - Self::LoadGlobal => 4, - Self::LoadSuperAttr => 1, - Self::PopJumpIfFalse => 1, - Self::PopJumpIfNone => 1, - Self::PopJumpIfNotNone => 1, - Self::PopJumpIfTrue => 1, - Self::Send => 1, - Self::StoreAttr => 4, - Self::UnpackSequence => 1, - _ => 0, - } + const CACHE_ENTRIES: [u8; 256] = [ + 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 3, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 3, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 9, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 4, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 4, 0, 0, 0, 0, 0, + 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 0, 0, 4, 4, 1, 1, 0, 1, 4, 4, 4, 1, 1, + 3, 3, 3, 3, 3, 3, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 3, 3, 0, 1, 0, 0, + ]; + + CACHE_ENTRIES[self.as_numeric() as usize] as usize } #[must_use] + #[inline] pub const fn deopt(self) -> Option { - Some(match self { - Self::ResumeCheck => Self::Resume, - Self::LoadConstMortal | Self::LoadConstImmortal => Self::LoadConst, - Self::ToBoolAlwaysTrue - | Self::ToBoolBool - | Self::ToBoolInt - | Self::ToBoolList - | Self::ToBoolNone - | Self::ToBoolStr => Self::ToBool, - Self::BinaryOpMultiplyInt - | Self::BinaryOpAddInt - | Self::BinaryOpSubtractInt - | Self::BinaryOpMultiplyFloat - | Self::BinaryOpAddFloat - | Self::BinaryOpSubtractFloat - | Self::BinaryOpAddUnicode - | Self::BinaryOpSubscrListInt - | Self::BinaryOpSubscrListSlice - | Self::BinaryOpSubscrTupleInt - | Self::BinaryOpSubscrStrInt - | Self::BinaryOpSubscrDict - | Self::BinaryOpSubscrGetitem - | Self::BinaryOpExtend - | Self::BinaryOpInplaceAddUnicode => Self::BinaryOp, - Self::StoreSubscrDict | Self::StoreSubscrListInt => Self::StoreSubscr, - Self::SendGen => Self::Send, - Self::UnpackSequenceTwoTuple | Self::UnpackSequenceTuple | Self::UnpackSequenceList => { - Self::UnpackSequence - } - Self::StoreAttrInstanceValue | Self::StoreAttrSlot | Self::StoreAttrWithHint => { - Self::StoreAttr - } - Self::LoadGlobalModule | Self::LoadGlobalBuiltin => Self::LoadGlobal, - Self::LoadSuperAttrAttr | Self::LoadSuperAttrMethod => Self::LoadSuperAttr, - Self::LoadAttrInstanceValue - | Self::LoadAttrModule - | Self::LoadAttrWithHint - | Self::LoadAttrSlot - | Self::LoadAttrClass - | Self::LoadAttrClassWithMetaclassCheck - | Self::LoadAttrProperty - | Self::LoadAttrGetattributeOverridden - | Self::LoadAttrMethodWithValues - | Self::LoadAttrMethodNoDict - | Self::LoadAttrMethodLazyDict - | Self::LoadAttrNondescriptorWithValues - | Self::LoadAttrNondescriptorNoDict => Self::LoadAttr, - Self::CompareOpFloat | Self::CompareOpInt | Self::CompareOpStr => Self::CompareOp, - Self::ContainsOpSet | Self::ContainsOpDict => Self::ContainsOp, - Self::JumpBackwardNoJit | Self::JumpBackwardJit => Self::JumpBackward, - Self::ForIterList | Self::ForIterTuple | Self::ForIterRange | Self::ForIterGen => { - Self::ForIter - } - Self::CallBoundMethodExactArgs - | Self::CallPyExactArgs - | Self::CallType1 - | Self::CallStr1 - | Self::CallTuple1 - | Self::CallBuiltinClass - | Self::CallBuiltinO - | Self::CallBuiltinFast - | Self::CallBuiltinFastWithKeywords - | Self::CallLen - | Self::CallIsinstance - | Self::CallListAppend - | Self::CallMethodDescriptorO - | Self::CallMethodDescriptorFastWithKeywords - | Self::CallMethodDescriptorNoargs - | Self::CallMethodDescriptorFast - | Self::CallAllocAndEnterInit - | Self::CallPyGeneral - | Self::CallBoundMethodGeneral - | Self::CallNonPyGeneral => Self::Call, - Self::CallKwBoundMethod | Self::CallKwPy | Self::CallKwNonPy => Self::CallKw, - _ => return None, - }) + const DEOPT: [Option; 256] = [ + None, + None, + None, + Some(super::Opcode::BinaryOp), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + Some(super::Opcode::BinaryOp), + Some(super::Opcode::BinaryOp), + Some(super::Opcode::BinaryOp), + Some(super::Opcode::BinaryOp), + Some(super::Opcode::BinaryOp), + Some(super::Opcode::BinaryOp), + Some(super::Opcode::BinaryOp), + Some(super::Opcode::BinaryOp), + Some(super::Opcode::BinaryOp), + Some(super::Opcode::BinaryOp), + Some(super::Opcode::BinaryOp), + Some(super::Opcode::BinaryOp), + Some(super::Opcode::BinaryOp), + Some(super::Opcode::BinaryOp), + Some(super::Opcode::Call), + Some(super::Opcode::Call), + Some(super::Opcode::Call), + Some(super::Opcode::Call), + Some(super::Opcode::Call), + Some(super::Opcode::Call), + Some(super::Opcode::Call), + Some(super::Opcode::Call), + Some(super::Opcode::CallKw), + Some(super::Opcode::CallKw), + Some(super::Opcode::CallKw), + Some(super::Opcode::Call), + Some(super::Opcode::Call), + Some(super::Opcode::Call), + Some(super::Opcode::Call), + Some(super::Opcode::Call), + Some(super::Opcode::Call), + Some(super::Opcode::Call), + Some(super::Opcode::Call), + Some(super::Opcode::Call), + Some(super::Opcode::Call), + Some(super::Opcode::Call), + Some(super::Opcode::Call), + Some(super::Opcode::CompareOp), + Some(super::Opcode::CompareOp), + Some(super::Opcode::CompareOp), + Some(super::Opcode::ContainsOp), + Some(super::Opcode::ContainsOp), + Some(super::Opcode::ForIter), + Some(super::Opcode::ForIter), + Some(super::Opcode::ForIter), + Some(super::Opcode::ForIter), + Some(super::Opcode::JumpBackward), + Some(super::Opcode::JumpBackward), + Some(super::Opcode::LoadAttr), + Some(super::Opcode::LoadAttr), + Some(super::Opcode::LoadAttr), + Some(super::Opcode::LoadAttr), + Some(super::Opcode::LoadAttr), + Some(super::Opcode::LoadAttr), + Some(super::Opcode::LoadAttr), + Some(super::Opcode::LoadAttr), + Some(super::Opcode::LoadAttr), + Some(super::Opcode::LoadAttr), + Some(super::Opcode::LoadAttr), + Some(super::Opcode::LoadAttr), + Some(super::Opcode::LoadAttr), + Some(super::Opcode::LoadConst), + Some(super::Opcode::LoadConst), + Some(super::Opcode::LoadGlobal), + Some(super::Opcode::LoadGlobal), + Some(super::Opcode::LoadSuperAttr), + Some(super::Opcode::LoadSuperAttr), + Some(super::Opcode::Resume), + Some(super::Opcode::Send), + Some(super::Opcode::StoreAttr), + Some(super::Opcode::StoreAttr), + Some(super::Opcode::StoreAttr), + Some(super::Opcode::StoreSubscr), + Some(super::Opcode::StoreSubscr), + Some(super::Opcode::ToBool), + Some(super::Opcode::ToBool), + Some(super::Opcode::ToBool), + Some(super::Opcode::ToBool), + Some(super::Opcode::ToBool), + Some(super::Opcode::ToBool), + Some(super::Opcode::UnpackSequence), + Some(super::Opcode::UnpackSequence), + Some(super::Opcode::UnpackSequence), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ]; + + DEOPT[self.as_numeric() as usize] } /// Does this opcode have 'HAS_ARG_FLAG' set. @@ -664,6 +842,7 @@ impl super::Opcode { } #[must_use] + #[inline] pub const fn to_base(self) -> Option { Some(match self { Self::InstrumentedCall => Self::Call, @@ -723,16 +902,19 @@ impl super::Opcode { impl super::PseudoOpcode { /// Returns [`Self`] as [`u16`]. #[must_use] + #[inline] pub const fn as_u16(self) -> u16 { self.as_numeric() } #[must_use] + #[inline] pub const fn cache_entries(self) -> usize { 0 } #[must_use] + #[inline] pub const fn deopt(self) -> Option { None } @@ -818,6 +1000,7 @@ impl super::PseudoOpcode { } #[must_use] + #[inline] pub const fn to_base(self) -> Option { None } diff --git a/crates/derive-impl/src/pyclass.rs b/crates/derive-impl/src/pyclass.rs index aed2c5d8c5f..809d3164b4a 100644 --- a/crates/derive-impl/src/pyclass.rs +++ b/crates/derive-impl/src/pyclass.rs @@ -796,8 +796,15 @@ pub(crate) fn impl_pyexception(attr: PunctuatedNestedMeta, item: &Item) -> Resul quote! {} }; + // Forward a `traverse` option to the generated `#[pyclass]` so exception + // payloads with a manual `Traverse` impl are GC-tracked and traversed. + let traverse_attr = match class_meta.inner()._optional_str("traverse").ok().flatten() { + Some(value) => quote! { , traverse = #value }, + None => quote! {}, + }; + let ret = quote! { - #[pyclass(module = false, name = #class_name, base = #base_class_name)] + #[pyclass(module = false, name = #class_name, base = #base_class_name #traverse_attr)] #item #impl_pyclass }; diff --git a/crates/derive-impl/src/util.rs b/crates/derive-impl/src/util.rs index 60b2296cea7..a0708444691 100644 --- a/crates/derive-impl/src/util.rs +++ b/crates/derive-impl/src/util.rs @@ -465,8 +465,15 @@ impl ClassItemMeta { pub(crate) struct ExceptionItemMeta(ClassItemMeta); impl ItemMeta for ExceptionItemMeta { - const ALLOWED_NAMES: &'static [&'static str] = - &["module", "name", "base", "unhashable", "ctx", "impl"]; + const ALLOWED_NAMES: &'static [&'static str] = &[ + "module", + "name", + "base", + "unhashable", + "ctx", + "impl", + "traverse", + ]; fn from_inner(inner: ItemMetaInner) -> Self { Self(ClassItemMeta(inner)) diff --git a/crates/stdlib/Cargo.toml b/crates/stdlib/Cargo.toml index 1e071869549..d32e7ce8b35 100644 --- a/crates/stdlib/Cargo.toml +++ b/crates/stdlib/Cargo.toml @@ -53,6 +53,7 @@ num_enum = { workspace = true } parking_lot = { workspace = true } phf = { workspace = true, default-features = true, features = ["macros"] } rapidhash = { workspace = true } +scopeguard = { workspace = true } memchr = { workspace = true } base64 = { workspace = true } diff --git a/crates/stdlib/src/faulthandler.rs b/crates/stdlib/src/faulthandler.rs index 3717c18b78f..900d66b76e6 100644 --- a/crates/stdlib/src/faulthandler.rs +++ b/crates/stdlib/src/faulthandler.rs @@ -241,7 +241,7 @@ mod decl { /// Dump traceback for a thread given its frame stack (for cross-thread dumping). /// # Safety /// Each `FramePtr` must point to a live frame (caller holds the Mutex). - #[cfg(all(any(unix, windows), feature = "threading"))] + #[cfg(all(windows, feature = "threading"))] fn dump_traceback_thread_frames( fd: i32, thread_id: u64, @@ -260,6 +260,35 @@ mod decl { } } + /// Dump a thread's traceback by walking its published top frame down the + /// `previous` chain (most recent first). Signal-safe: only atomic pointer + /// loads, no locks. Callers guarantee frame liveness — under stop-the-world + /// for `faulthandler.dump_traceback`, or best-effort for the watchdog (like + /// `_Py_DumpTracebackThreads`, which walks lock-free while other threads + /// may still run). + #[cfg(all(unix, feature = "threading"))] + fn dump_traceback_thread_chain(fd: i32, thread_id: u64, is_current: bool, top: *const Frame) { + const MAX_FRAME_DEPTH: usize = 100; + write_thread_id(fd, thread_id, is_current); + + if top.is_null() { + puts(fd, " \n"); + return; + } + let mut frame_ptr = top; + let mut depth = 0; + while !frame_ptr.is_null() && depth < MAX_FRAME_DEPTH { + // SAFETY: the frame is alive per the caller's liveness guarantee. + let frame = unsafe { &*frame_ptr }; + dump_frame_from_raw(fd, frame); + frame_ptr = frame.previous_frame(); + depth += 1; + } + if depth >= MAX_FRAME_DEPTH && !frame_ptr.is_null() { + puts(fd, " ...\n"); + } + } + #[derive(FromArgs)] struct DumpTracebackArgs { #[pyarg(any, default)] @@ -278,11 +307,9 @@ mod decl { dump_all_threads(fd, vm); } else { puts(fd, "Stack (most recent call first):\n"); - let frames = vm.frames.borrow(); - for fp in frames.iter().rev() { - // SAFETY: the frame is alive while it's in the Vec - dump_frame_from_ref(fd, unsafe { fp.as_ref() }); - } + crate::vm::frame::for_each_current_frame(|frame| { + dump_frame_from_ref(fd, frame); + }); } } @@ -298,7 +325,43 @@ mod decl { #[cfg(any(unix, windows))] fn dump_all_threads(fd: i32, vm: &VirtualMachine) { // Get all threads' frame stacks from the shared registry - #[cfg(feature = "threading")] + // unix: stop-the-world so every other thread is parked at a safepoint + // and its frame chain is quiescent and alive while we walk it (matches + // faulthandler.dump_traceback running with the GIL held). + #[cfg(all(unix, feature = "threading"))] + { + use core::sync::atomic::Ordering; + let current_tid = rustpython_vm::stdlib::_thread::get_ident(); + { + vm.state.stop_the_world.stop_the_world(vm); + scopeguard::defer! { vm.state.stop_the_world.start_the_world(vm); } + let registry = vm.state.thread_frames.lock(); + #[expect( + clippy::iter_over_hash_type, + reason = "Iteration order doesn't matter here" + )] + for (&tid, slot) in registry.iter() { + if tid == current_tid { + continue; + } + let top = slot.top_frame.load(Ordering::Relaxed) as *const Frame; + dump_traceback_thread_chain(fd, tid, false, top); + puts(fd, "\n"); + } + } + + // Now dump current thread from its live frame chain. + write_thread_id(fd, current_tid, true); + if crate::vm::vm::thread::get_current_frame().is_null() { + puts(fd, " \n"); + } else { + crate::vm::frame::for_each_current_frame(|frame| { + dump_frame_from_ref(fd, frame); + }); + } + } + + #[cfg(all(not(unix), feature = "threading"))] { let current_tid = rustpython_vm::stdlib::_thread::get_ident(); let registry = vm.state.thread_frames.lock(); @@ -318,25 +381,24 @@ mod decl { puts(fd, "\n"); } - // Now dump current thread (use vm.frames for most up-to-date data) + // Now dump current thread from its live frame chain. write_thread_id(fd, current_tid, true); - let frames = vm.frames.borrow(); - if frames.is_empty() { + if crate::vm::vm::thread::get_current_frame().is_null() { puts(fd, " \n"); } else { - for fp in frames.iter().rev() { - dump_frame_from_ref(fd, unsafe { fp.as_ref() }); - } + crate::vm::frame::for_each_current_frame(|frame| { + dump_frame_from_ref(fd, frame); + }); } } #[cfg(not(feature = "threading"))] { + let _ = vm; write_thread_id(fd, current_thread_id(), true); - let frames = vm.frames.borrow(); - for fp in frames.iter().rev() { - dump_frame_from_ref(fd, unsafe { fp.as_ref() }); - } + crate::vm::frame::for_each_current_frame(|frame| { + dump_frame_from_ref(fd, frame); + }); } } @@ -657,7 +719,20 @@ mod decl { // Use thread frame slots when threading is enabled (includes all threads). // Fall back to live frame walking for non-threaded builds. cfg_select! { - feature = "threading" => { + all(unix, feature = "threading") => { + // The watchdog is a plain OS thread, not attached to + // the VM, so it cannot stop-the-world. Walk each + // published top frame lock-free and best-effort, like + // the faulthandler watchdog thread. + for (tid, slot) in &thread_frame_slots { + let top = slot + .top_frame + .load(core::sync::atomic::Ordering::Relaxed) + as *const Frame; + dump_traceback_thread_chain(fd, *tid, false, top); + } + } + all(not(unix), feature = "threading") => { for (tid, slot) in &thread_frame_slots { let frames = slot.frames.lock(); dump_traceback_thread_frames(fd, *tid, false, &frames); diff --git a/crates/stdlib/src/overlapped.rs b/crates/stdlib/src/overlapped.rs index 4ce3d3ba830..8610fadb3bf 100644 --- a/crates/stdlib/src/overlapped.rs +++ b/crates/stdlib/src/overlapped.rs @@ -309,8 +309,9 @@ mod _overlapped { return Err(vm.new_value_error("operation failed to start")); } - let result = - host_overlapped::get_overlapped_result(inner.handle, &inner.overlapped, wait); + let result = vm.allow_threads(|| { + host_overlapped::get_overlapped_result(inner.handle, &inner.overlapped, wait) + }); let transferred = result.transferred; let err = result.error; inner.error = err; diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index edcdf18fa09..81d69b8c64e 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -2709,13 +2709,13 @@ mod _ssl { sni_name: Option<&str>, vm: &VirtualMachine, ) -> PyResult<()> { - let callback = self - .context - .read() - .sni_callback - .read() - .clone() - .ok_or_else(|| vm.new_value_error("SNI callback not set"))?; + // The callback may have been cleared (sni_callback = None) between the + // handshake deciding to invoke it and this point. A concurrent removal + // is not an error: there is simply nothing to run. + let callback = self.context.read().sni_callback.read().clone(); + let Some(callback) = callback else { + return Ok(()); + }; let ssl_sock = self.owner.read().clone().unwrap_or_else(|| vm.ctx.none()); let server_name_py: PyObjectRef = match sni_name { diff --git a/crates/vm/Cargo.toml b/crates/vm/Cargo.toml index 0b2feed50a3..24006c8b3b9 100644 --- a/crates/vm/Cargo.toml +++ b/crates/vm/Cargo.toml @@ -54,6 +54,7 @@ flame = { workspace = true, optional = true } hex = { workspace = true } indexmap = { workspace = true } itertools = { workspace = true } +itoa = { workspace = true } is-macro = { workspace = true } libc = { workspace = true } log = { workspace = true } diff --git a/crates/vm/src/builtins/code.rs b/crates/vm/src/builtins/code.rs index e30813bf87d..b1132a55a20 100644 --- a/crates/vm/src/builtins/code.rs +++ b/crates/vm/src/builtins/code.rs @@ -471,6 +471,11 @@ pub struct PyCode { pub monitoring_data: PyMutex>, /// Whether adaptive counters have been initialized (lazy quickening). pub quickened: core::sync::atomic::AtomicBool, + /// Whether the bytecode contains any instruction that mutates the current + /// exc_info slot (`vm.set_exception`). When false, a normal frame call for + /// this code cannot leave the slot unbalanced, so `with_frame` skips the + /// exc_info save/restore. Computed once by scanning the instruction stream. + pub has_exc_handling: bool, } impl Deref for PyCode { @@ -483,12 +488,26 @@ impl Deref for PyCode { impl PyCode { pub fn new(code: CodeObject) -> Self { let sp = code.source_path as *const PyStrInterned as *mut PyStrInterned; + // The only opcodes that call `vm.set_exception` (mutating the shared + // exc_info slot); instrumented variants only replace these base opcodes + // in place, so scanning the freshly-built stream is a sound predicate. + let has_exc_handling = code.instructions.iter().any(|u| { + matches!( + u.op, + Instruction::PushExcInfo + | Instruction::PopExcept + | Instruction::CheckEgMatch + | Instruction::EndAsyncFor + | Instruction::InstrumentedEndAsyncFor + ) + }); Self { code, source_path: AtomicPtr::new(sp), instrumentation_version: AtomicU64::new(0), monitoring_data: PyMutex::new(None), quickened: core::sync::atomic::AtomicBool::new(false), + has_exc_handling, } } diff --git a/crates/vm/src/builtins/frame.rs b/crates/vm/src/builtins/frame.rs index ab45f68673c..710d23b7c2f 100644 --- a/crates/vm/src/builtins/frame.rs +++ b/crates/vm/src/builtins/frame.rs @@ -4,7 +4,7 @@ use super::{PyCode, PyDictRef, PyIntRef, PyStrRef}; use crate::{ - Context, Py, PyObjectRef, PyRef, PyResult, VirtualMachine, + Context, Py, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, class::PyClassImpl, frame::{Frame, FrameOwner, FrameRef}, function::PySetterValue, @@ -454,14 +454,6 @@ impl Frame { self.builtins.clone() } - #[pygetset] - fn f_locals(&self, vm: &VirtualMachine) -> PyResult { - let result = self.f_locals_mapping(vm).map(Into::into); - self.locals_dirty - .store(true, core::sync::atomic::Ordering::Release); - result - } - #[pygetset] pub fn f_code(&self) -> PyRef { self.code.clone() @@ -704,10 +696,27 @@ impl Py { // Clear temporary refs self.temporary_refs.lock().clear(); self.f_locals_hidden_overlay.lock().take(); + self.f_extra_locals.lock().take(); + self.retained_back.lock().take(); Ok(()) } + #[pygetset] + fn f_locals(&self, vm: &VirtualMachine) -> PyResult { + // Optimized (function) frames expose a live write-through + // FrameLocalsProxy; class/module/exec frames expose their namespace + // mapping directly. + if self.code.flags.contains(bytecode::CodeFlags::OPTIMIZED) { + self.check_locals_access(vm)?; + self.mark_escaped(); + let proxy = crate::builtins::FrameLocalsProxy::new(self.to_owned()); + Ok(proxy.into_ref(&vm.ctx).into()) + } else { + self.f_locals_mapping(vm).map(Into::into) + } + } + #[pygetset] fn f_generator(&self) -> Option { self.generator.to_owned() @@ -715,27 +724,59 @@ impl Py { #[pygetset] pub fn f_back(&self, vm: &VirtualMachine) -> Option> { + #[cfg(not(feature = "threading"))] + let _ = vm; let previous = self.previous_frame(); if previous.is_null() { return None; } - if let Some(frame) = vm - .frames - .borrow() - .iter() - .find(|fp| { - // SAFETY: the caller keeps the FrameRef alive while it's in the Vec - let py: &Self = unsafe { fp.as_ref() }; - let ptr: *const Frame = &**py; - core::ptr::eq(ptr, previous) - }) - .map(|fp| unsafe { fp.as_ref() }.to_owned()) - { + // Look for the caller on the current thread's signal-safe frame chain. + // Finding it there proves it is still live on this thread. + if let Some(frame) = crate::frame::find_owned_chain_frame(previous) { + frame.mark_escaped(); return Some(frame); } - #[cfg(feature = "threading")] + // The caller already returned and left the live chain, but this frame + // escaped and retained a strong reference to it at release time. + let retained = self.retained_back.lock().clone(); + if let Some(frame) = retained { + frame.mark_escaped(); + return Some(frame); + } + + // The caller lives on another thread. unix: park every thread under + // stop-the-world so their frame chains are quiescent and alive, then + // walk each published top frame down its `previous` chain looking for + // the caller. Request stop-the-world before the registry lock. + #[cfg(all(unix, feature = "threading"))] + { + use core::sync::atomic::Ordering; + vm.state.stop_the_world.stop_the_world(vm); + scopeguard::defer! { vm.state.stop_the_world.start_the_world(vm); } + let registry = vm.state.thread_frames.lock(); + #[expect( + clippy::iter_over_hash_type, + reason = "Iteration order doesn't matter here" + )] + for slot in registry.values() { + let mut cur = slot.top_frame.load(Ordering::Relaxed) as *const Frame; + while !cur.is_null() { + if core::ptr::eq(cur, previous) { + // SAFETY: world stopped -> this frame is alive on its + // owning thread's parked call stack. + let f = unsafe { &*Self::from_payload_ptr(cur) }; + f.mark_escaped(); + return Some(f.to_owned()); + } + // SAFETY: chain frames on a parked thread are alive. + cur = unsafe { (*cur).previous_frame() }; + } + } + } + + #[cfg(all(not(unix), feature = "threading"))] { let registry = vm.state.thread_frames.lock(); #[expect( @@ -751,6 +792,7 @@ impl Py { let ptr: *const Frame = &**f; core::ptr::eq(ptr, previous).then(|| f.to_owned()) }) { + frame.mark_escaped(); return Some(frame); } } diff --git a/crates/vm/src/builtins/frame_locals_proxy.rs b/crates/vm/src/builtins/frame_locals_proxy.rs new file mode 100644 index 00000000000..fbbc7f5d9cd --- /dev/null +++ b/crates/vm/src/builtins/frame_locals_proxy.rs @@ -0,0 +1,326 @@ +//! The `FrameLocalsProxy` type returned by `frame.f_locals` for optimized +//! (function) frames. Implements PEP 667 write-through semantics on top of the +//! frame's fast-local slots and an extra-locals side dict. + +use super::{PyDict, PyDictRef, PyType}; +use crate::{ + AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, + atomic_func, + class::PyClassImpl, + frame::FrameRef, + function::{FuncArgs, OptionalArg, PyArithmeticValue, PyComparisonValue}, + object::{Traverse, TraverseFn}, + protocol::{PyMappingMethods, PyNumberMethods, PySequenceMethods}, + recursion::ReprGuard, + types::{ + AsMapping, AsNumber, AsSequence, Comparable, Constructor, Iterable, PyComparisonOp, + Representable, + }, +}; +use rustpython_common::lock::LazyLock; +use rustpython_common::wtf8::Wtf8Buf; + +#[pyclass(module = false, name = "FrameLocalsProxy", traverse = "manual")] +#[derive(Debug)] +pub struct FrameLocalsProxy { + frame: FrameRef, +} + +unsafe impl Traverse for FrameLocalsProxy { + fn traverse(&self, tracer_fn: &mut TraverseFn<'_>) { + self.frame.traverse(tracer_fn); + } +} + +impl PyPayload for FrameLocalsProxy { + #[inline] + fn class(ctx: &Context) -> &'static Py { + ctx.types.frame_locals_proxy_type + } +} + +impl FrameLocalsProxy { + pub(crate) fn new(frame: FrameRef) -> Self { + Self { frame } + } + + fn snapshot(&self, vm: &VirtualMachine) -> PyResult { + self.frame.framelocalsproxy_snapshot(vm) + } + + fn keys_vec(&self, vm: &VirtualMachine) -> PyResult> { + Ok(self.snapshot(vm)?.into_iter().map(|(k, _)| k).collect()) + } +} + +impl Constructor for FrameLocalsProxy { + type Args = FuncArgs; + + fn py_new(_cls: &Py, args: Self::Args, vm: &VirtualMachine) -> PyResult { + if !args.kwargs.is_empty() { + return Err(vm.new_type_error("FrameLocalsProxy() takes no keyword arguments")); + } + let mut args = args.args; + if args.len() != 1 { + return Err(vm.new_type_error(format!( + "FrameLocalsProxy expected 1 argument, got {}", + args.len() + ))); + } + let frame: FrameRef = args + .pop() + .unwrap() + .downcast() + .map_err(|_| vm.new_type_error("FrameLocalsProxy expected a frame"))?; + Ok(Self::new(frame)) + } +} + +#[pyclass(with( + Constructor, + AsMapping, + AsSequence, + AsNumber, + Iterable, + Comparable, + Representable +))] +impl FrameLocalsProxy { + fn __getitem__(&self, key: PyObjectRef, vm: &VirtualMachine) -> PyResult { + self.frame.framelocalsproxy_getitem(key, vm) + } + + fn __setitem__( + &self, + key: PyObjectRef, + value: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult<()> { + self.frame.framelocalsproxy_setitem(key, value, vm) + } + + fn __delitem__(&self, key: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { + self.frame.framelocalsproxy_delitem(key, vm) + } + + fn __contains__(&self, key: PyObjectRef, vm: &VirtualMachine) -> PyResult { + self.frame.framelocalsproxy_contains(key, vm) + } + + fn __len__(&self, vm: &VirtualMachine) -> PyResult { + Ok(self.snapshot(vm)?.__len__()) + } + + #[pymethod] + fn keys(&self, vm: &VirtualMachine) -> PyResult { + Ok(vm.ctx.new_list(self.keys_vec(vm)?).into()) + } + + #[pymethod] + fn values(&self, vm: &VirtualMachine) -> PyResult { + let values = self.snapshot(vm)?.into_iter().map(|(_, v)| v).collect(); + Ok(vm.ctx.new_list(values).into()) + } + + #[pymethod] + fn items(&self, vm: &VirtualMachine) -> PyResult { + let items = self + .snapshot(vm)? + .into_iter() + .map(|(k, v)| vm.ctx.new_tuple(vec![k, v]).into()) + .collect(); + Ok(vm.ctx.new_list(items).into()) + } + + #[pymethod] + fn get(&self, key: PyObjectRef, default: OptionalArg, vm: &VirtualMachine) -> PyResult { + match self.frame.framelocalsproxy_getitem(key, vm) { + Ok(value) => Ok(value), + Err(e) if e.fast_isinstance(vm.ctx.exceptions.key_error) => { + Ok(default.unwrap_or_none(vm)) + } + Err(e) => Err(e), + } + } + + #[pymethod] + fn pop(&self, key: PyObjectRef, default: OptionalArg, vm: &VirtualMachine) -> PyResult { + self.frame + .framelocalsproxy_pop(key, default.into_option(), vm) + } + + #[pymethod] + fn setdefault(&self, key: PyObjectRef, default: OptionalArg, vm: &VirtualMachine) -> PyResult { + self.frame + .framelocalsproxy_setdefault(key, default.unwrap_or_none(vm), vm) + } + + #[pymethod] + fn copy(&self, vm: &VirtualMachine) -> PyResult { + Ok(self.snapshot(vm)?.into()) + } + + #[pymethod] + fn update(&self, args: FuncArgs, vm: &VirtualMachine) -> PyResult<()> { + if !args.kwargs.is_empty() { + return Err(vm.new_type_error("FrameLocalsProxy.update() takes no keyword arguments")); + } + if args.args.len() != 1 { + return Err(vm.new_type_error(format!( + "FrameLocalsProxy.update() takes exactly one argument ({} given)", + args.args.len() + ))); + } + self.update_from(&args.args[0], vm) + } + + fn update_from(&self, other: &PyObject, vm: &VirtualMachine) -> PyResult<()> { + let items: Vec<(PyObjectRef, PyObjectRef)> = + if let Some(dict) = other.downcast_ref::() { + dict.into_iter().collect() + } else if let Some(proxy) = other.downcast_ref::() { + proxy.snapshot(vm)?.into_iter().collect() + } else { + return Err( + vm.new_type_error("update() argument must be dict or another FrameLocalsProxy") + ); + }; + for (key, value) in items { + self.frame.framelocalsproxy_setitem(key, value, vm)?; + } + Ok(()) + } + + #[pymethod] + fn __reversed__(&self, vm: &VirtualMachine) -> PyResult { + let mut keys = self.keys_vec(vm)?; + keys.reverse(); + Ok(vm.ctx.new_list(keys).into()) + } + + fn __ior__(zelf: PyRef, other: PyObjectRef, vm: &VirtualMachine) -> PyResult { + zelf.update_from(&other, vm)?; + Ok(zelf.into()) + } + + fn __or__(&self, other: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let base = self.snapshot(vm)?; + vm._or(base.as_object(), &other) + } + + fn __ror__(&self, other: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let base = self.snapshot(vm)?; + vm._or(&other, base.as_object()) + } + + #[pymethod] + fn __reduce__(&self, vm: &VirtualMachine) -> PyResult { + Err(vm.new_type_error("cannot pickle 'FrameLocalsProxy' object")) + } + + #[pymethod] + fn __reduce_ex__(&self, _protocol: OptionalArg, vm: &VirtualMachine) -> PyResult { + Err(vm.new_type_error("cannot pickle 'FrameLocalsProxy' object")) + } +} + +impl AsMapping for FrameLocalsProxy { + fn as_mapping() -> &'static PyMappingMethods { + static AS_MAPPING: LazyLock = LazyLock::new(|| PyMappingMethods { + length: atomic_func!( + |mapping, vm| FrameLocalsProxy::mapping_downcast(mapping).__len__(vm) + ), + subscript: atomic_func!(|mapping, needle, vm| { + FrameLocalsProxy::mapping_downcast(mapping).__getitem__(needle.to_owned(), vm) + }), + ass_subscript: atomic_func!(|mapping, needle, value, vm| { + let zelf = FrameLocalsProxy::mapping_downcast(mapping); + match value { + Some(value) => zelf.__setitem__(needle.to_owned(), value, vm), + None => zelf.__delitem__(needle.to_owned(), vm), + } + }), + }); + &AS_MAPPING + } +} + +impl AsSequence for FrameLocalsProxy { + fn as_sequence() -> &'static PySequenceMethods { + static AS_SEQUENCE: LazyLock = LazyLock::new(|| PySequenceMethods { + contains: atomic_func!(|seq, target, vm| { + FrameLocalsProxy::sequence_downcast(seq).__contains__(target.to_owned(), vm) + }), + ..PySequenceMethods::NOT_IMPLEMENTED + }); + &AS_SEQUENCE + } +} + +impl AsNumber for FrameLocalsProxy { + fn as_number() -> &'static PyNumberMethods { + static AS_NUMBER: PyNumberMethods = PyNumberMethods { + or: Some(|a, b, vm| { + if let Some(proxy) = a.downcast_ref::() { + proxy.__or__(b.to_owned(), vm) + } else if let Some(proxy) = b.downcast_ref::() { + proxy.__ror__(a.to_owned(), vm) + } else { + Ok(vm.ctx.not_implemented()) + } + }), + inplace_or: Some(|a, b, vm| { + let proxy = a + .to_owned() + .downcast::() + .map_err(|_| vm.new_type_error("expected FrameLocalsProxy"))?; + FrameLocalsProxy::__ior__(proxy, b.to_owned(), vm) + }), + ..PyNumberMethods::NOT_IMPLEMENTED + }; + &AS_NUMBER + } +} + +impl Iterable for FrameLocalsProxy { + fn iter(zelf: PyRef, vm: &VirtualMachine) -> PyResult { + let keys = vm.ctx.new_list(zelf.keys_vec(vm)?); + keys.as_object().to_owned().get_iter(vm).map(Into::into) + } +} + +impl Comparable for FrameLocalsProxy { + fn cmp( + zelf: &Py, + other: &PyObject, + op: PyComparisonOp, + vm: &VirtualMachine, + ) -> PyResult { + op.eq_only(|| { + let self_dict: PyObjectRef = zelf.snapshot(vm)?.into(); + let other_obj = match other.downcast_ref::() { + Some(proxy) => proxy.snapshot(vm)?.into(), + None => other.to_owned(), + }; + let res = self_dict.rich_compare(other_obj, PyComparisonOp::Eq, vm)?; + PyArithmeticValue::from_object(vm, res) + .map(|o| o.try_to_bool(vm)) + .transpose() + }) + } +} + +impl Representable for FrameLocalsProxy { + fn repr_wtf8(zelf: &Py, vm: &VirtualMachine) -> PyResult { + if let Some(_guard) = ReprGuard::enter(vm, zelf.as_object()) { + let dict = zelf.snapshot(vm)?; + Ok(dict.as_object().repr(vm)?.as_wtf8().to_owned()) + } else { + Ok(Wtf8Buf::from("{...}")) + } + } +} + +pub(crate) fn init(context: &'static Context) { + FrameLocalsProxy::extend_class(context, context.types.frame_locals_proxy_type); +} diff --git a/crates/vm/src/builtins/function.rs b/crates/vm/src/builtins/function.rs index 4125ef4c4c6..47fda299455 100644 --- a/crates/vm/src/builtins/function.rs +++ b/crates/vm/src/builtins/function.rs @@ -95,7 +95,12 @@ unsafe impl Traverse for PyFunction { fn traverse(&self, tracer_fn: &mut TraverseFn<'_>) { self.globals.traverse(tracer_fn); if let Some(closure) = self.closure.as_ref() { - closure.as_untyped().traverse(tracer_fn); + // Visit the closure tuple itself as an edge, not its cells: the + // tuple is a tracked object that can join a reference cycle, and + // `clear` releases the whole tuple. Visiting only the cells would + // leave the tuple's reference unaccounted, stranding it as a false + // GC root. + tracer_fn(closure.as_untyped().as_object()); } self.defaults_and_kwdefaults.traverse(tracer_fn); // Traverse additional fields that may contain references @@ -580,30 +585,40 @@ impl Py { .into_ref(&vm.ctx); self.fill_locals_from_args(&frame, func_args, vm)?; - if is_async_gen { - let obj = PyAsyncGen::new(frame.clone(), self.__name__(), self.__qualname__()) - .into_pyobject(vm); - frame.set_generator(&obj); - Ok(obj) - } else if is_gen { - let obj = PyGenerator::new(frame.clone(), self.__name__(), self.__qualname__()) - .into_pyobject(vm); - frame.set_generator(&obj); - Ok(obj) - } else if is_coro { - let obj = PyCoroutine::new(frame.clone(), self.__name__(), self.__qualname__()) - .into_pyobject(vm); - frame.set_generator(&obj); - Ok(obj) - } else { + if use_datastack { let result = vm.run_frame(frame.clone()); // Release data stack memory after frame execution completes. + crate::frame::release_datastack_frame(&frame, vm); + result + } else { + let obj = if is_async_gen { + PyAsyncGen::new(frame.clone(), self.__name__(), self.__qualname__()) + .into_pyobject(vm) + } else if is_gen { + PyGenerator::new(frame.clone(), self.__name__(), self.__qualname__()) + .into_pyobject(vm) + } else { + PyCoroutine::new(frame.clone(), self.__name__(), self.__qualname__()) + .into_pyobject(vm) + }; + // Generator/coroutine frames outlive this call and can join a + // reference cycle through their owning generator, so they must + // participate in the GC. They were created untracked + // (NEW_REF_UNTRACKED); track them now, before the back-reference + // is installed. Their localsplus is heap-backed by construction + // (use_datastack == false), so a collector never reads data-stack + // storage when it traverses them. + debug_assert!( + !frame.localsplus_is_datastack_backed(), + "generator frame is data-stack-backed" + ); + // SAFETY: the frame is alive (held by `frame`) and untracked. unsafe { - if let Some(base) = frame.materialize_localsplus() { - vm.datastack_pop(base); - } + crate::gc_state::gc_state() + .track_object(core::ptr::NonNull::from(frame.as_object())); } - result + frame.set_generator(&obj); + Ok(obj) } } @@ -634,16 +649,6 @@ impl Py { new_v } - /// function_kind(SIMPLE_FUNCTION) equivalent for CALL specialization. - /// Returns true if: CO_OPTIMIZED, no VARARGS, no VARKEYWORDS, no kwonly args. - pub(crate) fn is_simple_for_call_specialization(&self) -> bool { - let code: &Py = &self.code; - let flags = code.flags; - flags.contains(bytecode::CodeFlags::OPTIMIZED) - && !flags.intersects(bytecode::CodeFlags::VARARGS | bytecode::CodeFlags::VARKEYWORDS) - && code.kwonlyarg_count == 0 - } - /// Check if this function is eligible for exact-args call specialization. /// Returns true if: CO_OPTIMIZED, no VARARGS, no VARKEYWORDS, no kwonly args, /// and effective_nargs matches co_argcount. @@ -656,6 +661,16 @@ impl Py { && code.arg_count == effective_nargs } + /// True if the code object is a generator, coroutine or async generator. + #[inline] + pub(crate) fn is_generator_like(&self) -> bool { + self.code.flags.intersects( + bytecode::CodeFlags::GENERATOR + | bytecode::CodeFlags::COROUTINE + | bytecode::CodeFlags::ASYNC_GENERATOR, + ) + } + /// Runtime guard for CALL_*_EXACT_ARGS specialization: check only argcount. /// Other invariants are guaranteed by function versioning and specialization-time checks. #[inline] @@ -672,7 +687,7 @@ impl Py { pub(crate) fn prepare_exact_args_frame( &self, - mut args: Vec, + args: impl ExactSizeIterator, vm: &VirtualMachine, ) -> FrameRef { let code: PyRef = (*self.code).to_owned(); @@ -710,7 +725,7 @@ impl Py { { let fastlocals = unsafe { frame.fastlocals_mut() }; - for (slot, arg) in fastlocals.iter_mut().zip(args.drain(..)) { + for (slot, arg) in fastlocals.iter_mut().zip(args) { *slot = Some(arg); } } @@ -718,41 +733,55 @@ impl Py { frame } + fn invoke_prepared_exact_args( + &self, + args: impl ExactSizeIterator, + vm: &VirtualMachine, + ) -> PyResult { + let frame = self.prepare_exact_args_frame(args, vm); + + let result = vm.run_frame(frame.clone()); + crate::frame::release_datastack_frame(&frame, vm); + result + } + /// Fast path for calling a simple function with exact positional args. /// Skips FuncArgs allocation, prepend_arg, and fill_locals_from_args. /// Only valid when: CO_OPTIMIZED, no VARARGS, no VARKEYWORDS, no kwonlyargs, /// and nargs == co_argcount. pub fn invoke_exact_args(&self, args: Vec, vm: &VirtualMachine) -> PyResult { - let code: PyRef = (*self.code).to_owned(); - - debug_assert_eq!(args.len(), code.arg_count as usize); - debug_assert!(code.flags.contains(bytecode::CodeFlags::OPTIMIZED)); - debug_assert!( - !code - .flags - .intersects(bytecode::CodeFlags::VARARGS | bytecode::CodeFlags::VARKEYWORDS) - ); - debug_assert_eq!(code.kwonlyarg_count, 0); + debug_assert_eq!(args.len(), self.code.arg_count as usize); // Generator/coroutine code objects are SIMPLE_FUNCTION in call // specialization classification, but their call path must still // go through invoke() to produce generator/coroutine objects. - if code.flags.intersects( - bytecode::CodeFlags::GENERATOR - | bytecode::CodeFlags::COROUTINE - | bytecode::CodeFlags::ASYNC_GENERATOR, - ) { + if self.is_generator_like() { return self.invoke(FuncArgs::from(args), vm); } - let frame = self.prepare_exact_args_frame(args, vm); + self.invoke_prepared_exact_args(args.into_iter(), vm) + } - let result = vm.run_frame(frame.clone()); - unsafe { - if let Some(base) = frame.materialize_localsplus() { - vm.datastack_pop(base); - } + /// Like `invoke_exact_args`, but moves the args out of caller-provided + /// slots (all filled with `Some`), so callers can stage them in a + /// fixed-size stack buffer instead of allocating a Vec per call. + pub(crate) fn invoke_exact_args_slots( + &self, + args: &mut [Option], + vm: &VirtualMachine, + ) -> PyResult { + debug_assert_eq!(args.len(), self.code.arg_count as usize); + + let taken = args + .iter_mut() + .map(|slot| slot.take().expect("arg slot must be filled")); + // Generator/coroutine code objects are SIMPLE_FUNCTION in call + // specialization classification, but their call path must still + // go through invoke() to produce generator/coroutine objects. + if self.is_generator_like() { + let args: Vec = taken.collect(); + return self.invoke(FuncArgs::from(args), vm); } - result + self.invoke_prepared_exact_args(taken, vm) } } @@ -1476,14 +1505,10 @@ pub(crate) fn vectorcall_function( // FAST PATH: simple positional-only call, exact arg count. // Move owned args directly into fastlocals — no clone needed. args.truncate(nargs); - let frame = zelf.prepare_exact_args_frame(args, vm); + let frame = zelf.prepare_exact_args_frame(args.into_iter(), vm); let result = vm.run_frame(frame.clone()); - unsafe { - if let Some(base) = frame.materialize_localsplus() { - vm.datastack_pop(base); - } - } + crate::frame::release_datastack_frame(&frame, vm); return result; } diff --git a/crates/vm/src/builtins/int.rs b/crates/vm/src/builtins/int.rs index b9246149731..c5aa607d023 100644 --- a/crates/vm/src/builtins/int.rs +++ b/crates/vm/src/builtins/int.rs @@ -310,7 +310,7 @@ impl PyInt { #[must_use] pub fn to_str_radix_10(&self) -> String { match self.value.to_i64() { - Some(i) => i.to_string(), + Some(i) => itoa::Buffer::new().format(i).to_owned(), None => self.value.to_string(), } } diff --git a/crates/vm/src/builtins/mod.rs b/crates/vm/src/builtins/mod.rs index eba5af36686..ffc01b00f29 100644 --- a/crates/vm/src/builtins/mod.rs +++ b/crates/vm/src/builtins/mod.rs @@ -28,6 +28,8 @@ pub use filter::PyFilter; pub(crate) mod float; pub use float::PyFloat; pub(crate) mod frame; +pub(crate) mod frame_locals_proxy; +pub use frame_locals_proxy::FrameLocalsProxy; pub(crate) mod function; pub use function::{PyBoundMethod, PyFunction}; pub(crate) mod generator; diff --git a/crates/vm/src/builtins/object.rs b/crates/vm/src/builtins/object.rs index 147e215a0cb..633eaf48a44 100644 --- a/crates/vm/src/builtins/object.rs +++ b/crates/vm/src/builtins/object.rs @@ -65,34 +65,24 @@ impl Constructor for PyBaseObject { } // Ensure that all abstract methods are implemented before instantiating instance. - if let Some(abs_methods) = cls.get_attr(identifier!(vm, __abstractmethods__)) - && let Some(unimplemented_abstract_method_count) = abs_methods.length_opt(vm) - { + if let Some(abs_methods) = cls.get_attr(identifier!(vm, __abstractmethods__)) { let methods: Vec = abs_methods.try_to_value(vm)?; - let methods: String = Itertools::intersperse( - methods.iter().map(|name| name.as_str().to_owned()), - "', '".to_owned(), - ) - .collect(); - - let unimplemented_abstract_method_count = unimplemented_abstract_method_count?; - let name = cls.name().to_string(); - - match unimplemented_abstract_method_count { - 0 => {} - 1 => { - return Err(vm.new_type_error(format!( - "class {name} without an implementation for abstract method '{methods}'" - ))); - } - 2.. => { - return Err(vm.new_type_error(format!( - "class {name} without an implementation for abstract methods '{methods}'" - ))); - } - // TODO: remove `allow` when redox build doesn't complain about it - #[allow(unreachable_patterns)] - _ => unreachable!(), + let unimplemented_abstract_method_count = methods.len(); + if unimplemented_abstract_method_count > 0 { + let methods: String = Itertools::intersperse( + methods.iter().map(|name| name.as_str().to_owned()), + "', '".to_owned(), + ) + .collect(); + let name = cls.name().to_string(); + let noun = if unimplemented_abstract_method_count == 1 { + "method" + } else { + "methods" + }; + return Err(vm.new_type_error(format!( + "class {name} without an implementation for abstract {noun} '{methods}'" + ))); } } @@ -346,23 +336,7 @@ impl PyBaseObject { Ok(res) } - /// Implement setattr(self, name, value). - #[pymethod] - fn __setattr__( - obj: PyObjectRef, - name: PyStrRef, - value: PyObjectRef, - vm: &VirtualMachine, - ) -> PyResult<()> { - obj.generic_setattr(&name, PySetterValue::Assign(value), vm) - } - - /// Implement delattr(self, name). - #[pymethod] - fn __delattr__(obj: PyObjectRef, name: PyStrRef, vm: &VirtualMachine) -> PyResult<()> { - obj.generic_setattr(&name, PySetterValue::Delete, vm) - } - + // __setattr__ and __delattr__ are added as slot wrappers by add_operators. #[pyslot] pub(crate) fn slot_setattro( obj: &PyObject, @@ -461,39 +435,7 @@ impl PyBaseObject { && !cls.slots.flags.has_feature(PyTypeFlags::IMMUTABLETYPE); // FIXME(#1979) cls instances might have a payload if both_mutable || both_module { - let has_dict = - |typ: &Py| typ.slots.flags.has_feature(PyTypeFlags::HAS_DICT); - let has_weakref = - |typ: &Py| typ.slots.flags.has_feature(PyTypeFlags::HAS_WEAKREF); - // Compare slots tuples - let slots_equal = match ( - current_cls - .heaptype_ext - .as_ref() - .and_then(|e| e.slots.as_ref()), - cls.heaptype_ext.as_ref().and_then(|e| e.slots.as_ref()), - ) { - (Some(a), Some(b)) => { - a.len() == b.len() - && a.iter() - .zip(b.iter()) - .all(|(x, y)| x.as_wtf8() == y.as_wtf8()) - } - (None, None) => true, - _ => false, - }; - if current_cls.slots.basicsize != cls.slots.basicsize - || !slots_equal - || has_dict(current_cls) != has_dict(&cls) - || has_weakref(current_cls) != has_weakref(&cls) - || current_cls.slots.member_count != cls.slots.member_count - { - return Err(vm.new_type_error(format!( - "__class__ assignment: '{}' object layout differs from '{}'", - cls.name(), - current_cls.name() - ))); - } + super::type_::compatible_for_assignment(current_cls, &cls, "__class__", vm)?; instance.set_class(cls, vm); Ok(()) } else { @@ -513,17 +455,14 @@ impl PyBaseObject { } /// Return getattr(self, name). + /// + /// __getattribute__ is added as a slot wrapper by add_operators. #[pyslot] pub(crate) fn getattro(obj: &PyObject, name: &Py, vm: &VirtualMachine) -> PyResult { vm_trace!("object.__getattribute__({:?}, {:?})", obj, name); obj.as_object().generic_getattr(name, vm) } - #[pymethod] - fn __getattribute__(obj: PyObjectRef, name: PyStrRef, vm: &VirtualMachine) -> PyResult { - Self::getattro(&obj, &name, vm) - } - #[pymethod] fn __reduce__(obj: PyObjectRef, vm: &VirtualMachine) -> PyResult { common_reduce(obj, 0, vm) diff --git a/crates/vm/src/builtins/tuple.rs b/crates/vm/src/builtins/tuple.rs index 7ed815ce24d..d48639b2c11 100644 --- a/crates/vm/src/builtins/tuple.rs +++ b/crates/vm/src/builtins/tuple.rs @@ -51,46 +51,14 @@ unsafe impl Traverse for PyTuple { } } -// spell-checker:ignore MAXSAVESIZE -/// Per-size freelist storage for tuples, matching tuples[PyTuple_MAXSAVESIZE]. -/// Each bucket caches tuples of a specific element count (index = len - 1). -struct TupleFreeList { - buckets: [Vec>; Self::MAX_SAVE_SIZE], -} - -impl TupleFreeList { - /// Largest tuple size to cache on the freelist (sizes 1..=20). - const MAX_SAVE_SIZE: usize = 20; - const fn new() -> Self { - Self { - buckets: [const { Vec::new() }; Self::MAX_SAVE_SIZE], - } - } -} - -impl Default for TupleFreeList { - fn default() -> Self { - Self::new() - } -} - -impl Drop for TupleFreeList { - fn drop(&mut self) { - // Same safety pattern as FreeList::drop — free raw allocation - // without running payload destructors to avoid TLS-after-destruction panics. - let layout = crate::object::pyinner_layout::(); - for bucket in &mut self.buckets { - for ptr in bucket.drain(..) { - unsafe { - alloc::alloc::dealloc(ptr.as_ptr() as *mut u8, layout); - } - } - } - } -} - thread_local! { - static TUPLE_FREELIST: Cell = const { Cell::new(TupleFreeList::new()) }; + // A single freelist for all tuple sizes: `PyInner` is a + // fixed-size allocation (elements are a separate boxed slice that is + // dropped and replaced on reuse), so husks are interchangeable. + // freelist_push must not read the payload — it runs after tp_clear, + // which has already emptied `elements`. + static TUPLE_FREELIST: Cell> = + const { Cell::new(crate::object::FreeList::new()) }; } impl PyPayload for PyTuple { @@ -104,16 +72,11 @@ impl PyPayload for PyTuple { #[inline] unsafe fn freelist_push(obj: *mut PyObject) -> bool { - let len = unsafe { &*(obj as *const crate::Py) }.elements.len(); - if len == 0 || len > TupleFreeList::MAX_SAVE_SIZE { - return false; - } TUPLE_FREELIST .try_with(|fl| { let mut list = fl.take(); - let bucket = &mut list.buckets[len - 1]; - let stored = if bucket.len() < Self::MAX_FREELIST { - bucket.push(unsafe { NonNull::new_unchecked(obj) }); + let stored = if list.len() < Self::MAX_FREELIST { + list.push(obj); true } else { false @@ -125,15 +88,11 @@ impl PyPayload for PyTuple { } #[inline] - unsafe fn freelist_pop(payload: &Self) -> Option> { - let len = payload.elements.len(); - if len == 0 || len > TupleFreeList::MAX_SAVE_SIZE { - return None; - } + unsafe fn freelist_pop(_payload: &Self) -> Option> { TUPLE_FREELIST .try_with(|fl| { let mut list = fl.take(); - let result = list.buckets[len - 1].pop(); + let result = list.pop().map(|p| unsafe { NonNull::new_unchecked(p) }); fl.set(list); result }) diff --git a/crates/vm/src/builtins/type.rs b/crates/vm/src/builtins/type.rs index 19fca5cf473..1c98e6861bc 100644 --- a/crates/vm/src/builtins/type.rs +++ b/crates/vm/src/builtins/type.rs @@ -18,7 +18,7 @@ use crate::{ common::{ ascii, borrow::BorrowedValue, - lock::{PyMutex, PyRwLock, PyRwLockReadGuard}, + lock::{PyRwLock, PyRwLockReadGuard}, }, function::{FuncArgs, KwArgs, OptionalArg, PyMethodDef, PySetterValue}, object::{Traverse, TraverseFn}, @@ -44,7 +44,8 @@ use std::collections::HashSet; #[pyclass(module = false, name = "type", traverse = "manual")] pub struct PyType { - pub base: Option, + /// tp_base. Written under the type lock (see `set_bases`); read lock-free. + pub base: PyAtomicRef>, pub bases: PyRwLock>, pub mro: PyRwLock>, pub subclasses: PyRwLock>>, @@ -57,7 +58,7 @@ pub struct PyType { } /// Monotonic counter for type version tags. Once it reaches `u32::MAX`, -/// `assign_version_tag()` returns 0 permanently, disabling new inline-cache +/// version assignment returns 0 permanently, disabling new inline-cache /// entries but not invalidating correctness (cache misses fall back to the /// generic path). static NEXT_TYPE_VERSION: AtomicU32 = AtomicU32::new(1); @@ -217,9 +218,30 @@ pub(crate) fn type_cache_clear() { TYPE_CACHE_CLEARING.store(false, Ordering::Release); } +/// Repair type-cache SeqLock state in the post-fork child. +/// +/// If fork happens while a writer holds an entry SeqLock, the child inherits +/// the odd sequence value with no surviving writer to release it. Clear only +/// those in-progress entries, matching `_PyTypes_AfterFork()`. +#[cfg(all(feature = "host_env", unix))] +pub(crate) unsafe fn type_cache_after_fork() { + for entry in TYPE_CACHE.iter() { + let seq = entry.sequence.load(Ordering::Relaxed); + if (seq & 1) == 0 { + continue; + } + entry.value.store(core::ptr::null_mut(), Ordering::Relaxed); + entry.name.store(core::ptr::null_mut(), Ordering::Relaxed); + entry.version.store(0, Ordering::Relaxed); + entry.sequence.store(0, Ordering::Relaxed); + } +} + unsafe impl crate::object::Traverse for PyType { fn traverse(&self, tracer_fn: &mut crate::object::TraverseFn<'_>) { - self.base.traverse(tracer_fn); + if let Some(base) = self.base.deref() { + tracer_fn(base.as_object()); + } self.bases.traverse(tracer_fn); self.mro.traverse(tracer_fn); self.subclasses.traverse(tracer_fn); @@ -235,7 +257,8 @@ unsafe impl crate::object::Traverse for PyType { /// type_clear: break reference cycles in type objects fn clear(&mut self, out: &mut Vec) { - if let Some(base) = self.base.take() { + // SAFETY: tp_clear runs with exclusive access to the type object. + if let Some(base) = unsafe { self.base.swap(None) } { out.push(base.into()); } if let Some(mut guard) = self.bases.try_write() { @@ -275,65 +298,53 @@ pub struct HeapTypeExt { pub struct TypeSpecializationCache { pub init: PyAtomicRef>, + pub init_version: AtomicU32, pub getitem: PyAtomicRef>, pub getitem_version: AtomicU32, - // Serialize cache writes/invalidation similar to CPython's BEGIN_TYPE_LOCK. - write_lock: PyMutex<()>, - retired: PyRwLock>, } impl TypeSpecializationCache { fn new() -> Self { Self { init: PyAtomicRef::from(None::>), + init_version: AtomicU32::new(0), getitem: PyAtomicRef::from(None::>), getitem_version: AtomicU32::new(0), - write_lock: PyMutex::new(()), - retired: PyRwLock::new(Vec::new()), - } - } - - #[inline] - fn retire_old_function(&self, old: Option>) { - if let Some(old) = old { - self.retired.write().push(old.into()); } } #[inline] - fn swap_init(&self, new_init: Option>, vm: Option<&VirtualMachine>) { - if let Some(vm) = vm { - // Keep replaced refs alive for the currently executing frame, matching - // CPython-style "old pointer remains valid during ongoing execution" - // without accumulating global retired refs. - self.init.swap_to_temporary_refs(new_init, vm); - return; + fn swap_init(&self, new_init: Option>) { + if let Some(new) = &new_init { + new.as_object().mark_cache_published(); } - // SAFETY: old value is moved to `retired`, so it stays alive while - // concurrent readers may still hold borrowed references. + // SAFETY: reclamation of published objects is deferred via QSBR; + // racing try_to_owned readers never touch freed memory. let old = unsafe { self.init.swap(new_init) }; - self.retire_old_function(old); + if let Some(old) = old { + // Dropping may run arbitrary Python; defer past the type lock. + rustpython_common::refcount::try_defer_drop(move || drop(old)); + } } #[inline] - fn swap_getitem(&self, new_getitem: Option>, vm: Option<&VirtualMachine>) { - if let Some(vm) = vm { - self.getitem.swap_to_temporary_refs(new_getitem, vm); - return; + fn swap_getitem(&self, new_getitem: Option>) { + if let Some(new) = &new_getitem { + new.as_object().mark_cache_published(); } - // SAFETY: old value is moved to `retired`, so it stays alive while - // concurrent readers may still hold borrowed references. + // SAFETY: as in swap_init. let old = unsafe { self.getitem.swap(new_getitem) }; - self.retire_old_function(old); + if let Some(old) = old { + rustpython_common::refcount::try_defer_drop(move || drop(old)); + } } #[inline] fn invalidate_for_type_modified(&self) { - let _guard = self.write_lock.lock(); - // _spec_cache contract: type modification invalidates all cached - // specialization functions. - self.swap_init(None, None); - self.swap_getitem(None, None); + self.swap_init(None); + self.init_version.store(0, Ordering::Release); + self.swap_getitem(None); + self.getitem_version.store(0, Ordering::Release); } fn traverse(&self, tracer_fn: &mut TraverseFn<'_>) { @@ -343,25 +354,19 @@ impl TypeSpecializationCache { if let Some(getitem) = self.getitem.deref() { tracer_fn(getitem.as_object()); } - self.retired - .read() - .iter() - .map(|obj| obj.traverse(tracer_fn)) - .count(); } fn clear_into(&self, out: &mut Vec) { - let _guard = self.write_lock.lock(); let old_init = unsafe { self.init.swap(None) }; if let Some(old_init) = old_init { out.push(old_init.into()); } + self.init_version.store(0, Ordering::Release); let old_getitem = unsafe { self.getitem.swap(None) }; if let Some(old_getitem) = old_getitem { out.push(old_getitem.into()); } self.getitem_version.store(0, Ordering::Release); - out.extend(self.retired.write().drain(..)); } } @@ -460,9 +465,19 @@ fn is_subtype_with_mro(a_mro: &[PyTypeRef], a: &Py, b: &Py) -> b } impl PyType { + #[inline] + fn with_type_lock(vm: &VirtualMachine, f: impl FnOnce() -> R) -> R { + // Drops deferred via try_defer_drop inside the critical section run + // after the guard is released, outside the lock. + rustpython_common::refcount::with_deferred_drops(|| { + let _guard = vm.state.type_mutex.lock(); + f() + }) + } + /// Assign a fresh version tag. Returns 0 if the version counter has been /// exhausted, in which case no new cache entries can be created. - pub fn assign_version_tag(&self) -> u32 { + fn assign_version_tag_inner(&self) -> u32 { let v = self.tp_version_tag.load(Ordering::Acquire); if v != 0 { return v; @@ -470,7 +485,7 @@ impl PyType { // Assign versions to all direct bases first (MRO invariant). for base in self.bases.read().iter() { - if base.assign_version_tag() == 0 { + if base.assign_version_tag_inner() == 0 { return 0; } } @@ -490,27 +505,52 @@ impl PyType { } } - /// Invalidate this type's version tag and cascade to all subclasses. - pub fn modified(&self) { - if let Some(ext) = self.heaptype_ext.as_ref() { - ext.specialization_cache.invalidate_for_type_modified(); + pub(crate) fn version_for_specialization(&self, vm: &VirtualMachine) -> u32 { + let version = self.tp_version_tag.load(Ordering::Acquire); + if version != 0 { + return version; } - // If already invalidated, all subclasses must also be invalidated - // (guaranteed by the MRO invariant in assign_version_tag). + Self::with_type_lock(vm, || { + let version = self.tp_version_tag.load(Ordering::Acquire); + if version == 0 { + self.assign_version_tag_inner() + } else { + version + } + }) + } + + /// Invalidate this type's version tag and cascade to all subclasses. + fn modified_inner(&self) { let old_version = self.tp_version_tag.load(Ordering::Acquire); if old_version == 0 { return; } - self.tp_version_tag.store(0, Ordering::SeqCst); - // Nullify borrowed pointers in cache entries for this version - // so they don't dangle after the dict is modified. - type_cache_clear_version(old_version); let subclasses = self.subclasses.read(); for weak_ref in subclasses.iter() { if let Some(sub) = weak_ref.upgrade() { - sub.downcast_ref::().unwrap().modified(); + sub.downcast_ref::().unwrap().modified_inner(); } } + self.tp_version_tag.store(0, Ordering::SeqCst); + // Nullify borrowed pointers in cache entries for this version + // so they don't dangle after the dict is modified. + type_cache_clear_version(old_version); + if let Some(ext) = self.heaptype_ext.as_ref() { + ext.specialization_cache.invalidate_for_type_modified(); + } + } + + pub fn modified(&self) { + if self.tp_version_tag.load(Ordering::Acquire) == 0 { + return; + } + if let Some(()) = crate::vm::thread::try_with_current_vm(|vm| { + Self::with_type_lock(vm, || self.modified_inner()); + }) { + return; + } + self.modified_inner(); } pub fn new_simple_heap( @@ -773,7 +813,7 @@ impl PyType { let inherited_abc_tpflags = Self::inherited_abc_tpflags(&bases); let new_type = PyRef::new_ref( Self { - base: Some(base), + base: Some(base).into(), bases: PyRwLock::new(bases), mro: PyRwLock::new(mro), subclasses: PyRwLock::default(), @@ -837,7 +877,7 @@ impl PyType { let new_type = PyRef::new_ref( Self { - base: Some(base), + base: Some(base).into(), bases, mro: PyRwLock::new(mro), subclasses: PyRwLock::default(), @@ -864,8 +904,8 @@ impl PyType { // Note: inherit_slots is called in PyClassImpl::init_class after // slots are fully initialized by make_slots() - Self::set_new(&new_type.slots, new_type.base.as_ref()); - Self::set_alloc(&new_type.slots, new_type.base.as_ref()); + Self::set_new(&new_type.slots, new_type.base.deref()); + Self::set_alloc(&new_type.slots, new_type.base.deref()); let weakref_type = super::PyWeak::static_type(); for base in new_type.bases.read().iter() { @@ -911,11 +951,31 @@ impl PyType { self.update_slot::(attr_name, ctx); } - Self::set_new(&self.slots, self.base.as_ref()); - Self::set_alloc(&self.slots, self.base.as_ref()); + Self::set_new(&self.slots, self.base.deref()); + Self::set_alloc(&self.slots, self.base.deref()); + } + + /// Recompute every slot for this type and all its descendants. update_all_slots + /// + /// Unlike `init_slots`, which is additive and driven only by the dunder names + /// present in the current MRO, this iterates the full `SLOT_DEFS` name table so + /// a slot whose method left the MRO is reset instead of left stale. Must be + /// called under the type lock after MROs have been recomputed. + pub(crate) fn update_all_slots(&self, ctx: &Context) { + // Invalidate version tags first; cascades to subclasses. + self.modified_inner(); + // Distinct names only; update_slot fans out to every SLOT_DEFS entry + // sharing the name and recurses into subclasses on its own. + let mut seen = std::collections::HashSet::new(); + for def in SLOT_DEFS { + if seen.insert(def.name) { + let name = ctx.intern_str(def.name); + self.update_slot::(name, ctx); + } + } } - fn set_new(slots: &PyTypeSlots, base: Option<&PyTypeRef>) { + fn set_new(slots: &PyTypeSlots, base: Option<&Py>) { if slots.flags.contains(PyTypeFlags::DISALLOW_INSTANTIATION) { slots.new.store(None) } else if slots.new.load().is_none() { @@ -923,7 +983,7 @@ impl PyType { } } - fn set_alloc(slots: &PyTypeSlots, base: Option<&PyTypeRef>) { + fn set_alloc(slots: &PyTypeSlots, base: Option<&Py>) { if slots.alloc.load().is_none() { slots .alloc @@ -974,6 +1034,85 @@ impl PyType { self.find_name_in_mro(attr_name) } + /// `_PyType_LookupRefAndVersion` equivalent for interned names. + /// Returns the observed lookup result and the type version used for the lookup. + /// + /// Uses a lock-free SeqLock-style pattern: + /// Read: load sequence/version/name → load value + try_to_owned → + /// validate value pointer + sequence + /// Write: sequence(begin) → version=0 → swap value/name → version=assigned → sequence(end) + pub(crate) fn lookup_ref_and_version_interned( + &self, + name: &'static PyStrInterned, + vm: &VirtualMachine, + ) -> (Option, u32) { + #[cfg(all(feature = "threading", debug_assertions))] + crate::vm::thread::debug_assert_current_thread_attached(); + + let version = self.tp_version_tag.load(Ordering::Acquire); + if version != 0 { + let idx = type_cache_hash(version, name); + let entry = &TYPE_CACHE[idx]; + let name_ptr = name as *const _ as *mut _; + loop { + let seq1 = entry.begin_read(); + let entry_version = entry.version.load(Ordering::Acquire); + let type_version = self.tp_version_tag.load(Ordering::Acquire); + if entry_version != type_version + || !core::ptr::eq(entry.name.load(Ordering::Relaxed), name_ptr) + { + break; + } + let ptr = entry.value.load(Ordering::Acquire); + if ptr.is_null() { + if entry.end_read(seq1) { + return (None, entry_version); + } + continue; + } + if let Some(cloned) = unsafe { PyObject::try_to_owned_from_ptr(ptr) } { + let same_ptr = core::ptr::eq(entry.value.load(Ordering::Relaxed), ptr); + if same_ptr && entry.end_read(seq1) { + return (Some(cloned), entry_version); + } + drop(cloned); + continue; + } + break; + } + } + + Self::with_type_lock(vm, || { + let assigned = if self.tp_version_tag.load(Ordering::Acquire) == 0 { + self.assign_version_tag_inner() + } else { + self.tp_version_tag.load(Ordering::Acquire) + }; + let result = self.find_name_in_mro_uncached(name); + if assigned != 0 + && !TYPE_CACHE_CLEARING.load(Ordering::Acquire) + && self.tp_version_tag.load(Ordering::Acquire) == assigned + { + let idx = type_cache_hash(assigned, name); + let entry = &TYPE_CACHE[idx]; + let name_ptr = name as *const _ as *mut _; + entry.begin_write(); + entry.version.store(0, Ordering::Release); + let new_ptr = result.as_ref().map_or(core::ptr::null_mut(), |found| { + // Defer memory reclamation of cached values via QSBR so + // racing readers never try-incref freed memory. + found.mark_cache_published(); + &**found as *const PyObject as *mut _ + }); + entry.value.store(new_ptr, Ordering::Relaxed); + entry.name.store(name_ptr, Ordering::Relaxed); + entry.version.store(assigned, Ordering::Release); + entry.end_write(); + } + (result, assigned) + }) + } + /// Cache __init__ for CALL_ALLOC_AND_ENTER_INIT specialization. /// The cache is valid only when guarded by the type version check. pub(crate) fn cache_init_for_specialization( @@ -988,22 +1127,27 @@ impl PyType { if tp_version == 0 { return false; } - if self.tp_version_tag.load(Ordering::Acquire) != tp_version { - return false; - } - let _guard = ext.specialization_cache.write_lock.lock(); - if self.tp_version_tag.load(Ordering::Acquire) != tp_version { - return false; - } - ext.specialization_cache.swap_init(Some(init), Some(vm)); - true + Self::with_type_lock(vm, || { + if self.tp_version_tag.load(Ordering::Acquire) != tp_version { + return false; + } + let func_version = init.get_version_for_current_state(); + if func_version == 0 { + return false; + } + ext.specialization_cache.swap_init(Some(init)); + ext.specialization_cache + .init_version + .store(func_version, Ordering::Release); + true + }) } /// Read cached __init__ for CALL_ALLOC_AND_ENTER_INIT specialization. pub(crate) fn get_cached_init_for_specialization( &self, tp_version: u32, - ) -> Option> { + ) -> Option<(PyRef, u32)> { let ext = self.heaptype_ext.as_ref()?; if tp_version == 0 { return None; @@ -1011,9 +1155,19 @@ impl PyType { if self.tp_version_tag.load(Ordering::Acquire) != tp_version { return None; } - ext.specialization_cache + // Check order: pointer (Acquire) then function version. + let init = ext + .specialization_cache .init - .to_owned_ordering(Ordering::Acquire) + .try_to_owned(Ordering::Acquire)?; + let cached_version = ext + .specialization_cache + .init_version + .load(Ordering::Acquire); + if cached_version == 0 { + return None; + } + Some((init, cached_version)) } /// Cache __getitem__ for BINARY_OP_SUBSCR_GETITEM specialization. @@ -1030,34 +1184,34 @@ impl PyType { if tp_version == 0 { return false; } - let _guard = ext.specialization_cache.write_lock.lock(); - if self.tp_version_tag.load(Ordering::Acquire) != tp_version { - return false; - } - let func_version = getitem.get_version_for_current_state(); - if func_version == 0 { - return false; - } - ext.specialization_cache - .swap_getitem(Some(getitem), Some(vm)); - ext.specialization_cache - .getitem_version - .store(func_version, Ordering::Relaxed); - true + Self::with_type_lock(vm, || { + if self.tp_version_tag.load(Ordering::Acquire) != tp_version { + return false; + } + let func_version = getitem.get_version_for_current_state(); + if func_version == 0 { + return false; + } + ext.specialization_cache.swap_getitem(Some(getitem)); + ext.specialization_cache + .getitem_version + .store(func_version, Ordering::Release); + true + }) } /// Read cached __getitem__ for BINARY_OP_SUBSCR_GETITEM specialization. pub(crate) fn get_cached_getitem_for_specialization(&self) -> Option<(PyRef, u32)> { let ext = self.heaptype_ext.as_ref()?; - // Match CPython check order: pointer (Acquire) then function version. + // Check order: pointer (Acquire) then function version. let getitem = ext .specialization_cache .getitem - .to_owned_ordering(Ordering::Acquire)?; + .try_to_owned(Ordering::Acquire)?; let cached_version = ext .specialization_cache .getitem_version - .load(Ordering::Relaxed); + .load(Ordering::Acquire); if cached_version == 0 { return None; } @@ -1070,82 +1224,14 @@ impl PyType { /// find_name_in_mro with method cache (MCACHE). /// Looks in tp_dict of types in MRO, bypasses descriptors. - /// - /// Uses a lock-free SeqLock-style pattern: - /// Read: load sequence/version/name → load value + try_to_owned → - /// validate value pointer + sequence - /// Write: sequence(begin) → version=0 → swap value/name → version=assigned → sequence(end) fn find_name_in_mro(&self, name: &'static PyStrInterned) -> Option { - let version = self.tp_version_tag.load(Ordering::Acquire); - if version != 0 { - let idx = type_cache_hash(version, name); - let entry = &TYPE_CACHE[idx]; - let name_ptr = name as *const _ as *mut _; - loop { - let seq1 = entry.begin_read(); - let v1 = entry.version.load(Ordering::Acquire); - let type_version = self.tp_version_tag.load(Ordering::Acquire); - if v1 != type_version - || !core::ptr::eq(entry.name.load(Ordering::Relaxed), name_ptr) - { - break; - } - let ptr = entry.value.load(Ordering::Acquire); - if ptr.is_null() { - if entry.end_read(seq1) { - break; - } - continue; - } - // _Py_TryIncrefCompare-style validation: - // safe_inc via raw pointer, then ensure source is unchanged. - if let Some(cloned) = unsafe { PyObject::try_to_owned_from_ptr(ptr) } { - let same_ptr = core::ptr::eq(entry.value.load(Ordering::Relaxed), ptr); - if same_ptr && entry.end_read(seq1) { - return Some(cloned); - } - drop(cloned); - continue; - } - break; - } - } - - // Assign version BEFORE the MRO walk so that any concurrent - // modified() call during the walk invalidates this version. - let assigned = if version == 0 { - self.assign_version_tag() - } else { - version - }; - - // MRO walk - let result = self.find_name_in_mro_uncached(name); - - // Only cache positive results. Negative results are not cached to - // avoid stale entries from transient MRO walk failures during - // concurrent type modifications. - if let Some(ref found) = result - && assigned != 0 - && !TYPE_CACHE_CLEARING.load(Ordering::Acquire) - && self.tp_version_tag.load(Ordering::Acquire) == assigned - { - let idx = type_cache_hash(assigned, name); - let entry = &TYPE_CACHE[idx]; - let name_ptr = name as *const _ as *mut _; - entry.begin_write(); - // Invalidate first to prevent readers from seeing partial state - entry.version.store(0, Ordering::Release); - // Store borrowed pointer (no refcount increment). - let new_ptr = &**found as *const PyObject as *mut PyObject; - entry.value.store(new_ptr, Ordering::Relaxed); - entry.name.store(name_ptr, Ordering::Relaxed); - // Activate entry — Release ensures value/name writes are visible - entry.version.store(assigned, Ordering::Release); - entry.end_write(); - } - - result + crate::vm::thread::try_with_current_vm(|vm| { + self.lookup_ref_and_version_interned(name, vm).0 + }) + // No current VM: this thread is not registered for QSBR, so the + // lock-free cache read protocol is not sound here. Walk the MRO + // under the attributes locks instead (the dicts hold strong refs). + .unwrap_or_else(|| self.find_name_in_mro_uncached(name)) } /// Raw MRO walk without cache. @@ -1161,7 +1247,7 @@ impl PyType { /// _PyType_LookupRef: look up a name through the MRO without setting an exception. pub fn lookup_ref(&self, name: &Py, vm: &VirtualMachine) -> Option { let interned_name = vm.ctx.interned_str(name)?; - self.find_name_in_mro(interned_name) + self.lookup_ref_and_version_interned(interned_name, vm).0 } pub fn get_super_attr(&self, attr_name: &'static PyStrInterned) -> Option { @@ -1178,6 +1264,9 @@ impl PyType { /// Check if attribute exists in MRO, using method cache for fast check. /// Unlike find_name_in_mro, avoids cloning the value on cache hit. fn has_name_in_mro(&self, name: &'static PyStrInterned) -> bool { + #[cfg(all(feature = "threading", debug_assertions))] + crate::vm::thread::debug_assert_current_thread_attached(); + let version = self.tp_version_tag.load(Ordering::Acquire); if version != 0 { let idx = type_cache_hash(version, name); @@ -1350,7 +1439,7 @@ impl Py { } pub fn iter_base_chain(&self) -> impl Iterator { - core::iter::successors(Some(self), |cls| cls.base.as_deref()) + core::iter::successors(Some(self), |cls| cls.base.deref()) } pub fn extend_methods(&'static self, method_defs: &'static [PyMethodDef], ctx: &Context) { @@ -1397,58 +1486,144 @@ impl PyType { } if bases.is_empty() { return Err(vm.new_type_error(format!( - "can only assign non-empty tuple to %s.__bases__, not {}", + "can only assign non-empty tuple to {}.__bases__, not ()", zelf.name() ))); } // TODO: check for mro cycles - // TODO: Remove this class from all subclass lists - // for base in self.bases.read().iter() { - // let subclasses = base.subclasses.write(); - // // TODO: how to uniquely identify the subclasses to remove? - // } + // Compute the new solid base before committing anything. This also + // validates the new bases (BASETYPE flag, no instance layout + // conflict), the same checks type creation performs. + let new_base = best_base(&bases, vm)?.to_owned(); + + // Reject reparenting onto a base whose instances have an incompatible + // object layout. + let old_base = zelf.base.deref().unwrap_or(vm.ctx.types.object_type); + compatible_for_assignment(old_base, &new_base, "__bases__", vm)?; + + // References released inside the critical section are collected here + // and dropped after the lock: dropping them inside can run arbitrary + // code that re-acquires the non-reentrant type mutex. + let mut retired: Vec = Vec::new(); + + // A base swapped out of `zelf.base` may still be observed by + // concurrent lock-free readers; keep it alive in the frame's + // temporary refs so they never see a dangling pointer. + let keep_alive = |type_ref: PyTypeRef, retired: &mut Vec| { + if let Some(frame) = vm.current_frame() { + frame.temporary_refs.lock().push(type_ref.into()); + } else { + retired.push(type_ref.into()); + } + }; - *zelf.bases.write() = bases; - // Recursively update the mros of this class and all subclasses - fn update_mro_recursively(cls: &PyType, vm: &VirtualMachine) -> PyResult<()> { - let mut mro = - PyType::resolve_mro(&cls.bases.read()).map_err(|msg| vm.new_type_error(msg))?; - // Preserve self (mro[0]) when updating MRO - mro.insert(0, cls.mro.read()[0].to_owned()); - *cls.mro.write() = mro; - for subclass in cls.subclasses.write().iter() { - let subclass = subclass.upgrade().unwrap(); - let subclass: &Py = subclass.downcast_ref().unwrap(); - update_mro_recursively(subclass, vm)?; + // Register this type as a subclass of the given bases + let register_subclasses = |bases: &[PyTypeRef]| { + let weakref_type = super::PyWeak::static_type(); + for base in bases { + base.subclasses.write().push( + zelf.as_object() + .downgrade_with_weakref_typ_opt(None, weakref_type.to_owned()) + .unwrap(), + ); } - Ok(()) - } - update_mro_recursively(zelf, vm)?; + }; - // Invalidate inline caches - zelf.modified(); + let result = Self::with_type_lock(vm, || { + // Remove this class from the old bases' subclass lists, pruning + // dead entries along the way. Upgraded refs are retired so the + // last strong reference is never dropped under the lock. + for base in zelf.bases.read().iter() { + let mut subclasses = base.subclasses.write(); + let mut kept = Vec::with_capacity(subclasses.len()); + for weak in subclasses.drain(..) { + match weak.upgrade() { + Some(obj) if obj.is(zelf.as_object()) => { + retired.push(obj); + retired.push(weak.into()); + } + Some(obj) => { + retired.push(obj); + kept.push(weak); + } + None => retired.push(weak.into()), + } + } + *subclasses = kept; + } - // TODO: do any old slots need to be cleaned up first? - zelf.init_slots(&vm.ctx); + let old_bases = core::mem::replace(&mut *zelf.bases.write(), bases); + let old_base = unsafe { zelf.base.swap(Some(new_base)) }; + + // Recursively update the mros of this class and all subclasses, + // recording the previous mros so a failure can be rolled back. + fn update_mro_recursively( + cls: &Py, + undo: &mut Vec<(PyTypeRef, Vec)>, + vm: &VirtualMachine, + ) -> PyResult<()> { + let mut mro = + PyType::resolve_mro(&cls.bases.read()).map_err(|msg| vm.new_type_error(msg))?; + // Preserve self (mro[0]) when updating MRO + mro.insert(0, cls.mro.read()[0].to_owned()); + let old_mro = core::mem::replace(&mut *cls.mro.write(), mro); + undo.push((cls.to_owned(), old_mro)); + for subclass in cls.subclasses.read().iter() { + // Dead entries are pruned elsewhere; skip them here. + let Some(subclass) = subclass.upgrade() else { + continue; + }; + let subclass: &Py = subclass.downcast_ref().unwrap(); + update_mro_recursively(subclass, undo, vm)?; + } + Ok(()) + } + let mut undo = Vec::new(); + if let Err(err) = update_mro_recursively(zelf, &mut undo, vm) { + // Roll back to the previous state. A class reachable through + // multiple bases is recorded once per visit, so restore in + // reverse to end with the first-recorded (original) mro. + for (cls, old_mro) in undo.into_iter().rev() { + let failed_mro = core::mem::replace(&mut *cls.mro.write(), old_mro); + retired.extend(failed_mro.into_iter().map(Into::into)); + retired.push(cls.into()); + } + let failed_bases = core::mem::replace(&mut *zelf.bases.write(), old_bases); + if let Some(failed_base) = unsafe { zelf.base.swap(old_base) } { + keep_alive(failed_base, &mut retired); + } + register_subclasses(&zelf.bases.read()); + retired.extend(failed_bases.into_iter().map(Into::into)); + zelf.modified_inner(); + return Err(err); + } + // Retire the replaced mros as well; dropping them here would + // release them while the lock is held. + for (cls, old_mro) in undo { + retired.extend(old_mro.into_iter().map(Into::into)); + retired.push(cls.into()); + } + retired.extend(old_bases.into_iter().map(Into::into)); + if let Some(old_base) = old_base { + keep_alive(old_base, &mut retired); + } - // Register this type as a subclass of its new bases - let weakref_type = super::PyWeak::static_type(); - for base in zelf.bases.read().iter() { - base.subclasses.write().push( - zelf.as_object() - .downgrade_with_weakref_typ_opt(None, weakref_type.to_owned()) - .unwrap(), - ); - } + // Invalidate inline caches and rebuild every slot for this type and + // all descendants so slots whose methods left the MRO are reset. + zelf.update_all_slots(&vm.ctx); - Ok(()) + register_subclasses(&zelf.bases.read()); + Ok(()) + }); + drop(retired); + result } #[pygetset] fn __base__(&self) -> Option { - self.base.clone() + self.base.to_owned() } #[pygetset] @@ -1533,20 +1708,31 @@ impl PyType { ))); } - let mut attrs = self.attributes.write(); - // First try __annotate__, in case that's been set explicitly - if let Some(annotate) = attrs.get(identifier!(vm, __annotate__)).cloned() { + let annotate_key = identifier!(vm, __annotate__); + let annotate_func_key = identifier!(vm, __annotate_func__); + let attrs = self.attributes.read(); + if let Some(annotate) = attrs.get(annotate_key).cloned() { return Ok(annotate); } - // Then try __annotate_func__ - if let Some(annotate) = attrs.get(identifier!(vm, __annotate_func__)).cloned() { - // TODO: Apply descriptor tp_descr_get if needed + if let Some(annotate) = attrs.get(annotate_func_key).cloned() { return Ok(annotate); } - // Set __annotate_func__ = None and return None + drop(attrs); + let none = vm.ctx.none(); - attrs.insert(identifier!(vm, __annotate_func__), none.clone()); - Ok(none) + let (result, _prev) = Self::with_type_lock(vm, || { + let mut attrs = self.attributes.write(); + if let Some(annotate) = attrs.get(annotate_key).cloned() { + return (annotate, None); + } + if let Some(annotate) = attrs.get(annotate_func_key).cloned() { + return (annotate, None); + } + self.modified_inner(); + let prev = attrs.insert(annotate_func_key, none.clone()); + (none, prev) + }); + Ok(result) } #[pygetset(setter)] @@ -1569,20 +1755,28 @@ impl PyType { return Err(vm.new_type_error("__annotate__ must be callable or None")); } - let mut attrs = self.attributes.write(); - // Clear cached annotations only when setting to a new callable - if !vm.is_none(&value) { - attrs.swap_remove(identifier!(vm, __annotations_cache__)); - } - attrs.insert(identifier!(vm, __annotate_func__), value); + let _prev_values = Self::with_type_lock(vm, || { + self.modified_inner(); + let mut attrs = self.attributes.write(); + // Clear cached annotations only when setting to a new callable + let removed = if !vm.is_none(&value) { + attrs.swap_remove(identifier!(vm, __annotations_cache__)) + } else { + None + }; + let prev = attrs.insert(identifier!(vm, __annotate_func__), value); + (removed, prev) + }); Ok(()) } #[pygetset] fn __annotations__(&self, vm: &VirtualMachine) -> PyResult { + let annotations_key = identifier!(vm, __annotations__); + let annotations_cache_key = identifier!(vm, __annotations_cache__); let attrs = self.attributes.read(); - if let Some(annotations) = attrs.get(identifier!(vm, __annotations__)).cloned() { + if let Some(annotations) = attrs.get(annotations_key).cloned() { // Ignore the __annotations__ descriptor stored on type itself. if !annotations.class().is(vm.ctx.types.getset_type) { if vm.is_none(&annotations) @@ -1597,8 +1791,7 @@ impl PyType { ))); } } - // Then try __annotations_cache__ - if let Some(annotations) = attrs.get(identifier!(vm, __annotations_cache__)).cloned() { + if let Some(annotations) = attrs.get(annotations_cache_key).cloned() { if vm.is_none(&annotations) || annotations.class().is(vm.ctx.types.dict_type) || self.slots.flags.has_feature(PyTypeFlags::HEAPTYPE) @@ -1635,11 +1828,21 @@ impl PyType { vm.ctx.new_dict().into() }; - // Cache the result in __annotations_cache__ - self.attributes - .write() - .insert(identifier!(vm, __annotations_cache__), annotations.clone()); - Ok(annotations) + let (result, _prev) = Self::with_type_lock(vm, || { + let mut attrs = self.attributes.write(); + if let Some(existing) = attrs.get(annotations_key).cloned() + && !existing.class().is(vm.ctx.types.getset_type) + { + return (existing, None); + } + if let Some(existing) = attrs.get(annotations_cache_key).cloned() { + return (existing, None); + } + self.modified_inner(); + let prev = attrs.insert(annotations_cache_key, annotations.clone()); + (annotations, prev) + }); + Ok(result) } #[pygetset(setter)] @@ -1655,43 +1858,43 @@ impl PyType { ))); } - let mut attrs = self.attributes.write(); - let has_annotations = attrs.contains_key(identifier!(vm, __annotations__)); - - match value { - crate::function::PySetterValue::Assign(value) => { - // SET path: store the value (including None) - let key = if has_annotations { - identifier!(vm, __annotations__) - } else { - identifier!(vm, __annotations_cache__) - }; - attrs.insert(key, value); - if has_annotations { - attrs.swap_remove(identifier!(vm, __annotations_cache__)); - } - } - crate::function::PySetterValue::Delete => { - // DELETE path: remove the key - let removed = if has_annotations { - attrs - .swap_remove(identifier!(vm, __annotations__)) - .is_some() - } else { - attrs - .swap_remove(identifier!(vm, __annotations_cache__)) - .is_some() - }; - if !removed { - return Err(vm.new_attribute_error("__annotations__")); + let _prev_values = Self::with_type_lock(vm, || { + self.modified_inner(); + let mut attrs = self.attributes.write(); + let has_annotations = attrs.contains_key(identifier!(vm, __annotations__)); + + let mut prev = Vec::new(); + match value { + crate::function::PySetterValue::Assign(value) => { + let key = if has_annotations { + identifier!(vm, __annotations__) + } else { + identifier!(vm, __annotations_cache__) + }; + prev.extend(attrs.insert(key, value)); + if has_annotations { + prev.extend(attrs.swap_remove(identifier!(vm, __annotations_cache__))); + } } - if has_annotations { - attrs.swap_remove(identifier!(vm, __annotations_cache__)); + crate::function::PySetterValue::Delete => { + let removed = if has_annotations { + attrs.swap_remove(identifier!(vm, __annotations__)) + } else { + attrs.swap_remove(identifier!(vm, __annotations_cache__)) + }; + if removed.is_none() { + return Err(vm.new_attribute_error("__annotations__")); + } + prev.extend(removed); + if has_annotations { + prev.extend(attrs.swap_remove(identifier!(vm, __annotations_cache__))); + } } } - } - attrs.swap_remove(identifier!(vm, __annotate_func__)); - attrs.swap_remove(identifier!(vm, __annotate__)); + prev.extend(attrs.swap_remove(identifier!(vm, __annotate_func__))); + prev.extend(attrs.swap_remove(identifier!(vm, __annotate__))); + Ok(prev) + })?; Ok(()) } @@ -1724,9 +1927,13 @@ impl PyType { #[pygetset(setter)] fn set___module__(&self, value: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { self.check_set_special_type_attr(identifier!(vm, __module__), vm)?; - let mut attributes = self.attributes.write(); - attributes.swap_remove(identifier!(vm, __firstlineno__)); - attributes.insert(identifier!(vm, __module__), value); + let _prev_values = Self::with_type_lock(vm, || { + self.modified_inner(); + let mut attributes = self.attributes.write(); + let removed = attributes.swap_remove(identifier!(vm, __firstlineno__)); + let prev = attributes.insert(identifier!(vm, __module__), value); + (removed, prev) + }); Ok(()) } @@ -1848,24 +2055,26 @@ impl PyType { value: PySetterValue, vm: &VirtualMachine, ) -> PyResult<()> { + let key = identifier!(vm, __type_params__); match value { - PySetterValue::Assign(ref val) => { - let key = identifier!(vm, __type_params__); + PySetterValue::Assign(val) => { self.check_set_special_type_attr(key, vm)?; - self.modified(); - self.attributes.write().insert(key, val.clone().into()); + let _prev_value = Self::with_type_lock(vm, || { + self.modified_inner(); + self.attributes.write().insert(key, val.into()) + }); } PySetterValue::Delete => { - // For delete, we still need to check if the type is immutable if self.slots.flags.has_feature(PyTypeFlags::IMMUTABLETYPE) { return Err(vm.new_type_error(format!( "cannot delete '__type_params__' attribute of immutable type '{}'", self.slot_name() ))); } - let key = identifier!(vm, __type_params__); - self.modified(); - self.attributes.write().shift_remove(&key); + let _prev_value = Self::with_type_lock(vm, || { + self.modified_inner(); + self.attributes.write().shift_remove(&key) + }); } } Ok(()) @@ -2487,10 +2696,12 @@ impl Py { // Check if we can set this special type attribute self.check_set_special_type_attr(identifier!(vm, __doc__), vm)?; - // Set the __doc__ in the type's dict - self.attributes - .write() - .insert(identifier!(vm, __doc__), value); + let _prev_value = PyType::with_type_lock(vm, || { + self.modified_inner(); + self.attributes + .write() + .insert(identifier!(vm, __doc__), value) + }); Ok(()) } @@ -2552,31 +2763,40 @@ impl SetAttr for PyType { } let assign = value.is_assign(); - // Invalidate inline caches before modifying attributes. - // This ensures other threads see the version invalidation before - // any attribute changes, preventing use-after-free of cached descriptors. - zelf.modified(); - - if let PySetterValue::Assign(value) = value { - zelf.attributes.write().insert(attr_name, value); - } else { - let prev_value = zelf.attributes.write().shift_remove(attr_name); // TODO: swap_remove applicable? - if prev_value.is_none() { - return Err(vm.new_attribute_error(format!( - "type object '{}' has no attribute '{}'", - zelf.name(), - attr_name, - ))); - } - } - - if attr_name.as_wtf8().starts_with("__") && attr_name.as_wtf8().ends_with("__") { - if assign { - zelf.update_slot::(attr_name, &vm.ctx); + // Drop old value OUTSIDE the type lock to avoid deadlock: + // dropping may trigger weakref callbacks → method calls → + // LOAD_ATTR specialization → version_for_specialization → type lock. + let _prev_value = Self::with_type_lock(vm, || { + // Invalidate inline caches before modifying attributes. + // This ensures other threads see the version invalidation before + // any attribute changes, preventing use-after-free of cached descriptors. + zelf.modified_inner(); + + let prev_value = if let PySetterValue::Assign(value) = value { + zelf.attributes.write().insert(attr_name, value) } else { - zelf.update_slot::(attr_name, &vm.ctx); + let prev_value = zelf.attributes.write().shift_remove(attr_name); // TODO: swap_remove applicable? + if prev_value.is_none() { + return Err(vm.new_attribute_error(format!( + "type object '{}' has no attribute '{}'", + zelf.name(), + attr_name, + ))); + } + prev_value + }; + + // Keep the slot-table rewrite inside the same transaction as the + // dict mutation and version invalidation. + if attr_name.as_wtf8().starts_with("__") && attr_name.as_wtf8().ends_with("__") { + if assign { + zelf.update_slot::(attr_name, &vm.ctx); + } else { + zelf.update_slot::(attr_name, &vm.ctx); + } } - } + Ok(prev_value) + })?; Ok(()) } } @@ -2596,18 +2816,43 @@ impl Callable for PyType { } } - let obj = if let Some(slot_new) = zelf.slots.new.load() { - slot_new(zelf.to_owned(), args.clone(), vm)? - } else { + let Some(slot_new) = zelf.slots.new.load() else { return Err(vm.new_type_error(format!("cannot create '{}' instances", zelf.slots.name))); }; + // Both the new and init slots consume args, so the init call gets a + // separate copy prepared before slot_new runs. + let init_args = if args.is_empty() { + // Even cloning empty args costs a kwargs map clone; a default + // FuncArgs is indistinguishable from such a clone. + FuncArgs::default() + } else { + // Skip the clone when no init call can follow: the class has no + // init slot, is not `type` itself, and its new slot is a native + // function. new_wrapper is excluded because a Python `__new__` + // can install an `__init__` on the class or return an instance + // of another class while it runs. + // The address comparison is against the single new_wrapper fn item, + // so a mismatch is conservative: if it ever compared unequal for the + // wrapper it would only take the slower cloning path, never the fast + // path incorrectly. + if zelf.slots.init.load().is_none() + && !zelf.is(vm.ctx.types.type_type) + && slot_new as usize != crate::types::new_wrapper as crate::types::NewFunc as usize + { + return slot_new(zelf.to_owned(), args, vm); + } + args.clone() + }; + + let obj = slot_new(zelf.to_owned(), args, vm)?; + if !obj.class().fast_issubclass(zelf) { return Ok(obj); } if let Some(init_method) = obj.class().slots.init.load() { - init_method(obj.clone(), args, vm)?; + init_method(obj.clone(), init_args, vm)?; } Ok(obj) } @@ -2820,8 +3065,8 @@ pub(crate) fn call_slot_new( // that's not a heap type is this type. let mut staticbase = subtype.clone(); while staticbase.slots.flags.has_feature(PyTypeFlags::HEAPTYPE) { - if let Some(base) = staticbase.base.as_ref() { - staticbase = base.clone(); + if let Some(base) = staticbase.base.to_owned() { + staticbase = base; } else { break; } @@ -2953,7 +3198,7 @@ fn shape_differs(t1: &Py, t2: &Py) -> bool { } fn solid_base<'a>(typ: &'a Py, vm: &VirtualMachine) -> &'a Py { - let base = if let Some(base) = &typ.base { + let base = if let Some(base) = typ.base.deref() { solid_base(base, vm) } else { vm.ctx.types.object_type @@ -2997,6 +3242,90 @@ fn best_base<'a>(bases: &'a [PyTypeRef], vm: &VirtualMachine) -> PyResult<&'a Py Ok(base.unwrap()) } +fn type_has_dict(typ: &Py) -> bool { + typ.slots.flags.has_feature(PyTypeFlags::HAS_DICT) +} + +fn type_has_weakref(typ: &Py) -> bool { + typ.slots.flags.has_feature(PyTypeFlags::HAS_WEAKREF) +} + +/// Returns true if `child` adds no instance layout of its own beyond its base, +/// so the base can stand in for it when comparing object layouts. +fn compatible_with_base(child: &Py) -> bool { + let Some(parent) = child.base.deref() else { + return false; + }; + child.slots.basicsize == parent.slots.basicsize + && child.slots.itemsize == parent.slots.itemsize + && child.slots.member_count == parent.slots.member_count + && type_has_dict(child) == type_has_dict(parent) + && type_has_weakref(child) == type_has_weakref(parent) +} + +/// Walk up to the most derived base that actually fixes the instance layout. +fn layout_solid_base(mut typ: &Py) -> &Py { + while compatible_with_base(typ) { + typ = typ.base.deref().unwrap(); + } + typ +} + +/// Returns true if `a` and `b`, which share the same base, added the same +/// instance layout (`__dict__`, `__weakref__`, and `__slots__`). +fn same_slots_added(a: &Py, b: &Py) -> bool { + if a.slots.basicsize != b.slots.basicsize + || a.slots.itemsize != b.slots.itemsize + || a.slots.member_count != b.slots.member_count + || type_has_dict(a) != type_has_dict(b) + || type_has_weakref(a) != type_has_weakref(b) + { + return false; + } + match ( + a.heaptype_ext.as_ref().and_then(|e| e.slots.as_ref()), + b.heaptype_ext.as_ref().and_then(|e| e.slots.as_ref()), + ) { + (Some(x), Some(y)) => { + x.len() == y.len() + && x.iter() + .zip(y.iter()) + .all(|(p, q)| p.as_wtf8() == q.as_wtf8()) + } + (None, None) => true, + _ => false, + } +} + +/// Validates that instances of `old_to` and `new_to` share an interchangeable +/// object layout, the check `__class__` and `__bases__` assignment perform. +/// +/// `attr` names the attribute being assigned for the error message; the +/// message reports `new_to` first and `old_to` second. +pub(crate) fn compatible_for_assignment( + old_to: &Py, + new_to: &Py, + attr: &str, + vm: &VirtualMachine, +) -> PyResult<()> { + let newbase = layout_solid_base(new_to); + let oldbase = layout_solid_base(old_to); + let bases_equal = match (newbase.base.deref(), oldbase.base.deref()) { + (Some(x), Some(y)) => x.is(y), + (None, None) => true, + _ => false, + }; + let compatible = newbase.is(oldbase) || (bases_equal && same_slots_added(newbase, oldbase)); + if compatible { + return Ok(()); + } + Err(vm.new_type_error(format!( + "{attr} assignment: '{}' object layout differs from '{}'", + new_to.name(), + old_to.name() + ))) +} + /// Apply Python name mangling for private attributes. /// `__x` becomes `_ClassName__x` if inside a class. fn mangle_name(class_name: &str, name: &str) -> String { diff --git a/crates/vm/src/coroutine.rs b/crates/vm/src/coroutine.rs index 34d280acdca..844887f8520 100644 --- a/crates/vm/src/coroutine.rs +++ b/crates/vm/src/coroutine.rs @@ -73,6 +73,20 @@ impl Coro { } } + /// Free the finished frame's locals and stack, unless a frame object has + /// escaped (e.g. through an `f_locals` proxy or `sys._getframe`). An + /// escaped frame husk owns its heap-resident locals and must keep them + /// readable after the generator closes. + fn clear_frame_locals_on_close(&self) { + // Keep locals alive if a durable frame reference escaped (e.g. through + // an `f_locals` proxy or `sys._getframe`): that reference now owns the + // heap-resident locals and must keep them readable after close, + // matching `take_ownership`. + if !self.frame.has_escaped() { + self.frame.clear_locals_and_stack(); + } + } + fn maybe_close(&self, res: &PyResult, entered_frame: bool) { if !entered_frame { return; @@ -87,7 +101,7 @@ impl Coro { ); // Completed generators/coroutines should not keep their locals // alive while the wrapper object itself remains referenced. - self.frame.clear_locals_and_stack(); + self.clear_frame_locals_on_close(); } Ok(ExecutionResult::Yield(_)) => {} } @@ -169,10 +183,7 @@ impl Coro { } else { None }; - let (result, entered_frame) = self.run_with_context(jen, vm, |f| { - self.frame.locals_to_fast(vm)?; - f.resume(value, vm) - }); + let (result, entered_frame) = self.run_with_context(jen, vm, |f| f.resume(value, vm)); self.finalize_send_result(result, entered_frame, jen, vm) } @@ -198,10 +209,7 @@ impl Coro { } else { None }; - let (result, entered_frame) = self.run_with_context(jen, vm, |f| { - self.frame.locals_to_fast(vm)?; - f.resume(value, vm) - }); + let (result, entered_frame) = self.run_with_context(jen, vm, |f| f.resume(value, vm)); self.finalize_send_result(result, entered_frame, jen, vm) } @@ -259,7 +267,7 @@ impl Coro { self.closed.store(true); // Release frame locals and stack to free references held by the // closed generator, matching gen_send_ex2 with close_on_completion. - self.frame.clear_locals_and_stack(); + self.clear_frame_locals_on_close(); match result { Ok(ExecutionResult::Yield(_)) => { Err(vm.new_runtime_error(format!("{} ignored GeneratorExit", gen_name(jen, vm)))) diff --git a/crates/vm/src/exception_group.rs b/crates/vm/src/exception_group.rs index 198273a6914..a2c76378ab3 100644 --- a/crates/vm/src/exception_group.rs +++ b/crates/vm/src/exception_group.rs @@ -334,7 +334,7 @@ pub(super) mod types { let exceptions_tuple = vm.ctx.new_tuple(exceptions); let init_args = vec![message, exceptions_tuple.into()]; PyBaseException::new(init_args, vm) - .into_ref_with_type(vm, actual_cls) + .into_ref_with_type_lazy_dict(vm, actual_cls) .map(Into::into) } diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index 1ad86a35aed..845b01c3816 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -690,10 +690,8 @@ impl PyRef { #[pymethod] fn add_note(self, note: PyStrRef, vm: &VirtualMachine) -> PyResult<()> { - let dict = self - .as_object() - .dict() - .ok_or_else(|| vm.new_attribute_error("Exception object has no __dict__"))?; + let dict = crate::builtins::object::object_get_dict(self.as_object().to_owned(), vm) + .map_err(|_| vm.new_attribute_error("Exception object has no __dict__"))?; let notes = if let Ok(notes) = dict.get_item("__notes__", vm) { notes @@ -747,7 +745,7 @@ impl Constructor for PyBaseException { return Err(vm.new_type_error("BaseException() takes no keyword arguments")); } Self::new(args.args, vm) - .into_ref_with_type(vm, cls) + .into_ref_with_type_lazy_dict(vm, cls) .map(Into::into) } @@ -1364,7 +1362,7 @@ impl OSErrorBuilder { let payload = PyOSError::py_new(&exc_type, args.clone().into(), vm) .expect("new_os_error usage error"); let os_error = payload - .into_ref_with_type(vm, exc_type) + .into_ref_with_type_lazy_dict(vm, exc_type) .expect("new_os_error usage error"); PyOSError::slot_init(os_error.as_object().to_owned(), args.into(), vm) .expect("new_os_error usage error"); @@ -1585,7 +1583,7 @@ impl ToPyException for rustpython_host_env::multiprocessing::SemError { pub(super) mod types { use crate::common::lock::PyRwLock; - use crate::object::{MaybeTraverse, Traverse, TraverseFn}; + use crate::object::{Traverse, TraverseFn}; #[cfg_attr(target_arch = "wasm32", allow(unused_imports))] use crate::{ AsObject, Py, PyAtomicRef, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, @@ -1805,7 +1803,7 @@ pub(super) mod types { ))); } - let dict = zelf.dict().unwrap(); + let dict = crate::builtins::object::object_get_dict(zelf.clone(), vm)?; dict.set_item("name", vm.unwrap_or_none(name), vm)?; dict.set_item("path", vm.unwrap_or_none(path), vm)?; dict.set_item("name_from", vm.unwrap_or_none(name_from), vm)?; @@ -1902,7 +1900,7 @@ pub(super) mod types { #[repr(transparent)] pub struct PyUnboundLocalError(PyNameError); - #[pyexception(name, base = PyException, ctx = "os_error")] + #[pyexception(name, base = PyException, ctx = "os_error", traverse = "manual")] #[repr(C)] pub struct PyOSError { base: PyException, @@ -1932,7 +1930,10 @@ pub(super) mod types { unsafe impl Traverse for PyOSError { fn traverse(&self, tracer_fn: &mut TraverseFn<'_>) { - self.base.try_traverse(tracer_fn); + // `self.base` is a `PyException` newtype whose `MaybeTraverse` is a + // no-op; reach the underlying `PyBaseException` so its traceback, + // cause, context and args are visited by the collector. + self.base.0.traverse(tracer_fn); if let Some(obj) = self.errno.deref() { tracer_fn(obj); } @@ -2013,7 +2014,9 @@ pub(super) mod types { } } let payload = Self::py_new(&cls, args, vm)?; - payload.into_ref_with_type(vm, cls).map(Into::into) + payload + .into_ref_with_type_lazy_dict(vm, cls) + .map(Into::into) } } diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index 85b15aaac49..40499efb110 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -12,10 +12,7 @@ use crate::{ builtin_func::PyNativeFunction, descriptor::{MemberGetter, PyMemberDescriptor, PyMethodDescriptor}, frame::stack_analysis, - function::{ - PyBoundMethod, PyCell, PyCellRef, PyFunction, datastack_frame_size_bytes_for_code, - vectorcall_function, - }, + function::{PyBoundMethod, PyCell, PyCellRef, PyFunction, vectorcall_function}, list::PyListIterator, range::PyRangeIterator, tuple::{PyTuple, PyTupleIterator, PyTupleRef}, @@ -39,6 +36,7 @@ use crate::{ use alloc::fmt; use bstr::ByteSlice; use core::cell::UnsafeCell; +use core::ptr::NonNull; use core::sync::atomic; use core::sync::atomic::AtomicPtr; use core::sync::atomic::Ordering::{Acquire, Relaxed}; @@ -54,6 +52,71 @@ use rustpython_compiler_core::SourceLocation; pub type FrameRef = PyRef; +/// Recover an owned reference to a live chain frame, or `None` for null. +/// +/// # Safety +/// A non-null `frame` must reference a frame that is live on the current +/// thread's execution chain, so the object outlives this call. +unsafe fn owned_chain_frame(frame: *const Frame) -> Option { + if frame.is_null() { + return None; + } + // SAFETY: caller guarantees the frame is live; from_payload_ptr recovers + // the enclosing object from the payload address. + let py = unsafe { &*Py::::from_payload_ptr(frame) }; + Some(py.to_owned()) +} + +/// The current thread's topmost frame object, if any. +#[must_use] +pub fn current_thread_frame() -> Option { + // SAFETY: the chain top executes on this thread, hence is alive. + unsafe { owned_chain_frame(crate::vm::thread::get_current_frame()) } +} + +/// The frame `offset` positions below the current thread's top frame (offset 0 +/// is the top), or `None` if the stack is not that deep. +#[must_use] +pub fn frame_at_offset(offset: usize) -> Option { + let mut cur = crate::vm::thread::get_current_frame(); + for _ in 0..offset { + if cur.is_null() { + return None; + } + // SAFETY: chain frames are alive on the current thread's stack. + cur = unsafe { (*cur).previous_frame() }; + } + // SAFETY: same as above. + unsafe { owned_chain_frame(cur) } +} + +/// If `target` is a frame on the current thread's chain, return an owned +/// reference to it; otherwise `None`. Presence on the chain proves liveness. +#[must_use] +pub fn find_owned_chain_frame(target: *const Frame) -> Option { + let mut cur = crate::vm::thread::get_current_frame(); + while !cur.is_null() { + if core::ptr::eq(cur, target) { + // SAFETY: a frame on the current thread's chain is alive. + return unsafe { owned_chain_frame(cur) }; + } + // SAFETY: chain frames are alive on the current thread's stack. + cur = unsafe { (*cur).previous_frame() }; + } + None +} + +/// Invoke `f` for each frame on the current thread's chain, from the topmost +/// frame down to the bottom. +pub fn for_each_current_frame(mut f: impl FnMut(&Py)) { + let mut cur = crate::vm::thread::get_current_frame(); + while !cur.is_null() { + // SAFETY: chain frames are alive on the current thread's stack. + f(unsafe { &*Py::::from_payload_ptr(cur) }); + cur = unsafe { (*cur).previous_frame() }; + } +} + /// The reason why we might be unwinding a block. /// This could be return of function, exception being /// raised, a break or continue being hit, etc.. @@ -111,6 +174,12 @@ impl FrameUnsafeCell { unsafe fn get(&self) -> *mut T { self.0.get() } + + /// Safe exclusive access through `&mut self`. + #[inline(always)] + fn get_mut(&mut self) -> &mut T { + self.0.get_mut() + } } // SAFETY: Frame execution is single-threaded. See FrameUnsafeCell doc. @@ -175,8 +244,9 @@ impl LocalsPlus { /// Create a new LocalsPlus backed by the thread data stack. /// All slots are zero-initialized. /// - /// The caller must call `materialize_localsplus()` when the frame finishes - /// to migrate data to the heap, then `datastack_pop()` to free the memory. + /// When the frame finishes, the caller must migrate data to the heap with + /// `materialize_localsplus()` (or drop it in place with + /// `release_localsplus()`), then `datastack_pop()` to free the memory. fn new_on_datastack(nlocalsplus: usize, stacksize: usize, vm: &VirtualMachine) -> Self { let capacity = nlocalsplus .checked_add(stacksize) @@ -211,6 +281,29 @@ impl LocalsPlus { } } + /// Drop all contained values and detach the data stack backing without + /// copying to the heap, leaving an empty heap-backed husk. + /// Returns the data stack base pointer for `DataStack::pop()`. + /// Returns `None` if already heap-backed. + /// + /// Only valid when the values can never be observed again (the enclosing + /// frame is uniquely referenced): the locals are gone afterwards. + fn release_datastack(&mut self) -> Option<*mut u8> { + let LocalsPlusData::DataStack { ptr, .. } = &self.data else { + return None; + }; + let base = *ptr as *mut u8; + // Drop values while the backing store is still valid. Value drops may + // run `__del__`, which can push nested data stack frames above `base`; + // those are popped before the caller pops `base` (LIFO preserved). + self.drop_values(); + self.data = LocalsPlusData::Heap(Box::default()); + // Keep the accessors consistent with the empty backing store. + // stack_top is already 0 after drop_values(). + self.nlocalsplus = 0; + Some(base) + } + /// Drop all contained values without freeing the backing storage. fn drop_values(&mut self) { self.stack_clear(); @@ -251,6 +344,12 @@ impl LocalsPlus { } } + /// Whether the backing storage still lives on the thread data stack (a + /// running call frame that has not been materialized onto the heap). + fn is_datastack_backed(&self) -> bool { + matches!(self.data, LocalsPlusData::DataStack { .. }) + } + /// Stack capacity (max stack depth). #[inline(always)] fn stack_capacity(&self) -> usize { @@ -605,11 +704,20 @@ pub struct InterpreterFrame { /// Used by `frame.clear()` to reject clearing an executing frame, /// even when called from a different thread. pub(crate) owner: atomic::AtomicI8, - /// Set when f_locals is accessed. Cleared after locals_to_fast() sync. - pub(crate) locals_dirty: atomic::AtomicBool, /// Persistent overlay for `frame.f_locals` when hidden locals need a /// snapshot separate from the backing locals mapping. pub(crate) f_locals_hidden_overlay: PyMutex>, + /// Side storage for `f_locals` proxy keys that do not name a fast local. + /// Lazily created on first non-fast-key write. Mirrors `f_extra_locals`. + pub(crate) f_extra_locals: PyMutex>, + /// Set once a durable Python-level reference to this frame is handed out + /// (`f_locals` proxy, `sys._getframe`, `f_back`). A closed generator keeps + /// its locals alive while this is set, mirroring `frame_obj` ownership. + pub(crate) escaped: atomic::AtomicBool, + /// Strong reference to the caller frame, captured when this frame escapes + /// its execution so `f_back` still resolves after the caller returns and + /// leaves the live frame chain. + pub(crate) retained_back: PyMutex>, /// Number of stack entries to pop after set_f_lineno returns to the /// execution loop. set_f_lineno cannot pop directly because the /// execution loop holds the state mutex. @@ -624,7 +732,55 @@ pub struct InterpreterFrame { /// Analogous to CPython's `PyFrameObject`. #[pyclass(module = false, name = "frame", traverse = "manual")] pub struct Frame { - pub(crate) iframe: FrameUnsafeCell, + /// Always `Some` while the frame is reachable from Python. Emptied only + /// by `Traverse::clear` during deallocation, leaving a trivially-droppable + /// husk that the freelist can cache. + pub(crate) iframe: FrameUnsafeCell>, +} + +impl Frame { + /// Shared access to the embedded interpreter frame. + /// + /// # Safety + /// Caller must ensure no concurrent mutable access (see `FrameUnsafeCell`) + /// and that the frame has not been cleared (i.e. it is still reachable + /// from Python; `Traverse::clear` only runs during deallocation). + #[inline(always)] + unsafe fn iframe_ref(&self) -> &InterpreterFrame { + let opt = unsafe { &*self.iframe.get() }; + #[cfg(debug_assertions)] + if opt.is_none() { + cleared_frame_access(); + } + // SAFETY: iframe is always Some while the frame is reachable (see above). + unsafe { opt.as_ref().unwrap_unchecked() } + } + + /// Exclusive access to the embedded interpreter frame. + /// + /// # Safety + /// Caller must ensure exclusive access (see `FrameUnsafeCell`) and that + /// the frame has not been cleared. + #[inline(always)] + #[allow(clippy::mut_from_ref)] + unsafe fn iframe_mut(&self) -> &mut InterpreterFrame { + let opt = unsafe { &mut *self.iframe.get() }; + #[cfg(debug_assertions)] + if opt.is_none() { + cleared_frame_access(); + } + // SAFETY: iframe is always Some while the frame is reachable (see above). + unsafe { opt.as_mut().unwrap_unchecked() } + } +} + +/// Out-of-line panic for the debug-only cleared-frame check, keeping the +/// inlined accessors' stack frames minimal. +#[cfg(debug_assertions)] +#[cold] +#[inline(never)] +fn cleared_frame_access() -> ! { + panic!("frame accessed after clear"); } impl core::ops::Deref for Frame { @@ -638,21 +794,88 @@ impl core::ops::Deref for Frame { /// are only mutated during single-threaded execution via `with_exec`. #[inline(always)] fn deref(&self) -> &InterpreterFrame { - unsafe { &*self.iframe.get() } + unsafe { self.iframe_ref() } } } +thread_local! { + /// Free list of dead frame objects for reuse. Entries are cleared husks + /// (`iframe == None`) whose child references were already released. + /// PyInner is fixed-size (localsplus storage is out-of-line), + /// so a single bucket suffices. + static FRAME_FREELIST: core::cell::Cell> = + const { core::cell::Cell::new(crate::object::FreeList::new()) }; +} + impl PyPayload for Frame { + const MAX_FREELIST: usize = 200; + const HAS_FREELIST: bool = true; + // Ordinary call frames are created untracked and only enter the GC when + // they escape (see `release_datastack_frame`); generator/coroutine frames + // are tracked explicitly at creation in `invoke_with_locals`. + const NEW_REF_UNTRACKED: bool = true; + #[inline] fn class(ctx: &Context) -> &'static Py { ctx.types.frame_type } + + #[inline] + unsafe fn freelist_push(obj: *mut PyObject) -> bool { + FRAME_FREELIST + .try_with(|fl| { + let mut list = fl.take(); + let stored = if list.len() < Self::MAX_FREELIST { + list.push(obj); + true + } else { + false + }; + fl.set(list); + stored + }) + .unwrap_or(false) + } + + #[inline] + unsafe fn freelist_pop(_payload: &Self) -> Option> { + FRAME_FREELIST + .try_with(|fl| { + let mut list = fl.take(); + let result = list.pop().map(|p| unsafe { NonNull::new_unchecked(p) }); + fl.set(list); + result + }) + .ok() + .flatten() + } } unsafe impl Traverse for Frame { fn traverse(&self, tracer_fn: &mut TraverseFn<'_>) { - // SAFETY: GC traversal does not run concurrently with frame execution. - let iframe = unsafe { &*self.iframe.get() }; + // SAFETY: this traversal reads the frame's live interpreter state + // (`localsplus`), which the owning thread mutates without + // synchronization while executing bytecode. It is only sound when no + // other thread is executing this frame: in threading builds the + // collector stops the world around the traversal phases, and in + // single-threaded builds there is no other thread. A cleared frame + // (iframe == None) has no children to visit. + // + // Invariant (load-bearing for the untracked-frame optimization): every + // reference *to* a frame is recorded as a graph edge by + // `PyRef::traverse` — no other type's `traverse` recurses into a + // frame's payload. So the collector reads a frame's `localsplus` only + // when the frame is itself a tracked candidate. Tracked datastack + // frames are tracked only at `release_datastack_frame`, after they stop + // executing and their localsplus is materialized onto the heap; tracked + // generator frames are heap-backed by construction and their execution + // is stopped-the-world. Hence a running, data-stack-resident frame is + // never traversed by a concurrent collector. If a future change makes + // some type's `traverse` recurse into a frame payload, this invariant + // (and the debug asserts at the track sites) breaks. + let Some(iframe) = (unsafe { &*self.iframe.get() }) else { + return; + }; iframe.code.traverse(tracer_fn); iframe.func_obj.traverse(tracer_fn); iframe.localsplus.traverse(tracer_fn); @@ -662,6 +885,19 @@ unsafe impl Traverse for Frame { iframe.trace.traverse(tracer_fn); iframe.temporary_refs.traverse(tracer_fn); iframe.f_locals_hidden_overlay.traverse(tracer_fn); + iframe.f_extra_locals.traverse(tracer_fn); + iframe.retained_back.traverse(tracer_fn); + } + + fn clear(&mut self, _out: &mut Vec) { + // Drop the interpreter frame in place instead of extracting children + // into `_out`: pushing ~10 refs per frame would grow the buffer, a + // heap allocation on the hot dealloc path. Direct drops release the + // same references under the same recursion protection (trashcan in + // dealloc, deferred-drop context in cycle collection) as the payload + // drop did before the freelist existed. The payload is left as a + // trivially-droppable husk for the freelist. + drop(self.iframe.get_mut().take()); } } @@ -741,13 +977,15 @@ impl Frame { generator: PyAtomicBorrow::new(), previous: AtomicPtr::new(core::ptr::null_mut()), owner: atomic::AtomicI8::new(FrameOwner::FrameObject as i8), - locals_dirty: atomic::AtomicBool::new(false), f_locals_hidden_overlay: PyMutex::new(None), + f_extra_locals: PyMutex::new(None), + escaped: atomic::AtomicBool::new(false), + retained_back: PyMutex::new(None), pending_stack_pops: Default::default(), pending_unwind_from_stack: Default::default(), }; Self { - iframe: FrameUnsafeCell::new(iframe), + iframe: FrameUnsafeCell::new(Some(iframe)), } } @@ -758,7 +996,7 @@ impl Frame { /// or called from the same thread during trace callback). #[inline(always)] pub unsafe fn fastlocals(&self) -> &[Option] { - unsafe { (*self.iframe.get()).localsplus.fastlocals() } + unsafe { self.iframe_ref().localsplus.fastlocals() } } /// Access fastlocals mutably. @@ -768,7 +1006,7 @@ impl Frame { #[inline(always)] #[allow(clippy::mut_from_ref)] pub unsafe fn fastlocals_mut(&self) -> &mut [Option] { - unsafe { (*self.iframe.get()).localsplus.fastlocals_mut() } + unsafe { self.iframe_mut().localsplus.fastlocals_mut() } } /// Migrate data-stack-backed storage to the heap, preserving all values, @@ -779,7 +1017,29 @@ impl Frame { /// Caller must ensure the frame is not executing and the returned /// pointer is passed to `VirtualMachine::datastack_pop()`. pub(crate) unsafe fn materialize_localsplus(&self) -> Option<*mut u8> { - unsafe { (*self.iframe.get()).localsplus.materialize_to_heap() } + unsafe { self.iframe_mut().localsplus.materialize_to_heap() } + } + + /// Drop all localsplus values in place and detach the data stack backing + /// without the heap copy. Returns the data stack base pointer for + /// `VirtualMachine::datastack_pop()`, or `None` if heap-backed. + /// + /// # Safety + /// Caller must ensure the frame is not executing, that no other reference + /// to the frame exists or can be created (localsplus is unobservable + /// afterwards), and that the returned pointer is passed to + /// `VirtualMachine::datastack_pop()`. + pub(crate) unsafe fn release_localsplus(&self) -> Option<*mut u8> { + unsafe { self.iframe_mut().localsplus.release_datastack() } + } + + /// Whether this frame's localsplus is still data-stack-backed. A frame + /// must have heap-backed localsplus before it is GC-tracked so that a + /// concurrent collector never reads data-stack-resident, still-mutating + /// storage. Used only in debug assertions at the track sites. + pub(crate) fn localsplus_is_datastack_backed(&self) -> bool { + // SAFETY: called at a track site where the frame is not executing. + unsafe { self.iframe_ref().localsplus.is_datastack_backed() } } /// Clear evaluation stack and state-owned cell/free references. @@ -788,7 +1048,7 @@ impl Frame { // SAFETY: Called when frame is not executing (generator closed). // Cell refs in fastlocals[nlocals..] are cleared by clear_locals_and_stack(). unsafe { - (*self.iframe.get()).localsplus.stack_clear(); + self.iframe_mut().localsplus.stack_clear(); } } @@ -797,17 +1057,18 @@ impl Frame { pub(crate) fn clear_locals_and_stack(&self) { self.clear_stack_and_cells(); // SAFETY: Frame is not executing (generator closed). - let fastlocals = unsafe { (*self.iframe.get()).localsplus.fastlocals_mut() }; + let fastlocals = unsafe { self.iframe_mut().localsplus.fastlocals_mut() }; for slot in fastlocals.iter_mut() { *slot = None; } self.f_locals_hidden_overlay.lock().take(); + self.f_extra_locals.lock().take(); } /// Get cell contents by localsplus index. pub(crate) fn get_cell_contents(&self, localsplus_idx: usize) -> Option { // SAFETY: Frame not executing; no concurrent mutation. - let fastlocals = unsafe { (*self.iframe.get()).localsplus.fastlocals() }; + let fastlocals = unsafe { self.iframe_ref().localsplus.fastlocals() }; fastlocals .get(localsplus_idx) .and_then(|slot| slot.as_ref()) @@ -825,8 +1086,17 @@ impl Frame { /// Clear the generator back-reference. Called when the generator is finalized. pub fn clear_generator(&self) { - self.generator.clear(); - self.owner + // The generator's drop may run after this frame was already cleared + // by cycle collection (both were garbage and the frame was cleared + // first); nothing to unlink then. + // SAFETY: shared access; the finalizing generator owns the frame, + // which is not executing. + let Some(iframe) = (unsafe { &*self.iframe.get() }) else { + return; + }; + iframe.generator.clear(); + iframe + .owner .store(FrameOwner::FrameObject as i8, atomic::Ordering::Release); } @@ -839,6 +1109,16 @@ impl Frame { self.previous.load(atomic::Ordering::Relaxed) } + /// Record that a durable Python-level reference to this frame escaped. + pub(crate) fn mark_escaped(&self) { + self.escaped.store(true, atomic::Ordering::Release); + } + + /// Whether a durable reference to this frame has escaped. + pub(crate) fn has_escaped(&self) -> bool { + self.escaped.load(atomic::Ordering::Acquire) + } + pub fn lasti(&self) -> u32 { self.lasti.load(Relaxed) } @@ -863,41 +1143,10 @@ impl Frame { self.pending_unwind_from_stack.store(val, Relaxed); } - /// Sync locals dict back to fastlocals. Called before generator/coroutine resume - /// to apply any modifications made via f_locals. - pub fn locals_to_fast(&self, vm: &VirtualMachine) -> PyResult<()> { - if !self.locals_dirty.load(atomic::Ordering::Acquire) { - return Ok(()); - } - let code = &**self.code; - let overlay_locals = self - .has_active_hidden_locals() - .then(|| self.f_locals_hidden_overlay.lock().clone()) - .flatten() - .map(ArgMapping::from_dict_exact); - let locals_map = overlay_locals - .as_ref() - .map_or_else(|| self.locals.mapping(vm), ArgMapping::mapping); - // SAFETY: Called before generator resume; no concurrent access. - let fastlocals = unsafe { (*self.iframe.get()).localsplus.fastlocals_mut() }; - for (i, &varname) in code.varnames.iter().enumerate() { - if i >= fastlocals.len() { - break; - } - match locals_map.subscript(varname, vm) { - Ok(value) => fastlocals[i] = Some(value), - Err(e) if e.fast_isinstance(vm.ctx.exceptions.key_error) => {} - Err(e) => return Err(e), - } - } - self.locals_dirty.store(false, atomic::Ordering::Release); - Ok(()) - } - fn has_active_hidden_locals(&self) -> bool { use rustpython_compiler_core::bytecode::{CO_FAST_CELL, CO_FAST_FREE, CO_FAST_HIDDEN}; let code = &**self.code; - let fastlocals = unsafe { (*self.iframe.get()).localsplus.fastlocals() }; + let fastlocals = unsafe { self.iframe_ref().localsplus.fastlocals() }; let is_optimized = code.flags.contains(bytecode::CodeFlags::OPTIMIZED); !is_optimized && code.localspluskinds.iter().enumerate().any(|(i, &kind)| { @@ -929,7 +1178,7 @@ impl Frame { // SAFETY: Either the frame is not executing (caller checked owner), // or we're in a trace callback on the same thread that's executing. let code = &**self.code; - let fastlocals = unsafe { (*self.iframe.get()).localsplus.fastlocals() }; + let fastlocals = unsafe { self.iframe_ref().localsplus.fastlocals() }; // Iterate through all localsplus slots using localspluskinds let nlocalsplus = code.localspluskinds.len(); @@ -1023,13 +1272,38 @@ impl Frame { Ok(()) } + /// Reject locals access for a frame that is executing on another thread. + /// + /// A thread-owned frame mutates `localsplus` without synchronization, so + /// reading fastlocals from a different thread would be a data race (the + /// executing thread overwrites slots and drops the old values while the + /// reader clones them). Access from the executing thread itself (locals() + /// builtin, trace callbacks) is fine: the frame sits on the current + /// thread's frame chain and is at a bytecode boundary. + pub(crate) fn check_locals_access(&self, vm: &VirtualMachine) -> PyResult<()> { + let owner = FrameOwner::from_i8(self.owner.load(atomic::Ordering::Acquire)); + if owner != FrameOwner::Thread { + return Ok(()); + } + let mut cur = crate::vm::thread::get_current_frame(); + while !cur.is_null() { + if core::ptr::eq(cur, self) { + return Ok(()); + } + cur = unsafe { (*cur).previous_frame() }; + } + Err(vm.new_runtime_error( + "cannot access frame locals while the frame is executing in another thread", + )) + } + pub fn f_locals_mapping(&self, vm: &VirtualMachine) -> PyResult { + self.check_locals_access(vm)?; if !self.has_active_hidden_locals() { self.f_locals_hidden_overlay.lock().take(); return self.locals(vm); } - let needs_refresh = !self.locals_dirty.load(atomic::Ordering::Acquire); let overlay_dict = { let mut overlay = self.f_locals_hidden_overlay.lock(); match overlay.as_ref() { @@ -1041,25 +1315,240 @@ impl Frame { } } }; - if needs_refresh { - PyDict::clear(&overlay_dict); - let overlay = ArgMapping::from_dict_exact(overlay_dict.clone()); - self.sync_visible_locals_to_mapping(overlay.mapping(), vm)?; - } + PyDict::clear(&overlay_dict); + let overlay = ArgMapping::from_dict_exact(overlay_dict.clone()); + self.sync_visible_locals_to_mapping(overlay.mapping(), vm)?; Ok(ArgMapping::from_dict_exact(overlay_dict)) } pub fn locals(&self, vm: &VirtualMachine) -> PyResult { - if self.has_active_hidden_locals() { + let mapping = if self.has_active_hidden_locals() { // Match CPython's locals() behavior for frames with PEP 709 hidden // locals: return a fresh snapshot instead of the backing mapping. let overlay = ArgMapping::from_dict_exact(vm.ctx.new_dict()); self.sync_visible_locals_to_mapping(overlay.mapping(), vm)?; - Ok(overlay) + overlay } else { self.sync_visible_locals_to_mapping(self.locals.mapping(vm), vm)?; - Ok(self.locals.clone_mapping(vm)) + self.locals.clone_mapping(vm) + }; + self.fold_extra_locals(&mapping, vm)?; + Ok(mapping) + } + + /// Copy the frame's extra-locals side storage (proxy keys that are not + /// fast locals) into `mapping`. No-op when nothing was ever stored. + fn fold_extra_locals(&self, mapping: &ArgMapping, vm: &VirtualMachine) -> PyResult<()> { + let extra = self.f_extra_locals.lock().clone(); + if let Some(extra) = extra { + for (key, value) in &extra { + mapping.mapping().ass_subscript(&key, Some(value), vm)?; + } } + Ok(()) + } + + /// Read a fast-local slot's visible value, dereferencing cells. `None` if + /// the slot is empty or its cell holds no value. + fn framelocalsproxy_getval(&self, i: usize) -> Option { + use rustpython_compiler_core::bytecode::{CO_FAST_CELL, CO_FAST_FREE}; + // SAFETY: callers first pass through `check_locals_access`, so the + // frame is not executing on another thread. + let fastlocals = unsafe { self.iframe_ref().localsplus.fastlocals() }; + let obj = fastlocals.get(i)?.as_ref()?; + let kind = self.code.localspluskinds.get(i).copied().unwrap_or(0); + if kind & (CO_FAST_CELL | CO_FAST_FREE) != 0 { + if let Some(cell) = obj.downcast_ref::() { + cell.get() + } else { + Some(obj.clone()) + } + } else { + Some(obj.clone()) + } + } + + /// Write `value` into fast-local slot `i`, routing through the cell when + /// the slot holds one so closures keep sharing the same cell. + fn framelocalsproxy_setval(&self, i: usize, value: PyObjectRef) { + use rustpython_compiler_core::bytecode::{CO_FAST_CELL, CO_FAST_FREE}; + let kind = self.code.localspluskinds.get(i).copied().unwrap_or(0); + // SAFETY: callers first pass through `check_locals_access`. + let fastlocals = unsafe { self.iframe_mut().localsplus.fastlocals_mut() }; + if kind & (CO_FAST_CELL | CO_FAST_FREE) != 0 + && let Some(obj) = fastlocals[i].as_ref() + && let Some(cell) = obj.downcast_ref::() + { + cell.set(Some(value)); + return; + } + fastlocals[i] = Some(value); + } + + /// Resolve `key` to a fast-local slot index, or `None` if it names no fast + /// local. `read` selects read semantics (only bound slots match) versus + /// write semantics (hidden slots are skipped, unbound slots still match). + /// Raises `TypeError` for an unhashable key. + fn framelocalsproxy_getkeyindex( + &self, + key: &PyObject, + read: bool, + vm: &VirtualMachine, + ) -> PyResult> { + use rustpython_compiler_core::bytecode::CO_FAST_HIDDEN; + // The proxy hashes the key first; an unhashable key raises TypeError. + key.hash(vm)?; + for (i, &kind) in self.code.localspluskinds.iter().enumerate() { + let name = localsplus_name(&self.code, i); + if !name + .as_object() + .rich_compare_bool(key, PyComparisonOp::Eq, vm)? + { + continue; + } + if read { + if self.framelocalsproxy_getval(i).is_some() { + return Ok(Some(i)); + } + } else if kind & CO_FAST_HIDDEN == 0 { + return Ok(Some(i)); + } + } + Ok(None) + } + + /// Build a fresh ordered snapshot dict of the proxy's visible contents: + /// bound fast locals in localsplus order followed by extra locals. + pub(crate) fn framelocalsproxy_snapshot(&self, vm: &VirtualMachine) -> PyResult { + self.check_locals_access(vm)?; + let dict = vm.ctx.new_dict(); + let mapping = ArgMapping::from_dict_exact(dict.clone()); + self.sync_visible_locals_to_mapping(mapping.mapping(), vm)?; + self.fold_extra_locals(&mapping, vm)?; + Ok(dict) + } + + /// `proxy[key]`: read a fast local live, else fall back to extra locals. + pub(crate) fn framelocalsproxy_getitem( + &self, + key: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { + self.check_locals_access(vm)?; + if let Some(i) = self.framelocalsproxy_getkeyindex(&key, true, vm)? + && let Some(value) = self.framelocalsproxy_getval(i) + { + return Ok(value); + } + let extra = self.f_extra_locals.lock().clone(); + if let Some(extra) = extra + && let Some(value) = extra.get_item_opt(&*key, vm)? + { + return Ok(value); + } + Err(vm.new_key_error(key)) + } + + /// `key in proxy`. + pub(crate) fn framelocalsproxy_contains( + &self, + key: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { + self.check_locals_access(vm)?; + if self.framelocalsproxy_getkeyindex(&key, true, vm)?.is_some() { + return Ok(true); + } + let extra = self.f_extra_locals.lock().clone(); + if let Some(extra) = extra { + return Ok(extra.get_item_opt(&*key, vm)?.is_some()); + } + Ok(false) + } + + /// `proxy[key] = value`: fast-key writes the slot in place, other keys go + /// to the extra-locals side dict. + pub(crate) fn framelocalsproxy_setitem( + &self, + key: PyObjectRef, + value: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult<()> { + self.check_locals_access(vm)?; + if let Some(i) = self.framelocalsproxy_getkeyindex(&key, false, vm)? { + self.framelocalsproxy_setval(i, value); + return Ok(()); + } + let extra = self.extra_locals_get_or_create(vm); + extra.set_item(&*key, value, vm) + } + + /// `del proxy[key]`: deleting a fast local raises ValueError; extra keys are + /// removed (KeyError if absent). + pub(crate) fn framelocalsproxy_delitem( + &self, + key: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult<()> { + self.check_locals_access(vm)?; + if self + .framelocalsproxy_getkeyindex(&key, false, vm)? + .is_some() + { + return Err(vm.new_value_error("cannot remove local variables from FrameLocalsProxy")); + } + let extra = self.f_extra_locals.lock().clone(); + if let Some(extra) = extra + && extra.get_item_opt(&*key, vm)?.is_some() + { + return extra.del_item(&*key, vm); + } + Err(vm.new_key_error(key)) + } + + /// `proxy.pop(key[, default])`. + pub(crate) fn framelocalsproxy_pop( + &self, + key: PyObjectRef, + default: Option, + vm: &VirtualMachine, + ) -> PyResult { + self.check_locals_access(vm)?; + if self + .framelocalsproxy_getkeyindex(&key, false, vm)? + .is_some() + { + return Err(vm.new_value_error("cannot remove local variables from FrameLocalsProxy")); + } + let extra = self.f_extra_locals.lock().clone(); + if let Some(extra) = extra + && let Some(value) = extra.pop_item(&*key, vm)? + { + return Ok(value); + } + default.ok_or_else(|| vm.new_key_error(key)) + } + + /// `proxy.setdefault(key, default)`. + pub(crate) fn framelocalsproxy_setdefault( + &self, + key: PyObjectRef, + default: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { + match self.framelocalsproxy_getitem(key.clone(), vm) { + Ok(value) => Ok(value), + Err(e) if e.fast_isinstance(vm.ctx.exceptions.key_error) => { + self.framelocalsproxy_setitem(key, default.clone(), vm)?; + Ok(default) + } + Err(e) => Err(e), + } + } + + fn extra_locals_get_or_create(&self, vm: &VirtualMachine) -> PyDictRef { + let mut extra = self.f_extra_locals.lock(); + extra.get_or_insert_with(|| vm.ctx.new_dict()).clone() } } @@ -1069,7 +1558,7 @@ impl Py { // SAFETY: Frame execution is single-threaded. Only one thread at a time // executes a given frame (enforced by the owner field and generator // running flag). Same safety argument as FastLocals (UnsafeCell). - let iframe = unsafe { &mut *self.iframe.get() }; + let iframe = unsafe { self.iframe_mut() }; let exec = ExecutingFrame { code: &iframe.code, localsplus: &mut iframe.localsplus, @@ -1129,7 +1618,7 @@ impl Py { return None; } // SAFETY: Frame is not executing, so UnsafeCell access is safe. - let iframe = unsafe { &mut *self.iframe.get() }; + let iframe = unsafe { self.iframe_mut() }; let exec = ExecutingFrame { code: &iframe.code, localsplus: &mut iframe.localsplus, @@ -1220,12 +1709,117 @@ fn specialization_nonnegative_compact_index(i: &PyInt, vm: &VirtualMachine) -> O } } -fn release_datastack_frame(frame: &Py, vm: &VirtualMachine) { +/// Get the variable name for a localsplus index of `code`. +fn localsplus_name(code: &PyCode, idx: usize) -> &'static PyStrInterned { + use rustpython_compiler_core::bytecode::{CO_FAST_CELL, CO_FAST_FREE, CO_FAST_LOCAL}; + let nlocals = code.varnames.len(); + let kind = code.localspluskinds.get(idx).copied().unwrap_or(0); + if kind & CO_FAST_LOCAL != 0 { + // Merged cell or regular local: name is in varnames + code.varnames[idx] + } else if kind & CO_FAST_FREE != 0 { + // Free var: slots are at the end of localsplus + let nlocalsplus = code.localspluskinds.len(); + let nfrees = code.freevars.len(); + let free_start = nlocalsplus - nfrees; + code.freevars[idx - free_start] + } else if kind & CO_FAST_CELL != 0 { + // Non-merged cell: count how many non-merged cell slots are before + // this index to find the corresponding cellvars entry. + // Non-merged cellvars appear in their original order (skipping merged ones). + let nonmerged_pos = code.localspluskinds[nlocals..idx] + .iter() + .filter(|&&k| k == CO_FAST_CELL) + .count(); + // Skip merged cellvars to find the right one + let mut cv_idx = 0; + let mut nonmerged_count = 0; + for (i, name) in code.cellvars.iter().enumerate() { + let is_merged = code.varnames.contains(name); + if !is_merged { + if nonmerged_count == nonmerged_pos { + cv_idx = i; + break; + } + nonmerged_count += 1; + } + } + code.cellvars[cv_idx] + } else { + code.varnames[idx] + } +} + +/// Free a finished call frame's data stack storage. +/// +/// When the caller holds the only reference to the frame, the locals and +/// stack values are dropped in place and the storage is released without a +/// heap copy. Otherwise (the frame escaped through a traceback, +/// `sys._getframe`, a trace callback, ...) the values are copied to the heap +/// first so they stay readable through the escaped reference. +pub(crate) fn release_datastack_frame(frame: &Py, vm: &VirtualMachine) { + let frame_obj = frame.as_object(); + // Uniqueness argument: at this point the frame is already out of + // the thread-frames registry and the current-frame chain + // (both unlinked inside `with_frame` before it returned), and the + // frame type has no weakref support. A datastack frame is created + // untracked and stays untracked while it runs, so it is in no GC + // generation list and no collector can observe or incref it. Hence no + // thread can mint a new reference without already holding one, and every + // escape (traceback, `sys._getframe`, `f_back`, a stored trace-hook arg) + // is a heap reference created on this thread while the frame ran. + // Therefore `strong_count() == 1` here means nothing escaped, and + // `strong_count() > 1` means the frame escaped. + debug_assert!( + !frame_obj.is_gc_tracked(), + "datastack frame is GC-tracked at release" + ); + if frame_obj.strong_count() == 1 { + // A reference minted and already released by another thread (through a + // heap escape carried across threads) ends in a release-decref; the + // fence orders that thread's memory before our drops below. + atomic::fence(Acquire); + // SAFETY: unique owner and no way to mint a new reference, so + // localsplus can never be observed again. The base pointer came + // from this thread's data stack. + unsafe { + if let Some(base) = frame.release_localsplus() { + vm.datastack_pop(base); + } + } + return; + } + // Escaped. Stabilize localsplus on the heap FIRST, then join the GC. This + // order guarantees a concurrent (stop-the-world) collector only ever sees a + // tracked frame whose localsplus is heap-resident and no longer mutating: + // the frame has stopped executing before it becomes a candidate, so its + // outgoing edges are stable while a collector traverses them. + // SAFETY: the frame finished executing; the base pointer came from this + // thread's data stack. unsafe { if let Some(base) = frame.materialize_localsplus() { vm.datastack_pop(base); } } + // Retain a strong reference to the caller so `f_back` keeps resolving once + // the caller returns and leaves the live frame chain. The caller is still + // executing here (this frame is unwinding back into it), so its payload + // pointer is live. + // SAFETY: `previous` points at the live caller on this thread's stack. + *frame.retained_back.lock() = unsafe { owned_chain_frame(frame.previous_frame()) }; + // Invariant: a tracked frame must always have heap-backed localsplus + // (proven here for escaped datastack frames and by construction for + // generator frames, which are born heap-backed). A stop-the-world + // collector reads a frame's localsplus only when the frame is a tracked + // candidate, so this keeps it from ever reading data-stack-resident, + // still-mutating storage of an executing frame. + debug_assert!( + !frame.localsplus_is_datastack_backed(), + "escaped frame tracked before its localsplus was materialized" + ); + // SAFETY: the frame is alive (held by `frame` and the escaped reference) + // and untracked. + unsafe { crate::gc_state::gc_state().track_object(NonNull::from(frame_obj)) }; } type BinaryOpExtendGuard = fn(&PyObject, &PyObject, &VirtualMachine) -> bool; @@ -1239,6 +1833,34 @@ struct BinaryOpExtendSpecializationDescr { const BINARY_OP_EXTEND_EXTERNAL_CACHE_OFFSET: usize = 1; +/// Max total args (including self) staged in a fixed-size stack buffer by the +/// exact-args call fast paths; larger arities fall back to a heap buffer. +const MAX_INLINE_CALL_ARGS: usize = 8; + +/// Staging buffer for exact-args call fast paths: fixed-size inline storage +/// for small arities, avoiding a per-call Vec allocation. +enum CallArgBuffer { + Inline(usize, [Option; MAX_INLINE_CALL_ARGS]), + Heap(Vec>), +} + +impl CallArgBuffer { + fn new(total_nargs: usize) -> Self { + if total_nargs <= MAX_INLINE_CALL_ARGS { + Self::Inline(total_nargs, [const { None }; MAX_INLINE_CALL_ARGS]) + } else { + Self::Heap(vec![None; total_nargs]) + } + } + + fn slots(&mut self) -> &mut [Option] { + match self { + Self::Inline(len, buf) => &mut buf[..*len], + Self::Heap(buf) => buf, + } + } +} + #[inline] fn compactlongs_guard(lhs: &PyObject, rhs: &PyObject, vm: &VirtualMachine) -> bool { compact_int_from_obj(lhs, vm).is_some() && compact_int_from_obj(rhs, vm).is_some() @@ -1396,55 +2018,34 @@ impl fmt::Debug for ExecutingFrame<'_> { } impl ExecutingFrame<'_> { - #[inline] - fn monitoring_disabled_for_code(&self, vm: &VirtualMachine) -> bool { - self.code.is(&vm.ctx.init_cleanup_code) - } - - fn specialization_new_init_cleanup_frame(&self, vm: &VirtualMachine) -> FrameRef { - Frame::new( - vm.ctx.init_cleanup_code.clone(), - Scope::new( - Some(ArgMapping::from_dict_exact(vm.ctx.new_dict())), - self.globals.clone(), - ), - self.builtins.clone(), - &[], - None, - true, - vm, - ) - .into_ref(&vm.ctx) - } - - fn specialization_run_init_cleanup_shim( + /// Run `__init__` for the tp_new specialization. `args` holds the + /// `__init__` args with slot 0 left empty; it is filled with `new_obj` + /// here. Enforces the `__init__() should return None` contract and + /// returns the constructed object. + fn specialization_run_init( &self, new_obj: PyObjectRef, init_func: &Py, - pos_args: Vec, + args: &mut [Option], vm: &VirtualMachine, ) -> PyResult { - let shim = self.specialization_new_init_cleanup_frame(vm); - let shim_result = vm.with_frame_untraced(shim.clone(), |shim| { - shim.with_exec(vm, |mut exec| exec.push_value(new_obj.clone())); - - let mut all_args = Vec::with_capacity(pos_args.len() + 1); - all_args.push(new_obj.clone()); - all_args.extend(pos_args); + args[0] = Some(new_obj.clone()); + let taken = args + .iter_mut() + .map(|slot| slot.take().expect("arg slot must be filled")); - let init_frame = init_func.prepare_exact_args_frame(all_args, vm); - let init_result = vm.run_frame(init_frame.clone()); - release_datastack_frame(&init_frame, vm); - let init_result = init_result?; + let init_frame = init_func.prepare_exact_args_frame(taken, vm); + let init_result = vm.run_frame(init_frame.clone()); + release_datastack_frame(&init_frame, vm); + let init_result = init_result?; - shim.with_exec(vm, |mut exec| exec.push_value(init_result)); - match shim.run(vm)? { - ExecutionResult::Return(value) => Ok(value), - ExecutionResult::Yield(_) => unreachable!("_Py_InitCleanup shim cannot yield"), - } - }); - release_datastack_frame(&shim, vm); - shim_result + if !vm.is_none(&init_result) { + return Err(vm.new_type_error(format!( + "__init__() should return None, not '{:.200}'", + init_result.class().name() + ))); + } + Ok(new_obj) } #[inline(always)] @@ -1483,6 +2084,15 @@ impl ExecutingFrame<'_> { if stack_analysis::top_of_stack(cur_stack) == stack_analysis::Kind::Except as i64 && let Some(exc_obj) = val { + // An Except-typed stack slot is only produced by bytecode that + // also carries one of the opcodes scanned by + // `PyCode::has_exc_handling`; otherwise the save/restore that + // brackets this frame's exc_info is elided and this write would + // corrupt the shared exc_info slot. + debug_assert!( + self.code.has_exc_handling, + "unwinding an Except slot in a frame without exc-handling opcodes" + ); if vm.is_none(&exc_obj) { vm.set_exception(None); } else { @@ -1585,39 +2195,44 @@ impl ExecutingFrame<'_> { } } - if vm.eval_breaker_tripped() - && let Err(exception) = vm.check_signals() - { - #[cold] - fn handle_signal_exception( - frame: &mut ExecutingFrame<'_>, - exception: PyBaseExceptionRef, - idx: usize, - vm: &VirtualMachine, - ) -> FrameResult { - if let Some((loc, _end_loc)) = frame.code.locations.get(idx) { - let next = exception.__traceback__(); - let new_traceback = PyTraceback::new( - next, - frame.object.to_owned(), - idx as u32 * 2, - loc.line, - ); - exception.set_traceback_typed(Some(new_traceback.into_ref(&vm.ctx))); - } - vm.contextualize_exception(&exception); - frame.unwind_blocks(vm, UnwindReason::Raising { exception }) - } - match handle_signal_exception(self, exception, idx, vm) { - Ok(None) => {} - Ok(Some(value)) => { - break Ok(value); + if vm.eval_breaker_tripped() { + if let Err(exception) = vm.check_signals() { + #[cold] + fn handle_signal_exception( + frame: &mut ExecutingFrame<'_>, + exception: PyBaseExceptionRef, + idx: usize, + vm: &VirtualMachine, + ) -> FrameResult { + if let Some((loc, _end_loc)) = frame.code.locations.get(idx) { + let next = exception.__traceback__(); + let new_traceback = PyTraceback::new( + next, + frame.object.to_owned(), + idx as u32 * 2, + loc.line, + ); + exception.set_traceback_typed(Some(new_traceback.into_ref(&vm.ctx))); + } + vm.contextualize_exception(&exception); + frame.unwind_blocks(vm, UnwindReason::Raising { exception }) } - Err(exception) => { - break Err(exception); + match handle_signal_exception(self, exception, idx, vm) { + Ok(None) => {} + Ok(Some(value)) => { + break Ok(value); + } + Err(exception) => { + break Err(exception); + } } + continue; } - continue; + // Run a scheduled automatic collection here — a safepoint with + // no interpreter locks held — instead of synchronously inside + // the allocation that tripped the threshold. + #[cfg(feature = "threading")] + vm.run_scheduled_gc(); } let lasti_before = self.lasti(); let result = self.execute_instruction(op, arg, &mut do_extend_arg, vm); @@ -2045,43 +2660,7 @@ impl ExecutingFrame<'_> { /// Get the variable name for a localsplus index. fn localsplus_name(&self, idx: usize) -> &'static PyStrInterned { - use rustpython_compiler_core::bytecode::{CO_FAST_CELL, CO_FAST_FREE, CO_FAST_LOCAL}; - let nlocals = self.code.varnames.len(); - let kind = self.code.localspluskinds.get(idx).copied().unwrap_or(0); - if kind & CO_FAST_LOCAL != 0 { - // Merged cell or regular local: name is in varnames - self.code.varnames[idx] - } else if kind & CO_FAST_FREE != 0 { - // Free var: slots are at the end of localsplus - let nlocalsplus = self.code.localspluskinds.len(); - let nfrees = self.code.freevars.len(); - let free_start = nlocalsplus - nfrees; - self.code.freevars[idx - free_start] - } else if kind & CO_FAST_CELL != 0 { - // Non-merged cell: count how many non-merged cell slots are before - // this index to find the corresponding cellvars entry. - // Non-merged cellvars appear in their original order (skipping merged ones). - let nonmerged_pos = self.code.localspluskinds[nlocals..idx] - .iter() - .filter(|&&k| k == CO_FAST_CELL) - .count(); - // Skip merged cellvars to find the right one - let mut cv_idx = 0; - let mut nonmerged_count = 0; - for (i, name) in self.code.cellvars.iter().enumerate() { - let is_merged = self.code.varnames.contains(name); - if !is_merged { - if nonmerged_count == nonmerged_pos { - cv_idx = i; - break; - } - nonmerged_count += 1; - } - } - self.code.cellvars[cv_idx] - } else { - self.code.varnames[idx] - } + localsplus_name(self.code, idx) } /// Execute a single instruction. @@ -3369,17 +3948,6 @@ impl ExecutingFrame<'_> { self.code.instructions.quicken(); atomic::fence(atomic::Ordering::Release); } - if self.monitoring_disabled_for_code(vm) { - let global_ver = vm - .state - .instrumentation_version - .load(atomic::Ordering::Acquire); - monitoring::instrument_code(self.code, 0); - self.code - .instrumentation_version - .store(global_ver, atomic::Ordering::Release); - return Ok(None); - } // Check if bytecode needs re-instrumentation let global_ver = vm .state @@ -3752,7 +4320,7 @@ impl ExecutingFrame<'_> { let should_be_none = self.pop_value(); if !vm.is_none(&should_be_none) { return Err(vm.new_type_error(format!( - "__init__() should return None, not '{}'", + "__init__() should return None, not '{:.200}'", should_be_none.class().name() ))); } @@ -4090,7 +4658,8 @@ impl ExecutingFrame<'_> { debug_assert!(func.has_exact_argcount(2)); let owner = self.pop_value(); let attr_name = self.code.names[oparg.name_idx() as usize].to_owned().into(); - let result = func.invoke_exact_args(vec![owner, attr_name], vm)?; + let result = + func.invoke_exact_args_slots(&mut [Some(owner), Some(attr_name)], vm)?; self.push_value(result); return Ok(None); } @@ -4137,7 +4706,7 @@ impl ExecutingFrame<'_> { && self.specialization_has_datastack_space_for_func(vm, func) { let owner = self.pop_value(); - let result = func.invoke_exact_args(vec![owner], vm)?; + let result = func.invoke_exact_args_slots(&mut [Some(owner)], vm)?; self.push_value(result); return Ok(None); } @@ -4234,13 +4803,13 @@ impl ExecutingFrame<'_> { } // Specialized BINARY_OP opcodes Instruction::BinaryOpAddInt => { - self.execute_binary_op_int(vm, |a, b| a + b, bytecode::BinaryOperator::Add) + self.execute_binary_op_int(vm, Self::int_add, bytecode::BinaryOperator::Add) } Instruction::BinaryOpSubtractInt => { - self.execute_binary_op_int(vm, |a, b| a - b, bytecode::BinaryOperator::Subtract) + self.execute_binary_op_int(vm, Self::int_sub, bytecode::BinaryOperator::Subtract) } Instruction::BinaryOpMultiplyInt => { - self.execute_binary_op_int(vm, |a, b| a * b, bytecode::BinaryOperator::Multiply) + self.execute_binary_op_int(vm, Self::int_mul, bytecode::BinaryOperator::Multiply) } Instruction::BinaryOpAddFloat => { self.execute_binary_op_float(vm, |a, b| a + b, bytecode::BinaryOperator::Add) @@ -4268,8 +4837,12 @@ impl ExecutingFrame<'_> { } } Instruction::BinaryOpSubscrGetitem => { + let cache_base = self.lasti() as usize; + let type_version = self.code.instructions.read_cache_u32(cache_base + 1); let owner = self.nth_value(1); if !self.specialization_eval_frame_active(vm) + && type_version != 0 + && owner.class().tp_version_tag.load(Acquire) == type_version && let Some((func, func_version)) = owner.class().get_cached_getitem_for_specialization() && func.func_version() == func_version @@ -4278,7 +4851,7 @@ impl ExecutingFrame<'_> { debug_assert!(func.has_exact_argcount(2)); let sub = self.pop_value(); let owner = self.pop_value(); - let result = func.invoke_exact_args(vec![owner, sub], vm)?; + let result = func.invoke_exact_args_slots(&mut [Some(owner), Some(sub)], vm)?; self.push_value(result); return Ok(None); } @@ -4425,19 +4998,24 @@ impl ExecutingFrame<'_> { if self.specialization_call_recursion_guard(vm) { return self.execute_call_vectorcall(nargs, vm); } - let pos_args: Vec = self.pop_multiple(nargs as usize).collect(); + // Stage args without a per-call Vec: [self?, arg1, ..., argN] + let base = usize::from(self_or_null_is_some); + let mut arg_buf = CallArgBuffer::new(nargs as usize + base); + let args = arg_buf.slots(); + for (slot, arg) in args[base..] + .iter_mut() + .zip(self.pop_multiple(nargs as usize)) + { + *slot = Some(arg); + } let self_or_null = self.pop_value_opt(); + debug_assert_eq!(self_or_null.is_some(), self_or_null_is_some); + if self_or_null.is_some() { + args[0] = self_or_null; + } let callable = self.pop_value(); let func = callable.downcast_ref_if_exact::(vm).unwrap(); - let args = if let Some(self_val) = self_or_null { - let mut all_args = Vec::with_capacity(pos_args.len() + 1); - all_args.push(self_val); - all_args.extend(pos_args); - all_args - } else { - pos_args - }; - let result = func.invoke_exact_args(args, vm)?; + let result = func.invoke_exact_args_slots(args, vm)?; self.push_value(result); Ok(None) } else { @@ -4477,14 +5055,19 @@ impl ExecutingFrame<'_> { if self.specialization_call_recursion_guard(vm) { return self.execute_call_vectorcall(nargs, vm); } - let pos_args: Vec = - self.pop_multiple(nargs as usize).collect(); + // Stage args without a per-call Vec: + // [bound_self, arg1, ..., argN] + let mut arg_buf = CallArgBuffer::new(nargs as usize + 1); + let args = arg_buf.slots(); + for (slot, arg) in + args[1..].iter_mut().zip(self.pop_multiple(nargs as usize)) + { + *slot = Some(arg); + } self.pop_value_opt(); // null (self_or_null) self.pop_value(); // callable (bound method) - let mut all_args = Vec::with_capacity(pos_args.len() + 1); - all_args.push(bound_self); - all_args.extend(pos_args); - let result = func.invoke_exact_args(all_args, vm)?; + args[0] = Some(bound_self); + let result = func.invoke_exact_args_slots(args, vm)?; self.push_value(result); return Ok(None); } @@ -4532,16 +5115,18 @@ impl ExecutingFrame<'_> { .as_ref() .is_some_and(|isinstance_callable| callable.is(isinstance_callable)) { - let nargs_usize = nargs as usize; - let pos_args: Vec = self.pop_multiple(nargs_usize).collect(); - let self_or_null = self.pop_value_opt(); + // Stack: [callable, self_or_null, args...]; effective_nargs == 2, + // so the instance is either the first positional arg or self_or_null. + let cls = self.pop_value(); + let inst = if nargs == 2 { + let inst = self.pop_value(); + self.pop_value_opt(); // null + inst + } else { + self.pop_value() // self_or_null holds the instance + }; self.pop_value(); // callable - let mut all_args = Vec::with_capacity(2); - if let Some(self_val) = self_or_null { - all_args.push(self_val); - } - all_args.extend(pos_args); - let result = all_args[0].is_instance(&all_args[1], vm)?; + let result = inst.is_instance(&cls, vm)?; self.push_value(vm.ctx.new_bool(result).into()); return Ok(None); } @@ -4980,38 +5565,36 @@ impl ExecutingFrame<'_> { && cached_version != 0 && let Some(cls) = callable.downcast_ref::() && cls.tp_version_tag.load(Acquire) == cached_version - && let Some(init_func) = cls.get_cached_init_for_specialization(cached_version) + && let Some((init_func, init_func_version)) = + cls.get_cached_init_for_specialization(cached_version) + && init_func.func_version() == init_func_version + && init_func.has_exact_argcount(nargs + 1) && let Some(cls_alloc) = cls.slots.alloc.load() { - // Match CPython's `code->co_framesize + _Py_InitCleanup.co_framesize` - // shape, using RustPython's datastack-backed frame size - // equivalent for the extra shim frame. - let init_cleanup_stack_bytes = - datastack_frame_size_bytes_for_code(&vm.ctx.init_cleanup_code) - .expect("_Py_InitCleanup shim is not a generator/coroutine"); - if !self.specialization_has_datastack_space_for_func_with_extra( - vm, - &init_func, - init_cleanup_stack_bytes, - ) { + // The specialization runs `__init__` directly with no + // interpreter-visible trampoline frame. Deopt when the + // datastack or recursion budget for the `__init__` frame is + // unavailable. + if !self.specialization_has_datastack_space_for_func(vm, &init_func) { return self.execute_call_vectorcall(nargs, vm); } - // CPython creates `_Py_InitCleanup` + `__init__` frames here. - // Keep the guard conservative and deopt when the effective - // recursion budget for those two frames is not available. - if self.specialization_call_recursion_guard_with_extra_frames(vm, 1) { + if self.specialization_call_recursion_guard(vm) { return self.execute_call_vectorcall(nargs, vm); } // Allocate object directly (tp_new == object.__new__, tp_alloc == generic). let cls_ref = cls.to_owned(); let new_obj = cls_alloc(cls_ref, 0, vm)?; - // Build args: [new_obj, arg1, ..., argN] - let pos_args: Vec = self.pop_multiple(nargs as usize).collect(); + // Stage args as [new_obj, arg1, ..., argN]; slot 0 is + // filled by the init runner. + let mut arg_buf = CallArgBuffer::new(nargs as usize + 1); + let args = arg_buf.slots(); + for (slot, arg) in args[1..].iter_mut().zip(self.pop_multiple(nargs as usize)) { + *slot = Some(arg); + } let _null = self.pop_value_opt(); // self_or_null (None) let _callable = self.pop_value(); // callable (type) - let result = self - .specialization_run_init_cleanup_shim(new_obj, &init_func, pos_args, vm)?; + let result = self.specialization_run_init(new_obj, &init_func, args, vm)?; self.push_value(result); return Ok(None); } @@ -5798,18 +6381,6 @@ impl ExecutingFrame<'_> { instruction.is_instrumented(), "execute_instrumented called with non-instrumented opcode {instruction:?}" ); - if self.monitoring_disabled_for_code(vm) { - let global_ver = vm - .state - .instrumentation_version - .load(atomic::Ordering::Acquire); - monitoring::instrument_code(self.code, 0); - self.code - .instrumentation_version - .store(global_ver, atomic::Ordering::Release); - self.update_lasti(|i| *i -= 1); - return Ok(None); - } self.monitoring_mask = vm.state.monitoring_events.load(); match instruction { Instruction::InstrumentedResume => { @@ -7095,15 +7666,15 @@ impl ExecutingFrame<'_> { let b_ref = &self.pop_value(); let a_ref = &self.pop_value(); let value = match op { - // BINARY_OP_ADD_INT / BINARY_OP_SUBTRACT_INT fast paths: - // bypass binary_op1 dispatch for exact int types, use i64 arithmetic - // when possible to avoid BigInt heap allocation. + // Exact-int fast paths for +, -, *, //, %: bypass binary_op1 + // dispatch and use i64 arithmetic when possible to avoid BigInt + // heap allocation, falling back to the slow path otherwise. bytecode::BinaryOperator::Add | bytecode::BinaryOperator::InplaceAdd => { if let (Some(a), Some(b)) = ( a_ref.downcast_ref_if_exact::(vm), b_ref.downcast_ref_if_exact::(vm), ) { - Ok(self.int_add(a.as_bigint(), b.as_bigint(), vm)) + Ok(Self::int_add(a.as_bigint(), b.as_bigint(), vm)) } else if matches!(op, bytecode::BinaryOperator::Add) { vm._add(a_ref, b_ref) } else { @@ -7115,32 +7686,65 @@ impl ExecutingFrame<'_> { a_ref.downcast_ref_if_exact::(vm), b_ref.downcast_ref_if_exact::(vm), ) { - Ok(self.int_sub(a.as_bigint(), b.as_bigint(), vm)) + Ok(Self::int_sub(a.as_bigint(), b.as_bigint(), vm)) } else if matches!(op, bytecode::BinaryOperator::Subtract) { vm._sub(a_ref, b_ref) } else { vm._isub(a_ref, b_ref) } } - bytecode::BinaryOperator::Multiply => vm._mul(a_ref, b_ref), + bytecode::BinaryOperator::Multiply | bytecode::BinaryOperator::InplaceMultiply => { + if let (Some(a), Some(b)) = ( + a_ref.downcast_ref_if_exact::(vm), + b_ref.downcast_ref_if_exact::(vm), + ) { + Ok(Self::int_mul(a.as_bigint(), b.as_bigint(), vm)) + } else if matches!(op, bytecode::BinaryOperator::Multiply) { + vm._mul(a_ref, b_ref) + } else { + vm._imul(a_ref, b_ref) + } + } bytecode::BinaryOperator::MatrixMultiply => vm._matmul(a_ref, b_ref), bytecode::BinaryOperator::Power => vm._pow(a_ref, b_ref, vm.ctx.none.as_object()), bytecode::BinaryOperator::TrueDivide => vm._truediv(a_ref, b_ref), - bytecode::BinaryOperator::FloorDivide => vm._floordiv(a_ref, b_ref), - bytecode::BinaryOperator::Remainder => vm._mod(a_ref, b_ref), + bytecode::BinaryOperator::FloorDivide + | bytecode::BinaryOperator::InplaceFloorDivide => { + if let (Some(a), Some(b)) = ( + a_ref.downcast_ref_if_exact::(vm), + b_ref.downcast_ref_if_exact::(vm), + ) && let Some(result) = Self::int_floordiv(a.as_bigint(), b.as_bigint(), vm) + { + Ok(result) + } else if matches!(op, bytecode::BinaryOperator::FloorDivide) { + vm._floordiv(a_ref, b_ref) + } else { + vm._ifloordiv(a_ref, b_ref) + } + } + bytecode::BinaryOperator::Remainder | bytecode::BinaryOperator::InplaceRemainder => { + if let (Some(a), Some(b)) = ( + a_ref.downcast_ref_if_exact::(vm), + b_ref.downcast_ref_if_exact::(vm), + ) && let Some(result) = Self::int_mod(a.as_bigint(), b.as_bigint(), vm) + { + Ok(result) + } else if matches!(op, bytecode::BinaryOperator::Remainder) { + vm._mod(a_ref, b_ref) + } else { + vm._imod(a_ref, b_ref) + } + } bytecode::BinaryOperator::Lshift => vm._lshift(a_ref, b_ref), bytecode::BinaryOperator::Rshift => vm._rshift(a_ref, b_ref), bytecode::BinaryOperator::Xor => vm._xor(a_ref, b_ref), bytecode::BinaryOperator::Or => vm._or(a_ref, b_ref), bytecode::BinaryOperator::And => vm._and(a_ref, b_ref), - bytecode::BinaryOperator::InplaceMultiply => vm._imul(a_ref, b_ref), bytecode::BinaryOperator::InplaceMatrixMultiply => vm._imatmul(a_ref, b_ref), bytecode::BinaryOperator::InplacePower => { vm._ipow(a_ref, b_ref, vm.ctx.none.as_object()) } bytecode::BinaryOperator::InplaceTrueDivide => vm._itruediv(a_ref, b_ref), - bytecode::BinaryOperator::InplaceFloorDivide => vm._ifloordiv(a_ref, b_ref), - bytecode::BinaryOperator::InplaceRemainder => vm._imod(a_ref, b_ref), bytecode::BinaryOperator::InplaceLshift => vm._ilshift(a_ref, b_ref), bytecode::BinaryOperator::InplaceRshift => vm._irshift(a_ref, b_ref), bytecode::BinaryOperator::InplaceXor => vm._ixor(a_ref, b_ref), @@ -7153,28 +7757,107 @@ impl ExecutingFrame<'_> { Ok(None) } - /// Int addition with i64 fast path to avoid BigInt heap allocation. + /// Int binary op with an i64 fast path to avoid BigInt heap allocation. + /// `checked` computes the i64 result; on `None` (either operand does not + /// fit i64, or the op overflows i64) it falls through to `fallback` on the + /// full BigInt values. Result boxing always goes through `new_int` so the + /// small-int cache is consulted identically. #[inline] - fn int_add(&self, a: &BigInt, b: &BigInt, vm: &VirtualMachine) -> PyObjectRef { + fn int_fast_op( + a: &BigInt, + b: &BigInt, + vm: &VirtualMachine, + checked: fn(i64, i64) -> Option, + fallback: impl FnOnce(&BigInt, &BigInt) -> BigInt, + ) -> PyObjectRef { use num_traits::ToPrimitive; if let (Some(av), Some(bv)) = (a.to_i64(), b.to_i64()) - && let Some(result) = av.checked_add(bv) + && let Some(result) = checked(av, bv) { return vm.ctx.new_int(result).into(); } - vm.ctx.new_int(a + b).into() + vm.ctx.new_int(fallback(a, b)).into() + } + + /// Int addition with i64 fast path to avoid BigInt heap allocation. + #[inline] + fn int_add(a: &BigInt, b: &BigInt, vm: &VirtualMachine) -> PyObjectRef { + Self::int_fast_op(a, b, vm, i64::checked_add, |a, b| a + b) } /// Int subtraction with i64 fast path to avoid BigInt heap allocation. #[inline] - fn int_sub(&self, a: &BigInt, b: &BigInt, vm: &VirtualMachine) -> PyObjectRef { + fn int_sub(a: &BigInt, b: &BigInt, vm: &VirtualMachine) -> PyObjectRef { + Self::int_fast_op(a, b, vm, i64::checked_sub, |a, b| a - b) + } + + /// Int multiplication with i64 fast path to avoid BigInt heap allocation. + #[inline] + fn int_mul(a: &BigInt, b: &BigInt, vm: &VirtualMachine) -> PyObjectRef { + Self::int_fast_op(a, b, vm, i64::checked_mul, |a, b| a * b) + } + + /// Int divide/remainder i64 fast path. Returns `None` to signal the caller + /// to fall through to the slow path when either operand does not fit i64 + /// or `compute` reports a case it cannot handle (zero divisor or i64 + /// overflow). Result boxing goes through `new_int` so the small-int cache + /// is consulted identically. + #[inline] + fn int_div_fast_op( + a: &BigInt, + b: &BigInt, + vm: &VirtualMachine, + compute: fn(i64, i64) -> Option, + ) -> Option { use num_traits::ToPrimitive; - if let (Some(av), Some(bv)) = (a.to_i64(), b.to_i64()) - && let Some(result) = av.checked_sub(bv) - { - return vm.ctx.new_int(result).into(); + let (av, bv) = (a.to_i64()?, b.to_i64()?); + compute(av, bv).map(|r| vm.ctx.new_int(r).into()) + } + + /// Floor division of two i64 values with floor (toward negative infinity) + /// semantics. `None` when `b == 0` or the quotient overflows i64 + /// (`i64::MIN / -1`). + #[inline] + fn floordiv_i64(a: i64, b: i64) -> Option { + if b == 0 { + return None; } - vm.ctx.new_int(a - b).into() + let q = a.checked_div(b)?; + let r = a % b; + Some(if r != 0 && (r < 0) != (b < 0) { + q - 1 + } else { + q + }) + } + + /// Remainder of two i64 values, taking the sign of the divisor. `None` + /// when `b == 0` or the operation overflows i64 (`i64::MIN % -1`). + #[inline] + fn mod_i64(a: i64, b: i64) -> Option { + if b == 0 { + return None; + } + let r = a.checked_rem(b)?; + Some(if r != 0 && (r < 0) != (b < 0) { + r + b + } else { + r + }) + } + + /// Int floor division with i64 fast path. `None` falls through to the + /// slow path (bigint operands, zero divisor, or i64 overflow). + #[inline] + fn int_floordiv(a: &BigInt, b: &BigInt, vm: &VirtualMachine) -> Option { + Self::int_div_fast_op(a, b, vm, Self::floordiv_i64) + } + + /// Int remainder with i64 fast path. `None` falls through to the slow + /// path (bigint operands, zero divisor, or i64 overflow). + #[inline] + fn int_mod(a: &BigInt, b: &BigInt, vm: &VirtualMachine) -> Option { + Self::int_div_fast_op(a, b, vm, Self::mod_i64) } #[cold] @@ -7523,6 +8206,22 @@ impl ExecutingFrame<'_> { return; } + // Capture the version before inspecting getattro and the MRO so a + // concurrently installed __getattribute__/__getattr__ invalidates the + // version this specialization is cached against. + let type_version = cls.version_for_specialization(_vm); + if type_version == 0 { + unsafe { + self.code.instructions.write_adaptive_counter( + cache_base, + bytecode::adaptive_counter_backoff( + self.code.instructions.read_adaptive_counter(cache_base), + ), + ); + } + return; + } + // Only specialize if getattro is the default (PyBaseObject::getattro) let is_default_getattro = cls .slots @@ -7530,15 +8229,11 @@ impl ExecutingFrame<'_> { .load() .is_some_and(|f| f as usize == PyBaseObject::getattro as *const () as usize); if !is_default_getattro { - let mut type_version = cls.tp_version_tag.load(Acquire); - if type_version == 0 { - type_version = cls.assign_version_tag(); - } - if type_version != 0 - && !oparg.is_method() + let getattribute = cls.get_attr(identifier!(_vm, __getattribute__)); + if !oparg.is_method() && !self.specialization_eval_frame_active(_vm) && cls.get_attr(identifier!(_vm, __getattr__)).is_none() - && let Some(getattribute) = cls.get_attr(identifier!(_vm, __getattribute__)) + && let Some(getattribute) = getattribute && let Some(func) = getattribute.downcast_ref_if_exact::(_vm) && func.can_specialize_call(2) { @@ -7570,24 +8265,6 @@ impl ExecutingFrame<'_> { return; } - // Get or assign type version - let mut type_version = cls.tp_version_tag.load(Acquire); - if type_version == 0 { - type_version = cls.assign_version_tag(); - } - if type_version == 0 { - // Version counter overflow — backoff to avoid re-attempting every execution - unsafe { - self.code.instructions.write_adaptive_counter( - cache_base, - bytecode::adaptive_counter_backoff( - self.code.instructions.read_adaptive_counter(cache_base), - ), - ); - } - return; - } - let attr_name = self.code.names[oparg.name_idx() as usize]; // Match CPython: only specialize module attribute loads when the @@ -7620,7 +8297,6 @@ impl ExecutingFrame<'_> { return; } - // Look up attr in class via MRO let cls_attr = cls.get_attr(attr_name); let class_has_dict = cls.slots.flags.has_feature(PyTypeFlags::HAS_DICT); @@ -7792,29 +8468,11 @@ impl ExecutingFrame<'_> { ) { let obj = self.top_value(); let owner_type = obj.downcast_ref::().unwrap(); - - // Get or assign type version for the type object itself - let mut type_version = owner_type.tp_version_tag.load(Acquire); - if type_version == 0 { - type_version = owner_type.assign_version_tag(); - } - if type_version == 0 { - unsafe { - self.code.instructions.write_adaptive_counter( - cache_base, - bytecode::adaptive_counter_backoff( - self.code.instructions.read_adaptive_counter(cache_base), - ), - ); - } - return; - } - let attr_name = self.code.names[oparg.name_idx() as usize]; // Check metaclass: ensure no data descriptor on metaclass for this name let mcl = obj.class(); - let mcl_attr = mcl.get_attr(attr_name); + let (mcl_attr, mut metaclass_version) = mcl.lookup_ref_and_version_interned(attr_name, _vm); if let Some(ref attr) = mcl_attr { let attr_class = attr.class(); if attr_class.slots.descr_set.load().is_some() { @@ -7830,12 +8488,7 @@ impl ExecutingFrame<'_> { return; } } - let mut metaclass_version = 0; if !mcl.slots.flags.has_feature(PyTypeFlags::IMMUTABLETYPE) { - metaclass_version = mcl.tp_version_tag.load(Acquire); - if metaclass_version == 0 { - metaclass_version = mcl.assign_version_tag(); - } if metaclass_version == 0 { unsafe { self.code.instructions.write_adaptive_counter( @@ -7847,10 +8500,22 @@ impl ExecutingFrame<'_> { } return; } + } else { + metaclass_version = 0; } - // Look up attr in the type's own MRO - let cls_attr = owner_type.get_attr(attr_name); + let (cls_attr, type_version) = owner_type.lookup_ref_and_version_interned(attr_name, _vm); + if type_version == 0 { + unsafe { + self.code.instructions.write_adaptive_counter( + cache_base, + bytecode::adaptive_counter_backoff( + self.code.instructions.read_adaptive_counter(cache_base), + ), + ); + } + return; + } if let Some(ref descr) = cls_attr { let descr_class = descr.class(); let has_descr_get = descr_class.slots.descr_get.load().is_some(); @@ -8022,26 +8687,31 @@ impl ExecutingFrame<'_> { Some(Instruction::BinaryOpSubscrListSlice) } else { let cls = a.class(); + // Check the cheap gates before the __getitem__ lookup, which + // takes the global type lock and may allocate a version tag. if cls.slots.flags.has_feature(PyTypeFlags::HEAPTYPE) && !self.specialization_eval_frame_active(vm) - && let Some(_getitem) = cls.get_attr(identifier!(vm, __getitem__)) - && let Some(func) = _getitem.downcast_ref_if_exact::(vm) - && func.can_specialize_call(2) { - let mut type_version = cls.tp_version_tag.load(Acquire); - if type_version == 0 { - type_version = cls.assign_version_tag(); - } - if type_version != 0 { - if cls.cache_getitem_for_specialization( + let (getitem, type_version) = + cls.lookup_ref_and_version_interned(identifier!(vm, __getitem__), vm); + if type_version != 0 + && let Some(getitem) = getitem + && let Some(func) = getitem.downcast_ref_if_exact::(vm) + && func.can_specialize_call(2) + && cls.cache_getitem_for_specialization( func.to_owned(), type_version, vm, - ) { - Some(Instruction::BinaryOpSubscrGetitem) - } else { - None + ) + { + // Record the type version so the specialized handler + // can revalidate before using the cached __getitem__. + unsafe { + self.code + .instructions + .write_cache_u32(cache_base + 1, type_version); } + Some(Instruction::BinaryOpSubscrGetitem) } else { None } @@ -8211,7 +8881,7 @@ impl ExecutingFrame<'_> { fn execute_binary_op_int( &mut self, vm: &VirtualMachine, - op: impl FnOnce(&BigInt, &BigInt) -> BigInt, + op: impl FnOnce(&BigInt, &BigInt, &VirtualMachine) -> PyObjectRef, deopt_op: bytecode::BinaryOperator, ) -> FrameResult { let b = self.top_value(); @@ -8220,10 +8890,10 @@ impl ExecutingFrame<'_> { a.downcast_ref_if_exact::(vm), b.downcast_ref_if_exact::(vm), ) { - let result = op(a_int.as_bigint(), b_int.as_bigint()); + let result = op(a_int.as_bigint(), b_int.as_bigint(), vm); self.pop_value(); self.pop_value(); - self.push_value(vm.ctx.new_bigint(&result).into()); + self.push_value(result); Ok(None) } else { self.execute_bin_op(vm, deopt_op) @@ -8561,6 +9231,10 @@ impl ExecutingFrame<'_> { // CallAllocAndEnterInit: heap type with default __new__ if !self_or_null_is_some && cls.slots.flags.has_feature(PyTypeFlags::HEAPTYPE) { + // Capture the version before inspecting tp_new/tp_alloc so a + // concurrently installed __new__ invalidates the version this + // specialization is cached against. + let type_version = cls.version_for_specialization(vm); let object_new = vm.ctx.types.object_type.slots.new.load(); let cls_new = cls.slots.new.load(); let object_alloc = vm.ctx.types.object_type.slots.alloc.load(); @@ -8570,12 +9244,7 @@ impl ExecutingFrame<'_> { && cls_new_fn as usize == obj_new_fn as usize && cls_alloc_fn as usize == obj_alloc_fn as usize { - let init = cls.get_attr(identifier!(vm, __init__)); - let mut version = cls.tp_version_tag.load(Acquire); - if version == 0 { - version = cls.assign_version_tag(); - } - if version == 0 { + if type_version == 0 { unsafe { self.code.instructions.write_adaptive_counter( cache_base, @@ -8586,15 +9255,17 @@ impl ExecutingFrame<'_> { } return; } + let init = cls.get_attr(identifier!(vm, __init__)); if let Some(init) = init && let Some(init_func) = init.downcast_ref_if_exact::(vm) - && init_func.is_simple_for_call_specialization() - && cls.cache_init_for_specialization(init_func.to_owned(), version, vm) + && init_func.can_specialize_call(nargs + 1) + && !init_func.is_generator_like() + && cls.cache_init_for_specialization(init_func.to_owned(), type_version, vm) { unsafe { self.code .instructions - .write_cache_u32(cache_base + 1, version); + .write_cache_u32(cache_base + 1, type_version); } self.specialize_at( instr_idx, @@ -8888,34 +9559,35 @@ impl ExecutingFrame<'_> { Some(Instruction::ToBoolList) } else if cls.is(PyStr::class(&vm.ctx)) { Some(Instruction::ToBoolStr) - } else if cls.slots.flags.has_feature(PyTypeFlags::HEAPTYPE) - && cls.slots.as_number.boolean.load().is_none() - && cls.slots.as_mapping.length.load().is_none() - && cls.slots.as_sequence.length.load().is_none() - { - // Cache type version for ToBoolAlwaysTrue guard - let mut type_version = cls.tp_version_tag.load(Acquire); - if type_version == 0 { - type_version = cls.assign_version_tag(); - } - if type_version != 0 { - unsafe { - self.code - .instructions - .write_cache_u32(cache_base + 1, type_version); - } - self.specialize_at(instr_idx, cache_base, Instruction::ToBoolAlwaysTrue); - } else { - unsafe { - self.code.instructions.write_adaptive_counter( - cache_base, - bytecode::adaptive_counter_backoff( - self.code.instructions.read_adaptive_counter(cache_base), - ), - ); + } else if cls.slots.flags.has_feature(PyTypeFlags::HEAPTYPE) { + // Capture the version before inspecting the bool/len slots so a + // concurrently installed __bool__/__len__ invalidates the version + // the ToBoolAlwaysTrue guard is cached against. + let type_version = cls.version_for_specialization(vm); + let has_bool_or_len = cls.slots.as_number.boolean.load().is_some() + || cls.slots.as_mapping.length.load().is_some() + || cls.slots.as_sequence.length.load().is_some(); + if !has_bool_or_len { + if type_version != 0 { + unsafe { + self.code + .instructions + .write_cache_u32(cache_base + 1, type_version); + } + self.specialize_at(instr_idx, cache_base, Instruction::ToBoolAlwaysTrue); + } else { + unsafe { + self.code.instructions.write_adaptive_counter( + cache_base, + bytecode::adaptive_counter_backoff( + self.code.instructions.read_adaptive_counter(cache_base), + ), + ); + } } + return; } - return; + None } else { None }; @@ -9213,13 +9885,11 @@ impl ExecutingFrame<'_> { let owner = self.top_value(); let cls = owner.class(); - // Only specialize if setattr is the default (generic_setattr) - let is_default_setattr = cls - .slots - .setattro - .load() - .is_some_and(|f| f as usize == PyBaseObject::slot_setattro as *const () as usize); - if !is_default_setattr { + // Capture the version before inspecting the setattro slot so a + // concurrently installed __setattr__ invalidates the version this + // specialization is cached against. + let type_version = cls.version_for_specialization(vm); + if type_version == 0 { unsafe { self.code.instructions.write_adaptive_counter( cache_base, @@ -9231,12 +9901,13 @@ impl ExecutingFrame<'_> { return; } - // Get or assign type version - let mut type_version = cls.tp_version_tag.load(Acquire); - if type_version == 0 { - type_version = cls.assign_version_tag(); - } - if type_version == 0 { + // Only specialize if setattr is the default (generic_setattr) + let is_default_setattr = cls + .slots + .setattro + .load() + .is_some_and(|f| f as usize == PyBaseObject::slot_setattro as *const () as usize); + if !is_default_setattr { unsafe { self.code.instructions.write_adaptive_counter( cache_base, @@ -9248,7 +9919,6 @@ impl ExecutingFrame<'_> { return; } - // Check for data descriptor let attr_name = self.code.names[attr_idx as usize]; let cls_attr = cls.get_attr(attr_name); let has_data_descr = cls_attr.as_ref().is_some_and(|descr| { @@ -9670,7 +10340,9 @@ impl fmt::Debug for Frame { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // SAFETY: Debug is best-effort; concurrent mutation is unlikely // and would only affect debug output. - let iframe = unsafe { &*self.iframe.get() }; + let Some(iframe) = (unsafe { &*self.iframe.get() }) else { + return f.write_str("Frame Object { cleared }"); + }; let stack_str = iframe .localsplus diff --git a/crates/vm/src/function/argument.rs b/crates/vm/src/function/argument.rs index c37475ba48d..88ef5581bb2 100644 --- a/crates/vm/src/function/argument.rs +++ b/crates/vm/src/function/argument.rs @@ -12,7 +12,11 @@ pub trait IntoFuncArgs: Sized { fn into_args(self, vm: &VirtualMachine) -> FuncArgs; fn into_method_args(self, obj: PyObjectRef, vm: &VirtualMachine) -> FuncArgs { let mut args = self.into_args(vm); - args.prepend_arg(obj); + // Build the final vec once instead of prepending (realloc + memmove). + let mut with_obj = Vec::with_capacity(args.args.len() + 1); + with_obj.push(obj); + with_obj.append(&mut args.args); + args.args = with_obj; args } } @@ -202,7 +206,9 @@ impl FuncArgs { } pub fn prepend_arg(&mut self, item: PyObjectRef) { - self.args.reserve_exact(1); + // reserve (not reserve_exact): incoming vectors are usually built with + // exact capacity, so exact growth would realloc on every prepend. + self.args.reserve(1); self.args.insert(0, item) } diff --git a/crates/vm/src/gc_state.rs b/crates/vm/src/gc_state.rs index e20cbcb8ecf..ebae58f96cb 100644 --- a/crates/vm/src/gc_state.rs +++ b/crates/vm/src/gc_state.rs @@ -136,6 +136,61 @@ impl GcGeneration { #[derive(Clone, Copy, PartialEq, Eq, Hash)] struct GcPtr(NonNull); +/// RAII barrier that parks every other thread for the pointer-reading phases +/// of a collection and lets them run again before finalizers execute. +/// +/// Reference subtraction, the reachability walk and the strong-reference +/// snapshot dereference the interpreter state of every tracked object, +/// including the `localsplus` of frames that other threads are actively +/// executing. Those writes carry no synchronization, so the reads are only +/// well-defined while all other threads are parked at a safepoint. Restarting +/// happens explicitly once the snapshot has pinned every object; `Drop` is a +/// backstop that also restarts on the early-return paths. +#[cfg(feature = "threading")] +struct CollectStopTheWorld { + vm: *const crate::VirtualMachine, + stopped: bool, +} + +#[cfg(feature = "threading")] +impl CollectStopTheWorld { + /// Request stop-the-world when the current thread has an attached VM. + /// Falls back to no barrier when no VM is attached (the tracked-object + /// reads then run without other threads only if the caller guarantees it). + fn new() -> Self { + let vm = crate::vm::thread::try_with_current_vm(|vm| { + vm.state.stop_the_world.stop_the_world(vm); + vm as *const crate::VirtualMachine + }); + match vm { + Some(vm) => Self { vm, stopped: true }, + None => Self { + vm: core::ptr::null(), + stopped: false, + }, + } + } + + /// Restart the world. Idempotent. + fn restart(&mut self) { + if self.stopped { + // SAFETY: the current thread stays attached to this VM for the + // whole collection — the VM is never popped from the thread's VM + // stack while collecting — so the pointer is valid here. + let vm = unsafe { &*self.vm }; + vm.state.stop_the_world.start_the_world(vm); + self.stopped = false; + } + } +} + +#[cfg(feature = "threading")] +impl Drop for CollectStopTheWorld { + fn drop(&mut self) { + self.restart(); + } +} + /// Global GC state pub struct GcState { /// 3 generations (0 = youngest, 2 = oldest) @@ -366,8 +421,23 @@ impl GcState { let count0 = self.generations[0].count.load(Ordering::SeqCst) as u32; let threshold0 = self.generations[0].threshold(); if threshold0 > 0 && count0 >= threshold0 { - self.collect(0); - return true; + #[cfg(feature = "threading")] + { + // Defer to the next bytecode safepoint. Collecting here would + // stop the world while this thread may hold an internal lock + // (e.g. a lazily-initialized frame locals cell) that another + // thread is blocked on with no way to reach a safepoint — + // a deadlock. At a safepoint no such lock is held. + crate::signal::schedule_gc(); + return false; + } + // Without threading there is no safepoint to defer to and no other + // thread whose frames could be read mid-mutation, so collect inline. + #[cfg(not(feature = "threading"))] + { + self.collect(0); + return true; + } } false @@ -409,6 +479,32 @@ impl GcState { // might prevent cycle collection (_PyType_ClearCache). crate::builtins::type_::type_cache_clear(); + // Backstop for QSBR reclamation (threads may have missed requests). + #[cfg(feature = "threading")] + crate::object::qsbr::QSBR.process(); + + // Stop the world before reading any tracked object's interpreter + // state. Requested *before* the generation read locks are taken: a + // thread parking at a safepoint may still hold a generation write lock + // (track/untrack/promote) and must be able to release it to reach the + // safepoint. It could not do so if this thread already held a read + // lock it was waiting behind — hence the ordering. + // + // Auto-collection is deferred to a bytecode safepoint (see + // `maybe_collect`), where no internal lock is held, so it never stops + // the world under a lock. Explicit `gc.collect()` runs synchronously + // here; a re-entrant call from a finalizer during an in-progress + // collection is turned into a no-op by the `collecting` try_lock above. + // The one residual is an explicit `gc.collect()` reached from a + // finalizer/`__del__` that runs inline while a non-generation internal + // lock is still held (e.g. a container write lock during element + // replacement) with another thread blocked on that same lock: stopping + // the world then waits for a thread that cannot reach a safepoint. + // Closing it fully would require making those locks stop-the-world + // aware; the exclusion above only serializes the fork/GC requesters. + #[cfg(feature = "threading")] + let mut stw = CollectStopTheWorld::new(); + // Step 1: Gather objects from generations 0..=generation // Hold read locks for the entire scan to prevent concurrent modifications. let gen_locks: Vec<_> = (0..=generation) @@ -532,6 +628,40 @@ impl GcState { // Step 5: Find unreachable objects let unreachable: Vec = collecting.difference(&reachable).copied().collect(); + // With the world stopped, every frame on any thread's call stack is a + // live root that is externally referenced and must have been + // classified reachable. A running frame appearing in `unreachable` + // would mean the reachability analysis observed its interpreter state + // as garbage — the exact hazard the barrier exists to prevent. + #[cfg(all(unix, feature = "threading", debug_assertions))] + if stw.stopped { + let unreachable_set: HashSet = unreachable.iter().copied().collect(); + crate::vm::thread::try_with_current_vm(|vm| { + let registry = vm.state.thread_frames.lock(); + #[expect( + clippy::iter_over_hash_type, + reason = "assertion over every registered thread slot" + )] + for slot in registry.values() { + let mut cur = slot.top_frame.load(core::sync::atomic::Ordering::Relaxed) + as *const crate::frame::Frame; + while !cur.is_null() { + // SAFETY: frames on a thread's active call stack are + // alive, and the world is stopped so none can be popped. + let obj = + unsafe { &*crate::Py::::from_payload_ptr(cur) } + .as_object(); + let ptr = GcPtr(NonNull::from(obj)); + debug_assert!( + !unreachable_set.contains(&ptr), + "running frame {obj:p} classified unreachable during GC" + ); + cur = unsafe { (*cur).previous_frame() }; + } + } + }); + } + if debug.contains(GcDebugFlags::STATS) { eprintln!( "gc: {} reachable, {} unreachable", @@ -565,6 +695,14 @@ impl GcState { }) .collect(); + // The pointer-reading phases are done: strong references now pin every + // survivor and unreachable object, so the remaining phases can run with + // the world restarted. Finalizers and tp_clear must not run under + // stop-the-world — they execute arbitrary Python — and they only touch + // dead/husk objects, never a running frame. + #[cfg(feature = "threading")] + stw.restart(); + if unreachable.is_empty() { drop(gen_locks); self.promote_survivors(generation, &survivor_refs); @@ -727,11 +865,88 @@ impl GcState { if !truly_dead.is_empty() { // Break cycles by clearing references (tp_clear) // Use deferred drop context to prevent stack overflow. - rustpython_common::refcount::with_deferred_drops(|| { + // With DEBUG_SAVEALL the objects stay reachable through + // gc.garbage, so they must not be cleared (delete_garbage + // skips tp_clear for saved objects). + let save_all = debug.contains(GcDebugFlags::SAVEALL); + + // Untrack dead objects BEFORE clearing them, mirroring the + // untrack-then-clear ordering of the refcount dealloc path. + // A cleared object (e.g. a frame husk with iframe == None) must + // never be observable through the generation lists, or another + // thread could obtain a strong reference via gc.get_objects() + // and access the cleared payload. + let mut late_resurrected: HashSet = HashSet::new(); + if !save_all { + let mut expected_counts: std::collections::HashMap = + std::collections::HashMap::new(); + for obj_ref in &truly_dead { + let obj = obj_ref.as_ref(); + if obj.is_gc_tracked() { + unsafe { self.untrack_object(NonNull::from(obj)) }; + } + // One strong reference held by the `truly_dead` vec itself. + expected_counts.insert(GcPtr(NonNull::from(obj)), 1); + } + // With the objects out of the generation lists, no new external + // reference can appear. Count the references coming from within + // the dead set; any surplus in strong_count means another thread + // grabbed a reference before untracking (late resurrection) and + // the object must not be cleared. + let mut referents: std::collections::HashMap>> = + std::collections::HashMap::new(); for obj_ref in &truly_dead { - if obj_ref.gc_has_clear() { - let edges = unsafe { obj_ref.gc_clear() }; - drop(edges); + let referent_ptrs = unsafe { obj_ref.gc_get_referent_ptrs() }; + for child_ptr in &referent_ptrs { + if let Some(n) = expected_counts.get_mut(&GcPtr(*child_ptr)) { + *n += 1; + } + } + referents.insert(GcPtr(NonNull::from(obj_ref.as_ref())), referent_ptrs); + } + let mut worklist: Vec = Vec::new(); + for obj_ref in &truly_dead { + let ptr = GcPtr(NonNull::from(obj_ref.as_ref())); + if obj_ref.strong_count() > expected_counts[&ptr] + && late_resurrected.insert(ptr) + { + worklist.push(ptr); + } + } + // A holder of a late-resurrected object can reach its referents, + // so everything reachable from it must stay intact as well. + while let Some(ptr) = worklist.pop() { + let Some(referent_ptrs) = referents.get(&ptr) else { + continue; + }; + for child_ptr in referent_ptrs { + let child = GcPtr(*child_ptr); + if expected_counts.contains_key(&child) && late_resurrected.insert(child) { + worklist.push(child); + } + } + } + // Re-track late-resurrected objects so a future collection can + // retry once the external references are released. + #[expect( + clippy::iter_over_hash_type, + reason = "Iteration order doesn't matter here" + )] + for &ptr in &late_resurrected { + unsafe { self.track_object(ptr.0) }; + } + } + rustpython_common::refcount::with_deferred_drops(|| { + if !save_all { + for obj_ref in &truly_dead { + let obj = obj_ref.as_ref(); + if late_resurrected.contains(&GcPtr(NonNull::from(obj))) { + continue; + } + if obj.gc_has_clear() { + let edges = unsafe { obj.gc_clear() }; + drop(edges); + } } } drop(truly_dead); diff --git a/crates/vm/src/object/core.rs b/crates/vm/src/object/core.rs index 88ca646a4f1..f643be7e1fa 100644 --- a/crates/vm/src/object/core.rs +++ b/crates/vm/src/object/core.rs @@ -95,9 +95,23 @@ mod trashcan { type DeallocFn = unsafe fn(*mut super::PyObject); type DeallocQueue = Vec<(*mut super::PyObject, DeallocFn)>; + /// Per-thread trashcan state. Depth and deferral queue live in one + /// thread-local so a single access reaches both fields (one `_tlv_get_addr` + /// on platforms where thread-local access is a function call). Both fields + /// are `Cell`-based so reentrant deallocation (nested `begin`/`end` triggered + /// by draining deferred objects) never holds an outstanding borrow. + struct Trashcan { + depth: Cell, + queue: Cell, + } + thread_local! { - static DEALLOC_DEPTH: Cell = const { Cell::new(0) }; - static DEALLOC_QUEUE: Cell = const { Cell::new(Vec::new()) }; + static TRASHCAN: Trashcan = const { + Trashcan { + depth: Cell::new(0), + queue: Cell::new(Vec::new()), + } + }; } /// Try to begin deallocation. Returns true if we should proceed, @@ -107,18 +121,16 @@ mod trashcan { obj: *mut super::PyObject, dealloc: unsafe fn(*mut super::PyObject), ) -> bool { - DEALLOC_DEPTH.with(|d| { - let depth = d.get(); + TRASHCAN.with(|t| { + let depth = t.depth.get(); if depth >= TRASHCAN_LIMIT { // Depth exceeded: defer this deallocation - DEALLOC_QUEUE.with(|q| { - let mut queue = q.take(); - queue.push((obj, dealloc)); - q.set(queue); - }); + let mut queue = t.queue.take(); + queue.push((obj, dealloc)); + t.queue.set(queue); false } else { - d.set(depth + 1); + t.depth.set(depth + 1); true } }) @@ -127,29 +139,30 @@ mod trashcan { /// End deallocation and process any deferred objects if at outermost level. #[inline] pub(super) unsafe fn end() { - let depth = DEALLOC_DEPTH.with(|d| { - let depth = d.get(); + TRASHCAN.with(|t| { + let depth = t.depth.get(); debug_assert!(depth > 0, "trashcan::end called without matching begin"); let depth = depth - 1; - d.set(depth); - depth - }); - if depth == 0 { - // Process deferred deallocations iteratively + t.depth.set(depth); + if depth != 0 { + return; + } + // Process deferred deallocations iteratively. The queue is set back + // before each `dealloc` call so a reentrant `begin` can push freely. loop { - let next = DEALLOC_QUEUE.with(|q| { - let mut queue = q.take(); + let next = { + let mut queue = t.queue.take(); let item = queue.pop(); - q.set(queue); + t.queue.set(queue); item - }); + }; if let Some((obj, dealloc)) = next { unsafe { dealloc(obj) }; } else { break; } } - } + }) } } @@ -161,8 +174,17 @@ pub(super) unsafe fn default_dealloc(obj: *mut PyObject) { return; // resurrected by __del__ } + // Only tracked objects take the trashcan recursion guard and untrack path. + // Untracked objects either own no children (int, float, str, ...) or, like + // non-escaped frames, are released at interpreter depth with at most one + // unguarded link before their tracked children (dicts, functions, code) + // re-enter guarded deallocation, so recursion stays bounded. A frame stored + // in an object graph is forced to escape, becoming tracked and guarded here. + // Read once and reuse for both gates below. + let tracked = obj_ref.is_gc_tracked(); + // Trashcan: limit recursive deallocation depth to prevent stack overflow - if !unsafe { trashcan::begin(obj, default_dealloc::) } { + if tracked && !unsafe { trashcan::begin(obj, default_dealloc::) } { return; // deferred to queue } @@ -171,7 +193,7 @@ pub(super) unsafe fn default_dealloc(obj: *mut PyObject) { // Untrack from GC BEFORE deallocation. // Must happen before memory is freed because intrusive list removal // reads the object's gc_pointers (prev/next). - if obj_ref.is_gc_tracked() { + if tracked { let ptr = unsafe { NonNull::new_unchecked(obj) }; unsafe { crate::gc_state::gc_state().untrack_object(ptr); @@ -188,36 +210,49 @@ pub(super) unsafe fn default_dealloc(obj: *mut PyObject) { ); } - // Try to store in freelist for reuse BEFORE tp_clear, so that - // size-based freelists (e.g. PyTuple) can read the payload directly. + // Extract child references to break circular refs (tp_clear), then drop + // them. Some payloads (e.g. Frame) drop children in place inside clear_fn + // instead of extracting them, so user code (`__del__`) may run here. + let mut edges = Vec::new(); + if let Some(clear_fn) = vtable.clear { + unsafe { clear_fn(obj, &mut edges) }; + } + // Drop extracted child references - may trigger recursive destruction. + drop(edges); + + // Try to store in freelist for reuse. This must happen AFTER clear_fn and + // after the extracted-children drop: both can run user code (`__del__`) + // that allocates, and `PyRef::new_ref` pops from the same thread-local + // freelist. If the husk were already in the freelist, a reentrant + // allocation could pop it and write a fresh payload into it while clear_fn + // still holds a `&mut` borrow of that payload (aliasing UB). Pushing only + // once no borrows into the payload can be live closes that window. // Only exact base types (not heaptype or structseq subtypes) go into the freelist. + // Published objects (e.g. a tuple stored as a type attribute) must skip the + // freelist: `PyRef::new_ref` would reuse the slot and overwrite the refcount + // word with a non-atomic write, racing a reader's atomic try-incref. Route + // them through `PyInner::dealloc` instead, whose QSBR hook defers the actual + // memory free until readers can no longer observe it. let typ = obj_ref.class(); let pushed = if T::HAS_FREELIST && typ.heaptype_ext.is_none() && core::ptr::eq(typ, T::class(crate::vm::Context::genesis())) + && !obj_ref.0.ref_count.is_published() { unsafe { T::freelist_push(obj) } } else { false }; - // Extract child references to break circular refs (tp_clear). - // This runs regardless of freelist push — the object's children must be released. - let mut edges = Vec::new(); - if let Some(clear_fn) = vtable.clear { - unsafe { clear_fn(obj, &mut edges) }; - } - if !pushed { // Deallocate the object memory (handles ObjExt prefix if present) unsafe { PyInner::dealloc(obj as *mut PyInner) }; } - // Drop child references - may trigger recursive destruction. - drop(edges); - // Trashcan: decrement depth and process deferred objects at outermost level - unsafe { trashcan::end() }; + if tracked { + unsafe { trashcan::end() }; + } } pub(super) unsafe fn debug_obj( x: &PyObject, @@ -1005,6 +1040,9 @@ impl PyInner { let has_ext = flags.has_feature(crate::types::PyTypeFlags::HAS_DICT) || member_count > 0; let has_weakref = flags.has_feature(crate::types::PyTypeFlags::HAS_WEAKREF); + // Objects published to lock-free caches keep their memory mapped + // until a QSBR grace period passes; destructors still run now. + let published = (*ptr).ref_count.is_published(); if has_ext || has_weakref { // Reconstruct the same layout used in new() @@ -1037,7 +1075,15 @@ impl PyInner { } // WeakRefList has no Drop (just raw pointers), no drop_in_place needed - alloc::alloc::dealloc(alloc_ptr, combined); + if published { + crate::object::qsbr::free_delayed(alloc_ptr, combined); + } else { + alloc::alloc::dealloc(alloc_ptr, combined); + } + } else if published { + let layout = core::alloc::Layout::new::(); + core::ptr::drop_in_place(ptr); + crate::object::qsbr::free_delayed(ptr as *mut u8, layout); } else { drop(Box::from_raw(ptr)); } @@ -1141,11 +1187,6 @@ impl PyInner { } } -/// Returns the allocation layout for `PyInner`, for use in freelist Drop impls. -pub(crate) const fn pyinner_layout() -> core::alloc::Layout { - core::alloc::Layout::new::>() -} - /// Thread-local freelist storage for reusing object allocations. /// /// Wraps a `Vec<*mut PyObject>`. On thread teardown, `Drop` frees raw @@ -1287,6 +1328,13 @@ impl PyObject { None } } + + /// Mark this object as published to a lock-free cache. Its memory + /// reclamation is deferred through QSBR (see `object::qsbr`) so that + /// concurrent try-incref readers never touch freed memory. + pub(crate) fn mark_cache_published(&self) { + self.0.ref_count.mark_published(); + } } impl PyObjectRef { @@ -2085,6 +2133,19 @@ impl Py { pub fn payload(&self) -> &T { &self.0.payload } + + /// Recover the object pointer from a pointer to its `payload` field. + /// + /// # Safety + /// `payload` must point to the `payload` of a live `Py` (e.g. a `&T` + /// obtained by dereferencing a `Py`), and the object must outlive the + /// returned pointer's use. + #[inline] + pub(crate) unsafe fn from_payload_ptr(payload: *const T) -> *const Self { + let offset = core::mem::offset_of!(PyInner, payload); + // `Py` is a newtype over `PyInner`, so their addresses coincide. + unsafe { (payload as *const u8).sub(offset) as *const Self } + } } impl ToOwned for Py { @@ -2275,7 +2336,11 @@ impl PyRef { // - HAS_TRAVERSE is true (Rust payload implements Traverse), OR // - has instance dict (user-defined class instances), OR // - heap type (all heap type instances are GC-tracked, like Py_TPFLAGS_HAVE_GC) - if ::HAS_TRAVERSE || has_dict || is_heaptype { + // unless the payload opts out via NEW_REF_UNTRACKED (e.g. call frames, + // which are tracked lazily only on escape). + if (::HAS_TRAVERSE || has_dict || is_heaptype) + && !T::NEW_REF_UNTRACKED + { let gc = crate::gc_state::gc_state(); unsafe { gc.track_object(ptr.cast()); @@ -2471,7 +2536,7 @@ pub(crate) fn init_type_hierarchy() -> (PyTypeRef, PyTypeRef, PyTypeRef) { static_assertions::assert_eq_align!(MaybeUninit>, PyInner); let type_payload = PyType { - base: None, + base: None.into(), bases: PyRwLock::default(), mro: PyRwLock::default(), subclasses: PyRwLock::default(), @@ -2482,7 +2547,7 @@ pub(crate) fn init_type_hierarchy() -> (PyTypeRef, PyTypeRef, PyTypeRef) { abc_tpflags: core::sync::atomic::AtomicU64::new(0), }; let object_payload = PyType { - base: None, + base: None.into(), bases: PyRwLock::default(), mro: PyRwLock::default(), subclasses: PyRwLock::default(), @@ -2567,7 +2632,7 @@ pub(crate) fn init_type_hierarchy() -> (PyTypeRef, PyTypeRef, PyTypeRef) { (*object_type_ptr).payload.mro = PyRwLock::new(vec![object_type.clone()]); (*type_type_ptr).payload.bases = PyRwLock::new(vec![object_type.clone()]); - (*type_type_ptr).payload.base = Some(object_type.clone()); + (*type_type_ptr).payload.base = Some(object_type.clone()).into(); let type_type = PyTypeRef::from_raw(type_type_ptr.cast()); // type's mro is [type, object] @@ -2579,7 +2644,7 @@ pub(crate) fn init_type_hierarchy() -> (PyTypeRef, PyTypeRef, PyTypeRef) { }; let weakref_type = PyType { - base: Some(object_type.clone()), + base: Some(object_type.clone()).into(), bases: PyRwLock::new(vec![object_type.clone()]), mro: PyRwLock::new(vec![object_type.clone()]), subclasses: PyRwLock::default(), diff --git a/crates/vm/src/object/ext.rs b/crates/vm/src/object/ext.rs index d400de29c38..e576ac2c191 100644 --- a/crates/vm/src/object/ext.rs +++ b/crates/vm/src/object/ext.rs @@ -365,6 +365,35 @@ impl PyAtomicRef> { self.deref_ordering(ordering).map(|x| x.to_owned()) } + /// Try-incref read of the current value. + /// + /// Unlike [`Self::to_owned`], this never increfs a destructed object: + /// it uses a conditional incref and revalidates that the slot still + /// holds the same pointer. Returns `None` when the slot is empty. + /// + /// Soundness relies on published-object memory being reclaimed only + /// after a QSBR grace period (see `object::qsbr`), so the refcount + /// word of a concurrently swapped-out value stays readable. + pub fn try_to_owned(&self, ordering: Ordering) -> Option> { + loop { + let ptr = self.inner.load(ordering); + if ptr.is_null() { + return None; + } + if let Some(obj) = unsafe { PyObject::try_to_owned_from_ptr(ptr.cast::()) } { + if core::ptr::eq(self.inner.load(Ordering::Acquire), ptr) { + // SAFETY: the slot only ever stores `PyRef` values. + return Some(unsafe { obj.downcast_unchecked::() }); + } + drop(obj); + } + // Slot changed or the value was torn down mid-read; a failed + // incref with an unchanged slot is impossible (the slot's own + // strong ref keeps the value alive), so this loop progresses. + core::hint::spin_loop(); + } + } + /// # Safety /// The caller is responsible to keep the returned PyRef alive /// until no more reference can be used via PyAtomicRef::deref() diff --git a/crates/vm/src/object/mod.rs b/crates/vm/src/object/mod.rs index 56db97aef1d..b06957e1bc6 100644 --- a/crates/vm/src/object/mod.rs +++ b/crates/vm/src/object/mod.rs @@ -1,6 +1,7 @@ mod core; mod ext; mod payload; +pub(crate) mod qsbr; mod traverse; mod traverse_object; diff --git a/crates/vm/src/object/payload.rs b/crates/vm/src/object/payload.rs index 349b239f79f..36262607a1a 100644 --- a/crates/vm/src/object/payload.rs +++ b/crates/vm/src/object/payload.rs @@ -48,6 +48,13 @@ pub trait PyPayload: MaybeTraverse + PyThreadingConstraint + Sized + 'static { fn class(ctx: &Context) -> &'static Py; + /// Whether `PyRef::new_ref` skips auto-tracking this type in the GC even + /// when it would otherwise qualify (has traverse, dict, or heap type). + /// Such objects are created untracked and must be tracked explicitly if + /// and when they can become part of a reference cycle. Used by `Frame`, + /// which is created untracked and tracked lazily only on escape. + const NEW_REF_UNTRACKED: bool = false; + /// Whether this type has a freelist. Types with freelists require /// immediate (non-deferred) GC untracking during dealloc to prevent /// race conditions when the object is reused. @@ -58,11 +65,13 @@ pub trait PyPayload: MaybeTraverse + PyThreadingConstraint + Sized + 'static { /// Try to push a dead object onto this type's freelist for reuse. /// Returns true if the object was stored (caller must NOT free the memory). - /// Called before tp_clear, so the payload is still intact. + /// Called after tp_clear, so the payload is a cleared husk; implementations + /// must not rely on its pre-clear contents. /// /// # Safety - /// `obj` must be a valid pointer to a `PyInner` with refcount 0. - /// The payload is still initialized and can be read for bucket selection. + /// `obj` must be a valid pointer to a `PyInner` with refcount 0 + /// whose tp_clear has already run, with no outstanding borrows into the + /// payload (`PyRef::new_ref` may pop and reuse the husk immediately). #[inline] unsafe fn freelist_push(_obj: *mut PyObject) -> bool { false @@ -124,6 +133,34 @@ pub trait PyPayload: MaybeTraverse + PyThreadingConstraint + Sized + 'static { #[inline] fn into_ref_with_type(self, vm: &VirtualMachine, cls: PyTypeRef) -> PyResult> + where + Self: core::fmt::Debug, + { + self.into_ref_with_type_and_dict(vm, cls, true) + } + + /// Like `into_ref_with_type`, but leaves the instance `__dict__` unallocated + /// until the first attribute write or `__dict__` access. Only valid for types + /// whose attribute protocol materializes the dict lazily via `get_or_insert`. + #[inline] + fn into_ref_with_type_lazy_dict( + self, + vm: &VirtualMachine, + cls: PyTypeRef, + ) -> PyResult> + where + Self: core::fmt::Debug, + { + self.into_ref_with_type_and_dict(vm, cls, false) + } + + #[inline] + fn into_ref_with_type_and_dict( + self, + vm: &VirtualMachine, + cls: PyTypeRef, + eager_dict: bool, + ) -> PyResult> where Self: core::fmt::Debug, { @@ -145,7 +182,12 @@ pub trait PyPayload: MaybeTraverse + PyThreadingConstraint + Sized + 'static { } return Err(_into_ref_size_error(vm, &cls, exact_class)); } - Ok(self._into_ref(cls, &vm.ctx)) + let dict = if eager_dict && cls.slots.flags.has_feature(PyTypeFlags::HAS_DICT) { + Some(vm.ctx.new_dict()) + } else { + None + }; + Ok(PyRef::new_ref(self, cls, dict)) } else { #[cold] #[inline(never)] diff --git a/crates/vm/src/object/qsbr.rs b/crates/vm/src/object/qsbr.rs new file mode 100644 index 00000000000..576cadaeaed --- /dev/null +++ b/crates/vm/src/object/qsbr.rs @@ -0,0 +1,333 @@ +//! Quiescent-state-based reclamation (QSBR) for lock-free caches. +//! +//! Objects published to lock-free caches (type method cache, type +//! specialization caches) are read via borrowed pointers plus try-incref. +//! Their memory must stay mapped until every thread that could hold such a +//! borrowed pointer has passed a quiescent state. Destructors run at the +//! normal drop point; only the final deallocation is deferred. +//! +//! Mirrors _Py_qsbr (Python/qsbr.c): a global write sequence advances on +//! each retirement; each thread records the last sequence it observed at a +//! quiescent point (eval-breaker checkpoint, attach/detach). A retired +//! allocation is freed once every online thread's sequence passes its goal. + +use core::alloc::Layout; + +/// Sequence value of an offline (detached) thread. +#[cfg(feature = "threading")] +const QSBR_OFFLINE: u64 = 0; +/// Initial write sequence value. +#[cfg(feature = "threading")] +const QSBR_INITIAL: u64 = 1; +/// Write sequence increment. +#[cfg(feature = "threading")] +const QSBR_INCR: u64 = 2; + +#[cfg(feature = "threading")] +pub(crate) use threading::*; + +#[cfg(feature = "threading")] +mod threading { + use super::*; + use alloc::sync::{Arc, Weak}; + use core::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + use std::sync::Mutex; + + /// Per-thread QSBR state, owned by the thread's `ThreadSlot`. + pub(crate) struct QsbrSlot { + /// Last write sequence observed at a quiescent point; + /// `QSBR_OFFLINE` while the thread is detached. + seq: AtomicU64, + /// Set when this thread should pass a checkpoint (eval-breaker bit). + pub(crate) requested: AtomicBool, + } + + struct Retired { + ptr: *mut u8, + layout: Layout, + goal: u64, + } + // SAFETY: `ptr` is an exclusively owned dead allocation; only the + // processing thread touches it. + unsafe impl Send for Retired {} + + pub(crate) struct Qsbr { + /// Global write sequence (_Py_qsbr wr_seq). + wr_seq: AtomicU64, + /// Cached minimum observed read sequence (rd_seq). + rd_seq: AtomicU64, + threads: Mutex>>, + queue: Mutex>, + /// Set while the retire queue is non-empty; gates the per-instruction + /// eval-breaker check so the hot path pays only one relaxed static + /// load when nothing is pending. + pending: AtomicBool, + } + + pub(crate) static QSBR: Qsbr = Qsbr::new(); + + impl Qsbr { + const fn new() -> Self { + Self { + wr_seq: AtomicU64::new(QSBR_INITIAL), + rd_seq: AtomicU64::new(QSBR_INITIAL), + threads: Mutex::new(Vec::new()), + queue: Mutex::new(Vec::new()), + pending: AtomicBool::new(false), + } + } + + /// Whether retired allocations are pending. The hot path now reads + /// the mirrored bit in the eval-breaker word instead; this stays + /// only for unit tests that exercise local, non-global instances. + #[inline] + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn break_pending(&self) -> bool { + self.pending.load(Ordering::Relaxed) + } + + /// Mirror `pending` into the global eval-breaker word — only for + /// the global QSBR instance, so unit-test instances never touch + /// process-global state. + fn update_breaker_bit(&self, on: bool) { + if core::ptr::eq(self, &QSBR) { + if on { + crate::signal::set_qsbr_bit(); + } else { + crate::signal::clear_qsbr_bit(); + } + } + } + + /// Register the calling thread. The returned slot is stored in the + /// thread's `ThreadSlot`; dropping it unregisters the thread. + pub(crate) fn register(&self) -> Arc { + let slot = Arc::new(QsbrSlot { + seq: AtomicU64::new(self.wr_seq.load(Ordering::Acquire)), + requested: AtomicBool::new(false), + }); + self.threads.lock().unwrap().push(Arc::downgrade(&slot)); + slot + } + + /// Advance the write sequence; returns the goal a retirement must + /// wait for (_Py_qsbr_advance). + fn advance(&self) -> u64 { + self.wr_seq.fetch_add(QSBR_INCR, Ordering::AcqRel) + QSBR_INCR + } + + /// Record that the calling thread is at a quiescent point: it holds + /// no borrowed cache pointers (_Py_qsbr_quiescent_state). + pub(crate) fn quiescent_state(&self, slot: &QsbrSlot) { + slot.seq + .store(self.wr_seq.load(Ordering::Acquire), Ordering::Release); + } + + /// Mark a thread offline (detached); it no longer delays grace + /// periods (_Py_qsbr_detach). The thread must not perform lock-free + /// cache reads while offline. + pub(crate) fn offline(&self, slot: &QsbrSlot) { + slot.seq.store(QSBR_OFFLINE, Ordering::Release); + } + + /// Mark a thread online again (_Py_qsbr_attach). + pub(crate) fn online(&self, slot: &QsbrSlot) { + self.quiescent_state(slot); + } + + /// Whether every online thread has passed `goal` (_Py_qsbr_poll). + fn poll(&self, goal: u64) -> bool { + if self.rd_seq.load(Ordering::Acquire) >= goal { + return true; + } + self.poll_scan() >= goal + } + + /// Recompute the minimum sequence over all live online threads, + /// pruning dead ones. + fn poll_scan(&self) -> u64 { + let mut min_seq = self.wr_seq.load(Ordering::Acquire); + let mut threads = self.threads.lock().unwrap(); + threads.retain(|weak| match weak.upgrade() { + Some(slot) => { + let seq = slot.seq.load(Ordering::Acquire); + if seq != QSBR_OFFLINE { + min_seq = min_seq.min(seq); + } + true + } + None => false, + }); + drop(threads); + self.rd_seq.fetch_max(min_seq, Ordering::AcqRel); + min_seq + } + + /// Defer deallocation of a dead object's memory until a grace + /// period passes (_PyMem_FreeDelayed). + /// + /// # Safety + /// `ptr`/`layout` must describe an allocation whose contents have + /// been dropped and which nothing accesses afterwards except the + /// racing try-incref reads this mechanism protects against. + pub(crate) unsafe fn free_delayed(&self, ptr: *mut u8, layout: Layout) { + let goal = self.advance(); + { + let mut queue = self.queue.lock().unwrap(); + queue.push(Retired { ptr, layout, goal }); + // Set while still holding the queue lock, so this pairs with + // `process` clearing the flag under the same lock and no + // push can be left behind with the flag cleared. + self.pending.store(true, Ordering::Release); + self.update_breaker_bit(true); + } + // Ask every registered thread to pass a checkpoint. + for weak in self.threads.lock().unwrap().iter() { + if let Some(slot) = weak.upgrade() { + slot.requested.store(true, Ordering::Release); + } + } + } + + /// Free retired allocations whose grace period has passed + /// (_PyMem_ProcessDelayed). + pub(crate) fn process(&self) { + let Ok(mut queue) = self.queue.try_lock() else { + // Another thread is already processing. + return; + }; + // Goals are usually increasing in push order, but concurrent + // `free_delayed` calls can interleave their `advance()` and + // queue push, so a smaller goal can occasionally land behind a + // larger one. Free the longest prefix whose grace period has + // passed; each drained item individually passed `poll`, so this + // is sound regardless of ordering. A goal stuck behind an + // out-of-order neighbor just waits for the next checkpoint or + // GC pass, not a correctness issue. + let safe_prefix = queue + .iter() + .position(|item| !self.poll(item.goal)) + .unwrap_or(queue.len()); + for item in queue.drain(..safe_prefix) { + // SAFETY: grace period passed; no reader can hold `ptr`. + unsafe { alloc::alloc::dealloc(item.ptr, item.layout) }; + } + if queue.is_empty() { + self.pending.store(false, Ordering::Release); + self.update_breaker_bit(false); + } + } + + /// Free all retired allocations immediately. + /// + /// # Safety + /// Only sound when no other thread can be mid-read: the post-fork + /// child, or teardown after all threads exited. + #[cfg(unix)] + pub(crate) unsafe fn drain_all(&self) { + let mut queue = self.queue.lock().unwrap(); + for item in queue.drain(..) { + // SAFETY: guaranteed single-threaded by the caller. + unsafe { alloc::alloc::dealloc(item.ptr, item.layout) }; + } + self.pending.store(false, Ordering::Release); + self.update_breaker_bit(false); + } + + /// Reset after fork: drop all registered thread entries (dead + /// parent threads' slots would otherwise stay online forever and + /// stall every future grace period) and free all retired + /// allocations. + /// + /// # Safety + /// Only sound in the single-threaded post-fork child, before the + /// surviving thread re-registers. + #[cfg(unix)] + pub(crate) unsafe fn reset_after_fork(&self) { + self.threads.lock().unwrap().clear(); + // SAFETY: single-threaded child, no concurrent reader exists. + unsafe { self.drain_all() }; + } + + #[cfg(test)] + fn pending(&self) -> usize { + self.queue.lock().unwrap().len() + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn poll_requires_all_online_threads() { + let q = Qsbr::new(); + let a = q.register(); + let b = q.register(); + let goal = q.advance(); + assert!(!q.poll(goal)); + q.quiescent_state(&a); + assert!(!q.poll(goal)); + q.quiescent_state(&b); + assert!(q.poll(goal)); + } + + #[test] + fn offline_thread_does_not_delay_grace() { + let q = Qsbr::new(); + let a = q.register(); + let b = q.register(); + let goal = q.advance(); + q.quiescent_state(&a); + q.offline(&b); + assert!(q.poll(goal)); + } + + #[test] + fn dead_thread_is_pruned() { + let q = Qsbr::new(); + let a = q.register(); + let b = q.register(); + drop(b); + let goal = q.advance(); + q.quiescent_state(&a); + assert!(q.poll(goal)); + } + + #[test] + fn process_frees_only_after_grace() { + let q = Qsbr::new(); + let a = q.register(); + let layout = Layout::new::(); + let ptr = unsafe { alloc::alloc::alloc(layout) }; + unsafe { q.free_delayed(ptr, layout) }; + assert!(a.requested.load(Ordering::Acquire)); + assert!(q.break_pending()); + q.process(); + assert_eq!(q.pending(), 1); // grace period not passed yet + assert!(q.break_pending()); + q.quiescent_state(&a); + q.process(); + assert_eq!(q.pending(), 0); + assert!(!q.break_pending()); + } + } +} + +/// Defer (threading) or immediately perform (non-threading) deallocation +/// of a dead published object's memory. +/// +/// # Safety +/// Same contract as [`Qsbr::free_delayed`]. +#[inline] +pub(crate) unsafe fn free_delayed(ptr: *mut u8, layout: Layout) { + #[cfg(feature = "threading")] + unsafe { + QSBR.free_delayed(ptr, layout) + }; + #[cfg(not(feature = "threading"))] + // No concurrent readers can exist without threads. + unsafe { + alloc::alloc::dealloc(ptr, layout) + }; +} diff --git a/crates/vm/src/object/traverse.rs b/crates/vm/src/object/traverse.rs index 9a5ae324baf..d0a20d2afa7 100644 --- a/crates/vm/src/object/traverse.rs +++ b/crates/vm/src/object/traverse.rs @@ -111,19 +111,25 @@ where unsafe impl Traverse for PyRwLock { #[inline] fn traverse(&self, traverse_fn: &mut TraverseFn<'_>) { - // if can't get a lock, this means something else is holding the lock, - // but since gc stopped the world, during gc the lock is always held - // so it is safe to ignore those in gc + // A failed try_read means a writer holds the lock. Traversal runs with + // the world stopped, but a thread force-parked while DETACHED (CAS'd + // straight to SUSPENDED from native code) may still hold the write lock + // it was in the middle of taking. Skipping such an object is safe: the + // collector then does not see its outgoing edges, which only + // under-traverses and thus over-approximates liveness (a conservative + // keep-alive), never freeing a reachable object. In single-threaded + // builds a failure only reflects the current thread's own re-entrant + // read, likewise safely skipped. if let Some(inner) = self.try_read_recursive() { inner.traverse(traverse_fn) } } } -/// Safety: We can't hold lock during traverse it's child because it may cause deadlock. -/// TODO(discord9): check if this is thread-safe to do -/// (Outside of gc phase, only incref/decref will call trace, -/// and refcnt is atomic, so it should be fine?) +/// Safety: the lock is not held across visiting children to avoid a re-entrant +/// deadlock. In threading builds traversal runs under stop-the-world so no +/// other thread mutates the guarded value while we read it; in single-threaded +/// builds there is no other writer. unsafe impl Traverse for PyMutex { #[inline] fn traverse(&self, traverse_fn: &mut TraverseFn<'_>) { @@ -135,7 +141,9 @@ unsafe impl Traverse for PyMutex { } chs.iter() .map(|ch| { - // Safety: during gc, this should be fine, because nothing should write during gc's tracing? + // Safety: the world is stopped (threading builds) or the + // interpreter is single-threaded, so `ch` is not concurrently + // freed while we hand it to the tracer. let ch = unsafe { ch.as_ref() }; traverse_fn(ch); }) diff --git a/crates/vm/src/signal.rs b/crates/vm/src/signal.rs index eea42f4a87e..16a097ea62b 100644 --- a/crates/vm/src/signal.rs +++ b/crates/vm/src/signal.rs @@ -2,7 +2,7 @@ use core::{ cell::{Cell, RefCell}, fmt, ops::{Deref, DerefMut, Index, IndexMut, Range}, - sync::atomic::{AtomicBool, Ordering}, + sync::atomic::{AtomicBool, AtomicU8, Ordering}, }; use std::sync::mpsc; @@ -13,7 +13,21 @@ use crate::{PyObjectRef, PyResult, TryFromBorrowedObject, TryFromObject, Virtual pub(crate) const NSIG: usize = 64; -static ANY_TRIGGERED: AtomicBool = AtomicBool::new(false); +/// Eval-breaker word: bit flags checked once per bytecode instruction. +/// Signal handlers and QSBR set bits with fetch_or (async-signal-safe, +/// lock-free); consumers clear only their own bit with fetch_and. +static EVAL_BREAKER: AtomicU8 = AtomicU8::new(0); + +/// A signal handler recorded a pending signal. +const SIGNAL_BIT: u8 = 1 << 0; +/// QSBR has retired allocations pending reclamation. +#[cfg(feature = "threading")] +const QSBR_BIT: u8 = 1 << 1; +/// An automatic collection was scheduled by `maybe_collect` and must run at +/// the next bytecode safepoint rather than synchronously inside the +/// allocation that tripped the threshold. +#[cfg(feature = "threading")] +const GC_BIT: u8 = 1 << 2; #[expect( clippy::declare_interior_mutable_const, @@ -49,12 +63,12 @@ pub fn check_signals(vm: &VirtualMachine) -> PyResult<()> { // Read-only check first: avoids cache-line invalidation on every // instruction when no signal is pending (the common case). - if !ANY_TRIGGERED.load(Ordering::Relaxed) { + if EVAL_BREAKER.load(Ordering::Relaxed) & SIGNAL_BIT == 0 { return Ok(()); } // Atomic RMW only when a signal is actually pending. - if !ANY_TRIGGERED.swap(false, Ordering::Acquire) { + if EVAL_BREAKER.fetch_and(!SIGNAL_BIT, Ordering::Acquire) & SIGNAL_BIT == 0 { return Ok(()); } @@ -101,20 +115,49 @@ fn trigger_signals(vm: &VirtualMachine) -> PyResult<()> { } pub(crate) fn set_triggered() { - ANY_TRIGGERED.store(true, Ordering::Release); + // fetch_or (not store) so a signal handler never clobbers the QSBR bit; + // this compiles to a lock-free RMW, safe to call from a signal handler. + EVAL_BREAKER.fetch_or(SIGNAL_BIT, Ordering::Release); } +/// Any eval-breaker bit pending? One relaxed load; checked per instruction. #[inline(always)] -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn is_triggered() -> bool { - ANY_TRIGGERED.load(Ordering::Relaxed) +pub(crate) fn eval_breaker_pending() -> bool { + EVAL_BREAKER.load(Ordering::Relaxed) != 0 +} + +#[cfg(feature = "threading")] +pub(crate) fn set_qsbr_bit() { + EVAL_BREAKER.fetch_or(QSBR_BIT, Ordering::Release); +} + +#[cfg(feature = "threading")] +pub(crate) fn clear_qsbr_bit() { + EVAL_BREAKER.fetch_and(!QSBR_BIT, Ordering::Release); +} + +#[cfg(feature = "threading")] +pub(crate) fn qsbr_bit_set() -> bool { + EVAL_BREAKER.load(Ordering::Relaxed) & QSBR_BIT != 0 +} + +/// Schedule an automatic collection to run at the next bytecode safepoint. +#[cfg(feature = "threading")] +pub(crate) fn schedule_gc() { + EVAL_BREAKER.fetch_or(GC_BIT, Ordering::Release); +} + +/// Clear the scheduled-GC bit, returning whether it had been set. +#[cfg(feature = "threading")] +pub(crate) fn take_gc_scheduled() -> bool { + EVAL_BREAKER.fetch_and(!GC_BIT, Ordering::Acquire) & GC_BIT != 0 } /// Reset all signal trigger state after fork in child process. /// Stale triggers from the parent must not fire in the child. #[cfg(all(unix, feature = "host_env"))] pub(crate) fn clear_after_fork() { - ANY_TRIGGERED.store(false, Ordering::Release); + EVAL_BREAKER.fetch_and(!SIGNAL_BIT, Ordering::Release); for trigger in &TRIGGERS { trigger.store(false, Ordering::Relaxed); } diff --git a/crates/vm/src/stdlib/_imp.rs b/crates/vm/src/stdlib/_imp.rs index 7021895c9f7..322eaedd7d0 100644 --- a/crates/vm/src/stdlib/_imp.rs +++ b/crates/vm/src/stdlib/_imp.rs @@ -15,8 +15,14 @@ mod lock { static IMP_LOCK: RawRMutex = RawRMutex::INIT; #[pyfunction] - fn acquire_lock(_vm: &VirtualMachine) { - acquire_lock_for_fork() + fn acquire_lock(vm: &VirtualMachine) { + // Detach while blocking on IMP_LOCK. The import lock is held across + // bytecode by the importlib bootstrap, so its holder can be parked at a + // safepoint mid-hold. Blocking here while attached would keep this + // thread from honoring a stop-the-world request, so a requester could + // wait for this thread while this thread waits for the parked holder. + // Detaching makes the wait park-friendly. + vm.allow_threads(acquire_lock_for_fork); } #[pyfunction] @@ -76,9 +82,14 @@ mod lock { } /// Re-export for fork safety code in posix.rs +/// +/// Runs pre-fork on a normal attached VM thread. Detach while blocking so the +/// wait honors a concurrent stop-the-world request instead of pinning this +/// thread attached on IMP_LOCK; re-attach completes before `stop_the_world`, so +/// the fork requester protocol is unaffected. #[cfg(all(unix, feature = "threading", feature = "host_env"))] -pub(crate) fn acquire_imp_lock_for_fork() { - lock::acquire_lock_for_fork(); +pub(crate) fn acquire_imp_lock_for_fork(vm: &VirtualMachine) { + vm.allow_threads(lock::acquire_lock_for_fork); } #[cfg(all(unix, feature = "threading", feature = "host_env"))] diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index f3e6bec898f..99f9b1787ed 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -1053,18 +1053,46 @@ pub(crate) mod _thread { /// Get all threads' current (top) frames. Used by sys._current_frames(). pub(crate) fn get_all_current_frames(vm: &VirtualMachine) -> Vec<(u64, FrameRef)> { - let registry = vm.state.thread_frames.lock(); - registry - .iter() - .filter_map(|(id, slot)| { - let frames = slot.frames.lock(); - // SAFETY: the owning thread can't pop while we hold the Mutex, - // so the FramePtr is valid for the duration of the lock. - frames - .last() - .map(|fp| (*id, unsafe { fp.as_ref() }.to_owned())) - }) - .collect() + // unix: read each thread's published top frame under stop-the-world so + // the owning thread is parked at a safepoint and cannot pop or free the + // frame while we take a strong reference. Request stop-the-world before + // the registry lock to avoid deadlocking a thread parking mid-registry. + #[cfg(unix)] + { + use core::sync::atomic::Ordering; + vm.state.stop_the_world.stop_the_world(vm); + scopeguard::defer! { vm.state.stop_the_world.start_the_world(vm); } + let registry = vm.state.thread_frames.lock(); + registry + .iter() + .filter_map(|(id, slot)| { + let top = slot.top_frame.load(Ordering::Relaxed); + core::ptr::NonNull::new(top).map(|p| { + // SAFETY: world stopped -> the owning thread is parked + // and cannot pop or free this frame; it is alive on + // that thread's call stack. + let py = + unsafe { &*Py::::from_payload_ptr(p.as_ptr()) }; + (*id, py.to_owned()) + }) + }) + .collect() + } + #[cfg(not(unix))] + { + let registry = vm.state.thread_frames.lock(); + registry + .iter() + .filter_map(|(id, slot)| { + let frames = slot.frames.lock(); + // SAFETY: the owning thread can't pop while we hold the Mutex, + // so the FramePtr is valid for the duration of the lock. + frames + .last() + .map(|fp| (*id, unsafe { fp.as_ref() }.to_owned())) + }) + .collect() + } } /// Called after fork() in child process to mark all other threads as done. @@ -1659,17 +1687,22 @@ pub(crate) mod _thread { started_cvar.notify_all(); } // Don't execute the target function until parent marks the - // handle as running. + // handle as running. Detach while blocked so a concurrent + // stop-the-world (e.g. a GC on another thread) can park this + // thread instead of stalling waiting for it to reach a + // safepoint it will not reach until released. { let (ready_lock, ready_cvar) = &*handle_ready_event_clone; - let mut ready = ready_lock.lock().unwrap(); - while !*ready { - // Short timeout so we stay responsive to STW requests. - let (guard, _) = ready_cvar - .wait_timeout(ready, core::time::Duration::from_millis(1)) - .unwrap(); - ready = guard; - } + vm.allow_threads(|| { + let mut ready = ready_lock.lock().unwrap(); + while !*ready { + // Short timeout so we stay responsive to STW requests. + let (guard, _) = ready_cvar + .wait_timeout(ready, core::time::Duration::from_millis(1)) + .unwrap(); + ready = guard; + } + }); } // Ensure cleanup happens even if the function panics @@ -1750,16 +1783,22 @@ pub(crate) mod _thread { vm.new_runtime_error("can't start new thread") })?; - // Wait until the new thread has reported its ident. + // Wait until the new thread has reported its ident. Detach while + // waiting so a concurrent stop-the-world (e.g. a GC on another thread) + // can park this thread instead of stalling on it: the child may park + // itself at startup while the world is stopped and cannot report until + // released, so the waiter must be parkable too. { let (started_lock, started_cvar) = &*started_event; - let mut started = started_lock.lock().unwrap(); - while !*started { - let (guard, _) = started_cvar - .wait_timeout(started, core::time::Duration::from_millis(1)) - .unwrap(); - started = guard; - } + vm.allow_threads(|| { + let mut started = started_lock.lock().unwrap(); + while !*started { + let (guard, _) = started_cvar + .wait_timeout(started, core::time::Duration::from_millis(1)) + .unwrap(); + started = guard; + } + }); } // Mark the handle running in the parent thread (like CPython's diff --git a/crates/vm/src/stdlib/_winapi.rs b/crates/vm/src/stdlib/_winapi.rs index f6faf6a8a95..e1b86f2ef54 100644 --- a/crates/vm/src/stdlib/_winapi.rs +++ b/crates/vm/src/stdlib/_winapi.rs @@ -357,7 +357,8 @@ mod _winapi { } else { ms as u32 }; - host_winapi::wait_for_single_object(h.0, ms).map_err(|e| e.to_pyexception(vm)) + vm.allow_threads(|| host_winapi::wait_for_single_object(h.0, ms)) + .map_err(|e| e.to_pyexception(vm)) } #[pyfunction] @@ -381,8 +382,10 @@ mod _winapi { return Err(vm.new_value_error("WaitForMultipleObjects supports at most 64 handles")); } - host_winapi::wait_for_multiple_objects(&handles, wait_all, milliseconds) - .map_err(|e| e.to_pyexception(vm)) + vm.allow_threads(|| { + host_winapi::wait_for_multiple_objects(&handles, wait_all, milliseconds) + }) + .map_err(|e| e.to_pyexception(vm)) } #[pyfunction] @@ -566,8 +569,7 @@ mod _winapi { #[pymethod] fn GetOverlappedResult(&self, wait: bool, vm: &VirtualMachine) -> PyResult<(u32, u32)> { let mut inner = self.inner.lock(); - inner - .get_result(wait) + vm.allow_threads(|| inner.get_result(wait)) .map(|result| (result.transferred, result.error)) .map_err(|e| e.to_pyexception(vm)) } @@ -634,7 +636,8 @@ mod _winapi { } Ok(ov.into_pyobject(vm)) } else { - host_winapi::connect_named_pipe(handle.0).map_err(|e| e.to_pyexception(vm))?; + vm.allow_threads(|| host_winapi::connect_named_pipe(handle.0)) + .map_err(|e| e.to_pyexception(vm))?; Ok(vm.ctx.none()) } } @@ -802,7 +805,9 @@ mod _winapi { return Ok(result.into()); } - let result = host_winapi::read_file(handle.0, size).map_err(|e| e.to_pyexception(vm))?; + let result = vm + .allow_threads(|| host_winapi::read_file(handle.0, size)) + .map_err(|e| e.to_pyexception(vm))?; Ok(vm .ctx .new_tuple(vec![ @@ -926,12 +931,15 @@ mod _winapi { #[cfg(not(feature = "threading"))] let sigint_event: Option = None; - match host_winapi::batched_wait_for_multiple_objects( - &handles, - wait_all, - milliseconds, - sigint_event, - ) { + let batched_result = vm.allow_threads(|| { + host_winapi::batched_wait_for_multiple_objects( + &handles, + wait_all, + milliseconds, + sigint_event, + ) + }); + match batched_result { Ok(host_winapi::BatchedWaitResult::All) => Ok(vm.ctx.none()), Ok(host_winapi::BatchedWaitResult::Indices(indices)) => Ok(vm .ctx diff --git a/crates/vm/src/stdlib/gc.rs b/crates/vm/src/stdlib/gc.rs index 00eea1b39d5..b0007b4c867 100644 --- a/crates/vm/src/stdlib/gc.rs +++ b/crates/vm/src/stdlib/gc.rs @@ -199,15 +199,11 @@ mod gc { // PyObjects, so they never appear in get_referrers results. Since // RustPython materializes every frame as a PyObject, we must exclude // them manually to match the expected behavior. - let stack_frames: HashSet = vm - .frames - .borrow() - .iter() - .map(|fp| { - let frame: &crate::PyObject = unsafe { fp.as_ref() }.as_ref(); - frame as *const crate::PyObject as usize - }) - .collect(); + let mut stack_frames: HashSet = HashSet::new(); + crate::frame::for_each_current_frame(|frame| { + let obj: &crate::PyObject = frame.as_ref(); + stack_frames.insert(obj as *const crate::PyObject as usize); + }); let mut result = Vec::new(); diff --git a/crates/vm/src/stdlib/posix.rs b/crates/vm/src/stdlib/posix.rs index 21576e8da2d..80245aed08f 100644 --- a/crates/vm/src/stdlib/posix.rs +++ b/crates/vm/src/stdlib/posix.rs @@ -623,7 +623,7 @@ pub mod module { run_at_forkers(before_forkers, true, vm); #[cfg(feature = "threading")] - crate::stdlib::_imp::acquire_imp_lock_for_fork(); + crate::stdlib::_imp::acquire_imp_lock_for_fork(vm); #[cfg(feature = "threading")] vm.state.stop_the_world.stop_the_world(vm); @@ -655,6 +655,17 @@ pub mod module { #[cfg(feature = "threading")] crate::object::reset_weakref_locks_after_fork(); + // Repair any type-cache entries left mid-update at fork time. + unsafe { crate::builtins::type_::type_cache_after_fork() }; + + // Reset QSBR: dead parent threads' slots would stall reclamation + // forever, and retired memory can be freed immediately in the + // single-threaded child. + #[cfg(feature = "threading")] + unsafe { + crate::object::qsbr::QSBR.reset_after_fork() + }; + // Phase 3: Clean up thread state. Locks are now reinit'd so we can // acquire them normally instead of using try_lock(). #[cfg(feature = "threading")] @@ -694,6 +705,7 @@ pub mod module { reinit_mutex_after_fork(&vm.state.atexit_funcs); reinit_mutex_after_fork(&vm.state.global_trace_func); reinit_mutex_after_fork(&vm.state.global_profile_func); + reinit_mutex_after_fork(&vm.state.type_mutex); reinit_mutex_after_fork(&vm.state.monitoring); // PyGlobalState parking_lot::Mutex locks diff --git a/crates/vm/src/stdlib/sys.rs b/crates/vm/src/stdlib/sys.rs index b8fe578f238..4e31075da45 100644 --- a/crates/vm/src/stdlib/sys.rs +++ b/crates/vm/src/stdlib/sys.rs @@ -43,7 +43,7 @@ pub mod sys { hash::{PyHash, PyUHash}, }, convert::ToPyObject, - frame::{Frame, FrameRef}, + frame::FrameRef, function::{FuncArgs, KwArgs, OptionalArg, PosArgs}, stdlib::{_warnings::warn, builtins}, types::PyStructSequence, @@ -968,17 +968,9 @@ pub mod sys { #[pyfunction] fn _getframe(offset: OptionalArg, vm: &VirtualMachine) -> PyResult { let offset = offset.into_option().unwrap_or(0); - let frame_ref = { - let frames = vm.frames.borrow(); - if offset >= frames.len() { - return Err(vm.new_value_error("call stack is not deep enough")); - } - - let idx = frames.len() - offset - 1; - // SAFETY: the FrameRef is alive on the call stack while it's in the Vec - let py: &crate::Py = unsafe { frames[idx].as_ref() }; - py.to_owned() - }; + let frame_ref = crate::frame::frame_at_offset(offset) + .ok_or_else(|| vm.new_value_error("call stack is not deep enough"))?; + frame_ref.mark_escaped(); if let Ok(audit) = vm.sys_module.get_attr("audit", vm) { audit.call((vm.ctx.new_str("sys._getframe"), frame_ref.to_owned()), vm)?; @@ -998,15 +990,9 @@ pub mod sys { } // Get the frame at the specified depth - let func_obj = { - let frames = vm.frames.borrow(); - if depth >= frames.len() { - return Ok(vm.ctx.none()); - } - let idx = frames.len() - depth - 1; - // SAFETY: the FrameRef is alive on the call stack while it's in the Vec - let frame: &crate::Py = unsafe { frames[idx].as_ref() }; - frame.func_obj.clone() + let func_obj = match crate::frame::frame_at_offset(depth) { + Some(frame) => frame.func_obj.clone(), + None => return Ok(vm.ctx.none()), }; // If the frame has a function object, return its __module__ attribute diff --git a/crates/vm/src/stdlib/sys/monitoring.rs b/crates/vm/src/stdlib/sys/monitoring.rs index accf2001675..56a68cea619 100644 --- a/crates/vm/src/stdlib/sys/monitoring.rs +++ b/crates/vm/src/stdlib/sys/monitoring.rs @@ -528,9 +528,7 @@ fn update_events_mask(vm: &VirtualMachine, state: &MonitoringState) { // Each code object gets only the events that apply to it (global + its // own local events), preventing e.g. INSTRUCTION from being applied to // unrelated code objects. - for fp in vm.frames.borrow().iter() { - // SAFETY: frames in the Vec are alive while their FrameRef is on the call stack. - let frame = unsafe { fp.as_ref() }; + crate::frame::for_each_current_frame(|frame| { let code = &frame.code; let code_ver = code.instrumentation_version.load(Ordering::Acquire); if code_ver != new_ver { @@ -539,7 +537,7 @@ fn update_events_mask(vm: &VirtualMachine, state: &MonitoringState) { code.instrumentation_version .store(new_ver, Ordering::Release); } - } + }); } fn use_tool_id(tool_id: i32, name: &str, vm: &VirtualMachine) -> PyResult<()> { diff --git a/crates/vm/src/types/slot.rs b/crates/vm/src/types/slot.rs index 83d9706cb33..a5d7a41d3fd 100644 --- a/crates/vm/src/types/slot.rs +++ b/crates/vm/src/types/slot.rs @@ -739,6 +739,18 @@ impl PyType { // Helper macro for number/sequence/mapping sub-slots macro_rules! update_sub_slot { ($group:ident, $slot:ident, $wrapper:expr, $variant:ident) => {{ + // Fall back to the value inherited for this exact field. Left and + // right binary ops (e.g. add / right_add) share one accessor but + // occupy distinct fields, so the fallback must target this field + // rather than the accessor's default field, otherwise resolving + // an absent right op would overwrite the left op's dispatcher. + let inherit_this_field = || { + let mro = self.mro.read(); + let inherited = mro[1..] + .iter() + .find_map(|cls| cls.slots.$group.$slot.load()); + self.slots.$group.$slot.store(inherited); + }; if ADD { // Check if this type defines any method that maps to this slot. // Some slots like SqAssItem/MpAssSubscript are shared by multiple @@ -760,8 +772,15 @@ impl PyType { } result }; + // Reify the wrapper at a single site so the own and inherited + // branches store the same fn item. binary_op1 compares slot + // fn addresses to decide whether a subclass overrides the op; + // duplicating the wrapper closure across branches yields + // distinct addresses in unmerged debug builds and breaks that + // comparison for an inherited slot. + let store_wrapper = || self.slots.$group.$slot.store(Some($wrapper)); if has_own { - self.slots.$group.$slot.store(Some($wrapper)); + store_wrapper(); } else { match self.lookup_slot_in_mro(name, ctx, |sf| { if let SlotFunc::$variant(f) = sf { @@ -774,15 +793,15 @@ impl PyType { self.slots.$group.$slot.store(Some(func)); } SlotLookupResult::PythonMethod => { - self.slots.$group.$slot.store(Some($wrapper)); + store_wrapper(); } SlotLookupResult::NotFound => { - accessor.inherit_from_mro(self); + inherit_this_field(); } } } } else { - accessor.inherit_from_mro(self); + inherit_this_field(); } }}; } @@ -843,12 +862,31 @@ impl PyType { } } SlotAccessor::TpNew => { - // __new__ is not wrapped via PyWrapper - if ADD { + // __new__ is a staticmethod, not a PyWrapper descriptor, so + // lookup_slot_in_mro cannot classify it. Resolve __new__ + // through the MRO dicts instead: a Python-level definition + // needs the dynamic new_wrapper, while a native type's + // builtin __new__ entry (or no entry at all) means the slot + // is inherited from the solid base, matching update_one_slot's + // tp_new special case over the tp_base-inherited value. + let needs_wrapper = if ADD && self.attributes.read().contains_key(name) { + true + } else { + // mro[0] is self, so skip it + self.mro.read()[1..] + .iter() + .find(|cls| cls.attributes.read().contains_key(name)) + .is_some_and(|cls| { + cls.slots.new.load().map(|f| f as usize) + == Some(new_wrapper as NewFunc as usize) + }) + }; + if needs_wrapper { self.slots.new.store(Some(new_wrapper)); self.slots.vectorcall.store(None); } else { - accessor.inherit_from_mro(self); + let inherited = self.base.deref().and_then(|base| base.slots.new.load()); + self.slots.new.store(inherited); } } SlotAccessor::TpDel => update_main_slot!(del, del_wrapper, Del), @@ -897,46 +935,72 @@ impl PyType { } } SlotAccessor::TpSetattro => { - // __setattr__ and __delattr__ share the same slot - if ADD { - match self.lookup_slot_in_mro(name, ctx, |sf| match sf { - SlotFunc::SetAttro(f) | SlotFunc::DelAttro(f) => Some(*f), - _ => None, - }) { - SlotLookupResult::NativeSlot(func) => { - self.slots.setattro.store(Some(func)); - } - SlotLookupResult::PythonMethod => { - self.slots.setattro.store(Some(setattro_wrapper)); - } - SlotLookupResult::NotFound => { - accessor.inherit_from_mro(self); - } + // __setattr__ and __delattr__ share the same slot, so both + // names must be resolved together: a Python-level override of + // either one forces the dispatching wrapper, and the native + // slot is only usable when every resolved name agrees on it. + // This resolution reads the current attribute dicts, so it + // applies the same whether a name was just added or removed. + let extract = |sf: &SlotFunc| match sf { + SlotFunc::SetAttro(f) | SlotFunc::DelAttro(f) => Some(*f), + _ => None, + }; + let setattr = self.lookup_slot_in_mro(identifier!(ctx, __setattr__), ctx, extract); + let delattr = self.lookup_slot_in_mro(identifier!(ctx, __delattr__), ctx, extract); + use SlotLookupResult::{NativeSlot, NotFound, PythonMethod}; + match (setattr, delattr) { + (PythonMethod, _) | (_, PythonMethod) => { + self.slots.setattro.store(Some(setattro_wrapper)); + } + (NativeSlot(set), NativeSlot(del)) => { + let func = if set as usize == del as usize { + set + } else { + setattro_wrapper + }; + self.slots.setattro.store(Some(func)); + } + (NativeSlot(func), NotFound) | (NotFound, NativeSlot(func)) => { + self.slots.setattro.store(Some(func)); + } + (NotFound, NotFound) => { + accessor.inherit_from_mro(self); } - } else { - accessor.inherit_from_mro(self); } } SlotAccessor::TpDescrGet => update_main_slot!(descr_get, descr_get_wrapper, DescrGet), SlotAccessor::TpDescrSet => { - // __set__ and __delete__ share the same slot - if ADD { - match self.lookup_slot_in_mro(name, ctx, |sf| match sf { - SlotFunc::DescrSet(f) | SlotFunc::DescrDel(f) => Some(*f), - _ => None, - }) { - SlotLookupResult::NativeSlot(func) => { - self.slots.descr_set.store(Some(func)); - } - SlotLookupResult::PythonMethod => { - self.slots.descr_set.store(Some(descr_set_wrapper)); - } - SlotLookupResult::NotFound => { - accessor.inherit_from_mro(self); - } + // __set__ and __delete__ share the same slot, so both names + // must be resolved together: a Python-level definition of + // either one forces the dispatching wrapper, and the native + // slot is only usable when every resolved name agrees on it. + // This resolution reads the current attribute dicts, so it + // applies the same whether a name was just added or removed. + let extract = |sf: &SlotFunc| match sf { + SlotFunc::DescrSet(f) | SlotFunc::DescrDel(f) => Some(*f), + _ => None, + }; + let set = self.lookup_slot_in_mro(identifier!(ctx, __set__), ctx, extract); + let delete = self.lookup_slot_in_mro(identifier!(ctx, __delete__), ctx, extract); + use SlotLookupResult::{NativeSlot, NotFound, PythonMethod}; + match (set, delete) { + (PythonMethod, _) | (_, PythonMethod) => { + self.slots.descr_set.store(Some(descr_set_wrapper)); + } + (NativeSlot(set), NativeSlot(delete)) => { + let func = if set as usize == delete as usize { + set + } else { + descr_set_wrapper + }; + self.slots.descr_set.store(Some(func)); + } + (NativeSlot(func), NotFound) | (NotFound, NativeSlot(func)) => { + self.slots.descr_set.store(Some(func)); + } + (NotFound, NotFound) => { + accessor.inherit_from_mro(self); } - } else { - accessor.inherit_from_mro(self); } } diff --git a/crates/vm/src/types/slot_defs.rs b/crates/vm/src/types/slot_defs.rs index 8637f811272..34d62147e0c 100644 --- a/crates/vm/src/types/slot_defs.rs +++ b/crates/vm/src/types/slot_defs.rs @@ -608,7 +608,7 @@ impl SlotAccessor { if typ.slots.init.load().is_none() && let Some(base_val) = base.slots.init.load() { - let slot_defined = base.base.as_ref().is_none_or(|bb| { + let slot_defined = base.base.deref().is_none_or(|bb| { bb.slots.init.load().map(|v| v as usize) != Some(base_val as usize) }); if slot_defined { diff --git a/crates/vm/src/types/zoo.rs b/crates/vm/src/types/zoo.rs index 13d439345f7..64807fc0973 100644 --- a/crates/vm/src/types/zoo.rs +++ b/crates/vm/src/types/zoo.rs @@ -2,10 +2,10 @@ use crate::{ Py, builtins::{ asyncgenerator, bool_, builtin_func, bytearray, bytes, capsule, classmethod, code, complex, - coroutine, descriptor, dict, enumerate, filter, float, frame, function, generator, - genericalias, getset, int, interpolation, iter, list, map, mappingproxy, memory, module, - namespace, object, property, pystr, range, set, singletons, slice, staticmethod, super_, - template, traceback, tuple, + coroutine, descriptor, dict, enumerate, filter, float, frame, frame_locals_proxy, function, + generator, genericalias, getset, int, interpolation, iter, list, map, mappingproxy, memory, + module, namespace, object, property, pystr, range, set, singletons, slice, staticmethod, + super_, template, traceback, tuple, type_::{self, PyType}, union_, weakproxy, weakref, zip, }, @@ -39,6 +39,7 @@ pub struct TypeZoo { pub filter_type: &'static Py, pub float_type: &'static Py, pub frame_type: &'static Py, + pub frame_locals_proxy_type: &'static Py, pub frozenset_type: &'static Py, pub generator_type: &'static Py, pub int_type: &'static Py, @@ -178,6 +179,7 @@ impl TypeZoo { dict_reverseitemiterator_type: dict::PyDictReverseItemIterator::init_builtin_type(), ellipsis_type: slice::PyEllipsis::init_builtin_type(), frame_type: crate::frame::Frame::init_builtin_type(), + frame_locals_proxy_type: frame_locals_proxy::FrameLocalsProxy::init_builtin_type(), function_type: function::PyFunction::init_builtin_type(), generator_type: generator::PyGenerator::init_builtin_type(), getset_type: getset::PyGetSet::init_builtin_type(), @@ -253,6 +255,7 @@ impl TypeZoo { bool_::init(context); code::init(context); frame::init(context); + frame_locals_proxy::init(context); weakref::init(context); weakproxy::init(context); singletons::init(context); diff --git a/crates/vm/src/vm/context.rs b/crates/vm/src/vm/context.rs index 226d5f1a1a7..9a545663576 100644 --- a/crates/vm/src/vm/context.rs +++ b/crates/vm/src/vm/context.rs @@ -14,7 +14,6 @@ use crate::{ object, pystr, type_::PyAttributes, }, - bytecode::{self, CodeFlags, CodeUnit, Instruction, Opcode}, class::StaticType, common::rc::PyRc, exceptions, @@ -31,7 +30,6 @@ use malachite_bigint::BigInt; use num_complex::Complex64; use num_traits::ToPrimitive; use rustpython_common::lock::PyRwLock; -use rustpython_compiler_core::{OneIndexed, SourceLocation}; #[derive(Debug)] pub struct Context { @@ -52,7 +50,6 @@ pub struct Context { pub int_cache_pool: Vec, pub(crate) latin1_char_cache: Vec>, pub(crate) ascii_char_cache: Vec>, - pub(crate) init_cleanup_code: PyRef, // there should only be exact objects of str in here, no non-str objects and no subclasses pub(crate) string_pool: StringPool, pub(crate) slot_new_wrapper: PyMethodDef, @@ -362,8 +359,6 @@ impl Context { PyMethodFlags::METHOD, None, ); - let init_cleanup_code = Self::new_init_cleanup_code(&types, &names); - let empty_str = unsafe { string_pool.intern("", types.str_type.to_owned()) }; let empty_bytes = create_object(PyBytes::from(Vec::new()), types.bytes_type); @@ -389,7 +384,6 @@ impl Context { int_cache_pool, latin1_char_cache, ascii_char_cache, - init_cleanup_code, string_pool, slot_new_wrapper, names, @@ -399,49 +393,6 @@ impl Context { } } - fn new_init_cleanup_code(types: &TypeZoo, names: &ConstName) -> PyRef { - let loc = SourceLocation { - line: OneIndexed::MIN, - character_offset: OneIndexed::from_zero_indexed(0), - }; - let instructions = [ - CodeUnit { - op: Instruction::ExitInitCheck, - arg: 0.into(), - }, - CodeUnit { - op: Instruction::ReturnValue, - arg: 0.into(), - }, - CodeUnit { - op: Opcode::Resume.into(), - arg: 0.into(), - }, - ]; - let code = bytecode::CodeObject { - instructions: instructions.into(), - locations: vec![(loc, loc); instructions.len()].into_boxed_slice(), - flags: CodeFlags::OPTIMIZED, - posonlyarg_count: 0, - arg_count: 0, - kwonlyarg_count: 0, - source_path: names.__init__, - first_line_number: None, - max_stackdepth: 2, - obj_name: names.__init__, - qualname: names.__init__, - constants: core::iter::empty().collect(), - names: Vec::new().into_boxed_slice(), - varnames: Vec::new().into_boxed_slice(), - cellvars: Vec::new().into_boxed_slice(), - freevars: Vec::new().into_boxed_slice(), - localspluskinds: Vec::new().into_boxed_slice(), - linetable: Vec::new().into_boxed_slice(), - exceptiontable: Vec::new().into_boxed_slice(), - }; - PyRef::new_ref(PyCode::new(code), types.code_type.to_owned(), None) - } - pub fn intern_str(&self, s: S) -> &'static PyStrInterned { unsafe { self.string_pool.intern(s, self.types.str_type.to_owned()) } } diff --git a/crates/vm/src/vm/interpreter.rs b/crates/vm/src/vm/interpreter.rs index 56c9606f7de..f456e8587ea 100644 --- a/crates/vm/src/vm/interpreter.rs +++ b/crates/vm/src/vm/interpreter.rs @@ -1,4 +1,4 @@ -#[cfg(all(unix, feature = "threading"))] +#[cfg(feature = "threading")] use super::StopTheWorldState; use super::{Context, PyConfig, PyGlobalState, VirtualMachine, setting::Settings, thread}; use crate::{ @@ -122,6 +122,7 @@ where switch_interval: AtomicCell::new(0.005), global_trace_func: PyMutex::default(), global_profile_func: PyMutex::default(), + type_mutex: PyMutex::default(), #[cfg(feature = "threading")] main_thread_ident: AtomicCell::new(0), #[cfg(feature = "threading")] @@ -133,7 +134,7 @@ where monitoring: PyMutex::default(), monitoring_events: AtomicCell::new(0), instrumentation_version: AtomicU64::new(0), - #[cfg(all(unix, feature = "threading"))] + #[cfg(feature = "threading")] stop_the_world: StopTheWorldState::new(), }); @@ -149,7 +150,12 @@ where // Call custom init function (can mutate vm.state) init(&mut vm); + // `initialize()` runs Python bytecode directly (e.g. importing `codecs` + // and `encodings`) before any `enter_vm` scope exists, so attach this + // thread for the duration so type cache reads see it as ATTACHED. + let vm_guard = thread::VmBootstrapGuard::new(&vm); vm.initialize(); + drop(vm_guard); // Clone global_state for Interpreter after all initialization is done let global_state = vm.state.clone(); @@ -461,11 +467,18 @@ impl Interpreter { } // Match CPython: if exit_code is 0 and stdout flush failed, exit 120 - if exit_code == 0 && flush_status < 0 { + let exit_code = if exit_code == 0 && flush_status < 0 { EXITCODE_FLUSH_FAILURE } else { exit_code - } + }; + + // Daemon threads may still exist, so use the safe `process()`, + // not `drain_all()`. + #[cfg(feature = "threading")] + crate::object::qsbr::QSBR.process(); + + exit_code }) } } diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 8ad8a0d0bca..852e57cc0b4 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -44,11 +44,12 @@ use crate::{ warn::WarningsState, }; use alloc::{borrow::Cow, collections::BTreeMap}; -#[cfg(all(unix, feature = "threading"))] +#[cfg(all(not(unix), feature = "threading"))] +use core::ptr::NonNull; +#[cfg(feature = "threading")] use core::sync::atomic::AtomicI64; use core::{ cell::{Cell, OnceCell, RefCell}, - ptr::NonNull, sync::atomic::{AtomicBool, AtomicU64, Ordering}, }; use crossbeam_utils::atomic::AtomicCell; @@ -74,7 +75,6 @@ pub struct VirtualMachine { pub builtins: PyRef, pub sys_module: PyRef, pub ctx: PyRc, - pub frames: RefCell>, /// Thread-local data stack for bump-allocating frame-local data /// (localsplus arrays for non-generator frames). datastack: core::cell::UnsafeCell, @@ -108,11 +108,15 @@ pub struct VirtualMachine { pub(crate) audit_hooks: RefCell>, } -/// Non-owning frame pointer for the frames stack. +/// Non-owning frame pointer for the non-unix threading frames stack. /// The pointed-to frame is kept alive by the caller of with_frame/resume_gen_frame. +/// Unix threading builds publish the top frame through `ThreadSlot::top_frame` +/// and walk the rest via `Frame::previous`, so they do not use this type. +#[cfg(all(not(unix), feature = "threading"))] #[derive(Copy, Clone)] pub struct FramePtr(NonNull>); +#[cfg(all(not(unix), feature = "threading"))] impl FramePtr { /// # Safety /// The pointed-to frame must still be alive. @@ -122,8 +126,10 @@ impl FramePtr { } } -// SAFETY: FramePtr is only stored in the VM's frames Vec while the corresponding -// FrameRef is alive on the call stack. The Vec is always empty when the VM moves between threads. +// SAFETY: FramePtr is only stored in a thread's shared frame stack +// (`ThreadSlot::frames`) while the corresponding FrameRef is alive on that +// thread's call stack; readers dereference it under the slot mutex. +#[cfg(all(not(unix), feature = "threading"))] unsafe impl Send for FramePtr {} #[derive(Debug)] @@ -143,7 +149,7 @@ impl Default for ExceptionStack { /// Stop-the-world state for fork safety. Before `fork()`, the requester /// stops all other Python threads so they are not holding internal locks. -#[cfg(all(unix, feature = "threading"))] +#[cfg(feature = "threading")] pub struct StopTheWorldState { /// Fast-path flag checked in the bytecode loop (like `_PY_EVAL_PLEASE_STOP_BIT`) pub(crate) requested: AtomicBool, @@ -151,6 +157,11 @@ pub struct StopTheWorldState { world_stopped: AtomicBool, /// Ident of the thread that requested the stop (like `stw->requester`) requester: AtomicU64, + /// Single exclusion held for the whole stop→start span. Fork and GC are + /// both stop-the-world requesters driving this shared state; only one may + /// hold it at a time. Acquired before any stop bookkeeping (see + /// `acquire_exclusion`) and released by `start_the_world`/`reset_after_fork`. + exclusion: AtomicBool, /// Signaled by suspending threads when their state transitions to SUSPENDED notify_mutex: std::sync::Mutex<()>, notify_cv: std::sync::Condvar, @@ -178,7 +189,7 @@ pub struct StopTheWorldState { stats_suspend_wait_yields: AtomicU64, } -#[cfg(all(unix, feature = "threading"))] +#[cfg(feature = "threading")] #[derive(Debug, Clone, Copy)] pub struct StopTheWorldStats { pub stop_calls: u64, @@ -194,14 +205,14 @@ pub struct StopTheWorldStats { pub world_stopped: bool, } -#[cfg(all(unix, feature = "threading"))] +#[cfg(feature = "threading")] impl Default for StopTheWorldState { fn default() -> Self { Self::new() } } -#[cfg(all(unix, feature = "threading"))] +#[cfg(feature = "threading")] impl StopTheWorldState { #[must_use] pub const fn new() -> Self { @@ -209,6 +220,7 @@ impl StopTheWorldState { requested: AtomicBool::new(false), world_stopped: AtomicBool::new(false), requester: AtomicU64::new(0), + exclusion: AtomicBool::new(false), notify_mutex: std::sync::Mutex::new(()), notify_cv: std::sync::Condvar::new(), thread_countdown: AtomicI64::new(0), @@ -336,20 +348,76 @@ impl StopTheWorldState { forced_parks != 0 && self.thread_countdown.load(Ordering::Acquire) == 0 } + /// Acquire the single stop-the-world exclusion in a park-friendly way. + /// + /// Fork and GC both request stop-the-world through the same shared state; + /// without this exclusion their `requester`/`requested`/countdown words + /// could be clobbered by an interleaving requester, so the completion + /// check could never converge and a requester would wait on itself forever. + /// + /// The acquire must be park-friendly. While another requester's stop is in + /// progress it sets this thread's stop bit and waits for it to suspend; + /// blocking on a plain lock here would keep this thread from ever reaching + /// that safepoint, so the active requester would wait for this thread while + /// this thread waits for the lock — a deadlock swap. Instead we poll and + /// honor the suspend request between tries. Suspending here is safe as long + /// as any lock a spinning requester still holds is never acquired + /// attached-blocking by another thread. The fork requester holds IMP_LOCK, + /// but its acquisition detaches (`allow_threads`), so no attached thread + /// blocks on it; the GC requester holds only the `collecting` mutex, which + /// is only ever `try_lock`'d. The active requester therefore force-parks + /// this thread, finishes its whole stop→start span, releases the exclusion, + /// and only then does this thread resume and acquire it. + fn acquire_exclusion(&self) { + if self + .exclusion + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) + .is_ok() + { + return; + } + loop { + crate::vm::thread::suspend_if_needed(self); + std::thread::yield_now(); + if self + .exclusion + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) + .is_ok() + { + return; + } + } + } + + /// Release the stop-the-world exclusion taken by `acquire_exclusion`. + fn release_exclusion(&self) { + self.exclusion.store(false, Ordering::Release); + } + /// Stop all non-requester threads (`stop_the_world`). /// /// 1. Sets `requested`, marking the requester thread. /// 2. CAS detached threads to SUSPENDED. /// 3. Waits (polling with 1 ms condvar timeout) for attached threads /// to self-suspend in `check_signals`. + /// + /// Takes the shared exclusion first so at most one requester (fork or GC) + /// drives the stop→start span at a time; it is released by + /// `start_the_world`/`reset_after_fork`. pub fn stop_the_world(&self, vm: &VirtualMachine) { + self.acquire_exclusion(); let start = std::time::Instant::now(); let requester_ident = crate::stdlib::_thread::get_ident(); self.requester.store(requester_ident, Ordering::Relaxed); self.stats_stop_calls.fetch_add(1, Ordering::Relaxed); let initial_countdown = self.init_thread_countdown(vm); stw_trace(format_args!("stop begin requester={requester_ident}")); - if initial_countdown == 0 { + // Park detached threads and set stop bits, then confirm every other + // thread is SUSPENDED. The completion condition is level-triggered + // (`all_non_requester_suspended`) so an already-suspended thread that + // was counted but will not notify again cannot stall the stop. + self.park_detached_threads(vm); + if initial_countdown == 0 || self.all_non_requester_suspended(vm) { self.world_stopped.store(true, Ordering::Release); #[cfg(debug_assertions)] self.debug_assert_all_non_requester_suspended(vm); @@ -361,7 +429,8 @@ impl StopTheWorldState { let mut polls = 0u64; loop { - if self.park_detached_threads(vm) { + self.park_detached_threads(vm); + if self.all_non_requester_suspended(vm) { break; } polls = polls.saturating_add(1); @@ -369,8 +438,7 @@ impl StopTheWorldState { // Re-check under the wait mutex first to avoid a lost-wake race: // a thread may have suspended and notified right before we enter wait. let guard = self.notify_mutex.lock().unwrap(); - if self.thread_countdown.load(Ordering::Acquire) == 0 || self.park_detached_threads(vm) - { + if self.all_non_requester_suspended(vm) { drop(guard); break; } @@ -445,6 +513,9 @@ impl StopTheWorldState { self.requester.store(0, Ordering::Relaxed); #[cfg(debug_assertions)] self.debug_assert_all_non_requester_detached(vm); + // Release the exclusion last, ending the stop→start span so the next + // requester (fork or GC) can proceed. + self.release_exclusion(); stw_trace(format_args!("start end requester={requester}")); } @@ -454,6 +525,9 @@ impl StopTheWorldState { self.world_stopped.store(false, Ordering::Relaxed); self.requester.store(0, Ordering::Relaxed); self.thread_countdown.store(0, Ordering::Relaxed); + // The surviving child thread inherited the exclusion taken by the + // pre-fork `stop_the_world`; release it (no start_the_world runs here). + self.release_exclusion(); stw_trace(format_args!("reset-after-fork")); } @@ -514,6 +588,33 @@ impl StopTheWorldState { } } + /// Whether every non-requester registered thread is currently SUSPENDED. + /// + /// Level-triggered stop-the-world completion check. Relying on this rather + /// than solely on the edge-triggered `thread_countdown` avoids a + /// lost-decrement race under rapid back-to-back stops: a thread that is + /// already SUSPENDED when a new stop counts it neither notifies nor is + /// force-parked again, so an edge-based countdown could never reach zero. + fn all_non_requester_suspended(&self, vm: &VirtualMachine) -> bool { + use thread::THREAD_SUSPENDED; + let requester = self.requester.load(Ordering::Relaxed); + let registry = vm.state.thread_frames.lock(); + + #[expect( + clippy::iter_over_hash_type, + reason = "Iteration order doesn't matter here" + )] + for (&id, slot) in registry.iter() { + if id == requester { + continue; + } + if slot.state.load(Ordering::Acquire) != THREAD_SUSPENDED { + return false; + } + } + true + } + #[cfg(debug_assertions)] fn debug_assert_all_non_requester_suspended(&self, vm: &VirtualMachine) { use thread::THREAD_SUSPENDED; @@ -561,13 +662,13 @@ impl StopTheWorldState { } } -#[cfg(all(unix, feature = "threading"))] +#[cfg(feature = "threading")] pub(super) fn stw_trace_enabled() -> bool { static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); *ENABLED.get_or_init(|| crate::host_env::os::var_os("RUSTPYTHON_STW_TRACE").is_some()) } -#[cfg(all(unix, feature = "threading"))] +#[cfg(feature = "threading")] pub(super) fn stw_trace(msg: core::fmt::Arguments<'_>) { if stw_trace_enabled() { use core::fmt::Write as _; @@ -603,7 +704,13 @@ pub(super) fn stw_trace(msg: core::fmt::Arguments<'_>) { crate::stdlib::_thread::get_ident(), msg ); + #[cfg(unix)] crate::host_env::io::write_stderr_raw(&out.buf[..out.len]); + #[cfg(not(unix))] + { + use std::io::Write as _; + let _ = std::io::stderr().write_all(&out.buf[..out.len]); + } } } @@ -637,6 +744,8 @@ pub struct PyGlobalState { pub global_trace_func: PyMutex>, /// Global profile function for all threads (set by sys._setprofileallthreads) pub global_profile_func: PyMutex>, + /// Global type mutation/versioning mutex for CPython-style FT type operations. + pub type_mutex: PyMutex<()>, /// Main thread identifier (pthread_self on Unix) #[cfg(feature = "threading")] pub main_thread_ident: AtomicCell, @@ -657,7 +766,7 @@ pub struct PyGlobalState { /// local version against this to decide whether re-instrumentation is needed. pub instrumentation_version: AtomicU64, /// Stop-the-world state for pre-fork thread suspension - #[cfg(all(unix, feature = "threading"))] + #[cfg(feature = "threading")] pub stop_the_world: StopTheWorldState, } @@ -759,7 +868,6 @@ impl VirtualMachine { builtins, sys_module, ctx, - frames: RefCell::new(vec![]), datastack: core::cell::UnsafeCell::new(crate::datastack::DataStack::new()), wasm_id: None, exceptions: RefCell::default(), @@ -1219,6 +1327,15 @@ impl VirtualMachine { #[inline(always)] pub fn run_frame(&self, frame: FrameRef) -> PyResult { + // Only ordinary (datastack) call frames reach `run_frame`; generator + // and coroutine frames are resumed through `resume_gen_frame`. A + // datastack frame is created untracked and is tracked lazily only when + // it escapes, which happens no earlier than `release_datastack_frame` + // after this call returns. So it must be untracked on entry. + debug_assert!( + !frame.as_object().is_gc_tracked(), + "datastack frame is GC-tracked before execution" + ); match self.with_frame(frame, |f| f.run(self))? { ExecutionResult::Return(value) => Ok(value), _ => panic!("Got unexpected result from function"), @@ -1458,7 +1575,7 @@ impl VirtualMachine { /// evaluation consumes more native stack in those configurations. #[cfg_attr(any(miri, target_env = "musl"), allow(dead_code))] const STACK_MARGIN_BYTES: usize = - (if cfg!(debug_assertions) { 4096 } else { 2048 }) * core::mem::size_of::(); + (if cfg!(debug_assertions) { 16384 } else { 2048 }) * core::mem::size_of::(); /// Get the stack boundaries using platform-specific APIs. /// Returns (base, top) where base is the lowest address and top is the highest. @@ -1519,11 +1636,16 @@ impl VirtualMachine { } /// Calculate the C stack soft limit based on actual stack boundaries. - /// soft_limit = base + 2 * margin (for downward-growing stacks) + /// soft_limit = base + 2 * margin (for downward-growing stacks). + /// The margin is clamped to half the stack so threads created with a stack + /// smaller than 2 * (2 * margin) still get usable headroom instead of a + /// soft limit above their stack top (which would trip on entry). #[cfg(all(not(miri), not(target_env = "musl")))] fn calculate_c_stack_soft_limit() -> usize { - let (base, _top) = Self::get_stack_bounds(); - base + Self::STACK_MARGIN_BYTES * 2 + let (base, top) = Self::get_stack_bounds(); + let stack_size = top.saturating_sub(base); + let margin = (Self::STACK_MARGIN_BYTES * 2).min(stack_size / 2); + base + margin } /// Musl currently reports stack bounds in a way that trips the VM's @@ -1535,15 +1657,14 @@ impl VirtualMachine { } /// Check if we're near the C stack limit (like _Py_MakeRecCheck). - /// Returns true only when stack pointer is in the "danger zone" between - /// soft_limit and hard_limit (soft_limit - 2*margin). + /// One-sided: any stack pointer below the soft limit is in danger, since a + /// single native frame can exceed the margin and step past it. #[cfg(all(not(miri), not(target_env = "musl")))] #[inline(always)] fn check_c_stack_overflow(&self) -> bool { let current_sp = psm::stack_pointer() as usize; let soft_limit = self.c_stack_soft_limit.get(); current_sp < soft_limit - && current_sp >= soft_limit.saturating_sub(Self::STACK_MARGIN_BYTES * 2) } /// Miri does not support the native stack probe, and musl currently trips @@ -1574,34 +1695,19 @@ impl VirtualMachine { &self, frame: FrameRef, f: F, - ) -> PyResult { - self.with_frame_impl(frame, true, f) - } - - pub(crate) fn with_frame_untraced PyResult>( - &self, - frame: FrameRef, - f: F, - ) -> PyResult { - self.with_frame_impl(frame, false, f) - } - - fn with_frame_impl PyResult>( - &self, - frame: FrameRef, - traced: bool, - f: F, ) -> PyResult { self.with_recursion("", || { // SAFETY: `frame` (FrameRef) stays alive for the entire closure scope, // keeping the FramePtr valid. We pass a clone to `f` so that `f` // consuming its FrameRef doesn't invalidate our pointer. - let fp = FramePtr(NonNull::from(&*frame)); - self.frames.borrow_mut().push(fp); - // Update the shared frame stack for sys._current_frames() and faulthandler - #[cfg(feature = "threading")] - crate::vm::thread::push_thread_frame(fp); - // Link frame into the signal-safe frame chain (previous pointer) + // Publish the frame for sys._current_frames() and faulthandler. + // On unix, set_current_frame below publishes the top frame into the + // thread slot; only non-unix builds maintain the mutex-guarded Vec. + #[cfg(all(not(unix), feature = "threading"))] + crate::vm::thread::push_thread_frame(FramePtr(NonNull::from(&*frame))); + // Link frame into the signal-safe frame chain (previous pointer). + // This chain is the single source for the current thread's frame + // stack (current_frame, sys._getframe, f_back, monitoring). let old_frame = crate::vm::thread::set_current_frame((&**frame) as *const Frame); frame.previous.store( old_frame as *mut Frame, @@ -1613,27 +1719,31 @@ impl VirtualMachine { // exc_info pollution from frames with unbalanced // PUSH_EXC_INFO/POP_EXCEPT (e.g., exception escaping an except block // whose cleanup entry is missing from the exception table). - let saved_exc = self.current_exception(); + // A callee whose bytecode never mutates the slot cannot pollute it, + // so the save/restore is skipped for it. + let save_exc = frame.code.has_exc_handling; + let saved_exc = if save_exc { + self.current_exception() + } else { + None + }; let old_owner = frame.owner.swap( crate::frame::FrameOwner::Thread as i8, core::sync::atomic::Ordering::AcqRel, ); - // Ensure cleanup on panic: restore owner, exc_info, frame chain, and frames Vec. + // Ensure cleanup on panic: restore owner, exc_info, and frame chain. scopeguard::defer! { frame.owner.store(old_owner, core::sync::atomic::Ordering::Release); - self.set_exception(saved_exc); + if save_exc { + self.restore_exception(saved_exc); + } crate::vm::thread::set_current_frame(old_frame); - self.frames.borrow_mut().pop(); - #[cfg(feature = "threading")] + #[cfg(all(not(unix), feature = "threading"))] crate::vm::thread::pop_thread_frame(); } - if traced { - self.dispatch_traced_frame(&frame, |frame| f(frame.to_owned())) - } else { - f(frame.to_owned()) - } + self.dispatch_traced_frame(&frame, |frame| f(frame.to_owned())) }) } @@ -1653,10 +1763,8 @@ impl VirtualMachine { self.recursion_depth.update(|d| d + 1); // SAFETY: frame (&FrameRef) stays alive for the duration, so NonNull is valid until pop. - let fp = FramePtr(NonNull::from(&**frame)); - self.frames.borrow_mut().push(fp); - #[cfg(feature = "threading")] - crate::vm::thread::push_thread_frame(fp); + #[cfg(all(not(unix), feature = "threading"))] + crate::vm::thread::push_thread_frame(FramePtr(NonNull::from(&**frame))); let old_frame = crate::vm::thread::set_current_frame((&***frame) as *const Frame); frame.previous.store( old_frame as *mut Frame, @@ -1677,8 +1785,7 @@ impl VirtualMachine { frame.owner.store(old_owner, core::sync::atomic::Ordering::Release); self.pop_exception(); crate::vm::thread::set_current_frame(old_frame); - self.frames.borrow_mut().pop(); - #[cfg(feature = "threading")] + #[cfg(all(not(unix), feature = "threading"))] crate::vm::thread::pop_thread_frame(); self.recursion_depth.update(|d| d - 1); @@ -1768,10 +1875,7 @@ impl VirtualMachine { } pub fn current_frame(&self) -> Option { - self.frames.borrow().last().map(|fp| { - // SAFETY: the caller keeps the FrameRef alive while it's in the Vec - unsafe { fp.as_ref() }.to_owned() - }) + crate::frame::current_thread_frame() } pub fn current_locals(&self) -> PyResult { @@ -2034,13 +2138,15 @@ impl VirtualMachine { return true; } - #[cfg(all(unix, feature = "threading"))] + #[cfg(feature = "threading")] if thread::stop_requested_for_current_thread() { return true; } + // Signal and QSBR bits share one word: a single relaxed load per + // instruction covers both. #[cfg(not(target_arch = "wasm32"))] - if crate::signal::is_triggered() { + if crate::signal::eval_breaker_pending() { return true; } @@ -2059,15 +2165,33 @@ impl VirtualMachine { } // Suspend this thread if stop-the-world is in progress - #[cfg(all(unix, feature = "threading"))] + #[cfg(feature = "threading")] thread::suspend_if_needed(&self.state.stop_the_world); + // Pass a QSBR checkpoint if requested (deferred memory reclamation). + #[cfg(feature = "threading")] + if crate::signal::qsbr_bit_set() && thread::qsbr_break_requested() { + thread::qsbr_checkpoint(); + } + #[cfg(not(target_arch = "wasm32"))] crate::signal::check_signals(self)?; Ok(()) } + /// Run an automatic collection scheduled by `maybe_collect`, if any. + /// + /// Called only from the bytecode-loop safepoint, where no interpreter + /// locks are held, so the stop-the-world it performs cannot deadlock + /// against a thread blocked on a lock this thread would otherwise hold. + #[cfg(feature = "threading")] + pub(crate) fn run_scheduled_gc(&self) { + if crate::signal::take_gc_scheduled() { + crate::gc_state::gc_state().collect(0); + } + } + /// Push a new exc_info slot (for generator/coroutine resume). pub(crate) fn push_exception(&self, exc: Option) { self.exceptions.borrow_mut().stack.push(exc); @@ -2112,6 +2236,25 @@ impl VirtualMachine { thread::update_thread_exception(self.topmost_exception()); } + /// Restore an exc_info slot value saved by `with_frame`, skipping the + /// store when the slot is unchanged. `saved` is a strong reference taken + /// at save time, so the object it points to cannot have been freed and + /// its address reused while the frame ran; pointer identity therefore + /// proves the slot still holds the same value and both the store and the + /// thread-exception mirror update would be no-ops. + pub(crate) fn restore_exception(&self, saved: Option) { + let excs = self.exceptions.borrow(); + let unchanged = match (excs.stack.last(), &saved) { + (Some(Some(current)), Some(saved)) => current.is(saved), + (Some(None), None) => true, + _ => false, + }; + drop(excs); + if !unchanged { + self.set_exception(saved); + } + } + pub fn take_raised_exception(&self) -> Option { let mut excs = self.exceptions.borrow_mut(); if let Some(top) = excs.stack.last_mut() { diff --git a/crates/vm/src/vm/thread.rs b/crates/vm/src/vm/thread.rs index 5e73cc5f618..5009cb695c6 100644 --- a/crates/vm/src/vm/thread.rs +++ b/crates/vm/src/vm/thread.rs @@ -1,4 +1,4 @@ -#[cfg(feature = "threading")] +#[cfg(all(not(unix), feature = "threading"))] use super::FramePtr; #[cfg(feature = "threading")] use crate::builtins::PyBaseExceptionRef; @@ -19,30 +19,40 @@ use std::thread_local; // DETACHED: not executing Python bytecode (in native code, or idle) // ATTACHED: actively executing Python bytecode // SUSPENDED: parked by a stop-the-world request -#[cfg(all(unix, feature = "threading"))] +#[cfg(feature = "threading")] pub const THREAD_DETACHED: i32 = 0; -#[cfg(all(unix, feature = "threading"))] +#[cfg(feature = "threading")] pub const THREAD_ATTACHED: i32 = 1; -#[cfg(all(unix, feature = "threading"))] +#[cfg(feature = "threading")] pub const THREAD_SUSPENDED: i32 = 2; /// Per-thread shared state for sys._current_frames() and sys._current_exceptions(). /// The exception field uses atomic operations for lock-free cross-thread reads. #[cfg(feature = "threading")] pub struct ThreadSlot { + /// Top of the owning thread's Python call stack, published for + /// cross-thread readers (`sys._current_frames`, cross-thread `f_back`). + /// The rest of the stack is reachable via each frame's `previous` pointer. + /// Written lock-free on the hot push/pop path with relaxed ordering; every + /// cross-thread read runs under stop-the-world, which parks the owning + /// thread at a safepoint and supplies the happens-before edge, so the + /// pointer and the frames it reaches are quiescent and alive at read time. + #[cfg(unix)] + pub top_frame: AtomicPtr, /// Raw frame pointers, valid while the owning thread's call stack is active. /// Readers must hold the Mutex and convert to FrameRef inside the lock. + /// Used on non-unix threading builds, which have no stop-the-world. + #[cfg(not(unix))] pub frames: parking_lot::Mutex>, pub exception: crate::PyAtomicRef>, /// Thread state for stop-the-world: DETACHED / ATTACHED / SUSPENDED - #[cfg(unix)] pub state: core::sync::atomic::AtomicI32, /// Per-thread stop request bit (eval breaker equivalent). - #[cfg(unix)] pub stop_requested: core::sync::atomic::AtomicBool, /// Handle for waking this thread from park in stop-the-world paths. - #[cfg(unix)] pub thread: std::thread::Thread, + /// QSBR state for deferred memory reclamation. + pub(crate) qsbr: Arc, } #[cfg(feature = "threading")] @@ -79,6 +89,15 @@ thread_local! { pub(crate) static CURRENT_FRAME: AtomicPtr = const { AtomicPtr::new(core::ptr::null_mut()) }; + /// Cached pointer to this thread's `ThreadSlot::top_frame`, so the hot + /// push/pop path can publish the top frame with a single relaxed store and + /// no `CURRENT_THREAD_SLOT` RefCell borrow. Null until the slot is + /// initialized; the `Arc` in `CURRENT_THREAD_SLOT` keeps the + /// pointee alive until `cleanup_current_thread_frames` clears this. + #[cfg(all(unix, feature = "threading"))] + static CURRENT_TOP_FRAME_SLOT: Cell<*const AtomicPtr> = + const { Cell::new(core::ptr::null()) }; + } #[must_use] @@ -109,23 +128,32 @@ fn set_current_vm(vm: &VirtualMachine, f: impl FnOnce() -> R) -> R { }) } +pub fn try_with_current_vm(f: impl FnOnce(&VirtualMachine) -> R) -> Option { + VM_STACK.with(|vms| { + let vm = vms.borrow().last().copied()?; + // SAFETY: entries in VM_STACK either borrow a VM for the dynamic + // scope of a set_current_vm()/enter_vm() call or point at GILSTATE_VM. + Some(f(unsafe { vm.as_ref() })) + }) +} + pub fn enter_vm(vm: &VirtualMachine, f: impl FnOnce() -> R) -> R { // Outermost enter_vm: transition DETACHED → ATTACHED - #[cfg(all(unix, feature = "threading"))] + #[cfg(feature = "threading")] let was_outermost = !current_vm_is_set(); // Initialize thread slot for this thread if not already done #[cfg(feature = "threading")] init_thread_slot_if_needed(vm); - #[cfg(all(unix, feature = "threading"))] + #[cfg(feature = "threading")] if was_outermost { attach_thread(vm); } scopeguard::defer! { // Outermost exit: transition ATTACHED → DETACHED - #[cfg(all(unix, feature = "threading"))] + #[cfg(feature = "threading")] if was_outermost { detach_thread(); } @@ -134,6 +162,61 @@ pub fn enter_vm(vm: &VirtualMachine, f: impl FnOnce() -> R) -> R { set_current_vm(vm, f) } +/// RAII counterpart to `enter_vm`, for code that runs Python bytecode across +/// several statements interspersed with `&mut VirtualMachine` calls +/// (`VirtualMachine::initialize`), where a single closure-based `enter_vm` +/// scope cannot be expressed because the borrow checker won't let a closure +/// hold `&mut VirtualMachine` at the same time `enter_vm` reborrows it as +/// `&VirtualMachine`. Construction only needs a transient `&VirtualMachine` +/// borrow, so it can be dropped before subsequent `&mut` use. +/// +/// Without this, code that runs Python bytecode before any `enter_vm` scope +/// exists would leave the thread not ATTACHED, making lock-free type cache +/// reads unsound. +#[must_use] +pub(crate) struct VmBootstrapGuard { + #[cfg(feature = "threading")] + was_outermost: bool, +} + +impl VmBootstrapGuard { + pub(crate) fn new(vm: &VirtualMachine) -> Self { + // Outermost: transition DETACHED → ATTACHED + #[cfg(feature = "threading")] + let was_outermost = !current_vm_is_set(); + + // Initialize thread slot for this thread if not already done + #[cfg(feature = "threading")] + init_thread_slot_if_needed(vm); + + #[cfg(feature = "threading")] + if was_outermost { + attach_thread(vm); + } + + VM_STACK.with(|vms| vms.borrow_mut().push(vm.into())); + + Self { + #[cfg(feature = "threading")] + was_outermost, + } + } +} + +impl Drop for VmBootstrapGuard { + fn drop(&mut self) { + VM_STACK.with(|vms| { + vms.borrow_mut().pop(); + }); + + // Outermost exit: transition ATTACHED → DETACHED + #[cfg(feature = "threading")] + if self.was_outermost { + detach_thread(); + } + } +} + #[cfg(feature = "threading")] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CurrentVmAttachState { @@ -161,7 +244,6 @@ pub fn attach_current_thread( init_thread_slot_if_needed(vm); - #[cfg(unix)] attach_thread(vm); VM_STACK.with(|vms| { @@ -188,7 +270,6 @@ pub fn release_current_thread(state: CurrentVmAttachState) { .expect("release_current_thread() called without an attached VM"); }); - #[cfg(unix)] detach_thread(); } @@ -201,9 +282,11 @@ fn init_thread_slot_if_needed(vm: &VirtualMachine) { let thread_id = crate::stdlib::_thread::get_ident(); let mut registry = vm.state.thread_frames.lock(); let new_slot = Arc::new(ThreadSlot { + #[cfg(unix)] + top_frame: AtomicPtr::new(core::ptr::null_mut()), + #[cfg(not(unix))] frames: parking_lot::Mutex::new(Vec::new()), exception: crate::PyAtomicRef::from(None::), - #[cfg(unix)] state: core::sync::atomic::AtomicI32::new( if vm.state.stop_the_world.requested.load(Ordering::Acquire) { // Match init_threadstate(): new thread-state starts @@ -213,13 +296,14 @@ fn init_thread_slot_if_needed(vm: &VirtualMachine) { THREAD_DETACHED }, ), - #[cfg(unix)] stop_requested: core::sync::atomic::AtomicBool::new(false), - #[cfg(unix)] thread: std::thread::current(), + qsbr: crate::object::qsbr::QSBR.register(), }); registry.insert(thread_id, new_slot.clone()); drop(registry); + #[cfg(all(unix, feature = "threading"))] + CURRENT_TOP_FRAME_SLOT.with(|c| c.set(&new_slot.top_frame)); *slot.borrow_mut() = Some(new_slot); } }); @@ -227,7 +311,7 @@ fn init_thread_slot_if_needed(vm: &VirtualMachine) { /// Transition DETACHED → ATTACHED. Blocks if the thread was SUSPENDED by /// a stop-the-world request (like `_PyThreadState_Attach` + `tstate_wait_attach`). -#[cfg(all(unix, feature = "threading"))] +#[cfg(feature = "threading")] fn wait_while_suspended(slot: &ThreadSlot) -> u64 { let mut wait_yields = 0u64; while slot.state.load(Ordering::Acquire) == THREAD_SUSPENDED { @@ -237,7 +321,7 @@ fn wait_while_suspended(slot: &ThreadSlot) -> u64 { wait_yields } -#[cfg(all(unix, feature = "threading"))] +#[cfg(feature = "threading")] fn attach_thread(vm: &VirtualMachine) { CURRENT_THREAD_SLOT.with(|slot| { if let Some(s) = slot.borrow().as_ref() { @@ -250,6 +334,7 @@ fn attach_thread(vm: &VirtualMachine) { Ordering::Relaxed, ) { Ok(_) => { + crate::object::qsbr::QSBR.online(&s.qsbr); super::stw_trace(format_args!("attach DETACHED->ATTACHED")); break; } @@ -268,10 +353,18 @@ fn attach_thread(vm: &VirtualMachine) { } } }); + // A stop-the-world may have been requested while this thread was detached. + // Honoring it here (rather than only at the next bytecode safepoint) keeps + // a thread doing rapid allow_threads calls from re-attaching and running + // past the requester forever, which would stall stop-the-world. Done + // outside the CURRENT_THREAD_SLOT borrow above because suspend re-borrows + // it. Safe against a concurrent start_the_world: suspend_if_needed only + // parks while the request is still live and self-recovers otherwise. + suspend_if_needed(&vm.state.stop_the_world); } /// Transition ATTACHED → DETACHED (like `_PyThreadState_Detach`). -#[cfg(all(unix, feature = "threading"))] +#[cfg(feature = "threading")] fn detach_thread() { CURRENT_THREAD_SLOT.with(|slot| { if let Some(s) = slot.borrow().as_ref() { @@ -281,7 +374,9 @@ fn detach_thread() { Ordering::AcqRel, Ordering::Acquire, ) { - Ok(_) => {} + Ok(_) => { + crate::object::qsbr::QSBR.offline(&s.qsbr); + } Err(THREAD_DETACHED) => { debug_assert!(false, "detach called while already DETACHED"); return; @@ -301,7 +396,7 @@ fn detach_thread() { /// to park this thread during blocking operations. /// /// `Py_BEGIN_ALLOW_THREADS` / `Py_END_ALLOW_THREADS` equivalent. -#[cfg(all(unix, feature = "threading"))] +#[cfg(feature = "threading")] pub fn allow_threads(vm: &VirtualMachine, f: impl FnOnce() -> R) -> R { // Preserve save/restore semantics: // only detach if this call observed ATTACHED at entry, and always restore @@ -322,8 +417,8 @@ pub fn allow_threads(vm: &VirtualMachine, f: impl FnOnce() -> R) -> R { result } -/// No-op on non-unix or non-threading builds. -#[cfg(not(all(unix, feature = "threading")))] +/// No-op on non-threading builds. +#[cfg(not(feature = "threading"))] pub fn allow_threads(_vm: &VirtualMachine, f: impl FnOnce() -> R) -> R { f() } @@ -331,7 +426,7 @@ pub fn allow_threads(_vm: &VirtualMachine, f: impl FnOnce() -> R) -> R { /// Called from check_signals when stop-the-world is requested. /// Transitions ATTACHED → SUSPENDED and waits until released /// (like `_PyThreadState_Suspend` + `_PyThreadState_Attach`). -#[cfg(all(unix, feature = "threading"))] +#[cfg(feature = "threading")] pub fn suspend_if_needed(stw: &super::StopTheWorldState) { let should_suspend = CURRENT_THREAD_SLOT.with(|slot| { slot.borrow() @@ -354,7 +449,7 @@ pub fn suspend_if_needed(stw: &super::StopTheWorldState) { do_suspend(stw); } -#[cfg(all(unix, feature = "threading"))] +#[cfg(feature = "threading")] #[cold] fn do_suspend(stw: &super::StopTheWorldState) { CURRENT_THREAD_SLOT.with(|slot| { @@ -431,7 +526,7 @@ fn do_suspend(stw: &super::StopTheWorldState) { }); } -#[cfg(all(unix, feature = "threading"))] +#[cfg(feature = "threading")] #[inline] #[must_use] pub fn stop_requested_for_current_thread() -> bool { @@ -442,9 +537,55 @@ pub fn stop_requested_for_current_thread() -> bool { }) } +/// Whether the QSBR subsystem asked this thread to pass a checkpoint. +/// A missed or racing read of this flag is harmless: the pending +/// retirement is still processed at the next checkpoint or by the GC +/// backstop. +#[cfg(feature = "threading")] +pub(crate) fn qsbr_break_requested() -> bool { + CURRENT_THREAD_SLOT.with(|slot| { + slot.borrow() + .as_ref() + .is_some_and(|s| s.qsbr.requested.load(Ordering::Relaxed)) + }) +} + +/// Pass a QSBR checkpoint: the calling thread holds no borrowed cache +/// pointers here (instruction boundary), so mark it quiescent and try to +/// free retired allocations. +#[cfg(feature = "threading")] +pub(crate) fn qsbr_checkpoint() { + use crate::object::qsbr::QSBR; + CURRENT_THREAD_SLOT.with(|slot| { + if let Some(s) = slot.borrow().as_ref() { + s.qsbr.requested.store(false, Ordering::Relaxed); + QSBR.quiescent_state(&s.qsbr); + } + }); + QSBR.process(); +} + +/// Debug check: lock-free type-cache reads are only sound on threads that +/// are registered with QSBR and currently ATTACHED. +#[cfg(all(feature = "threading", debug_assertions))] +pub(crate) fn debug_assert_current_thread_attached() { + CURRENT_THREAD_SLOT.with(|slot| { + if let Some(s) = slot.borrow().as_ref() { + debug_assert_eq!( + s.state.load(Ordering::Relaxed), + THREAD_ATTACHED, + "type cache read while thread not ATTACHED" + ); + } + }); +} + /// Push a frame pointer onto the current thread's shared frame stack. /// The pointed-to frame must remain alive until the matching pop. -#[cfg(feature = "threading")] +/// +/// Only used on non-unix threading builds; unix builds publish the top frame +/// through `set_current_frame` writing `ThreadSlot::top_frame`. +#[cfg(all(not(unix), feature = "threading"))] pub fn push_thread_frame(fp: FramePtr) { CURRENT_THREAD_SLOT.with(|slot| { if let Some(s) = slot.borrow().as_ref() { @@ -460,7 +601,7 @@ pub fn push_thread_frame(fp: FramePtr) { /// Pop a frame from the current thread's shared frame stack. /// Called when a frame is exited. -#[cfg(feature = "threading")] +#[cfg(all(not(unix), feature = "threading"))] pub fn pop_thread_frame() { CURRENT_THREAD_SLOT.with(|slot| { if let Some(s) = slot.borrow().as_ref() { @@ -477,6 +618,17 @@ pub fn pop_thread_frame() { /// Set the current thread's top frame pointer for signal-safe traceback walking. /// Returns the previous frame pointer so it can be restored on pop. pub fn set_current_frame(frame: *const Frame) -> *const Frame { + // Publish the top frame for cross-thread readers. The relaxed store is + // ordered by stop-the-world at read time (see `ThreadSlot::top_frame`). + #[cfg(all(unix, feature = "threading"))] + { + let slot_top = CURRENT_TOP_FRAME_SLOT.with(Cell::get); + if !slot_top.is_null() { + // SAFETY: points to this thread's `ThreadSlot::top_frame`, kept + // alive by the Arc in `CURRENT_THREAD_SLOT` for the thread's life. + unsafe { (*slot_top).store(frame as *mut Frame, Ordering::Relaxed) }; + } + } CURRENT_FRAME.with(|c| c.swap(frame as *mut Frame, Ordering::Relaxed) as *const Frame) } @@ -519,7 +671,7 @@ pub fn cleanup_current_thread_frames(vm: &VirtualMachine) { // A dying thread should not remain logically ATTACHED while its // thread-state slot is being removed. - #[cfg(all(unix, feature = "threading"))] + #[cfg(feature = "threading")] if let Some(slot) = ¤t_slot { let _ = slot.state.compare_exchange( THREAD_ATTACHED, @@ -541,7 +693,7 @@ pub fn cleanup_current_thread_frames(vm: &VirtualMachine) { None }; - #[cfg(all(unix, feature = "threading"))] + #[cfg(feature = "threading")] if let Some(slot) = &_removed && vm.state.stop_the_world.requested.load(Ordering::Acquire) && thread_id != vm.state.stop_the_world.requester_ident() @@ -551,6 +703,10 @@ pub fn cleanup_current_thread_frames(vm: &VirtualMachine) { // Unblock requester countdown progress. vm.state.stop_the_world.notify_thread_gone(); } + // Clear the cached top-frame pointer before dropping the slot Arc so no + // later `set_current_frame` dereferences freed slot memory. + #[cfg(all(unix, feature = "threading"))] + CURRENT_TOP_FRAME_SLOT.with(|c| c.set(core::ptr::null())); CURRENT_THREAD_SLOT.with(|s| { *s.borrow_mut() = None; }); @@ -558,24 +714,43 @@ pub fn cleanup_current_thread_frames(vm: &VirtualMachine) { /// Reinitialize thread slot after fork. Called in child process. /// Creates a fresh slot and registers it for the current thread, -/// preserving the current thread's frames from `vm.frames`. +/// preserving the current thread's frames from the signal-safe frame chain. /// /// Precondition: `reinit_locks_after_fork()` has already reset all /// VmState locks to unlocked. #[cfg(feature = "threading")] pub fn reinit_frame_slot_after_fork(vm: &VirtualMachine) { let current_ident = crate::stdlib::_thread::get_ident(); - let current_frames: Vec = vm.frames.borrow().clone(); + // On non-unix, rebuild the shared frame stack (bottom-to-top) from the + // current thread's frame chain, which walks top-to-bottom via `previous`. + #[cfg(not(unix))] + let current_frames: Vec = { + let mut current_frames = Vec::new(); + let mut cur = get_current_frame(); + while !cur.is_null() { + // SAFETY: the forking thread's chain frames are alive. + let py = unsafe { crate::Py::::from_payload_ptr(cur) }; + current_frames.push(FramePtr(unsafe { NonNull::new_unchecked(py as *mut _) })); + cur = unsafe { (*cur).previous_frame() }; + } + current_frames.reverse(); + current_frames + }; let new_slot = Arc::new(ThreadSlot { + // The surviving child thread keeps executing its current frame chain, + // whose top is the signal-safe `get_current_frame()`. + #[cfg(unix)] + top_frame: AtomicPtr::new(get_current_frame() as *mut Frame), + #[cfg(not(unix))] frames: parking_lot::Mutex::new(current_frames), exception: crate::PyAtomicRef::from(vm.topmost_exception()), - #[cfg(unix)] state: core::sync::atomic::AtomicI32::new(THREAD_ATTACHED), - #[cfg(unix)] stop_requested: core::sync::atomic::AtomicBool::new(false), - #[cfg(unix)] thread: std::thread::current(), + qsbr: crate::object::qsbr::QSBR.register(), }); + #[cfg(all(unix, feature = "threading"))] + CURRENT_TOP_FRAME_SLOT.with(|c| c.set(&new_slot.top_frame)); // Lock is safe: reinit_locks_after_fork() already reset it to unlocked. let mut registry = vm.state.thread_frames.lock(); @@ -710,7 +885,6 @@ impl VirtualMachine { builtins: self.builtins.clone(), sys_module: self.sys_module.clone(), ctx: self.ctx.clone(), - frames: RefCell::new(vec![]), datastack: core::cell::UnsafeCell::new(crate::datastack::DataStack::new()), wasm_id: self.wasm_id.clone(), exceptions: RefCell::default(), diff --git a/crates/vm/src/vm/vm_new.rs b/crates/vm/src/vm/vm_new.rs index c71cf842520..ae728aaba67 100644 --- a/crates/vm/src/vm/vm_new.rs +++ b/crates/vm/src/vm/vm_new.rs @@ -10,7 +10,7 @@ use rustpython_compiler_core::SourceLocation; use rustpython_compiler::{CompileError, ParseError}; use crate::{ - AsObject, Py, PyObject, PyObjectRef, PyRef, PyResult, + AsObject, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, builtins::{ PyBaseException, PyBaseExceptionRef, PyBytesRef, PyDictRef, PyModule, PyOSError, PyStrRef, PyType, PyTypeRef, @@ -348,11 +348,9 @@ impl VirtualMachine { exc_type.name() ); - PyRef::new_ref( - PyBaseException::new(args, self), - exc_type, - Some(self.ctx.new_dict()), - ) + PyBaseException::new(args, self) + .into_ref_with_type_lazy_dict(self, exc_type) + .expect("vm.new_exception() called with an invalid exception type") } pub fn new_os_error(&self, msg: impl ToPyObject) -> PyRef { diff --git a/extra_tests/custom_text_test_runner.py b/extra_tests/custom_text_test_runner.py index 3457bdfd0e4..865265750f4 100644 --- a/extra_tests/custom_text_test_runner.py +++ b/extra_tests/custom_text_test_runner.py @@ -389,11 +389,9 @@ def startTest(self, test): } self.start_time = time.time() if self.test_types: - if "test_type" in getattr( - test, test._testMethodName - ).__func__.__dict__ and set([s.lower() for s in self.test_types]) == set( - [s.lower() for s in _get_method_dict(test)["test_type"]] - ): + if "test_type" in _get_method_dict(test) and set( + [s.lower() for s in self.test_types] + ) == set([s.lower() for s in _get_method_dict(test)["test_type"]]): pass else: _get_method_dict(test)["__unittest_skip_why__"] = ( diff --git a/extra_tests/snippets/builtin_type_bases.py b/extra_tests/snippets/builtin_type_bases.py new file mode 100644 index 00000000000..1d413e48e5c --- /dev/null +++ b/extra_tests/snippets/builtin_type_bases.py @@ -0,0 +1,293 @@ +from testutils import assert_raises + +# Reassigning __bases__ must rebuild slot dispatchers for the type and all its +# descendants: a slot whose method left the new MRO must be reset, not left stale. + + +# --- zelf itself loses __add__ (nb_add) --- +class OldAdd: + def __add__(self, other): + return "OLD" + + +class Bare: + pass + + +class C(OldAdd): + pass + + +c = C() +assert c + 1 == "OLD" +C.__bases__ = (Bare,) +with assert_raises(TypeError): + c + 1 + + +# --- 3-level descendant loses __iter__ (tp_iter) --- +class Itr: + def __iter__(self): + return iter([1, 2, 3]) + + +class New: + pass + + +class C2(Itr): + pass + + +class D2(C2): + pass + + +class E2(D2): + pass + + +e = E2() +assert list(e) == [1, 2, 3] +C2.__bases__ = (New,) +with assert_raises(TypeError): + list(e) + + +# --- descendant loses __len__ (sq_length), sibling slot untouched --- +class Sized: + def __len__(self): + return 7 + + +class C3(Sized): + pass + + +class D3(C3): + pass + + +d3 = D3() +assert len(d3) == 7 +C3.__bases__ = (Bare,) +with assert_raises(TypeError): + len(d3) + + +# --- descendant loses __getitem__ (mp_subscript) --- +class Subscriptable: + def __getitem__(self, key): + return key * 2 + + +class C4(Subscriptable): + pass + + +class D4(C4): + pass + + +d4 = D4() +assert d4[3] == 6 +C4.__bases__ = (Bare,) +with assert_raises(TypeError): + d4[3] + + +# --- descendant loses __call__ (tp_call) --- +class Callable: + def __call__(self): + return "called" + + +class C5(Callable): + pass + + +class D5(C5): + pass + + +d5 = D5() +assert d5() == "called" +C5.__bases__ = (Bare,) +with assert_raises(TypeError): + d5() + + +# --- guard: stale-wrong-target, name present in both bases must switch --- +class OldTarget: + def __add__(self, other): + return "OLD.__add__" + + +class NewTarget: + def __add__(self, other): + return "NEW.__add__" + + +class C6(OldTarget): + pass + + +class D6(C6): + pass + + +d6 = D6() +assert d6 + 1 == "OLD.__add__" +C6.__bases__ = (NewTarget,) +assert d6 + 1 == "NEW.__add__" + + +# --- guard: __getattr__ resolves at call time, stays correct --- +class OldGetattr: + def __getattr__(self, name): + return "OLD:" + name + + +class C7(OldGetattr): + pass + + +class D7(C7): + pass + + +d7 = D7() +assert d7.missing == "OLD:missing" +C7.__bases__ = (Bare,) +with assert_raises(AttributeError): + d7.missing + + +# --- mirror: new base ADDS a dunder the old chain lacked --- +class Adder: + def __add__(self, other): + return "ADDED" + + +class C8(Bare): + pass + + +class D8(C8): + pass + + +d8 = D8() +with assert_raises(TypeError): + d8 + 1 +C8.__bases__ = (Adder,) +assert d8 + 1 == "ADDED" + + +# --- round trip: swap away then back restores the slot --- +class C9(OldAdd): + pass + + +class D9(C9): + pass + + +d9 = D9() +assert d9 + 1 == "OLD" +C9.__bases__ = (Bare,) +with assert_raises(TypeError): + d9 + 1 +C9.__bases__ = (OldAdd,) +assert d9 + 1 == "OLD" + + +# --- left-only __add__ defined on the type itself survives a base swap --- +# __add__ and __radd__ share one accessor but occupy distinct fields; resolving +# the absent __radd__ must not overwrite the __add__ dispatcher. +class Mixin: + pass + + +class Other: + pass + + +class C10(Mixin): + def __add__(self, o): + return "C10" + + +c10 = C10() +assert c10 + 1 == "C10" +C10.__bases__ = (Other,) +assert c10 + 1 == "C10" + + +# --- right-only __radd__ survives a base swap --- +class C11(Mixin): + def __radd__(self, o): + return "C11" + + +c11 = C11() +assert 1 + c11 == "C11" +C11.__bases__ = (Other,) +assert 1 + c11 == "C11" + + +# --- subclass/grandchild shadowing __add__ keeps it when an ancestor swaps bases --- +class AddBase: + def __add__(self, o): + return "AddBase" + + +class Ancestor(AddBase): + pass + + +class Shadow(Ancestor): + def __add__(self, o): + return "Shadow" + + +class GrandShadow(Shadow): + pass + + +sh = Shadow() +gsh = GrandShadow() +assert sh + 1 == "Shadow" +assert gsh + 1 == "Shadow" +Ancestor.__bases__ = (Mixin,) +assert sh + 1 == "Shadow" +assert gsh + 1 == "Shadow" + + +# --- another Nb* pair: left-only __sub__ survives a base swap --- +class C12(Mixin): + def __sub__(self, o): + return "C12" + + +c12 = C12() +assert c12 - 1 == "C12" +C12.__bases__ = (Other,) +assert c12 - 1 == "C12" + + +# --- setattr/delattr-driven right-op updates keep the left op intact --- +class C13: + def __add__(self, o): + return "C13.add" + + +c13 = C13() +assert c13 + 1 == "C13.add" +C13.__radd__ = lambda self, o: "C13.radd" +assert c13 + 1 == "C13.add" +assert 1 + c13 == "C13.radd" +del C13.__radd__ +assert c13 + 1 == "C13.add" +with assert_raises(TypeError): + 1 + c13 diff --git a/extra_tests/snippets/stdlib_threading_gc_fork.py b/extra_tests/snippets/stdlib_threading_gc_fork.py new file mode 100644 index 00000000000..cd00cf00983 --- /dev/null +++ b/extra_tests/snippets/stdlib_threading_gc_fork.py @@ -0,0 +1,62 @@ +"""Fork while other threads drive concurrent GC stop-the-world. + +fork() and the cycle collector both stop the world through the same shared +state. Without a single exclusion around each stop->start span, an interleaving +of the fork requester and a GC requester clobbers that state (requester word, +suspension countdown) so the completion check never converges and a requester +waits on itself forever. + +Worker threads allocate cyclic garbage with GC enabled while the main thread +forks repeatedly; each child collects and exits. A regression shows up as a +hang in the parent (never finishing the fork loop). The allocation rate is kept +light so the collection stays cheap even in unoptimized builds. +""" + +import gc +import os +import threading +import time + +if not hasattr(os, "fork"): + print("skipped (no fork)") + raise SystemExit(0) + +gc.enable() +stop = threading.Event() + + +def churn(): + while not stop.is_set(): + a = {} + b = {"a": a} + a["b"] = b # cycle collectable only by the cycle collector + lst = [a, b] + lst.append(lst) + del a, b, lst + # Throttle so the collector keeps the heap small; the point is to + # interleave fork with concurrent collections, not to grow the heap. + time.sleep(0.001) + + +workers = [threading.Thread(target=churn) for _ in range(4)] +for w in workers: + w.start() + +# Let the workers get going before forking. +time.sleep(0.05) + +N = 25 +for _ in range(N): + pid = os.fork() + if pid == 0: + # Child: run its own stop-the-world collection, then exit. + gc.collect() + os._exit(0) + _, status = os.waitpid(pid, 0) + assert status == 0, status + +stop.set() +for w in workers: + w.join() + +print("ok") diff --git a/extra_tests/snippets/stdlib_threading_gc_frame_race.py b/extra_tests/snippets/stdlib_threading_gc_frame_race.py new file mode 100644 index 00000000000..37cdbbf122c --- /dev/null +++ b/extra_tests/snippets/stdlib_threading_gc_frame_race.py @@ -0,0 +1,101 @@ +"""Stress GC traversal against concurrently executing frames. + +The cycle collector reads each tracked object's interpreter state, including +the data stack and fast locals of frames that other threads are actively +executing. Those slots are written without synchronization by the running +thread, so the collector must only read them while the world is stopped. + +Workers churn frame state hard: deep recursion (many nested frames), heavy +local rebind / stack traffic, and generators repeatedly resumed. Meanwhile a +collector thread loops gc.collect() and an introspector walks live frame +objects via gc.get_objects(). A regression (torn read of a running frame) +shows up as a crash, a use-after-free, or a hang. +""" + +import gc +import sys +import threading +import time + +DURATION = 1.5 + + +def deep(n): + # Deep recursion + local rebind churns fast locals and the data stack. + a = n + b = [n, n + 1] + c = {"k": a} + if n <= 0: + return a + len(b) + len(c) + a = a - 1 + b.append(a) + return deep(n - 1) + a + + +def gen_worker(): + def counter(limit): + acc = 0 + i = 0 + while i < limit: + box = {"i": i} + box["self"] = box # a cycle held by the running generator frame + acc += i + yield acc + i += 1 + + g = counter(200) + total = 0 + for v in g: + total += v + return total + + +def make_frame_cycles(n): + for _ in range(n): + + def inner(): + fr = sys._getframe() + box = {"fr": fr} + box["self"] = box + return None + + inner() + + +def worker(stop): + # deep() nesting is kept modest so the recursion also fits the smaller + # worker-thread stack of unoptimized (debug) builds; the generators and + # frame cycles supply the rest of the frame churn. + while not stop.is_set(): + deep(12) + gen_worker() + make_frame_cycles(20) + + +def collector(stop): + while not stop.is_set(): + gc.collect() + + +def introspector(stop): + while not stop.is_set(): + for o in gc.get_objects(): + if type(o).__name__ == "frame": + try: + _ = o.f_lineno + _ = o.f_code.co_name + except Exception: + pass + + +stop = threading.Event() +threads = [threading.Thread(target=worker, args=(stop,)) for _ in range(4)] +threads.append(threading.Thread(target=collector, args=(stop,))) +threads.append(threading.Thread(target=introspector, args=(stop,))) +for t in threads: + t.start() +time.sleep(DURATION) +stop.set() +for t in threads: + t.join() +print("ok") diff --git a/extra_tests/snippets/stdlib_threading_gc_import.py b/extra_tests/snippets/stdlib_threading_gc_import.py new file mode 100644 index 00000000000..340184093f3 --- /dev/null +++ b/extra_tests/snippets/stdlib_threading_gc_import.py @@ -0,0 +1,58 @@ +"""Concurrent imports plus GC stop-the-world must not deadlock (no fork). + +The global import lock is held across bytecode by the importlib bootstrap, so +its holder can be parked at a safepoint mid-hold. If another thread blocks on +that lock while attached, a GC stop-the-world requester waits forever for that +attached thread to suspend while the lock holder stays parked -- a three-party +deadlock. Acquiring the import lock must therefore detach so the wait honors a +stop-the-world request. + +Two threads repeatedly re-import modules (contending the import lock) while a +third storms the cycle collector and a fourth allocates cyclic garbage. A +regression shows up as a hang (the importer threads never finishing). +""" + +import gc +import importlib +import sys +import threading + +gc.enable() +stop = threading.Event() + +# Modules cheap to import and safe to drop/re-import repeatedly. +MODS = ("colorsys", "stringprep") +ITERS = 2000 + + +def importer(mod): + for _ in range(ITERS): + if stop.is_set(): + break + sys.modules.pop(mod, None) + importlib.import_module(mod) + + +def collector(): + while not stop.is_set(): + gc.collect() + + +def allocator(): + while not stop.is_set(): + y = [{"i": i} for i in range(50)] + y[0]["self"] = y # cycle collectable only by the cycle collector + + +importers = [threading.Thread(target=importer, args=(m,)) for m in MODS] +helpers = [threading.Thread(target=collector), threading.Thread(target=allocator)] + +for t in importers + helpers: + t.start() +for t in importers: + t.join() +stop.set() +for t in helpers: + t.join() + +print("ok") diff --git a/extra_tests/snippets/stdlib_threading_type_cache.py b/extra_tests/snippets/stdlib_threading_type_cache.py new file mode 100644 index 00000000000..92368e8b83d --- /dev/null +++ b/extra_tests/snippets/stdlib_threading_type_cache.py @@ -0,0 +1,69 @@ +"""Stress the lock-free type method cache against concurrent type mutation. + +Readers hammer method lookups while a mutator continuously replaces and +deletes the method, dropping the old function objects. Guards against +use-after-free in the cache read protocol (QSBR deferred reclamation). + +Also churns a freelist-eligible published value (a tuple class attribute): +tuples normally go back through the freelist on dealloc, but once one is +published to the type cache it must instead go through the QSBR-deferred +reclamation path, so this exercises that bypass. +""" + +import threading +import time + + +class C: + def m(self): + return -1 + + +DURATION = 1.5 + + +def reader(stop): + obj = C() + while not stop.is_set(): + for _ in range(1000): + try: + obj.m() + except AttributeError: + pass + try: + obj.shape + except AttributeError: + pass + + +def mutator(stop): + i = 0 + while not stop.is_set(): + + def m(self, _i=i): + return _i + + C.m = m + C.shape = (i, i + 1) + i += 1 + if i % 97 == 0: + try: + del C.m + except AttributeError: + pass + try: + del C.shape + except AttributeError: + pass + + +stop = threading.Event() +threads = [threading.Thread(target=reader, args=(stop,)) for _ in range(4)] +threads.append(threading.Thread(target=mutator, args=(stop,))) +for t in threads: + t.start() +time.sleep(DURATION) +stop.set() +for t in threads: + t.join() +print("ok") diff --git a/tools/opcode_metadata/generate_rs_opcode_metadata.py b/tools/opcode_metadata/generate_rs_opcode_metadata.py index fd13e026613..97482f337a1 100644 --- a/tools/opcode_metadata/generate_rs_opcode_metadata.py +++ b/tools/opcode_metadata/generate_rs_opcode_metadata.py @@ -28,6 +28,7 @@ def fn_as_info_size(self) -> str: return f""" /// Returns [`Self`] as [`{self.size}`]. #[must_use] + #[inline] pub const fn as_{self.size}(self) -> {self.size} {{ self.as_numeric() }} @@ -113,6 +114,7 @@ def fn_to_base(self) -> str: return f""" #[must_use] + #[inline] pub const fn to_base(self) -> Option {{ {inner} }} @@ -146,25 +148,30 @@ def fn_to_instrumented(self) -> str: @property def fn_deopt(self) -> str: - arms = "" - for target, specialized in self.info.deopts.items(): - ops = "|".join(f"Self::{op}" for op in specialized) - arms += f"{ops} => Self::{target},\n" + specialized_to_base = self.specialized_to_base - arms = arms.strip() - - if not arms: + if not specialized_to_base: inner = "None" else: + table_type = f"super::{self.info.enum_name}" + entries = ",\n".join( + f"Some({table_type}::{specialized_to_base[name]})" + if name in specialized_to_base + else "None" + for name in self.rust_names_by_id + ) + inner = f""" - Some(match self {{ - {arms} - _ => return None, - }}) + const DEOPT: [Option<{table_type}>; {self.table_size}] = [ + {entries} + ]; + + DEOPT[self.as_numeric() as usize] """ return f""" #[must_use] + #[inline] pub const fn deopt(self) -> Option {{ {inner} }} @@ -172,7 +179,7 @@ def fn_deopt(self) -> str: @property def fn_cache_entries(self) -> str: - arms = "" + entries_by_base: dict[str, int] = {} for opcode in self: name = opcode.rust_name if opcode.is_instrumented: @@ -186,21 +193,25 @@ def fn_cache_entries(self) -> str: continue if size > 1: - arms += f"Self::{name} => {size - 1},\n" + entries_by_base[name] = size - 1 - arms = arms.strip() - if not arms: + if not entries_by_base: inner = "0" else: + entries = ", ".join( + str(entries_by_base.get(self.resolve_deoptimized(name), 0)) + for name in self.rust_names_by_id + ) + inner = f""" - match self.deoptimize() {{ - {arms} - _ => 0, - }} + const CACHE_ENTRIES: [u8; {self.table_size}] = [{entries}]; + + CACHE_ENTRIES[self.as_numeric() as usize] as usize """ return f""" #[must_use] + #[inline] pub const fn cache_entries(self) -> usize {{ {inner} }} @@ -323,6 +334,42 @@ def instrumented_mapping(self) -> dict[str, str]: return res + @property + def specialized_to_base(self) -> dict[str, str]: + """Maps a specialized opcode's name to its family's base opcode name.""" + res = {} + for target, specialized in self.info.deopts.items(): + for name in specialized: + res[name] = target + + return res + + @property + def instrumented_to_base(self) -> dict[str, str]: + """Maps an instrumented opcode's name to its base opcode name.""" + return {iname: name for name, iname in self.instrumented_mapping.items()} + + def resolve_deoptimized(self, name: str) -> str: + """ + Mirrors `deoptimize`: resolves a specialized opcode to its family's + base, an instrumented opcode to its base, or returns the name + unchanged. + """ + if name in self.specialized_to_base: + return self.specialized_to_base[name] + + return self.instrumented_to_base.get(name, name) + + @property + def table_size(self) -> int: + return {"u8": 256, "u16": 65536}[self.size] + + @property + def rust_names_by_id(self) -> list[str | None]: + """The opcode name at each numeric id, `None` where no opcode is assigned.""" + names_by_id = {opcode.id: opcode.rust_name for opcode in self} + return [names_by_id.get(i) for i in range(self.table_size)] + @property def size(self) -> str: return self.info.size From 6a118b33dd0f468a8f82f7a2d806aede87abed5b Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:18:44 +0900 Subject: [PATCH 093/351] Complete rustpython-unicode isolation: case mapping, casing predicates, sre parity (#8237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Move str casing into rustpython-unicode::case and drop icu from vm Add code-point simple mappings (simple_lowercase/uppercase/titlecase/fold), casing predicates (is_lowercase/is_uppercase/is_titlecase/is_cased/ is_case_ignorable), and the string-level capitalize/title/swapcase/casefold helpers to crates/unicode/src/case.rs, moving the titlecase segmentation and final-sigma logic out of vm/builtins/str.rs verbatim. str.rs and anystr.rs now call through the crate; the is_cased kernel takes plain fn(char)->bool predicates instead of icu BinaryProperty generics. Remove icu_casemap/icu_locale/icu_properties/writeable from crates/vm and the now-unused VecFmtWriter helper. crates/unicode is the only workspace member with a direct icu dependency. str.lower/upper keep using Wtf8::to_lowercase/ to_uppercase. Assisted-by: Claude * Route sre is_uni_space through unicode::classify::is_space SRE_UNI_IS_SPACE is Py_UNICODE_ISSPACE. Replace the hand-rolled BMP code-point list with classify::is_space, which is differential-tested against CPython. A full-range sweep confirms the two agree on every code point. Assisted-by: Claude * Use simple case mappings and the Cased property for sre IGNORECASE lower_unicode/upper_unicode took the first char of the full case mapping, so code points with a full mapping but no simple one were miscased (e.g. upper_unicode('ß') returned 'S'). Route them through case::simple_lowercase/ simple_uppercase, matching Py_UNICODE_TOLOWER/TOUPPER. _sre.unicode_iscased derived casedness from those mappings, which only held while the full mapping was used; with simple mappings a cased code point that maps to itself (e.g. the ſt/st ligatures, U+FB05/06) read as uncased and lost its _casefix equivalence. Query the Cased property directly via case::is_cased. Assisted-by: Claude * Sweep casing predicates and simple lowercase mapping against CPython Extend the differential harness to cover is_lowercase/is_uppercase/is_titlecase/ is_cased over the full scalar range, sourced from str.islower/isupper, the Lt category, and _sre.unicode_iscased. Add a parallel sweep of the simple lowercase mapping (Py_UNICODE_TOLOWER via _sre.unicode_tolower) with its own version-skew allow-list; CPython exposes no simple-uppercase oracle, so toupper stays on the SRE unit tests. Record the U+0295 Ll->Lo recategorization (Unicode 16.0.0 -> 17.0.0) as a known reverse-direction divergence so the skew regenerator still rejects genuine regressions. Assisted-by: Claude --- Cargo.lock | 5 +- crates/sre_engine/src/string.rs | 44 +-- crates/unicode/Cargo.toml | 1 + crates/unicode/src/case.rs | 332 +++++++++++++++++- .../tests/data/cpython3.14_mappings.txt | 2 + .../tests/data/cpython3.14_predicates.txt | 4 + .../tests/data/version_skew_cpython3.14.txt | 3 + .../version_skew_mappings_cpython3.14.txt | 5 + crates/unicode/tests/differential.rs | 201 ++++++++++- crates/unicode/tests/generate_reference.py | 48 ++- crates/vm/Cargo.toml | 6 - crates/vm/src/anystr.rs | 16 +- crates/vm/src/builtins/str.rs | 192 +--------- crates/vm/src/stdlib/_sre.rs | 5 +- crates/vm/src/utils.rs | 15 - 15 files changed, 594 insertions(+), 285 deletions(-) create mode 100644 crates/unicode/tests/data/cpython3.14_mappings.txt create mode 100644 crates/unicode/tests/data/version_skew_mappings_cpython3.14.txt diff --git a/Cargo.lock b/Cargo.lock index 73afeda8a18..3a0392b188e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3722,6 +3722,7 @@ name = "rustpython-unicode" version = "0.5.0" dependencies = [ "icu_casemap", + "icu_locale", "icu_normalizer", "icu_properties", "rustpython-wtf8", @@ -3749,9 +3750,6 @@ dependencies = [ "glob", "half", "hex", - "icu_casemap", - "icu_locale", - "icu_properties", "indexmap", "is-macro", "itertools 0.15.0", @@ -3793,7 +3791,6 @@ dependencies = [ "timsort", "wasm-bindgen", "widestring", - "writeable", ] [[package]] diff --git a/crates/sre_engine/src/string.rs b/crates/sre_engine/src/string.rs index 5deeab67eb6..ca7303a2a7f 100644 --- a/crates/sre_engine/src/string.rs +++ b/crates/sre_engine/src/string.rs @@ -399,40 +399,8 @@ pub(crate) fn is_uni_digit(ch: u32) -> bool { #[inline] pub(crate) fn is_uni_space(ch: u32) -> bool { - // TODO: check with cpython - is_space(ch) - || matches!( - ch, - 0x0009 - | 0x000A - | 0x000B - | 0x000C - | 0x000D - | 0x001C - | 0x001D - | 0x001E - | 0x001F - | 0x0020 - | 0x0085 - | 0x00A0 - | 0x1680 - | 0x2000 - | 0x2001 - | 0x2002 - | 0x2003 - | 0x2004 - | 0x2005 - | 0x2006 - | 0x2007 - | 0x2008 - | 0x2009 - | 0x200A - | 0x2028 - | 0x2029 - | 0x202F - | 0x205F - | 0x3000 - ) + // SRE_UNI_IS_SPACE is Py_UNICODE_ISSPACE. + char::try_from(ch).is_ok_and(rustpython_unicode::classify::is_space) } #[inline] @@ -457,13 +425,13 @@ pub(crate) fn is_uni_word(ch: u32) -> bool { #[inline] #[must_use] pub fn lower_unicode(ch: u32) -> u32 { - // TODO: check with cpython - char::try_from(ch).map_or(ch, |x| x.to_lowercase().next().unwrap() as u32) + // SRE_UNI_LOWER is Py_UNICODE_TOLOWER, the simple one-to-one mapping. + char::try_from(ch).map_or(ch, |x| rustpython_unicode::case::simple_lowercase(x) as u32) } #[inline] #[must_use] pub fn upper_unicode(ch: u32) -> u32 { - // TODO: check with cpython - char::try_from(ch).map_or(ch, |x| x.to_uppercase().next().unwrap() as u32) + // SRE_UNI_UPPER is Py_UNICODE_TOUPPER, the simple one-to-one mapping. + char::try_from(ch).map_or(ch, |x| rustpython_unicode::case::simple_uppercase(x) as u32) } diff --git a/crates/unicode/Cargo.toml b/crates/unicode/Cargo.toml index 678305ee09e..52ab05f4d1f 100644 --- a/crates/unicode/Cargo.toml +++ b/crates/unicode/Cargo.toml @@ -12,6 +12,7 @@ rust-version = { workspace = true } rustpython-wtf8 = { workspace = true } icu_casemap = { workspace = true } +icu_locale = { workspace = true } icu_properties = { workspace = true } icu_normalizer = { workspace = true } unicode_names2 = { workspace = true } diff --git a/crates/unicode/src/case.rs b/crates/unicode/src/case.rs index 872d0b29ac1..0d74133399a 100644 --- a/crates/unicode/src/case.rs +++ b/crates/unicode/src/case.rs @@ -1,19 +1,92 @@ -//! Case folding for Python `str.casefold`. +//! Case mapping, case folding, and casing predicates for Python string casing. //! -//! Lower, upper, and title casing of `str` objects stay with the runtime -//! because they iterate the string with special final-sigma handling. Case -//! folding has no such context dependence, so it lives here and is shared with -//! other runtimes. +//! Code-point mappings (`simple_*`) return a single `char` and back the SRE +//! engine's `IGNORECASE` handling. String-level helpers (`capitalize`, `title`, +//! `swapcase`, `casefold`) implement the full, context-sensitive mappings used +//! by `str` methods and pass lone surrogates through unchanged. The casing +//! predicates expose the derived properties that `str.islower`/`isupper`/ +//! `istitle` need. +//! +//! Plain `str.lower`/`str.upper` have no such context beyond the final-sigma +//! rule that `str::to_lowercase` already applies, so they stay on +//! `rustpython_wtf8::Wtf8::to_lowercase`/`to_uppercase` rather than being +//! duplicated here. + +// spell-checker:ignore ΟΔΟΣ Οδος use alloc::{ string::{String, ToString}, vec::Vec, }; -use icu_casemap::CaseMapper; -use rustpython_wtf8::{Wtf8, Wtf8Buf, Wtf8Chunk}; +use icu_casemap::{CaseMapper, TitlecaseMapper}; +use icu_locale::LanguageIdentifier; +use icu_properties::props::{ + BinaryProperty, CaseIgnorable, Cased, EnumeratedProperty, GeneralCategory, Lowercase, Uppercase, +}; +use rustpython_wtf8::{CodePoint, Wtf8, Wtf8Buf, Wtf8Chunk}; use writeable::Writeable; +// Code-point mappings + +/// Simple (one-to-one) lowercase mapping of `c` (`Py_UNICODE_TOLOWER`). +#[must_use] +pub fn simple_lowercase(c: char) -> char { + CaseMapper::new().simple_lowercase(c) +} + +/// Simple (one-to-one) uppercase mapping of `c` (`Py_UNICODE_TOUPPER`). +#[must_use] +pub fn simple_uppercase(c: char) -> char { + CaseMapper::new().simple_uppercase(c) +} + +/// Simple (one-to-one) titlecase mapping of `c` (`Py_UNICODE_TOTITLE`). +#[must_use] +pub fn simple_titlecase(c: char) -> char { + CaseMapper::new().simple_titlecase(c) +} + +/// Simple (one-to-one) case fold of `c`. +#[must_use] +pub fn simple_fold(c: char) -> char { + CaseMapper::new().simple_fold(c) +} + +// Casing predicates + +/// Whether `c` has the `Lowercase` property. +#[must_use] +pub fn is_lowercase(c: char) -> bool { + Lowercase::for_char(c) +} + +/// Whether `c` has the `Uppercase` property. +#[must_use] +pub fn is_uppercase(c: char) -> bool { + Uppercase::for_char(c) +} + +/// Whether `c` is a titlecase letter (general category `Lt`). +#[must_use] +pub fn is_titlecase(c: char) -> bool { + GeneralCategory::for_char(c) == GeneralCategory::TitlecaseLetter +} + +/// Whether `c` has the `Cased` property. +#[must_use] +pub fn is_cased(c: char) -> bool { + Cased::for_char(c) +} + +/// Whether `c` has the `Case_Ignorable` property. +#[must_use] +pub fn is_case_ignorable(c: char) -> bool { + CaseIgnorable::for_char(c) +} + +// String-level mappings + /// Full Unicode case fold of `text` (`str.casefold`). #[must_use] pub fn casefold_str(text: &str) -> String { @@ -23,29 +96,204 @@ pub fn casefold_str(text: &str) -> String { /// Full Unicode case fold of `text`, passing lone surrogates through unchanged. #[must_use] pub fn casefold_wtf8(text: &Wtf8) -> Wtf8Buf { + map_wtf8(text, |s, out| { + CaseMapper::new() + .fold(s) + .write_to(out) + .expect("writing to an in-memory buffer cannot fail"); + }) +} + +/// Capitalize `text` (`str.capitalize`): titlecase the first cased character, +/// lowercase the rest, with final-sigma context. +#[must_use] +pub fn capitalize_str(text: &str) -> String { + let mut out = Vec::with_capacity(text.len()); + capitalize_utf8(text, &mut FmtWriter(&mut out)); + // SAFETY: capitalize_utf8 only appends valid UTF-8. + unsafe { String::from_utf8_unchecked(out) } +} + +/// Capitalize `text`, passing lone surrogates through unchanged. +/// +/// Only the first character of the whole string is titlecased; every later +/// character (including the first of a run that follows a lone surrogate) is +/// lowercased. +#[must_use] +pub fn capitalize_wtf8(text: &Wtf8) -> Wtf8Buf { let mut out = Vec::with_capacity(text.len()); - let mapper = CaseMapper::new(); + let mut first = true; for chunk in text.chunks() { match chunk { Wtf8Chunk::Utf8(s) => { - mapper - .fold(s) - .write_to(&mut FmtWriter(&mut out)) - .expect("writing to an in-memory buffer cannot fail"); + let mut writer = FmtWriter(&mut out); + if first { + capitalize_utf8(s, &mut writer); + first = false; + } else { + for (i, ch) in s.char_indices() { + lowercase_or_sigma(ch, s, i, &mut writer); + } + } } Wtf8Chunk::Surrogate(c) => { - let mut buf = Wtf8Buf::new(); - buf.push(c); - out.extend_from_slice(buf.as_bytes()); + first = false; + push_surrogate(&mut out, c); } } } // SAFETY: - // * CaseMapper only produces valid UTF-8. + // * capitalize_utf8 / lowercase_or_sigma only append valid UTF-8. // * Surrogates are appended as valid WTF-8 (encoded via Wtf8Buf::push). unsafe { Wtf8Buf::from_bytes_unchecked(out) } } +/// Title case `text` (`str.title`). +#[must_use] +pub fn title_str(text: &str) -> String { + let mut out = Vec::with_capacity(text.len()); + titlecase_string(text, &mut FmtWriter(&mut out)); + // SAFETY: titlecase_string only appends valid UTF-8. + unsafe { String::from_utf8_unchecked(out) } +} + +/// Title case `text`, passing lone surrogates through unchanged. +#[must_use] +pub fn title_wtf8(text: &Wtf8) -> Wtf8Buf { + map_wtf8(text, titlecase_string) +} + +/// Swap the case of every character in `text` (`str.swapcase`). +#[must_use] +pub fn swapcase_str(text: &str) -> String { + let mut out = Vec::with_capacity(text.len()); + swapcase_utf8(text, &mut FmtWriter(&mut out)); + // SAFETY: swapcase_utf8 only appends valid UTF-8. + unsafe { String::from_utf8_unchecked(out) } +} + +/// Swap the case of every character in `text`, passing lone surrogates through. +#[must_use] +pub fn swapcase_wtf8(text: &Wtf8) -> Wtf8Buf { + map_wtf8(text, swapcase_utf8) +} + +// Internal helpers + +/// Run `f` over each valid UTF-8 run of `text`, appending the mapped output and +/// carrying lone surrogates through unchanged. +fn map_wtf8(text: &Wtf8, f: impl Fn(&str, &mut FmtWriter<'_>)) -> Wtf8Buf { + let mut out = Vec::with_capacity(text.len()); + for chunk in text.chunks() { + match chunk { + Wtf8Chunk::Utf8(s) => f(s, &mut FmtWriter(&mut out)), + Wtf8Chunk::Surrogate(c) => push_surrogate(&mut out, c), + } + } + // SAFETY: + // * `f` only appends valid UTF-8. + // * Surrogates are appended as valid WTF-8 (encoded via Wtf8Buf::push). + unsafe { Wtf8Buf::from_bytes_unchecked(out) } +} + +/// Append a lone surrogate to `out` as valid WTF-8 bytes. +fn push_surrogate(out: &mut Vec, c: CodePoint) { + let mut buf = Wtf8Buf::new(); + buf.push(c); + out.extend_from_slice(buf.as_bytes()); +} + +fn capitalize_utf8(s: &str, out: &mut FmtWriter<'_>) { + let mut chars = s.char_indices(); + if let Some((first_pos, first_ch)) = chars.next() { + let first = &s[..first_pos + first_ch.len_utf8()]; + titlecase_segment(first, out); + } + for (i, ch) in chars { + lowercase_or_sigma(ch, s, i, out); + } +} + +/// Title case a string following CPython conventions. +/// +/// The first character of each run of cased characters is title cased and the +/// rest are lowercased; a new run starts after any non-cased character (digits, +/// whitespace, punctuation, etc.). +/// "123abc" -> "123Abc" +/// "123abc456def" -> "123Abc456Def" +/// "123 abc" -> "123 Abc" +fn titlecase_string(s: &str, out: &mut FmtWriter<'_>) { + let mut previous_is_cased = false; + for (i, ch) in s.char_indices() { + if previous_is_cased { + lowercase_or_sigma(ch, s, i, out); + } else { + titlecase_segment(&s[i..i + ch.len_utf8()], out); + } + + previous_is_cased = is_cased(ch); + } +} + +fn titlecase_segment(s: &str, out: &mut FmtWriter<'_>) { + TitlecaseMapper::new() + .titlecase_segment(s, &LanguageIdentifier::UNKNOWN, Default::default()) + .write_to(out) + .expect("writing to an in-memory buffer cannot fail"); +} + +fn lowercase_or_sigma(ch: char, s: &str, i: usize, out: &mut FmtWriter<'_>) { + let sigma = 'Σ'; + if ch == sigma { + push_char(handle_capital_sigma(s, i), out); + } else { + for ch in ch.to_lowercase() { + push_char(ch, out); + } + } +} + +// Handle context-sensitive sigma. +// +// Sigma is handled as a special case. This is more efficient than using icu4x +// to scan the entire string with CaseMapper because CaseMapper would allocate +// to produce a new string. +fn handle_capital_sigma(s: &str, i: usize) -> char { + let (left, rest) = s.split_at(i); + let right = &rest['Σ'.len_utf8()..]; + + // Check if any chars before or after sigma are cased. + let before = left + .chars() + .rev() + .find(|&ch| !is_case_ignorable(ch)) + .is_some_and(is_cased); + let after = right + .chars() + .find(|&ch| !is_case_ignorable(ch)) + .is_some_and(is_cased); + if before && !after { 'ς' } else { 'σ' } +} + +fn swapcase_utf8(s: &str, out: &mut FmtWriter<'_>) { + for (i, ch) in s.char_indices() { + if ch.is_uppercase() { + lowercase_or_sigma(ch, s, i, out); + } else if ch.is_lowercase() { + for ch in ch.to_uppercase() { + push_char(ch, out); + } + } else { + push_char(ch, out); + } + } +} + +fn push_char(ch: char, out: &mut FmtWriter<'_>) { + let mut buf = [0u8; 4]; + out.0.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes()); +} + /// Adapter so `icu`'s `Writeable` output can be appended to a byte buffer. struct FmtWriter<'a>(&'a mut Vec); @@ -58,7 +306,12 @@ impl core::fmt::Write for FmtWriter<'_> { #[cfg(test)] mod tests { - use super::casefold_str; + use rustpython_wtf8::{CodePoint, Wtf8Buf}; + + use super::{ + capitalize_str, casefold_str, is_case_ignorable, is_cased, is_lowercase, is_titlecase, + is_uppercase, simple_lowercase, simple_uppercase, swapcase_str, title_str, title_wtf8, + }; #[test] fn casefold_full_mappings() { @@ -66,4 +319,49 @@ mod tests { assert_eq!(casefold_str("ß"), "ss"); assert_eq!(casefold_str("Σ"), "σ"); } + + #[test] + fn simple_mappings_are_one_to_one() { + // ß has no simple uppercase mapping, so it stays unchanged (unlike the + // full mapping "SS"). + assert_eq!(simple_uppercase('ß'), 'ß'); + assert_eq!(simple_uppercase('a'), 'A'); + assert_eq!(simple_lowercase('A'), 'a'); + // Dž (U+01C5) simple-titlecases to itself but upper/lowercases away. + assert_eq!(simple_uppercase('Dž'), 'DŽ'); + assert_eq!(simple_lowercase('Dž'), 'dž'); + } + + #[test] + fn casing_predicates() { + assert!(is_lowercase('a')); + assert!(!is_lowercase('A')); + assert!(is_uppercase('A')); + assert!(is_titlecase('Dž')); + assert!(!is_titlecase('D')); + assert!(is_cased('a') && is_cased('A')); + assert!(!is_cased('1')); + assert!(is_case_ignorable('\'')); + assert!(!is_case_ignorable('a')); + } + + #[test] + fn capitalize_final_sigma() { + // Final sigma at end of a cased run becomes ς. + assert_eq!(capitalize_str("ΟΔΟΣ"), "Οδος"); + assert_eq!(title_str("hello world"), "Hello World"); + assert_eq!(swapcase_str("Hello"), "hELLO"); + } + + #[test] + fn wtf8_passes_surrogates_through() { + let mut buf = Wtf8Buf::from("ab cd"); + buf.push(CodePoint::from_u32(0xD800).unwrap()); + let titled = title_wtf8(&buf); + assert!(titled.code_points().any(|c| c.to_u32() == 0xD800)); + assert_eq!( + titled.code_points().next().and_then(|c| c.to_char()), + Some('A') + ); + } } diff --git a/crates/unicode/tests/data/cpython3.14_mappings.txt b/crates/unicode/tests/data/cpython3.14_mappings.txt new file mode 100644 index 00000000000..f686c5fd15a --- /dev/null +++ b/crates/unicode/tests/data/cpython3.14_mappings.txt @@ -0,0 +1,2 @@ +# unidata_version 16.0.0 +tolower 41:61,42:62,43:63,44:64,45:65,46:66,47:67,48:68,49:69,4A:6A,4B:6B,4C:6C,4D:6D,4E:6E,4F:6F,50:70,51:71,52:72,53:73,54:74,55:75,56:76,57:77,58:78,59:79,5A:7A,C0:E0,C1:E1,C2:E2,C3:E3,C4:E4,C5:E5,C6:E6,C7:E7,C8:E8,C9:E9,CA:EA,CB:EB,CC:EC,CD:ED,CE:EE,CF:EF,D0:F0,D1:F1,D2:F2,D3:F3,D4:F4,D5:F5,D6:F6,D8:F8,D9:F9,DA:FA,DB:FB,DC:FC,DD:FD,DE:FE,100:101,102:103,104:105,106:107,108:109,10A:10B,10C:10D,10E:10F,110:111,112:113,114:115,116:117,118:119,11A:11B,11C:11D,11E:11F,120:121,122:123,124:125,126:127,128:129,12A:12B,12C:12D,12E:12F,130:69,132:133,134:135,136:137,139:13A,13B:13C,13D:13E,13F:140,141:142,143:144,145:146,147:148,14A:14B,14C:14D,14E:14F,150:151,152:153,154:155,156:157,158:159,15A:15B,15C:15D,15E:15F,160:161,162:163,164:165,166:167,168:169,16A:16B,16C:16D,16E:16F,170:171,172:173,174:175,176:177,178:FF,179:17A,17B:17C,17D:17E,181:253,182:183,184:185,186:254,187:188,189:256,18A:257,18B:18C,18E:1DD,18F:259,190:25B,191:192,193:260,194:263,196:269,197:268,198:199,19C:26F,19D:272,19F:275,1A0:1A1,1A2:1A3,1A4:1A5,1A6:280,1A7:1A8,1A9:283,1AC:1AD,1AE:288,1AF:1B0,1B1:28A,1B2:28B,1B3:1B4,1B5:1B6,1B7:292,1B8:1B9,1BC:1BD,1C4:1C6,1C5:1C6,1C7:1C9,1C8:1C9,1CA:1CC,1CB:1CC,1CD:1CE,1CF:1D0,1D1:1D2,1D3:1D4,1D5:1D6,1D7:1D8,1D9:1DA,1DB:1DC,1DE:1DF,1E0:1E1,1E2:1E3,1E4:1E5,1E6:1E7,1E8:1E9,1EA:1EB,1EC:1ED,1EE:1EF,1F1:1F3,1F2:1F3,1F4:1F5,1F6:195,1F7:1BF,1F8:1F9,1FA:1FB,1FC:1FD,1FE:1FF,200:201,202:203,204:205,206:207,208:209,20A:20B,20C:20D,20E:20F,210:211,212:213,214:215,216:217,218:219,21A:21B,21C:21D,21E:21F,220:19E,222:223,224:225,226:227,228:229,22A:22B,22C:22D,22E:22F,230:231,232:233,23A:2C65,23B:23C,23D:19A,23E:2C66,241:242,243:180,244:289,245:28C,246:247,248:249,24A:24B,24C:24D,24E:24F,370:371,372:373,376:377,37F:3F3,386:3AC,388:3AD,389:3AE,38A:3AF,38C:3CC,38E:3CD,38F:3CE,391:3B1,392:3B2,393:3B3,394:3B4,395:3B5,396:3B6,397:3B7,398:3B8,399:3B9,39A:3BA,39B:3BB,39C:3BC,39D:3BD,39E:3BE,39F:3BF,3A0:3C0,3A1:3C1,3A3:3C3,3A4:3C4,3A5:3C5,3A6:3C6,3A7:3C7,3A8:3C8,3A9:3C9,3AA:3CA,3AB:3CB,3CF:3D7,3D8:3D9,3DA:3DB,3DC:3DD,3DE:3DF,3E0:3E1,3E2:3E3,3E4:3E5,3E6:3E7,3E8:3E9,3EA:3EB,3EC:3ED,3EE:3EF,3F4:3B8,3F7:3F8,3F9:3F2,3FA:3FB,3FD:37B,3FE:37C,3FF:37D,400:450,401:451,402:452,403:453,404:454,405:455,406:456,407:457,408:458,409:459,40A:45A,40B:45B,40C:45C,40D:45D,40E:45E,40F:45F,410:430,411:431,412:432,413:433,414:434,415:435,416:436,417:437,418:438,419:439,41A:43A,41B:43B,41C:43C,41D:43D,41E:43E,41F:43F,420:440,421:441,422:442,423:443,424:444,425:445,426:446,427:447,428:448,429:449,42A:44A,42B:44B,42C:44C,42D:44D,42E:44E,42F:44F,460:461,462:463,464:465,466:467,468:469,46A:46B,46C:46D,46E:46F,470:471,472:473,474:475,476:477,478:479,47A:47B,47C:47D,47E:47F,480:481,48A:48B,48C:48D,48E:48F,490:491,492:493,494:495,496:497,498:499,49A:49B,49C:49D,49E:49F,4A0:4A1,4A2:4A3,4A4:4A5,4A6:4A7,4A8:4A9,4AA:4AB,4AC:4AD,4AE:4AF,4B0:4B1,4B2:4B3,4B4:4B5,4B6:4B7,4B8:4B9,4BA:4BB,4BC:4BD,4BE:4BF,4C0:4CF,4C1:4C2,4C3:4C4,4C5:4C6,4C7:4C8,4C9:4CA,4CB:4CC,4CD:4CE,4D0:4D1,4D2:4D3,4D4:4D5,4D6:4D7,4D8:4D9,4DA:4DB,4DC:4DD,4DE:4DF,4E0:4E1,4E2:4E3,4E4:4E5,4E6:4E7,4E8:4E9,4EA:4EB,4EC:4ED,4EE:4EF,4F0:4F1,4F2:4F3,4F4:4F5,4F6:4F7,4F8:4F9,4FA:4FB,4FC:4FD,4FE:4FF,500:501,502:503,504:505,506:507,508:509,50A:50B,50C:50D,50E:50F,510:511,512:513,514:515,516:517,518:519,51A:51B,51C:51D,51E:51F,520:521,522:523,524:525,526:527,528:529,52A:52B,52C:52D,52E:52F,531:561,532:562,533:563,534:564,535:565,536:566,537:567,538:568,539:569,53A:56A,53B:56B,53C:56C,53D:56D,53E:56E,53F:56F,540:570,541:571,542:572,543:573,544:574,545:575,546:576,547:577,548:578,549:579,54A:57A,54B:57B,54C:57C,54D:57D,54E:57E,54F:57F,550:580,551:581,552:582,553:583,554:584,555:585,556:586,10A0:2D00,10A1:2D01,10A2:2D02,10A3:2D03,10A4:2D04,10A5:2D05,10A6:2D06,10A7:2D07,10A8:2D08,10A9:2D09,10AA:2D0A,10AB:2D0B,10AC:2D0C,10AD:2D0D,10AE:2D0E,10AF:2D0F,10B0:2D10,10B1:2D11,10B2:2D12,10B3:2D13,10B4:2D14,10B5:2D15,10B6:2D16,10B7:2D17,10B8:2D18,10B9:2D19,10BA:2D1A,10BB:2D1B,10BC:2D1C,10BD:2D1D,10BE:2D1E,10BF:2D1F,10C0:2D20,10C1:2D21,10C2:2D22,10C3:2D23,10C4:2D24,10C5:2D25,10C7:2D27,10CD:2D2D,13A0:AB70,13A1:AB71,13A2:AB72,13A3:AB73,13A4:AB74,13A5:AB75,13A6:AB76,13A7:AB77,13A8:AB78,13A9:AB79,13AA:AB7A,13AB:AB7B,13AC:AB7C,13AD:AB7D,13AE:AB7E,13AF:AB7F,13B0:AB80,13B1:AB81,13B2:AB82,13B3:AB83,13B4:AB84,13B5:AB85,13B6:AB86,13B7:AB87,13B8:AB88,13B9:AB89,13BA:AB8A,13BB:AB8B,13BC:AB8C,13BD:AB8D,13BE:AB8E,13BF:AB8F,13C0:AB90,13C1:AB91,13C2:AB92,13C3:AB93,13C4:AB94,13C5:AB95,13C6:AB96,13C7:AB97,13C8:AB98,13C9:AB99,13CA:AB9A,13CB:AB9B,13CC:AB9C,13CD:AB9D,13CE:AB9E,13CF:AB9F,13D0:ABA0,13D1:ABA1,13D2:ABA2,13D3:ABA3,13D4:ABA4,13D5:ABA5,13D6:ABA6,13D7:ABA7,13D8:ABA8,13D9:ABA9,13DA:ABAA,13DB:ABAB,13DC:ABAC,13DD:ABAD,13DE:ABAE,13DF:ABAF,13E0:ABB0,13E1:ABB1,13E2:ABB2,13E3:ABB3,13E4:ABB4,13E5:ABB5,13E6:ABB6,13E7:ABB7,13E8:ABB8,13E9:ABB9,13EA:ABBA,13EB:ABBB,13EC:ABBC,13ED:ABBD,13EE:ABBE,13EF:ABBF,13F0:13F8,13F1:13F9,13F2:13FA,13F3:13FB,13F4:13FC,13F5:13FD,1C89:1C8A,1C90:10D0,1C91:10D1,1C92:10D2,1C93:10D3,1C94:10D4,1C95:10D5,1C96:10D6,1C97:10D7,1C98:10D8,1C99:10D9,1C9A:10DA,1C9B:10DB,1C9C:10DC,1C9D:10DD,1C9E:10DE,1C9F:10DF,1CA0:10E0,1CA1:10E1,1CA2:10E2,1CA3:10E3,1CA4:10E4,1CA5:10E5,1CA6:10E6,1CA7:10E7,1CA8:10E8,1CA9:10E9,1CAA:10EA,1CAB:10EB,1CAC:10EC,1CAD:10ED,1CAE:10EE,1CAF:10EF,1CB0:10F0,1CB1:10F1,1CB2:10F2,1CB3:10F3,1CB4:10F4,1CB5:10F5,1CB6:10F6,1CB7:10F7,1CB8:10F8,1CB9:10F9,1CBA:10FA,1CBD:10FD,1CBE:10FE,1CBF:10FF,1E00:1E01,1E02:1E03,1E04:1E05,1E06:1E07,1E08:1E09,1E0A:1E0B,1E0C:1E0D,1E0E:1E0F,1E10:1E11,1E12:1E13,1E14:1E15,1E16:1E17,1E18:1E19,1E1A:1E1B,1E1C:1E1D,1E1E:1E1F,1E20:1E21,1E22:1E23,1E24:1E25,1E26:1E27,1E28:1E29,1E2A:1E2B,1E2C:1E2D,1E2E:1E2F,1E30:1E31,1E32:1E33,1E34:1E35,1E36:1E37,1E38:1E39,1E3A:1E3B,1E3C:1E3D,1E3E:1E3F,1E40:1E41,1E42:1E43,1E44:1E45,1E46:1E47,1E48:1E49,1E4A:1E4B,1E4C:1E4D,1E4E:1E4F,1E50:1E51,1E52:1E53,1E54:1E55,1E56:1E57,1E58:1E59,1E5A:1E5B,1E5C:1E5D,1E5E:1E5F,1E60:1E61,1E62:1E63,1E64:1E65,1E66:1E67,1E68:1E69,1E6A:1E6B,1E6C:1E6D,1E6E:1E6F,1E70:1E71,1E72:1E73,1E74:1E75,1E76:1E77,1E78:1E79,1E7A:1E7B,1E7C:1E7D,1E7E:1E7F,1E80:1E81,1E82:1E83,1E84:1E85,1E86:1E87,1E88:1E89,1E8A:1E8B,1E8C:1E8D,1E8E:1E8F,1E90:1E91,1E92:1E93,1E94:1E95,1E9E:DF,1EA0:1EA1,1EA2:1EA3,1EA4:1EA5,1EA6:1EA7,1EA8:1EA9,1EAA:1EAB,1EAC:1EAD,1EAE:1EAF,1EB0:1EB1,1EB2:1EB3,1EB4:1EB5,1EB6:1EB7,1EB8:1EB9,1EBA:1EBB,1EBC:1EBD,1EBE:1EBF,1EC0:1EC1,1EC2:1EC3,1EC4:1EC5,1EC6:1EC7,1EC8:1EC9,1ECA:1ECB,1ECC:1ECD,1ECE:1ECF,1ED0:1ED1,1ED2:1ED3,1ED4:1ED5,1ED6:1ED7,1ED8:1ED9,1EDA:1EDB,1EDC:1EDD,1EDE:1EDF,1EE0:1EE1,1EE2:1EE3,1EE4:1EE5,1EE6:1EE7,1EE8:1EE9,1EEA:1EEB,1EEC:1EED,1EEE:1EEF,1EF0:1EF1,1EF2:1EF3,1EF4:1EF5,1EF6:1EF7,1EF8:1EF9,1EFA:1EFB,1EFC:1EFD,1EFE:1EFF,1F08:1F00,1F09:1F01,1F0A:1F02,1F0B:1F03,1F0C:1F04,1F0D:1F05,1F0E:1F06,1F0F:1F07,1F18:1F10,1F19:1F11,1F1A:1F12,1F1B:1F13,1F1C:1F14,1F1D:1F15,1F28:1F20,1F29:1F21,1F2A:1F22,1F2B:1F23,1F2C:1F24,1F2D:1F25,1F2E:1F26,1F2F:1F27,1F38:1F30,1F39:1F31,1F3A:1F32,1F3B:1F33,1F3C:1F34,1F3D:1F35,1F3E:1F36,1F3F:1F37,1F48:1F40,1F49:1F41,1F4A:1F42,1F4B:1F43,1F4C:1F44,1F4D:1F45,1F59:1F51,1F5B:1F53,1F5D:1F55,1F5F:1F57,1F68:1F60,1F69:1F61,1F6A:1F62,1F6B:1F63,1F6C:1F64,1F6D:1F65,1F6E:1F66,1F6F:1F67,1F88:1F80,1F89:1F81,1F8A:1F82,1F8B:1F83,1F8C:1F84,1F8D:1F85,1F8E:1F86,1F8F:1F87,1F98:1F90,1F99:1F91,1F9A:1F92,1F9B:1F93,1F9C:1F94,1F9D:1F95,1F9E:1F96,1F9F:1F97,1FA8:1FA0,1FA9:1FA1,1FAA:1FA2,1FAB:1FA3,1FAC:1FA4,1FAD:1FA5,1FAE:1FA6,1FAF:1FA7,1FB8:1FB0,1FB9:1FB1,1FBA:1F70,1FBB:1F71,1FBC:1FB3,1FC8:1F72,1FC9:1F73,1FCA:1F74,1FCB:1F75,1FCC:1FC3,1FD8:1FD0,1FD9:1FD1,1FDA:1F76,1FDB:1F77,1FE8:1FE0,1FE9:1FE1,1FEA:1F7A,1FEB:1F7B,1FEC:1FE5,1FF8:1F78,1FF9:1F79,1FFA:1F7C,1FFB:1F7D,1FFC:1FF3,2126:3C9,212A:6B,212B:E5,2132:214E,2160:2170,2161:2171,2162:2172,2163:2173,2164:2174,2165:2175,2166:2176,2167:2177,2168:2178,2169:2179,216A:217A,216B:217B,216C:217C,216D:217D,216E:217E,216F:217F,2183:2184,24B6:24D0,24B7:24D1,24B8:24D2,24B9:24D3,24BA:24D4,24BB:24D5,24BC:24D6,24BD:24D7,24BE:24D8,24BF:24D9,24C0:24DA,24C1:24DB,24C2:24DC,24C3:24DD,24C4:24DE,24C5:24DF,24C6:24E0,24C7:24E1,24C8:24E2,24C9:24E3,24CA:24E4,24CB:24E5,24CC:24E6,24CD:24E7,24CE:24E8,24CF:24E9,2C00:2C30,2C01:2C31,2C02:2C32,2C03:2C33,2C04:2C34,2C05:2C35,2C06:2C36,2C07:2C37,2C08:2C38,2C09:2C39,2C0A:2C3A,2C0B:2C3B,2C0C:2C3C,2C0D:2C3D,2C0E:2C3E,2C0F:2C3F,2C10:2C40,2C11:2C41,2C12:2C42,2C13:2C43,2C14:2C44,2C15:2C45,2C16:2C46,2C17:2C47,2C18:2C48,2C19:2C49,2C1A:2C4A,2C1B:2C4B,2C1C:2C4C,2C1D:2C4D,2C1E:2C4E,2C1F:2C4F,2C20:2C50,2C21:2C51,2C22:2C52,2C23:2C53,2C24:2C54,2C25:2C55,2C26:2C56,2C27:2C57,2C28:2C58,2C29:2C59,2C2A:2C5A,2C2B:2C5B,2C2C:2C5C,2C2D:2C5D,2C2E:2C5E,2C2F:2C5F,2C60:2C61,2C62:26B,2C63:1D7D,2C64:27D,2C67:2C68,2C69:2C6A,2C6B:2C6C,2C6D:251,2C6E:271,2C6F:250,2C70:252,2C72:2C73,2C75:2C76,2C7E:23F,2C7F:240,2C80:2C81,2C82:2C83,2C84:2C85,2C86:2C87,2C88:2C89,2C8A:2C8B,2C8C:2C8D,2C8E:2C8F,2C90:2C91,2C92:2C93,2C94:2C95,2C96:2C97,2C98:2C99,2C9A:2C9B,2C9C:2C9D,2C9E:2C9F,2CA0:2CA1,2CA2:2CA3,2CA4:2CA5,2CA6:2CA7,2CA8:2CA9,2CAA:2CAB,2CAC:2CAD,2CAE:2CAF,2CB0:2CB1,2CB2:2CB3,2CB4:2CB5,2CB6:2CB7,2CB8:2CB9,2CBA:2CBB,2CBC:2CBD,2CBE:2CBF,2CC0:2CC1,2CC2:2CC3,2CC4:2CC5,2CC6:2CC7,2CC8:2CC9,2CCA:2CCB,2CCC:2CCD,2CCE:2CCF,2CD0:2CD1,2CD2:2CD3,2CD4:2CD5,2CD6:2CD7,2CD8:2CD9,2CDA:2CDB,2CDC:2CDD,2CDE:2CDF,2CE0:2CE1,2CE2:2CE3,2CEB:2CEC,2CED:2CEE,2CF2:2CF3,A640:A641,A642:A643,A644:A645,A646:A647,A648:A649,A64A:A64B,A64C:A64D,A64E:A64F,A650:A651,A652:A653,A654:A655,A656:A657,A658:A659,A65A:A65B,A65C:A65D,A65E:A65F,A660:A661,A662:A663,A664:A665,A666:A667,A668:A669,A66A:A66B,A66C:A66D,A680:A681,A682:A683,A684:A685,A686:A687,A688:A689,A68A:A68B,A68C:A68D,A68E:A68F,A690:A691,A692:A693,A694:A695,A696:A697,A698:A699,A69A:A69B,A722:A723,A724:A725,A726:A727,A728:A729,A72A:A72B,A72C:A72D,A72E:A72F,A732:A733,A734:A735,A736:A737,A738:A739,A73A:A73B,A73C:A73D,A73E:A73F,A740:A741,A742:A743,A744:A745,A746:A747,A748:A749,A74A:A74B,A74C:A74D,A74E:A74F,A750:A751,A752:A753,A754:A755,A756:A757,A758:A759,A75A:A75B,A75C:A75D,A75E:A75F,A760:A761,A762:A763,A764:A765,A766:A767,A768:A769,A76A:A76B,A76C:A76D,A76E:A76F,A779:A77A,A77B:A77C,A77D:1D79,A77E:A77F,A780:A781,A782:A783,A784:A785,A786:A787,A78B:A78C,A78D:265,A790:A791,A792:A793,A796:A797,A798:A799,A79A:A79B,A79C:A79D,A79E:A79F,A7A0:A7A1,A7A2:A7A3,A7A4:A7A5,A7A6:A7A7,A7A8:A7A9,A7AA:266,A7AB:25C,A7AC:261,A7AD:26C,A7AE:26A,A7B0:29E,A7B1:287,A7B2:29D,A7B3:AB53,A7B4:A7B5,A7B6:A7B7,A7B8:A7B9,A7BA:A7BB,A7BC:A7BD,A7BE:A7BF,A7C0:A7C1,A7C2:A7C3,A7C4:A794,A7C5:282,A7C6:1D8E,A7C7:A7C8,A7C9:A7CA,A7CB:264,A7CC:A7CD,A7D0:A7D1,A7D6:A7D7,A7D8:A7D9,A7DA:A7DB,A7DC:19B,A7F5:A7F6,FF21:FF41,FF22:FF42,FF23:FF43,FF24:FF44,FF25:FF45,FF26:FF46,FF27:FF47,FF28:FF48,FF29:FF49,FF2A:FF4A,FF2B:FF4B,FF2C:FF4C,FF2D:FF4D,FF2E:FF4E,FF2F:FF4F,FF30:FF50,FF31:FF51,FF32:FF52,FF33:FF53,FF34:FF54,FF35:FF55,FF36:FF56,FF37:FF57,FF38:FF58,FF39:FF59,FF3A:FF5A,10400:10428,10401:10429,10402:1042A,10403:1042B,10404:1042C,10405:1042D,10406:1042E,10407:1042F,10408:10430,10409:10431,1040A:10432,1040B:10433,1040C:10434,1040D:10435,1040E:10436,1040F:10437,10410:10438,10411:10439,10412:1043A,10413:1043B,10414:1043C,10415:1043D,10416:1043E,10417:1043F,10418:10440,10419:10441,1041A:10442,1041B:10443,1041C:10444,1041D:10445,1041E:10446,1041F:10447,10420:10448,10421:10449,10422:1044A,10423:1044B,10424:1044C,10425:1044D,10426:1044E,10427:1044F,104B0:104D8,104B1:104D9,104B2:104DA,104B3:104DB,104B4:104DC,104B5:104DD,104B6:104DE,104B7:104DF,104B8:104E0,104B9:104E1,104BA:104E2,104BB:104E3,104BC:104E4,104BD:104E5,104BE:104E6,104BF:104E7,104C0:104E8,104C1:104E9,104C2:104EA,104C3:104EB,104C4:104EC,104C5:104ED,104C6:104EE,104C7:104EF,104C8:104F0,104C9:104F1,104CA:104F2,104CB:104F3,104CC:104F4,104CD:104F5,104CE:104F6,104CF:104F7,104D0:104F8,104D1:104F9,104D2:104FA,104D3:104FB,10570:10597,10571:10598,10572:10599,10573:1059A,10574:1059B,10575:1059C,10576:1059D,10577:1059E,10578:1059F,10579:105A0,1057A:105A1,1057C:105A3,1057D:105A4,1057E:105A5,1057F:105A6,10580:105A7,10581:105A8,10582:105A9,10583:105AA,10584:105AB,10585:105AC,10586:105AD,10587:105AE,10588:105AF,10589:105B0,1058A:105B1,1058C:105B3,1058D:105B4,1058E:105B5,1058F:105B6,10590:105B7,10591:105B8,10592:105B9,10594:105BB,10595:105BC,10C80:10CC0,10C81:10CC1,10C82:10CC2,10C83:10CC3,10C84:10CC4,10C85:10CC5,10C86:10CC6,10C87:10CC7,10C88:10CC8,10C89:10CC9,10C8A:10CCA,10C8B:10CCB,10C8C:10CCC,10C8D:10CCD,10C8E:10CCE,10C8F:10CCF,10C90:10CD0,10C91:10CD1,10C92:10CD2,10C93:10CD3,10C94:10CD4,10C95:10CD5,10C96:10CD6,10C97:10CD7,10C98:10CD8,10C99:10CD9,10C9A:10CDA,10C9B:10CDB,10C9C:10CDC,10C9D:10CDD,10C9E:10CDE,10C9F:10CDF,10CA0:10CE0,10CA1:10CE1,10CA2:10CE2,10CA3:10CE3,10CA4:10CE4,10CA5:10CE5,10CA6:10CE6,10CA7:10CE7,10CA8:10CE8,10CA9:10CE9,10CAA:10CEA,10CAB:10CEB,10CAC:10CEC,10CAD:10CED,10CAE:10CEE,10CAF:10CEF,10CB0:10CF0,10CB1:10CF1,10CB2:10CF2,10D50:10D70,10D51:10D71,10D52:10D72,10D53:10D73,10D54:10D74,10D55:10D75,10D56:10D76,10D57:10D77,10D58:10D78,10D59:10D79,10D5A:10D7A,10D5B:10D7B,10D5C:10D7C,10D5D:10D7D,10D5E:10D7E,10D5F:10D7F,10D60:10D80,10D61:10D81,10D62:10D82,10D63:10D83,10D64:10D84,10D65:10D85,118A0:118C0,118A1:118C1,118A2:118C2,118A3:118C3,118A4:118C4,118A5:118C5,118A6:118C6,118A7:118C7,118A8:118C8,118A9:118C9,118AA:118CA,118AB:118CB,118AC:118CC,118AD:118CD,118AE:118CE,118AF:118CF,118B0:118D0,118B1:118D1,118B2:118D2,118B3:118D3,118B4:118D4,118B5:118D5,118B6:118D6,118B7:118D7,118B8:118D8,118B9:118D9,118BA:118DA,118BB:118DB,118BC:118DC,118BD:118DD,118BE:118DE,118BF:118DF,16E40:16E60,16E41:16E61,16E42:16E62,16E43:16E63,16E44:16E64,16E45:16E65,16E46:16E66,16E47:16E67,16E48:16E68,16E49:16E69,16E4A:16E6A,16E4B:16E6B,16E4C:16E6C,16E4D:16E6D,16E4E:16E6E,16E4F:16E6F,16E50:16E70,16E51:16E71,16E52:16E72,16E53:16E73,16E54:16E74,16E55:16E75,16E56:16E76,16E57:16E77,16E58:16E78,16E59:16E79,16E5A:16E7A,16E5B:16E7B,16E5C:16E7C,16E5D:16E7D,16E5E:16E7E,16E5F:16E7F,1E900:1E922,1E901:1E923,1E902:1E924,1E903:1E925,1E904:1E926,1E905:1E927,1E906:1E928,1E907:1E929,1E908:1E92A,1E909:1E92B,1E90A:1E92C,1E90B:1E92D,1E90C:1E92E,1E90D:1E92F,1E90E:1E930,1E90F:1E931,1E910:1E932,1E911:1E933,1E912:1E934,1E913:1E935,1E914:1E936,1E915:1E937,1E916:1E938,1E917:1E939,1E918:1E93A,1E919:1E93B,1E91A:1E93C,1E91B:1E93D,1E91C:1E93E,1E91D:1E93F,1E91E:1E940,1E91F:1E941,1E920:1E942,1E921:1E943 diff --git a/crates/unicode/tests/data/cpython3.14_predicates.txt b/crates/unicode/tests/data/cpython3.14_predicates.txt index 9339f4e4744..22f9d8d6105 100644 --- a/crates/unicode/tests/data/cpython3.14_predicates.txt +++ b/crates/unicode/tests/data/cpython3.14_predicates.txt @@ -7,3 +7,7 @@ isnumeric 30:39,B2:B3,B9:B9,BC:BE,660:669,6F0:6F9,7C0:7C9,966:96F,9E6:9EF,9F4:9F isspace 9:D,1C:20,85:85,A0:A0,1680:1680,2000:200A,2028:2029,202F:202F,205F:205F,3000:3000 isprintable 20:7E,A1:AC,AE:377,37A:37F,384:38A,38C:38C,38E:3A1,3A3:52F,531:556,559:58A,58D:58F,591:5C7,5D0:5EA,5EF:5F4,606:61B,61D:6DC,6DE:70D,710:74A,74D:7B1,7C0:7FA,7FD:82D,830:83E,840:85B,85E:85E,860:86A,870:88E,897:8E1,8E3:983,985:98C,98F:990,993:9A8,9AA:9B0,9B2:9B2,9B6:9B9,9BC:9C4,9C7:9C8,9CB:9CE,9D7:9D7,9DC:9DD,9DF:9E3,9E6:9FE,A01:A03,A05:A0A,A0F:A10,A13:A28,A2A:A30,A32:A33,A35:A36,A38:A39,A3C:A3C,A3E:A42,A47:A48,A4B:A4D,A51:A51,A59:A5C,A5E:A5E,A66:A76,A81:A83,A85:A8D,A8F:A91,A93:AA8,AAA:AB0,AB2:AB3,AB5:AB9,ABC:AC5,AC7:AC9,ACB:ACD,AD0:AD0,AE0:AE3,AE6:AF1,AF9:AFF,B01:B03,B05:B0C,B0F:B10,B13:B28,B2A:B30,B32:B33,B35:B39,B3C:B44,B47:B48,B4B:B4D,B55:B57,B5C:B5D,B5F:B63,B66:B77,B82:B83,B85:B8A,B8E:B90,B92:B95,B99:B9A,B9C:B9C,B9E:B9F,BA3:BA4,BA8:BAA,BAE:BB9,BBE:BC2,BC6:BC8,BCA:BCD,BD0:BD0,BD7:BD7,BE6:BFA,C00:C0C,C0E:C10,C12:C28,C2A:C39,C3C:C44,C46:C48,C4A:C4D,C55:C56,C58:C5A,C5D:C5D,C60:C63,C66:C6F,C77:C8C,C8E:C90,C92:CA8,CAA:CB3,CB5:CB9,CBC:CC4,CC6:CC8,CCA:CCD,CD5:CD6,CDD:CDE,CE0:CE3,CE6:CEF,CF1:CF3,D00:D0C,D0E:D10,D12:D44,D46:D48,D4A:D4F,D54:D63,D66:D7F,D81:D83,D85:D96,D9A:DB1,DB3:DBB,DBD:DBD,DC0:DC6,DCA:DCA,DCF:DD4,DD6:DD6,DD8:DDF,DE6:DEF,DF2:DF4,E01:E3A,E3F:E5B,E81:E82,E84:E84,E86:E8A,E8C:EA3,EA5:EA5,EA7:EBD,EC0:EC4,EC6:EC6,EC8:ECE,ED0:ED9,EDC:EDF,F00:F47,F49:F6C,F71:F97,F99:FBC,FBE:FCC,FCE:FDA,1000:10C5,10C7:10C7,10CD:10CD,10D0:1248,124A:124D,1250:1256,1258:1258,125A:125D,1260:1288,128A:128D,1290:12B0,12B2:12B5,12B8:12BE,12C0:12C0,12C2:12C5,12C8:12D6,12D8:1310,1312:1315,1318:135A,135D:137C,1380:1399,13A0:13F5,13F8:13FD,1400:167F,1681:169C,16A0:16F8,1700:1715,171F:1736,1740:1753,1760:176C,176E:1770,1772:1773,1780:17DD,17E0:17E9,17F0:17F9,1800:180D,180F:1819,1820:1878,1880:18AA,18B0:18F5,1900:191E,1920:192B,1930:193B,1940:1940,1944:196D,1970:1974,1980:19AB,19B0:19C9,19D0:19DA,19DE:1A1B,1A1E:1A5E,1A60:1A7C,1A7F:1A89,1A90:1A99,1AA0:1AAD,1AB0:1ACE,1B00:1B4C,1B4E:1BF3,1BFC:1C37,1C3B:1C49,1C4D:1C8A,1C90:1CBA,1CBD:1CC7,1CD0:1CFA,1D00:1F15,1F18:1F1D,1F20:1F45,1F48:1F4D,1F50:1F57,1F59:1F59,1F5B:1F5B,1F5D:1F5D,1F5F:1F7D,1F80:1FB4,1FB6:1FC4,1FC6:1FD3,1FD6:1FDB,1FDD:1FEF,1FF2:1FF4,1FF6:1FFE,2010:2027,2030:205E,2070:2071,2074:208E,2090:209C,20A0:20C0,20D0:20F0,2100:218B,2190:2429,2440:244A,2460:2B73,2B76:2B95,2B97:2CF3,2CF9:2D25,2D27:2D27,2D2D:2D2D,2D30:2D67,2D6F:2D70,2D7F:2D96,2DA0:2DA6,2DA8:2DAE,2DB0:2DB6,2DB8:2DBE,2DC0:2DC6,2DC8:2DCE,2DD0:2DD6,2DD8:2DDE,2DE0:2E5D,2E80:2E99,2E9B:2EF3,2F00:2FD5,2FF0:2FFF,3001:303F,3041:3096,3099:30FF,3105:312F,3131:318E,3190:31E5,31EF:321E,3220:A48C,A490:A4C6,A4D0:A62B,A640:A6F7,A700:A7CD,A7D0:A7D1,A7D3:A7D3,A7D5:A7DC,A7F2:A82C,A830:A839,A840:A877,A880:A8C5,A8CE:A8D9,A8E0:A953,A95F:A97C,A980:A9CD,A9CF:A9D9,A9DE:A9FE,AA00:AA36,AA40:AA4D,AA50:AA59,AA5C:AAC2,AADB:AAF6,AB01:AB06,AB09:AB0E,AB11:AB16,AB20:AB26,AB28:AB2E,AB30:AB6B,AB70:ABED,ABF0:ABF9,AC00:D7A3,D7B0:D7C6,D7CB:D7FB,F900:FA6D,FA70:FAD9,FB00:FB06,FB13:FB17,FB1D:FB36,FB38:FB3C,FB3E:FB3E,FB40:FB41,FB43:FB44,FB46:FBC2,FBD3:FD8F,FD92:FDC7,FDCF:FDCF,FDF0:FE19,FE20:FE52,FE54:FE66,FE68:FE6B,FE70:FE74,FE76:FEFC,FF01:FFBE,FFC2:FFC7,FFCA:FFCF,FFD2:FFD7,FFDA:FFDC,FFE0:FFE6,FFE8:FFEE,FFFC:FFFD,10000:1000B,1000D:10026,10028:1003A,1003C:1003D,1003F:1004D,10050:1005D,10080:100FA,10100:10102,10107:10133,10137:1018E,10190:1019C,101A0:101A0,101D0:101FD,10280:1029C,102A0:102D0,102E0:102FB,10300:10323,1032D:1034A,10350:1037A,10380:1039D,1039F:103C3,103C8:103D5,10400:1049D,104A0:104A9,104B0:104D3,104D8:104FB,10500:10527,10530:10563,1056F:1057A,1057C:1058A,1058C:10592,10594:10595,10597:105A1,105A3:105B1,105B3:105B9,105BB:105BC,105C0:105F3,10600:10736,10740:10755,10760:10767,10780:10785,10787:107B0,107B2:107BA,10800:10805,10808:10808,1080A:10835,10837:10838,1083C:1083C,1083F:10855,10857:1089E,108A7:108AF,108E0:108F2,108F4:108F5,108FB:1091B,1091F:10939,1093F:1093F,10980:109B7,109BC:109CF,109D2:10A03,10A05:10A06,10A0C:10A13,10A15:10A17,10A19:10A35,10A38:10A3A,10A3F:10A48,10A50:10A58,10A60:10A9F,10AC0:10AE6,10AEB:10AF6,10B00:10B35,10B39:10B55,10B58:10B72,10B78:10B91,10B99:10B9C,10BA9:10BAF,10C00:10C48,10C80:10CB2,10CC0:10CF2,10CFA:10D27,10D30:10D39,10D40:10D65,10D69:10D85,10D8E:10D8F,10E60:10E7E,10E80:10EA9,10EAB:10EAD,10EB0:10EB1,10EC2:10EC4,10EFC:10F27,10F30:10F59,10F70:10F89,10FB0:10FCB,10FE0:10FF6,11000:1104D,11052:11075,1107F:110BC,110BE:110C2,110D0:110E8,110F0:110F9,11100:11134,11136:11147,11150:11176,11180:111DF,111E1:111F4,11200:11211,11213:11241,11280:11286,11288:11288,1128A:1128D,1128F:1129D,1129F:112A9,112B0:112EA,112F0:112F9,11300:11303,11305:1130C,1130F:11310,11313:11328,1132A:11330,11332:11333,11335:11339,1133B:11344,11347:11348,1134B:1134D,11350:11350,11357:11357,1135D:11363,11366:1136C,11370:11374,11380:11389,1138B:1138B,1138E:1138E,11390:113B5,113B7:113C0,113C2:113C2,113C5:113C5,113C7:113CA,113CC:113D5,113D7:113D8,113E1:113E2,11400:1145B,1145D:11461,11480:114C7,114D0:114D9,11580:115B5,115B8:115DD,11600:11644,11650:11659,11660:1166C,11680:116B9,116C0:116C9,116D0:116E3,11700:1171A,1171D:1172B,11730:11746,11800:1183B,118A0:118F2,118FF:11906,11909:11909,1190C:11913,11915:11916,11918:11935,11937:11938,1193B:11946,11950:11959,119A0:119A7,119AA:119D7,119DA:119E4,11A00:11A47,11A50:11AA2,11AB0:11AF8,11B00:11B09,11BC0:11BE1,11BF0:11BF9,11C00:11C08,11C0A:11C36,11C38:11C45,11C50:11C6C,11C70:11C8F,11C92:11CA7,11CA9:11CB6,11D00:11D06,11D08:11D09,11D0B:11D36,11D3A:11D3A,11D3C:11D3D,11D3F:11D47,11D50:11D59,11D60:11D65,11D67:11D68,11D6A:11D8E,11D90:11D91,11D93:11D98,11DA0:11DA9,11EE0:11EF8,11F00:11F10,11F12:11F3A,11F3E:11F5A,11FB0:11FB0,11FC0:11FF1,11FFF:12399,12400:1246E,12470:12474,12480:12543,12F90:12FF2,13000:1342F,13440:13455,13460:143FA,14400:14646,16100:16139,16800:16A38,16A40:16A5E,16A60:16A69,16A6E:16ABE,16AC0:16AC9,16AD0:16AED,16AF0:16AF5,16B00:16B45,16B50:16B59,16B5B:16B61,16B63:16B77,16B7D:16B8F,16D40:16D79,16E40:16E9A,16F00:16F4A,16F4F:16F87,16F8F:16F9F,16FE0:16FE4,16FF0:16FF1,17000:187F7,18800:18CD5,18CFF:18D08,1AFF0:1AFF3,1AFF5:1AFFB,1AFFD:1AFFE,1B000:1B122,1B132:1B132,1B150:1B152,1B155:1B155,1B164:1B167,1B170:1B2FB,1BC00:1BC6A,1BC70:1BC7C,1BC80:1BC88,1BC90:1BC99,1BC9C:1BC9F,1CC00:1CCF9,1CD00:1CEB3,1CF00:1CF2D,1CF30:1CF46,1CF50:1CFC3,1D000:1D0F5,1D100:1D126,1D129:1D172,1D17B:1D1EA,1D200:1D245,1D2C0:1D2D3,1D2E0:1D2F3,1D300:1D356,1D360:1D378,1D400:1D454,1D456:1D49C,1D49E:1D49F,1D4A2:1D4A2,1D4A5:1D4A6,1D4A9:1D4AC,1D4AE:1D4B9,1D4BB:1D4BB,1D4BD:1D4C3,1D4C5:1D505,1D507:1D50A,1D50D:1D514,1D516:1D51C,1D51E:1D539,1D53B:1D53E,1D540:1D544,1D546:1D546,1D54A:1D550,1D552:1D6A5,1D6A8:1D7CB,1D7CE:1DA8B,1DA9B:1DA9F,1DAA1:1DAAF,1DF00:1DF1E,1DF25:1DF2A,1E000:1E006,1E008:1E018,1E01B:1E021,1E023:1E024,1E026:1E02A,1E030:1E06D,1E08F:1E08F,1E100:1E12C,1E130:1E13D,1E140:1E149,1E14E:1E14F,1E290:1E2AE,1E2C0:1E2F9,1E2FF:1E2FF,1E4D0:1E4F9,1E5D0:1E5FA,1E5FF:1E5FF,1E7E0:1E7E6,1E7E8:1E7EB,1E7ED:1E7EE,1E7F0:1E7FE,1E800:1E8C4,1E8C7:1E8D6,1E900:1E94B,1E950:1E959,1E95E:1E95F,1EC71:1ECB4,1ED01:1ED3D,1EE00:1EE03,1EE05:1EE1F,1EE21:1EE22,1EE24:1EE24,1EE27:1EE27,1EE29:1EE32,1EE34:1EE37,1EE39:1EE39,1EE3B:1EE3B,1EE42:1EE42,1EE47:1EE47,1EE49:1EE49,1EE4B:1EE4B,1EE4D:1EE4F,1EE51:1EE52,1EE54:1EE54,1EE57:1EE57,1EE59:1EE59,1EE5B:1EE5B,1EE5D:1EE5D,1EE5F:1EE5F,1EE61:1EE62,1EE64:1EE64,1EE67:1EE6A,1EE6C:1EE72,1EE74:1EE77,1EE79:1EE7C,1EE7E:1EE7E,1EE80:1EE89,1EE8B:1EE9B,1EEA1:1EEA3,1EEA5:1EEA9,1EEAB:1EEBB,1EEF0:1EEF1,1F000:1F02B,1F030:1F093,1F0A0:1F0AE,1F0B1:1F0BF,1F0C1:1F0CF,1F0D1:1F0F5,1F100:1F1AD,1F1E6:1F202,1F210:1F23B,1F240:1F248,1F250:1F251,1F260:1F265,1F300:1F6D7,1F6DC:1F6EC,1F6F0:1F6FC,1F700:1F776,1F77B:1F7D9,1F7E0:1F7EB,1F7F0:1F7F0,1F800:1F80B,1F810:1F847,1F850:1F859,1F860:1F887,1F890:1F8AD,1F8B0:1F8BB,1F8C0:1F8C1,1F900:1FA53,1FA60:1FA6D,1FA70:1FA7C,1FA80:1FA89,1FA8F:1FAC6,1FACE:1FADC,1FADF:1FAE9,1FAF0:1FAF8,1FB00:1FB92,1FB94:1FBF9,20000:2A6DF,2A700:2B739,2B740:2B81D,2B820:2CEA1,2CEB0:2EBE0,2EBF0:2EE5D,2F800:2FA1D,30000:3134A,31350:323AF,E0100:E01EF isidentifier 41:5A,5F:5F,61:7A,AA:AA,B5:B5,BA:BA,C0:D6,D8:F6,F8:2C1,2C6:2D1,2E0:2E4,2EC:2EC,2EE:2EE,370:374,376:377,37B:37D,37F:37F,386:386,388:38A,38C:38C,38E:3A1,3A3:3F5,3F7:481,48A:52F,531:556,559:559,560:588,5D0:5EA,5EF:5F2,620:64A,66E:66F,671:6D3,6D5:6D5,6E5:6E6,6EE:6EF,6FA:6FC,6FF:6FF,710:710,712:72F,74D:7A5,7B1:7B1,7CA:7EA,7F4:7F5,7FA:7FA,800:815,81A:81A,824:824,828:828,840:858,860:86A,870:887,889:88E,8A0:8C9,904:939,93D:93D,950:950,958:961,971:980,985:98C,98F:990,993:9A8,9AA:9B0,9B2:9B2,9B6:9B9,9BD:9BD,9CE:9CE,9DC:9DD,9DF:9E1,9F0:9F1,9FC:9FC,A05:A0A,A0F:A10,A13:A28,A2A:A30,A32:A33,A35:A36,A38:A39,A59:A5C,A5E:A5E,A72:A74,A85:A8D,A8F:A91,A93:AA8,AAA:AB0,AB2:AB3,AB5:AB9,ABD:ABD,AD0:AD0,AE0:AE1,AF9:AF9,B05:B0C,B0F:B10,B13:B28,B2A:B30,B32:B33,B35:B39,B3D:B3D,B5C:B5D,B5F:B61,B71:B71,B83:B83,B85:B8A,B8E:B90,B92:B95,B99:B9A,B9C:B9C,B9E:B9F,BA3:BA4,BA8:BAA,BAE:BB9,BD0:BD0,C05:C0C,C0E:C10,C12:C28,C2A:C39,C3D:C3D,C58:C5A,C5D:C5D,C60:C61,C80:C80,C85:C8C,C8E:C90,C92:CA8,CAA:CB3,CB5:CB9,CBD:CBD,CDD:CDE,CE0:CE1,CF1:CF2,D04:D0C,D0E:D10,D12:D3A,D3D:D3D,D4E:D4E,D54:D56,D5F:D61,D7A:D7F,D85:D96,D9A:DB1,DB3:DBB,DBD:DBD,DC0:DC6,E01:E30,E32:E32,E40:E46,E81:E82,E84:E84,E86:E8A,E8C:EA3,EA5:EA5,EA7:EB0,EB2:EB2,EBD:EBD,EC0:EC4,EC6:EC6,EDC:EDF,F00:F00,F40:F47,F49:F6C,F88:F8C,1000:102A,103F:103F,1050:1055,105A:105D,1061:1061,1065:1066,106E:1070,1075:1081,108E:108E,10A0:10C5,10C7:10C7,10CD:10CD,10D0:10FA,10FC:1248,124A:124D,1250:1256,1258:1258,125A:125D,1260:1288,128A:128D,1290:12B0,12B2:12B5,12B8:12BE,12C0:12C0,12C2:12C5,12C8:12D6,12D8:1310,1312:1315,1318:135A,1380:138F,13A0:13F5,13F8:13FD,1401:166C,166F:167F,1681:169A,16A0:16EA,16EE:16F8,1700:1711,171F:1731,1740:1751,1760:176C,176E:1770,1780:17B3,17D7:17D7,17DC:17DC,1820:1878,1880:18A8,18AA:18AA,18B0:18F5,1900:191E,1950:196D,1970:1974,1980:19AB,19B0:19C9,1A00:1A16,1A20:1A54,1AA7:1AA7,1B05:1B33,1B45:1B4C,1B83:1BA0,1BAE:1BAF,1BBA:1BE5,1C00:1C23,1C4D:1C4F,1C5A:1C7D,1C80:1C8A,1C90:1CBA,1CBD:1CBF,1CE9:1CEC,1CEE:1CF3,1CF5:1CF6,1CFA:1CFA,1D00:1DBF,1E00:1F15,1F18:1F1D,1F20:1F45,1F48:1F4D,1F50:1F57,1F59:1F59,1F5B:1F5B,1F5D:1F5D,1F5F:1F7D,1F80:1FB4,1FB6:1FBC,1FBE:1FBE,1FC2:1FC4,1FC6:1FCC,1FD0:1FD3,1FD6:1FDB,1FE0:1FEC,1FF2:1FF4,1FF6:1FFC,2071:2071,207F:207F,2090:209C,2102:2102,2107:2107,210A:2113,2115:2115,2118:211D,2124:2124,2126:2126,2128:2128,212A:2139,213C:213F,2145:2149,214E:214E,2160:2188,2C00:2CE4,2CEB:2CEE,2CF2:2CF3,2D00:2D25,2D27:2D27,2D2D:2D2D,2D30:2D67,2D6F:2D6F,2D80:2D96,2DA0:2DA6,2DA8:2DAE,2DB0:2DB6,2DB8:2DBE,2DC0:2DC6,2DC8:2DCE,2DD0:2DD6,2DD8:2DDE,3005:3007,3021:3029,3031:3035,3038:303C,3041:3096,309D:309F,30A1:30FA,30FC:30FF,3105:312F,3131:318E,31A0:31BF,31F0:31FF,3400:4DBF,4E00:A48C,A4D0:A4FD,A500:A60C,A610:A61F,A62A:A62B,A640:A66E,A67F:A69D,A6A0:A6EF,A717:A71F,A722:A788,A78B:A7CD,A7D0:A7D1,A7D3:A7D3,A7D5:A7DC,A7F2:A801,A803:A805,A807:A80A,A80C:A822,A840:A873,A882:A8B3,A8F2:A8F7,A8FB:A8FB,A8FD:A8FE,A90A:A925,A930:A946,A960:A97C,A984:A9B2,A9CF:A9CF,A9E0:A9E4,A9E6:A9EF,A9FA:A9FE,AA00:AA28,AA40:AA42,AA44:AA4B,AA60:AA76,AA7A:AA7A,AA7E:AAAF,AAB1:AAB1,AAB5:AAB6,AAB9:AABD,AAC0:AAC0,AAC2:AAC2,AADB:AADD,AAE0:AAEA,AAF2:AAF4,AB01:AB06,AB09:AB0E,AB11:AB16,AB20:AB26,AB28:AB2E,AB30:AB5A,AB5C:AB69,AB70:ABE2,AC00:D7A3,D7B0:D7C6,D7CB:D7FB,F900:FA6D,FA70:FAD9,FB00:FB06,FB13:FB17,FB1D:FB1D,FB1F:FB28,FB2A:FB36,FB38:FB3C,FB3E:FB3E,FB40:FB41,FB43:FB44,FB46:FBB1,FBD3:FC5D,FC64:FD3D,FD50:FD8F,FD92:FDC7,FDF0:FDF9,FE71:FE71,FE73:FE73,FE77:FE77,FE79:FE79,FE7B:FE7B,FE7D:FE7D,FE7F:FEFC,FF21:FF3A,FF41:FF5A,FF66:FF9D,FFA0:FFBE,FFC2:FFC7,FFCA:FFCF,FFD2:FFD7,FFDA:FFDC,10000:1000B,1000D:10026,10028:1003A,1003C:1003D,1003F:1004D,10050:1005D,10080:100FA,10140:10174,10280:1029C,102A0:102D0,10300:1031F,1032D:1034A,10350:10375,10380:1039D,103A0:103C3,103C8:103CF,103D1:103D5,10400:1049D,104B0:104D3,104D8:104FB,10500:10527,10530:10563,10570:1057A,1057C:1058A,1058C:10592,10594:10595,10597:105A1,105A3:105B1,105B3:105B9,105BB:105BC,105C0:105F3,10600:10736,10740:10755,10760:10767,10780:10785,10787:107B0,107B2:107BA,10800:10805,10808:10808,1080A:10835,10837:10838,1083C:1083C,1083F:10855,10860:10876,10880:1089E,108E0:108F2,108F4:108F5,10900:10915,10920:10939,10980:109B7,109BE:109BF,10A00:10A00,10A10:10A13,10A15:10A17,10A19:10A35,10A60:10A7C,10A80:10A9C,10AC0:10AC7,10AC9:10AE4,10B00:10B35,10B40:10B55,10B60:10B72,10B80:10B91,10C00:10C48,10C80:10CB2,10CC0:10CF2,10D00:10D23,10D4A:10D65,10D6F:10D85,10E80:10EA9,10EB0:10EB1,10EC2:10EC4,10F00:10F1C,10F27:10F27,10F30:10F45,10F70:10F81,10FB0:10FC4,10FE0:10FF6,11003:11037,11071:11072,11075:11075,11083:110AF,110D0:110E8,11103:11126,11144:11144,11147:11147,11150:11172,11176:11176,11183:111B2,111C1:111C4,111DA:111DA,111DC:111DC,11200:11211,11213:1122B,1123F:11240,11280:11286,11288:11288,1128A:1128D,1128F:1129D,1129F:112A8,112B0:112DE,11305:1130C,1130F:11310,11313:11328,1132A:11330,11332:11333,11335:11339,1133D:1133D,11350:11350,1135D:11361,11380:11389,1138B:1138B,1138E:1138E,11390:113B5,113B7:113B7,113D1:113D1,113D3:113D3,11400:11434,11447:1144A,1145F:11461,11480:114AF,114C4:114C5,114C7:114C7,11580:115AE,115D8:115DB,11600:1162F,11644:11644,11680:116AA,116B8:116B8,11700:1171A,11740:11746,11800:1182B,118A0:118DF,118FF:11906,11909:11909,1190C:11913,11915:11916,11918:1192F,1193F:1193F,11941:11941,119A0:119A7,119AA:119D0,119E1:119E1,119E3:119E3,11A00:11A00,11A0B:11A32,11A3A:11A3A,11A50:11A50,11A5C:11A89,11A9D:11A9D,11AB0:11AF8,11BC0:11BE0,11C00:11C08,11C0A:11C2E,11C40:11C40,11C72:11C8F,11D00:11D06,11D08:11D09,11D0B:11D30,11D46:11D46,11D60:11D65,11D67:11D68,11D6A:11D89,11D98:11D98,11EE0:11EF2,11F02:11F02,11F04:11F10,11F12:11F33,11FB0:11FB0,12000:12399,12400:1246E,12480:12543,12F90:12FF0,13000:1342F,13441:13446,13460:143FA,14400:14646,16100:1611D,16800:16A38,16A40:16A5E,16A70:16ABE,16AD0:16AED,16B00:16B2F,16B40:16B43,16B63:16B77,16B7D:16B8F,16D40:16D6C,16E40:16E7F,16F00:16F4A,16F50:16F50,16F93:16F9F,16FE0:16FE1,16FE3:16FE3,17000:187F7,18800:18CD5,18CFF:18D08,1AFF0:1AFF3,1AFF5:1AFFB,1AFFD:1AFFE,1B000:1B122,1B132:1B132,1B150:1B152,1B155:1B155,1B164:1B167,1B170:1B2FB,1BC00:1BC6A,1BC70:1BC7C,1BC80:1BC88,1BC90:1BC99,1D400:1D454,1D456:1D49C,1D49E:1D49F,1D4A2:1D4A2,1D4A5:1D4A6,1D4A9:1D4AC,1D4AE:1D4B9,1D4BB:1D4BB,1D4BD:1D4C3,1D4C5:1D505,1D507:1D50A,1D50D:1D514,1D516:1D51C,1D51E:1D539,1D53B:1D53E,1D540:1D544,1D546:1D546,1D54A:1D550,1D552:1D6A5,1D6A8:1D6C0,1D6C2:1D6DA,1D6DC:1D6FA,1D6FC:1D714,1D716:1D734,1D736:1D74E,1D750:1D76E,1D770:1D788,1D78A:1D7A8,1D7AA:1D7C2,1D7C4:1D7CB,1DF00:1DF1E,1DF25:1DF2A,1E030:1E06D,1E100:1E12C,1E137:1E13D,1E14E:1E14E,1E290:1E2AD,1E2C0:1E2EB,1E4D0:1E4EB,1E5D0:1E5ED,1E5F0:1E5F0,1E7E0:1E7E6,1E7E8:1E7EB,1E7ED:1E7EE,1E7F0:1E7FE,1E800:1E8C4,1E900:1E943,1E94B:1E94B,1EE00:1EE03,1EE05:1EE1F,1EE21:1EE22,1EE24:1EE24,1EE27:1EE27,1EE29:1EE32,1EE34:1EE37,1EE39:1EE39,1EE3B:1EE3B,1EE42:1EE42,1EE47:1EE47,1EE49:1EE49,1EE4B:1EE4B,1EE4D:1EE4F,1EE51:1EE52,1EE54:1EE54,1EE57:1EE57,1EE59:1EE59,1EE5B:1EE5B,1EE5D:1EE5D,1EE5F:1EE5F,1EE61:1EE62,1EE64:1EE64,1EE67:1EE6A,1EE6C:1EE72,1EE74:1EE77,1EE79:1EE7C,1EE7E:1EE7E,1EE80:1EE89,1EE8B:1EE9B,1EEA1:1EEA3,1EEA5:1EEA9,1EEAB:1EEBB,20000:2A6DF,2A700:2B739,2B740:2B81D,2B820:2CEA1,2CEB0:2EBE0,2EBF0:2EE5D,2F800:2FA1D,30000:3134A,31350:323AF +is_lowercase 61:7A,AA:AA,B5:B5,BA:BA,DF:F6,F8:FF,101:101,103:103,105:105,107:107,109:109,10B:10B,10D:10D,10F:10F,111:111,113:113,115:115,117:117,119:119,11B:11B,11D:11D,11F:11F,121:121,123:123,125:125,127:127,129:129,12B:12B,12D:12D,12F:12F,131:131,133:133,135:135,137:138,13A:13A,13C:13C,13E:13E,140:140,142:142,144:144,146:146,148:149,14B:14B,14D:14D,14F:14F,151:151,153:153,155:155,157:157,159:159,15B:15B,15D:15D,15F:15F,161:161,163:163,165:165,167:167,169:169,16B:16B,16D:16D,16F:16F,171:171,173:173,175:175,177:177,17A:17A,17C:17C,17E:180,183:183,185:185,188:188,18C:18D,192:192,195:195,199:19B,19E:19E,1A1:1A1,1A3:1A3,1A5:1A5,1A8:1A8,1AA:1AB,1AD:1AD,1B0:1B0,1B4:1B4,1B6:1B6,1B9:1BA,1BD:1BF,1C6:1C6,1C9:1C9,1CC:1CC,1CE:1CE,1D0:1D0,1D2:1D2,1D4:1D4,1D6:1D6,1D8:1D8,1DA:1DA,1DC:1DD,1DF:1DF,1E1:1E1,1E3:1E3,1E5:1E5,1E7:1E7,1E9:1E9,1EB:1EB,1ED:1ED,1EF:1F0,1F3:1F3,1F5:1F5,1F9:1F9,1FB:1FB,1FD:1FD,1FF:1FF,201:201,203:203,205:205,207:207,209:209,20B:20B,20D:20D,20F:20F,211:211,213:213,215:215,217:217,219:219,21B:21B,21D:21D,21F:21F,221:221,223:223,225:225,227:227,229:229,22B:22B,22D:22D,22F:22F,231:231,233:239,23C:23C,23F:240,242:242,247:247,249:249,24B:24B,24D:24D,24F:293,295:2B8,2C0:2C1,2E0:2E4,345:345,371:371,373:373,377:377,37A:37D,390:390,3AC:3CE,3D0:3D1,3D5:3D7,3D9:3D9,3DB:3DB,3DD:3DD,3DF:3DF,3E1:3E1,3E3:3E3,3E5:3E5,3E7:3E7,3E9:3E9,3EB:3EB,3ED:3ED,3EF:3F3,3F5:3F5,3F8:3F8,3FB:3FC,430:45F,461:461,463:463,465:465,467:467,469:469,46B:46B,46D:46D,46F:46F,471:471,473:473,475:475,477:477,479:479,47B:47B,47D:47D,47F:47F,481:481,48B:48B,48D:48D,48F:48F,491:491,493:493,495:495,497:497,499:499,49B:49B,49D:49D,49F:49F,4A1:4A1,4A3:4A3,4A5:4A5,4A7:4A7,4A9:4A9,4AB:4AB,4AD:4AD,4AF:4AF,4B1:4B1,4B3:4B3,4B5:4B5,4B7:4B7,4B9:4B9,4BB:4BB,4BD:4BD,4BF:4BF,4C2:4C2,4C4:4C4,4C6:4C6,4C8:4C8,4CA:4CA,4CC:4CC,4CE:4CF,4D1:4D1,4D3:4D3,4D5:4D5,4D7:4D7,4D9:4D9,4DB:4DB,4DD:4DD,4DF:4DF,4E1:4E1,4E3:4E3,4E5:4E5,4E7:4E7,4E9:4E9,4EB:4EB,4ED:4ED,4EF:4EF,4F1:4F1,4F3:4F3,4F5:4F5,4F7:4F7,4F9:4F9,4FB:4FB,4FD:4FD,4FF:4FF,501:501,503:503,505:505,507:507,509:509,50B:50B,50D:50D,50F:50F,511:511,513:513,515:515,517:517,519:519,51B:51B,51D:51D,51F:51F,521:521,523:523,525:525,527:527,529:529,52B:52B,52D:52D,52F:52F,560:588,10D0:10FA,10FC:10FF,13F8:13FD,1C80:1C88,1C8A:1C8A,1D00:1DBF,1E01:1E01,1E03:1E03,1E05:1E05,1E07:1E07,1E09:1E09,1E0B:1E0B,1E0D:1E0D,1E0F:1E0F,1E11:1E11,1E13:1E13,1E15:1E15,1E17:1E17,1E19:1E19,1E1B:1E1B,1E1D:1E1D,1E1F:1E1F,1E21:1E21,1E23:1E23,1E25:1E25,1E27:1E27,1E29:1E29,1E2B:1E2B,1E2D:1E2D,1E2F:1E2F,1E31:1E31,1E33:1E33,1E35:1E35,1E37:1E37,1E39:1E39,1E3B:1E3B,1E3D:1E3D,1E3F:1E3F,1E41:1E41,1E43:1E43,1E45:1E45,1E47:1E47,1E49:1E49,1E4B:1E4B,1E4D:1E4D,1E4F:1E4F,1E51:1E51,1E53:1E53,1E55:1E55,1E57:1E57,1E59:1E59,1E5B:1E5B,1E5D:1E5D,1E5F:1E5F,1E61:1E61,1E63:1E63,1E65:1E65,1E67:1E67,1E69:1E69,1E6B:1E6B,1E6D:1E6D,1E6F:1E6F,1E71:1E71,1E73:1E73,1E75:1E75,1E77:1E77,1E79:1E79,1E7B:1E7B,1E7D:1E7D,1E7F:1E7F,1E81:1E81,1E83:1E83,1E85:1E85,1E87:1E87,1E89:1E89,1E8B:1E8B,1E8D:1E8D,1E8F:1E8F,1E91:1E91,1E93:1E93,1E95:1E9D,1E9F:1E9F,1EA1:1EA1,1EA3:1EA3,1EA5:1EA5,1EA7:1EA7,1EA9:1EA9,1EAB:1EAB,1EAD:1EAD,1EAF:1EAF,1EB1:1EB1,1EB3:1EB3,1EB5:1EB5,1EB7:1EB7,1EB9:1EB9,1EBB:1EBB,1EBD:1EBD,1EBF:1EBF,1EC1:1EC1,1EC3:1EC3,1EC5:1EC5,1EC7:1EC7,1EC9:1EC9,1ECB:1ECB,1ECD:1ECD,1ECF:1ECF,1ED1:1ED1,1ED3:1ED3,1ED5:1ED5,1ED7:1ED7,1ED9:1ED9,1EDB:1EDB,1EDD:1EDD,1EDF:1EDF,1EE1:1EE1,1EE3:1EE3,1EE5:1EE5,1EE7:1EE7,1EE9:1EE9,1EEB:1EEB,1EED:1EED,1EEF:1EEF,1EF1:1EF1,1EF3:1EF3,1EF5:1EF5,1EF7:1EF7,1EF9:1EF9,1EFB:1EFB,1EFD:1EFD,1EFF:1F07,1F10:1F15,1F20:1F27,1F30:1F37,1F40:1F45,1F50:1F57,1F60:1F67,1F70:1F7D,1F80:1F87,1F90:1F97,1FA0:1FA7,1FB0:1FB4,1FB6:1FB7,1FBE:1FBE,1FC2:1FC4,1FC6:1FC7,1FD0:1FD3,1FD6:1FD7,1FE0:1FE7,1FF2:1FF4,1FF6:1FF7,2071:2071,207F:207F,2090:209C,210A:210A,210E:210F,2113:2113,212F:212F,2134:2134,2139:2139,213C:213D,2146:2149,214E:214E,2170:217F,2184:2184,24D0:24E9,2C30:2C5F,2C61:2C61,2C65:2C66,2C68:2C68,2C6A:2C6A,2C6C:2C6C,2C71:2C71,2C73:2C74,2C76:2C7D,2C81:2C81,2C83:2C83,2C85:2C85,2C87:2C87,2C89:2C89,2C8B:2C8B,2C8D:2C8D,2C8F:2C8F,2C91:2C91,2C93:2C93,2C95:2C95,2C97:2C97,2C99:2C99,2C9B:2C9B,2C9D:2C9D,2C9F:2C9F,2CA1:2CA1,2CA3:2CA3,2CA5:2CA5,2CA7:2CA7,2CA9:2CA9,2CAB:2CAB,2CAD:2CAD,2CAF:2CAF,2CB1:2CB1,2CB3:2CB3,2CB5:2CB5,2CB7:2CB7,2CB9:2CB9,2CBB:2CBB,2CBD:2CBD,2CBF:2CBF,2CC1:2CC1,2CC3:2CC3,2CC5:2CC5,2CC7:2CC7,2CC9:2CC9,2CCB:2CCB,2CCD:2CCD,2CCF:2CCF,2CD1:2CD1,2CD3:2CD3,2CD5:2CD5,2CD7:2CD7,2CD9:2CD9,2CDB:2CDB,2CDD:2CDD,2CDF:2CDF,2CE1:2CE1,2CE3:2CE4,2CEC:2CEC,2CEE:2CEE,2CF3:2CF3,2D00:2D25,2D27:2D27,2D2D:2D2D,A641:A641,A643:A643,A645:A645,A647:A647,A649:A649,A64B:A64B,A64D:A64D,A64F:A64F,A651:A651,A653:A653,A655:A655,A657:A657,A659:A659,A65B:A65B,A65D:A65D,A65F:A65F,A661:A661,A663:A663,A665:A665,A667:A667,A669:A669,A66B:A66B,A66D:A66D,A681:A681,A683:A683,A685:A685,A687:A687,A689:A689,A68B:A68B,A68D:A68D,A68F:A68F,A691:A691,A693:A693,A695:A695,A697:A697,A699:A699,A69B:A69D,A723:A723,A725:A725,A727:A727,A729:A729,A72B:A72B,A72D:A72D,A72F:A731,A733:A733,A735:A735,A737:A737,A739:A739,A73B:A73B,A73D:A73D,A73F:A73F,A741:A741,A743:A743,A745:A745,A747:A747,A749:A749,A74B:A74B,A74D:A74D,A74F:A74F,A751:A751,A753:A753,A755:A755,A757:A757,A759:A759,A75B:A75B,A75D:A75D,A75F:A75F,A761:A761,A763:A763,A765:A765,A767:A767,A769:A769,A76B:A76B,A76D:A76D,A76F:A778,A77A:A77A,A77C:A77C,A77F:A77F,A781:A781,A783:A783,A785:A785,A787:A787,A78C:A78C,A78E:A78E,A791:A791,A793:A795,A797:A797,A799:A799,A79B:A79B,A79D:A79D,A79F:A79F,A7A1:A7A1,A7A3:A7A3,A7A5:A7A5,A7A7:A7A7,A7A9:A7A9,A7AF:A7AF,A7B5:A7B5,A7B7:A7B7,A7B9:A7B9,A7BB:A7BB,A7BD:A7BD,A7BF:A7BF,A7C1:A7C1,A7C3:A7C3,A7C8:A7C8,A7CA:A7CA,A7CD:A7CD,A7D1:A7D1,A7D3:A7D3,A7D5:A7D5,A7D7:A7D7,A7D9:A7D9,A7DB:A7DB,A7F2:A7F4,A7F6:A7F6,A7F8:A7FA,AB30:AB5A,AB5C:AB69,AB70:ABBF,FB00:FB06,FB13:FB17,FF41:FF5A,10428:1044F,104D8:104FB,10597:105A1,105A3:105B1,105B3:105B9,105BB:105BC,10780:10780,10783:10785,10787:107B0,107B2:107BA,10CC0:10CF2,10D70:10D85,118C0:118DF,16E60:16E7F,1D41A:1D433,1D44E:1D454,1D456:1D467,1D482:1D49B,1D4B6:1D4B9,1D4BB:1D4BB,1D4BD:1D4C3,1D4C5:1D4CF,1D4EA:1D503,1D51E:1D537,1D552:1D56B,1D586:1D59F,1D5BA:1D5D3,1D5EE:1D607,1D622:1D63B,1D656:1D66F,1D68A:1D6A5,1D6C2:1D6DA,1D6DC:1D6E1,1D6FC:1D714,1D716:1D71B,1D736:1D74E,1D750:1D755,1D770:1D788,1D78A:1D78F,1D7AA:1D7C2,1D7C4:1D7C9,1D7CB:1D7CB,1DF00:1DF09,1DF0B:1DF1E,1DF25:1DF2A,1E030:1E06D,1E922:1E943 +is_uppercase 41:5A,C0:D6,D8:DE,100:100,102:102,104:104,106:106,108:108,10A:10A,10C:10C,10E:10E,110:110,112:112,114:114,116:116,118:118,11A:11A,11C:11C,11E:11E,120:120,122:122,124:124,126:126,128:128,12A:12A,12C:12C,12E:12E,130:130,132:132,134:134,136:136,139:139,13B:13B,13D:13D,13F:13F,141:141,143:143,145:145,147:147,14A:14A,14C:14C,14E:14E,150:150,152:152,154:154,156:156,158:158,15A:15A,15C:15C,15E:15E,160:160,162:162,164:164,166:166,168:168,16A:16A,16C:16C,16E:16E,170:170,172:172,174:174,176:176,178:179,17B:17B,17D:17D,181:182,184:184,186:187,189:18B,18E:191,193:194,196:198,19C:19D,19F:1A0,1A2:1A2,1A4:1A4,1A6:1A7,1A9:1A9,1AC:1AC,1AE:1AF,1B1:1B3,1B5:1B5,1B7:1B8,1BC:1BC,1C4:1C4,1C7:1C7,1CA:1CA,1CD:1CD,1CF:1CF,1D1:1D1,1D3:1D3,1D5:1D5,1D7:1D7,1D9:1D9,1DB:1DB,1DE:1DE,1E0:1E0,1E2:1E2,1E4:1E4,1E6:1E6,1E8:1E8,1EA:1EA,1EC:1EC,1EE:1EE,1F1:1F1,1F4:1F4,1F6:1F8,1FA:1FA,1FC:1FC,1FE:1FE,200:200,202:202,204:204,206:206,208:208,20A:20A,20C:20C,20E:20E,210:210,212:212,214:214,216:216,218:218,21A:21A,21C:21C,21E:21E,220:220,222:222,224:224,226:226,228:228,22A:22A,22C:22C,22E:22E,230:230,232:232,23A:23B,23D:23E,241:241,243:246,248:248,24A:24A,24C:24C,24E:24E,370:370,372:372,376:376,37F:37F,386:386,388:38A,38C:38C,38E:38F,391:3A1,3A3:3AB,3CF:3CF,3D2:3D4,3D8:3D8,3DA:3DA,3DC:3DC,3DE:3DE,3E0:3E0,3E2:3E2,3E4:3E4,3E6:3E6,3E8:3E8,3EA:3EA,3EC:3EC,3EE:3EE,3F4:3F4,3F7:3F7,3F9:3FA,3FD:42F,460:460,462:462,464:464,466:466,468:468,46A:46A,46C:46C,46E:46E,470:470,472:472,474:474,476:476,478:478,47A:47A,47C:47C,47E:47E,480:480,48A:48A,48C:48C,48E:48E,490:490,492:492,494:494,496:496,498:498,49A:49A,49C:49C,49E:49E,4A0:4A0,4A2:4A2,4A4:4A4,4A6:4A6,4A8:4A8,4AA:4AA,4AC:4AC,4AE:4AE,4B0:4B0,4B2:4B2,4B4:4B4,4B6:4B6,4B8:4B8,4BA:4BA,4BC:4BC,4BE:4BE,4C0:4C1,4C3:4C3,4C5:4C5,4C7:4C7,4C9:4C9,4CB:4CB,4CD:4CD,4D0:4D0,4D2:4D2,4D4:4D4,4D6:4D6,4D8:4D8,4DA:4DA,4DC:4DC,4DE:4DE,4E0:4E0,4E2:4E2,4E4:4E4,4E6:4E6,4E8:4E8,4EA:4EA,4EC:4EC,4EE:4EE,4F0:4F0,4F2:4F2,4F4:4F4,4F6:4F6,4F8:4F8,4FA:4FA,4FC:4FC,4FE:4FE,500:500,502:502,504:504,506:506,508:508,50A:50A,50C:50C,50E:50E,510:510,512:512,514:514,516:516,518:518,51A:51A,51C:51C,51E:51E,520:520,522:522,524:524,526:526,528:528,52A:52A,52C:52C,52E:52E,531:556,10A0:10C5,10C7:10C7,10CD:10CD,13A0:13F5,1C89:1C89,1C90:1CBA,1CBD:1CBF,1E00:1E00,1E02:1E02,1E04:1E04,1E06:1E06,1E08:1E08,1E0A:1E0A,1E0C:1E0C,1E0E:1E0E,1E10:1E10,1E12:1E12,1E14:1E14,1E16:1E16,1E18:1E18,1E1A:1E1A,1E1C:1E1C,1E1E:1E1E,1E20:1E20,1E22:1E22,1E24:1E24,1E26:1E26,1E28:1E28,1E2A:1E2A,1E2C:1E2C,1E2E:1E2E,1E30:1E30,1E32:1E32,1E34:1E34,1E36:1E36,1E38:1E38,1E3A:1E3A,1E3C:1E3C,1E3E:1E3E,1E40:1E40,1E42:1E42,1E44:1E44,1E46:1E46,1E48:1E48,1E4A:1E4A,1E4C:1E4C,1E4E:1E4E,1E50:1E50,1E52:1E52,1E54:1E54,1E56:1E56,1E58:1E58,1E5A:1E5A,1E5C:1E5C,1E5E:1E5E,1E60:1E60,1E62:1E62,1E64:1E64,1E66:1E66,1E68:1E68,1E6A:1E6A,1E6C:1E6C,1E6E:1E6E,1E70:1E70,1E72:1E72,1E74:1E74,1E76:1E76,1E78:1E78,1E7A:1E7A,1E7C:1E7C,1E7E:1E7E,1E80:1E80,1E82:1E82,1E84:1E84,1E86:1E86,1E88:1E88,1E8A:1E8A,1E8C:1E8C,1E8E:1E8E,1E90:1E90,1E92:1E92,1E94:1E94,1E9E:1E9E,1EA0:1EA0,1EA2:1EA2,1EA4:1EA4,1EA6:1EA6,1EA8:1EA8,1EAA:1EAA,1EAC:1EAC,1EAE:1EAE,1EB0:1EB0,1EB2:1EB2,1EB4:1EB4,1EB6:1EB6,1EB8:1EB8,1EBA:1EBA,1EBC:1EBC,1EBE:1EBE,1EC0:1EC0,1EC2:1EC2,1EC4:1EC4,1EC6:1EC6,1EC8:1EC8,1ECA:1ECA,1ECC:1ECC,1ECE:1ECE,1ED0:1ED0,1ED2:1ED2,1ED4:1ED4,1ED6:1ED6,1ED8:1ED8,1EDA:1EDA,1EDC:1EDC,1EDE:1EDE,1EE0:1EE0,1EE2:1EE2,1EE4:1EE4,1EE6:1EE6,1EE8:1EE8,1EEA:1EEA,1EEC:1EEC,1EEE:1EEE,1EF0:1EF0,1EF2:1EF2,1EF4:1EF4,1EF6:1EF6,1EF8:1EF8,1EFA:1EFA,1EFC:1EFC,1EFE:1EFE,1F08:1F0F,1F18:1F1D,1F28:1F2F,1F38:1F3F,1F48:1F4D,1F59:1F59,1F5B:1F5B,1F5D:1F5D,1F5F:1F5F,1F68:1F6F,1FB8:1FBB,1FC8:1FCB,1FD8:1FDB,1FE8:1FEC,1FF8:1FFB,2102:2102,2107:2107,210B:210D,2110:2112,2115:2115,2119:211D,2124:2124,2126:2126,2128:2128,212A:212D,2130:2133,213E:213F,2145:2145,2160:216F,2183:2183,24B6:24CF,2C00:2C2F,2C60:2C60,2C62:2C64,2C67:2C67,2C69:2C69,2C6B:2C6B,2C6D:2C70,2C72:2C72,2C75:2C75,2C7E:2C80,2C82:2C82,2C84:2C84,2C86:2C86,2C88:2C88,2C8A:2C8A,2C8C:2C8C,2C8E:2C8E,2C90:2C90,2C92:2C92,2C94:2C94,2C96:2C96,2C98:2C98,2C9A:2C9A,2C9C:2C9C,2C9E:2C9E,2CA0:2CA0,2CA2:2CA2,2CA4:2CA4,2CA6:2CA6,2CA8:2CA8,2CAA:2CAA,2CAC:2CAC,2CAE:2CAE,2CB0:2CB0,2CB2:2CB2,2CB4:2CB4,2CB6:2CB6,2CB8:2CB8,2CBA:2CBA,2CBC:2CBC,2CBE:2CBE,2CC0:2CC0,2CC2:2CC2,2CC4:2CC4,2CC6:2CC6,2CC8:2CC8,2CCA:2CCA,2CCC:2CCC,2CCE:2CCE,2CD0:2CD0,2CD2:2CD2,2CD4:2CD4,2CD6:2CD6,2CD8:2CD8,2CDA:2CDA,2CDC:2CDC,2CDE:2CDE,2CE0:2CE0,2CE2:2CE2,2CEB:2CEB,2CED:2CED,2CF2:2CF2,A640:A640,A642:A642,A644:A644,A646:A646,A648:A648,A64A:A64A,A64C:A64C,A64E:A64E,A650:A650,A652:A652,A654:A654,A656:A656,A658:A658,A65A:A65A,A65C:A65C,A65E:A65E,A660:A660,A662:A662,A664:A664,A666:A666,A668:A668,A66A:A66A,A66C:A66C,A680:A680,A682:A682,A684:A684,A686:A686,A688:A688,A68A:A68A,A68C:A68C,A68E:A68E,A690:A690,A692:A692,A694:A694,A696:A696,A698:A698,A69A:A69A,A722:A722,A724:A724,A726:A726,A728:A728,A72A:A72A,A72C:A72C,A72E:A72E,A732:A732,A734:A734,A736:A736,A738:A738,A73A:A73A,A73C:A73C,A73E:A73E,A740:A740,A742:A742,A744:A744,A746:A746,A748:A748,A74A:A74A,A74C:A74C,A74E:A74E,A750:A750,A752:A752,A754:A754,A756:A756,A758:A758,A75A:A75A,A75C:A75C,A75E:A75E,A760:A760,A762:A762,A764:A764,A766:A766,A768:A768,A76A:A76A,A76C:A76C,A76E:A76E,A779:A779,A77B:A77B,A77D:A77E,A780:A780,A782:A782,A784:A784,A786:A786,A78B:A78B,A78D:A78D,A790:A790,A792:A792,A796:A796,A798:A798,A79A:A79A,A79C:A79C,A79E:A79E,A7A0:A7A0,A7A2:A7A2,A7A4:A7A4,A7A6:A7A6,A7A8:A7A8,A7AA:A7AE,A7B0:A7B4,A7B6:A7B6,A7B8:A7B8,A7BA:A7BA,A7BC:A7BC,A7BE:A7BE,A7C0:A7C0,A7C2:A7C2,A7C4:A7C7,A7C9:A7C9,A7CB:A7CC,A7D0:A7D0,A7D6:A7D6,A7D8:A7D8,A7DA:A7DA,A7DC:A7DC,A7F5:A7F5,FF21:FF3A,10400:10427,104B0:104D3,10570:1057A,1057C:1058A,1058C:10592,10594:10595,10C80:10CB2,10D50:10D65,118A0:118BF,16E40:16E5F,1D400:1D419,1D434:1D44D,1D468:1D481,1D49C:1D49C,1D49E:1D49F,1D4A2:1D4A2,1D4A5:1D4A6,1D4A9:1D4AC,1D4AE:1D4B5,1D4D0:1D4E9,1D504:1D505,1D507:1D50A,1D50D:1D514,1D516:1D51C,1D538:1D539,1D53B:1D53E,1D540:1D544,1D546:1D546,1D54A:1D550,1D56C:1D585,1D5A0:1D5B9,1D5D4:1D5ED,1D608:1D621,1D63C:1D655,1D670:1D689,1D6A8:1D6C0,1D6E2:1D6FA,1D71C:1D734,1D756:1D76E,1D790:1D7A8,1D7CA:1D7CA,1E900:1E921,1F130:1F149,1F150:1F169,1F170:1F189 +is_titlecase 1C5:1C5,1C8:1C8,1CB:1CB,1F2:1F2,1F88:1F8F,1F98:1F9F,1FA8:1FAF,1FBC:1FBC,1FCC:1FCC,1FFC:1FFC +is_cased 41:5A,61:7A,B5:B5,C0:D6,D8:F6,F8:137,139:18C,18E:1A9,1AC:1B9,1BC:1BD,1BF:1BF,1C4:220,222:233,23A:254,256:257,259:259,25B:25C,260:261,263:266,268:26C,26F:26F,271:272,275:275,27D:27D,280:280,282:283,287:28C,292:292,29D:29E,345:345,370:373,376:377,37B:37D,37F:37F,386:386,388:38A,38C:38C,38E:3A1,3A3:3D1,3D5:3F5,3F7:3FB,3FD:481,48A:52F,531:556,561:587,10A0:10C5,10C7:10C7,10CD:10CD,10D0:10FA,10FD:10FF,13A0:13F5,13F8:13FD,1C80:1C8A,1C90:1CBA,1CBD:1CBF,1D79:1D79,1D7D:1D7D,1D8E:1D8E,1E00:1E9B,1E9E:1E9E,1EA0:1F15,1F18:1F1D,1F20:1F45,1F48:1F4D,1F50:1F57,1F59:1F59,1F5B:1F5B,1F5D:1F5D,1F5F:1F7D,1F80:1FB4,1FB6:1FBC,1FBE:1FBE,1FC2:1FC4,1FC6:1FCC,1FD0:1FD3,1FD6:1FDB,1FE0:1FEC,1FF2:1FF4,1FF6:1FFC,2126:2126,212A:212B,2132:2132,214E:214E,2160:217F,2183:2184,24B6:24E9,2C00:2C70,2C72:2C73,2C75:2C76,2C7E:2CE3,2CEB:2CEE,2CF2:2CF3,2D00:2D25,2D27:2D27,2D2D:2D2D,A640:A66D,A680:A69B,A722:A72F,A732:A76F,A779:A787,A78B:A78D,A790:A794,A796:A7AE,A7B0:A7CD,A7D0:A7D1,A7D6:A7DC,A7F5:A7F6,AB53:AB53,AB70:ABBF,FB00:FB06,FB13:FB17,FF21:FF3A,FF41:FF5A,10400:1044F,104B0:104D3,104D8:104FB,10570:1057A,1057C:1058A,1058C:10592,10594:10595,10597:105A1,105A3:105B1,105B3:105B9,105BB:105BC,10C80:10CB2,10CC0:10CF2,10D50:10D65,10D70:10D85,118A0:118DF,16E40:16E7F,1E900:1E943 diff --git a/crates/unicode/tests/data/version_skew_cpython3.14.txt b/crates/unicode/tests/data/version_skew_cpython3.14.txt index acdb26089ba..e135c422fb4 100644 --- a/crates/unicode/tests/data/version_skew_cpython3.14.txt +++ b/crates/unicode/tests/data/version_skew_cpython3.14.txt @@ -2,6 +2,9 @@ # and the Rust std / icu4x build used here (a later Unicode release assigns them). # Regenerate with RUSTPYTHON_UNICODE_REGEN_SKEW=1. # Format: `predicate start:end,...` with inclusive hex ranges. +is_cased AA:AA,BA:BA,138:138,18D:18D,1AA:1AB,1BA:1BA,1BE:1BE,221:221,234:239,255:255,258:258,25A:25A,25D:25F,262:262,267:267,26D:26E,270:270,273:274,276:27C,27E:27F,281:281,284:286,28D:291,293:293,296:29C,29F:2B8,2C0:2C1,2E0:2E4,37A:37A,3D2:3D4,3FC:3FC,560:560,588:588,10FC:10FC,1D00:1D78,1D7A:1D7C,1D7E:1D8D,1D8F:1DBF,1E9C:1E9D,1E9F:1E9F,2071:2071,207F:207F,2090:209C,2102:2102,2107:2107,210A:2113,2115:2115,2119:211D,2124:2124,2128:2128,212C:212D,212F:2131,2133:2134,2139:2139,213C:213F,2145:2149,2C71:2C71,2C74:2C74,2C77:2C7D,2CE4:2CE4,A69C:A69D,A730:A731,A770:A778,A78E:A78E,A795:A795,A7AF:A7AF,A7CE:A7CF,A7D2:A7D5,A7F1:A7F4,A7F8:A7FA,AB30:AB52,AB54:AB5A,AB5C:AB69,10780:10780,10783:10785,10787:107B0,107B2:107BA,16EA0:16EB8,16EBB:16ED3,1D400:1D454,1D456:1D49C,1D49E:1D49F,1D4A2:1D4A2,1D4A5:1D4A6,1D4A9:1D4AC,1D4AE:1D4B9,1D4BB:1D4BB,1D4BD:1D4C3,1D4C5:1D505,1D507:1D50A,1D50D:1D514,1D516:1D51C,1D51E:1D539,1D53B:1D53E,1D540:1D544,1D546:1D546,1D54A:1D550,1D552:1D6A5,1D6A8:1D6C0,1D6C2:1D6DA,1D6DC:1D6FA,1D6FC:1D714,1D716:1D734,1D736:1D74E,1D750:1D76E,1D770:1D788,1D78A:1D7A8,1D7AA:1D7C2,1D7C4:1D7CB,1DF00:1DF09,1DF0B:1DF1E,1DF25:1DF2A,1E030:1E06D,1F130:1F149,1F150:1F169,1F170:1F189 +is_lowercase 295:295,A7CF:A7CF,A7F1:A7F1,16EBB:16ED3 +is_uppercase A7CE:A7CE,A7D2:A7D2,A7D4:A7D4,16EA0:16EB8 isalnum 88F:88F,C5C:C5C,CDC:CDC,A7CE:A7CF,A7D2:A7D2,A7D4:A7D4,A7F1:A7F1,10940:10959,10EC5:10EC7,11DB0:11DDB,11DE0:11DE9,16EA0:16EB8,16EBB:16ED3,16FF2:16FF6,187F8:187FF,18D09:18D1E,18D80:18DF2,1E6C0:1E6DE,1E6E0:1E6E2,1E6E4:1E6E5,1E6E7:1E6ED,1E6F0:1E6F4,1E6FE:1E6FF,2B73A:2B73F,2CEA2:2CEAD,323B0:33479 isalpha 88F:88F,C5C:C5C,CDC:CDC,A7CE:A7CF,A7D2:A7D2,A7D4:A7D4,A7F1:A7F1,10940:10959,10EC5:10EC7,11DB0:11DDB,16EA0:16EB8,16EBB:16ED3,16FF2:16FF3,187F8:187FF,18D09:18D1E,18D80:18DF2,1E6C0:1E6DE,1E6E0:1E6E2,1E6E4:1E6E5,1E6E7:1E6ED,1E6F0:1E6F4,1E6FE:1E6FF,2B73A:2B73F,2CEA2:2CEAD,323B0:33479 isdecimal 11DE0:11DE9 diff --git a/crates/unicode/tests/data/version_skew_mappings_cpython3.14.txt b/crates/unicode/tests/data/version_skew_mappings_cpython3.14.txt new file mode 100644 index 00000000000..8992b18989c --- /dev/null +++ b/crates/unicode/tests/data/version_skew_mappings_cpython3.14.txt @@ -0,0 +1,5 @@ +# Code points whose simple case mapping differs between CPython 3.14 +# (Unicode 16.0.0) and the icu4x build used here (a later Unicode release +# assigns the pair). Regenerate with RUSTPYTHON_UNICODE_REGEN_SKEW=1. +# Format: `mapping start:end,...` with inclusive hex ranges. +tolower A7CE:A7CE,A7D2:A7D2,A7D4:A7D4,16EA0:16EB8 diff --git a/crates/unicode/tests/differential.rs b/crates/unicode/tests/differential.rs index fef3451fdb9..1bed13e7faf 100644 --- a/crates/unicode/tests/differential.rs +++ b/crates/unicode/tests/differential.rs @@ -11,17 +11,21 @@ //! Both data files use the same run-length format: one `predicate` line per //! str method, followed by comma-separated hex `start:end` inclusive ranges. +// spell-checker:ignore recategorized recategorizations + #[cfg(test)] mod tests { extern crate alloc; use alloc::collections::{BTreeMap, BTreeSet}; - use rustpython_unicode::classify; + use rustpython_unicode::{case, classify}; const MAX: u32 = 0x110000; const REFERENCE: &str = include_str!("data/cpython3.14_predicates.txt"); const VERSION_SKEW: &str = include_str!("data/version_skew_cpython3.14.txt"); + const MAPPINGS: &str = include_str!("data/cpython3.14_mappings.txt"); + const MAPPING_SKEW: &str = include_str!("data/version_skew_mappings_cpython3.14.txt"); fn crate_predicate(name: &str, cp: u32) -> bool { let Some(c) = char::from_u32(cp) else { @@ -41,10 +45,25 @@ mod tests { // is "may start an identifier". classify_is_identifier_char(c) } + "is_lowercase" => case::is_lowercase(c), + "is_uppercase" => case::is_uppercase(c), + "is_titlecase" => case::is_titlecase(c), + "is_cased" => case::is_cased(c), other => panic!("unknown predicate {other}"), } } + /// The crate's simple case mapping for `name` at `cp`, as a code point. + fn crate_mapping(name: &str, cp: u32) -> u32 { + let Some(c) = char::from_u32(cp) else { + return cp; + }; + match name { + "tolower" => case::simple_lowercase(c) as u32, + other => panic!("unknown mapping {other}"), + } + } + fn classify_is_identifier_char(c: char) -> bool { rustpython_unicode::identifier::is_start(c) } @@ -80,6 +99,36 @@ mod tests { map } + /// Parse a `name -> {code point -> mapped code point}` table. + /// + /// Each non-comment line is `name cp:mapped,cp:mapped,...` listing only the + /// code points whose mapping differs from identity. + fn parse_mappings(text: &str) -> BTreeMap> { + let mut map = BTreeMap::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (name, packed) = match line.split_once(' ') { + Some((name, packed)) => (name, packed.trim()), + None => (line, ""), + }; + let mut table = BTreeMap::new(); + if !packed.is_empty() { + for pair in packed.split(',') { + let (cp, mapped) = pair.split_once(':').expect("pair is cp:mapped"); + table.insert( + u32::from_str_radix(cp, 16).unwrap(), + u32::from_str_radix(mapped, 16).unwrap(), + ); + } + } + map.insert(name.to_string(), table); + } + map + } + /// Collapse a sorted code-point set into inclusive `start:end` runs. fn encode_ranges(set: &BTreeSet) -> String { let mut runs = Vec::new(); @@ -119,14 +168,23 @@ mod tests { out } + /// Documented `cpython=true/crate=false` divergences: a code point that had a + /// property in Unicode 16.0.0 but lost it in the release icu4x ships, because + /// the code point was recategorized (not a regression in this crate). + /// + /// * U+0295 LATIN LETTER PHARYNGEAL VOICED FRICATIVE was general category `Ll` + /// in Unicode 16.0.0 and `Lo` from 17.0.0, so it is no longer `Lowercase`. + const KNOWN_RECATEGORIZATIONS: &[(&str, u32)] = &[("is_lowercase", 0x0295)]; + /// Regenerate `data/version_skew_cpython3.14.txt` from the current toolchain. /// /// Run with `RUSTPYTHON_UNICODE_REGEN_SKEW=1 cargo test -p rustpython-unicode - /// --test differential` after bumping the Rust/icu toolchain. All divergences - /// must be one-directional (crate=true, cpython=false) — newly-assigned code + /// --test differential` after bumping the Rust/icu toolchain. Divergences are + /// normally one-directional (crate=true, cpython=false) — newly-assigned code /// points from a later Unicode release. A `cpython=true, crate=false` entry - /// means a code point lost a property, which is a real regression, so this - /// refuses to record it. + /// means a code point lost a property; that is a real regression unless it is + /// an explicit entry in `KNOWN_RECATEGORIZATIONS`, so this refuses to record + /// any other reverse-direction divergence. #[test] fn regen_version_skew() { if std::env::var_os("RUSTPYTHON_UNICODE_REGEN_SKEW").is_none() { @@ -137,7 +195,9 @@ mod tests { let regressions: Vec<_> = divergences .iter() - .filter(|(_, _, expected)| *expected) + .filter(|(name, cp, expected)| { + *expected && !KNOWN_RECATEGORIZATIONS.contains(&(name.as_str(), *cp)) + }) .collect(); assert!( regressions.is_empty(), @@ -232,4 +292,133 @@ mod tests { panic!("{msg}"); } } + + /// All `(mapping, code point)` where the crate and CPython map differently. + fn all_mapping_divergences( + reference: &BTreeMap>, + ) -> Vec<(String, u32)> { + let mut out = Vec::new(); + for (name, table) in reference { + for cp in 0..MAX { + let expected = table.get(&cp).copied().unwrap_or(cp); + if crate_mapping(name, cp) != expected { + out.push((name.clone(), cp)); + } + } + } + out + } + + /// Regenerate `data/version_skew_mappings_cpython3.14.txt` from the current + /// toolchain (`RUSTPYTHON_UNICODE_REGEN_SKEW=1`). + /// + /// Divergences are normally the crate gaining a mapping a later Unicode + /// release assigns. A code point that CPython maps but the crate leaves + /// unmapped is a regression, not version skew, so this refuses to record it. + #[test] + fn regen_mapping_version_skew() { + if std::env::var_os("RUSTPYTHON_UNICODE_REGEN_SKEW").is_none() { + return; + } + let reference = parse_mappings(MAPPINGS); + let divergences = all_mapping_divergences(&reference); + + let regressions: Vec<_> = divergences + .iter() + .filter(|(name, cp)| { + let expected = reference.get(name).and_then(|t| t.get(cp)).copied(); + expected.is_some_and(|e| e != *cp) && crate_mapping(name, *cp) == *cp + }) + .collect(); + assert!( + regressions.is_empty(), + "refusing to record {} cpython-maps/crate-unmapped divergence(s) — these are \ + regressions, not version skew: {:?}", + regressions.len(), + ®ressions[..regressions.len().min(20)] + ); + + let mut by_mapping: BTreeMap> = BTreeMap::new(); + for (name, cp) in &divergences { + by_mapping.entry(name.clone()).or_default().insert(*cp); + } + + let mut body = String::from( + "# Code points whose simple case mapping differs between CPython 3.14\n\ + # (Unicode 16.0.0) and the icu4x build used here (a later Unicode release\n\ + # assigns the pair). Regenerate with RUSTPYTHON_UNICODE_REGEN_SKEW=1.\n\ + # Format: `mapping start:end,...` with inclusive hex ranges.\n", + ); + for (name, set) in &by_mapping { + body.push_str(&format!("{name} {}\n", encode_ranges(set))); + } + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/data/version_skew_mappings_cpython3.14.txt" + ); + std::fs::write(path, body).unwrap(); + eprintln!( + "wrote {} skew code points across {} mappings to {path}", + divergences.len(), + by_mapping.len() + ); + } + + #[test] + fn simple_mappings_match_cpython_except_documented_version_skew() { + let reference = parse_mappings(MAPPINGS); + let skew = parse_ranges(MAPPING_SKEW); + + let allowed = |name: &str, cp: u32| skew.get(name).is_some_and(|set| set.contains(&cp)); + + let mut unexpected: Vec<(String, u32, u32, u32)> = Vec::new(); + for (name, table) in &reference { + for cp in 0..MAX { + let expected = table.get(&cp).copied().unwrap_or(cp); + let actual = crate_mapping(name, cp); + if expected != actual && !allowed(name, cp) { + unexpected.push((name.clone(), cp, expected, actual)); + } + } + } + + let mut stale: Vec<(String, u32)> = Vec::new(); + for (name, set) in &skew { + for &cp in set { + let expected = reference + .get(name) + .and_then(|t| t.get(&cp)) + .copied() + .unwrap_or(cp); + if crate_mapping(name, cp) == expected { + stale.push((name.clone(), cp)); + } + } + } + + if !unexpected.is_empty() || !stale.is_empty() { + let mut msg = String::new(); + if !unexpected.is_empty() { + msg.push_str(&format!( + "{} undocumented mapping divergence(s) from CPython:\n", + unexpected.len() + )); + for (name, cp, expected, actual) in unexpected.iter().take(50) { + msg.push_str(&format!( + " {name} U+{cp:04X}: cpython=U+{expected:04X} crate=U+{actual:04X}\n" + )); + } + } + if !stale.is_empty() { + msg.push_str(&format!( + "{} stale version_skew_mappings_cpython3.14.txt entries that now agree:\n", + stale.len() + )); + for (name, cp) in stale.iter().take(50) { + msg.push_str(&format!(" {name} U+{cp:04X}\n")); + } + } + panic!("{msg}"); + } + } } diff --git a/crates/unicode/tests/generate_reference.py b/crates/unicode/tests/generate_reference.py index c7b21e66fc4..6a78230de75 100644 --- a/crates/unicode/tests/generate_reference.py +++ b/crates/unicode/tests/generate_reference.py @@ -14,6 +14,7 @@ from __future__ import annotations +import _sre import pathlib import sys import unicodedata @@ -32,6 +33,29 @@ "isidentifier": str.isidentifier, } +# Casing predicates, keyed by the crate function each one exercises. Each is +# sourced from the exact property that function computes: +# * is_lowercase / is_uppercase mirror Py_UNICODE_ISLOWER / ISUPPER, which for a +# single character are str.islower() / str.isupper(). +# * is_titlecase is the Lt general category (NOT str.istitle(), which also +# reports plain uppercase letters as titlecased). +# * is_cased is the Cased property (Py_UNICODE_ISCASED), via _sre. +CASE_PREDICATES = { + "is_lowercase": lambda c: c.islower(), + "is_uppercase": lambda c: c.isupper(), + "is_titlecase": lambda c: unicodedata.category(c) == "Lt", + "is_cased": lambda c: _sre.unicode_iscased(ord(c)), +} + +# Simple one-to-one lowercase mapping (Py_UNICODE_TOLOWER via _sre). This is the +# mapping the regex IGNORECASE path depends on. Emitted as `cp:mapping,...` for +# code points that map to something other than themselves. CPython exposes no +# Python-level simple-uppercase oracle (_sre has unicode_tolower only), so +# toupper is left to the SRE unit tests. +CASE_MAPPINGS = { + "tolower": _sre.unicode_tolower, +} + def encode_ranges(is_true) -> list[tuple[int, int]]: """Collapse the true-set of ``is_true`` into inclusive ``[start, end]`` runs.""" @@ -56,17 +80,33 @@ def main() -> int: "expected 16.0.0 (CPython 3.14); regenerating anyway\n" ) - out = pathlib.Path(__file__).parent / "data" / "cpython3.14_predicates.txt" - out.parent.mkdir(parents=True, exist_ok=True) + data = pathlib.Path(__file__).parent / "data" + data.mkdir(parents=True, exist_ok=True) lines = [f"# unidata_version {unicodedata.unidata_version}"] for name, method in STR_PREDICATES.items(): ranges = encode_ranges(lambda cp, m=method: m(chr(cp))) packed = ",".join(f"{s:X}:{e:X}" for s, e in ranges) lines.append(f"{name} {packed}") + for name, method in CASE_PREDICATES.items(): + ranges = encode_ranges(lambda cp, m=method: m(chr(cp))) + packed = ",".join(f"{s:X}:{e:X}" for s, e in ranges) + lines.append(f"{name} {packed}") + + predicates = data / "cpython3.14_predicates.txt" + predicates.write_text("\n".join(lines) + "\n") + print(f"wrote {predicates} ({predicates.stat().st_size} bytes)") + + mapping_lines = [f"# unidata_version {unicodedata.unidata_version}"] + for name, method in CASE_MAPPINGS.items(): + pairs = [ + f"{cp:X}:{mapped:X}" for cp in range(MAX) if (mapped := method(cp)) != cp + ] + mapping_lines.append(f"{name} {','.join(pairs)}") - out.write_text("\n".join(lines) + "\n") - print(f"wrote {out} ({out.stat().st_size} bytes)") + mappings = data / "cpython3.14_mappings.txt" + mappings.write_text("\n".join(mapping_lines) + "\n") + print(f"wrote {mappings} ({mappings.stat().st_size} bytes)") return 0 diff --git a/crates/vm/Cargo.toml b/crates/vm/Cargo.toml index 24006c8b3b9..ca92b9dfd76 100644 --- a/crates/vm/Cargo.toml +++ b/crates/vm/Cargo.toml @@ -82,12 +82,6 @@ optional = { workspace = true } result-like = { workspace = true } timsort = { workspace = true } -## unicode stuff -icu_casemap = { workspace = true } -icu_locale = { workspace = true } -icu_properties = { workspace = true } -writeable = { workspace = true } - [target.'cfg(unix)'.dependencies] exitcode = { workspace = true } diff --git a/crates/vm/src/anystr.rs b/crates/vm/src/anystr.rs index 3aa40eaf3a2..69ba525267a 100644 --- a/crates/vm/src/anystr.rs +++ b/crates/vm/src/anystr.rs @@ -1,9 +1,7 @@ use core::ops::Range; -use icu_properties::props::{ - BinaryProperty, EnumeratedProperty, GeneralCategory, GeneralCategoryGroup, -}; use num_traits::{cast::ToPrimitive, sign::Signed}; +use rustpython_unicode::case; use crate::{ Py, PyObject, PyObjectRef, PyResult, TryFromObject, VirtualMachine, @@ -448,24 +446,18 @@ pub(crate) trait AnyStr { // Unified form of CPython functions: // unicode_isupper_impl // unicode_islower_impl - fn is_cased(&self) -> bool - where - VALID: BinaryProperty, - INVALID: BinaryProperty, - { + fn is_cased(&self, valid: fn(char) -> bool, invalid: fn(char) -> bool) -> bool { let mut all_cased = false; for c in self .as_bytes() .utf8_chunks() .flat_map(|c| c.valid().chars()) { - if INVALID::for_char(c) - || GeneralCategoryGroup::TitlecaseLetter.contains(GeneralCategory::for_char(c)) - { + if invalid(c) || case::is_titlecase(c) { return false; } - if !all_cased && VALID::for_char(c) { + if !all_cased && valid(c) { all_cased = true; } } diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index a72a272679b..b1a119f5ef2 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -30,7 +30,6 @@ use crate::{ AsMapping, AsNumber, AsSequence, Comparable, Constructor, Hashable, IterNext, Iterable, PyComparisonOp, Representable, SelfIter, }, - utils::VecFmtWriter, }; use alloc::{borrow::Cow, fmt}; use ascii::{AsciiChar, AsciiStr, AsciiString}; @@ -48,14 +47,7 @@ use rustpython_common::{ wtf8::{CodePoint, Wtf8, Wtf8Buf, Wtf8Concat}, }; -use icu_casemap::TitlecaseMapper; -use icu_locale::LanguageIdentifier; -use icu_properties::props::{ - BinaryProperty, CaseIgnorable, Cased, EnumeratedProperty, GeneralCategory, - GeneralCategoryGroup, Lowercase, Uppercase, -}; -use rustpython_unicode as unicode; -use writeable::Writeable; +use rustpython_unicode::{self as unicode, case}; impl<'a> TryFromBorrowedObject<'a> for String { fn try_from_borrowed_object(vm: &VirtualMachine, obj: &'a PyObject) -> PyResult { @@ -778,40 +770,8 @@ impl PyStr { } s.into() } - PyKindStr::Utf8(s) => { - let mut chars = s.char_indices(); - let mut out = VecFmtWriter(Vec::with_capacity(s.len())); - titlecase_first(s, &mut chars, &mut out); - for (i, ch) in chars { - lowercase_or_sigma(ch, s, i, &mut out); - } - unsafe { Wtf8Buf::from_bytes_unchecked(out.0) } - } - PyKindStr::Wtf8(s) => { - let mut out = VecFmtWriter(Vec::with_capacity(s.len())); - let mut chunks = s.as_bytes().utf8_chunks(); - - if let Some(first) = chunks.next() { - let s = first.valid(); - let mut chars = s.char_indices(); - titlecase_first(s, &mut chars, &mut out); - for (i, ch) in chars { - lowercase_or_sigma(ch, s, i, &mut out); - } - out.0.extend(first.invalid()); - } - // This loop is only hit if the WTF-8 buffer contains invalid Unicode. Otherwise, - // everything is handled above without chunking. - for chunk in chunks { - let s = chunk.valid(); - for (i, ch) in s.char_indices() { - lowercase_or_sigma(ch, s, i, &mut out); - } - out.0.extend(chunk.invalid()); - } - - unsafe { Wtf8Buf::from_bytes_unchecked(out.0) } - } + PyKindStr::Utf8(s) => case::capitalize_str(s).into(), + PyKindStr::Wtf8(s) => case::capitalize_wtf8(s), } } @@ -1062,23 +1022,8 @@ impl PyStr { PyKindStr::Ascii(_) => unsafe { Wtf8Buf::from_bytes_unchecked(title_ascii(self.as_bytes())) }, - PyKindStr::Utf8(s) => { - let mut out = VecFmtWriter(Vec::with_capacity(s.len())); - titlecase_string(s, &mut out); - // SAFETY: `s` is valid UTF-8 and titlecase_string only works on Unicode. - unsafe { Wtf8Buf::from_bytes_unchecked(out.0) } - } - PyKindStr::Wtf8(s) => { - let mut out = VecFmtWriter(Vec::with_capacity(s.len())); - for chunk in s.as_bytes().utf8_chunks() { - titlecase_string(chunk.valid(), &mut out); - out.0.extend(chunk.invalid()); - } - // SAFETY: - // * `s` is valid WTF-8; surrogate bytes were appended without processing. - // * TitlecaseMapper produces valid UTF-8. - unsafe { Wtf8Buf::from_bytes_unchecked(out.0) } - } + PyKindStr::Utf8(s) => case::title_str(s).into(), + PyKindStr::Wtf8(s) => case::title_wtf8(s), } } @@ -1089,23 +1034,8 @@ impl PyStr { // SAFETY: ASCII is valid Unicode and swapcase_ascii does not produce non-ASCII. Wtf8Buf::from_bytes_unchecked(swapcase_ascii(s.as_bytes())) }, - PyKindStr::Utf8(s) => { - let mut out = VecFmtWriter(Vec::with_capacity(s.len())); - swapcase_utf8(s, &mut out); - // SAFETY: `s` is valid UTF-8 and swapcase_utf8 only works on Unicode. - unsafe { Wtf8Buf::from_bytes_unchecked(out.0) } - } - PyKindStr::Wtf8(s) => { - let mut out = VecFmtWriter(Vec::with_capacity(s.len())); - for chunk in s.as_bytes().utf8_chunks() { - swapcase_utf8(chunk.valid(), &mut out); - out.0.extend(chunk.invalid()); - } - // SAFETY: - // * `s` is valid WTF-8; surrogate bytes were appended without processing. - // * swapcase_utf8 produces valid UTF-8. - unsafe { Wtf8Buf::from_bytes_unchecked(out.0) } - } + PyKindStr::Utf8(s) => case::swapcase_str(s).into(), + PyKindStr::Wtf8(s) => case::swapcase_wtf8(s), } } @@ -1307,9 +1237,7 @@ impl PyStr { let mut cased = false; let mut previous_is_cased = false; for c in self.as_wtf8().code_points().map(CodePoint::to_char_lossy) { - if c.is_uppercase() - || GeneralCategoryGroup::TitlecaseLetter.contains(GeneralCategory::for_char(c)) - { + if c.is_uppercase() || case::is_titlecase(c) { if previous_is_cased { return false; } @@ -1546,102 +1474,6 @@ impl PyStr { } } -/// Title case first char if it is cased or write as is. -/// -/// This matches CPython's behavior: -/// "123abc" -> "123abc" -/// "abc" -> "Abc" -fn titlecase_first(s: &str, chars: &mut core::str::CharIndices<'_>, out: &mut VecFmtWriter) { - if let Some((first_pos, first_ch)) = chars.next() { - let first = &s[..first_pos + first_ch.len_utf8()]; - let tm = TitlecaseMapper::new(); - tm.titlecase_segment(first, &LanguageIdentifier::UNKNOWN, Default::default()) - .write_to(out) - .expect("Writing to an in-memory buffer cannot fail."); - } -} - -/// Title case a string following CPython conventions. -/// -/// CPython title cases each char in a segment. A "segment" is split by case ignorable characters -/// rather than whitespace. -/// "123abc" -> "123Abc" -/// "123abc456def" -> "123Abc456Def" -/// "123 abc" -> "123 Abc" -fn titlecase_string(s: &str, out: &mut VecFmtWriter) { - let mut previous_is_cased = false; - let mapper = TitlecaseMapper::new(); - for (i, ch) in s.char_indices() { - if previous_is_cased { - lowercase_or_sigma(ch, s, i, out); - } else { - let s = &s[i..i + ch.len_utf8()]; - mapper - .titlecase_segment(s, &LanguageIdentifier::UNKNOWN, Default::default()) - .write_to(out) - .expect("Writing to an in-memory buffer cannot fail."); - } - - previous_is_cased = Cased::for_char(ch); - } -} - -fn lowercase_or_sigma(ch: char, s: &str, i: usize, out: &mut VecFmtWriter) { - let sigma = 'Σ'; - if ch == sigma { - let sigma_cased = handle_capital_sigma(s, i); - let mut buf = [0u8; 4]; - let s = sigma_cased.encode_utf8(&mut buf); - out.0.extend(s.as_bytes()); - } else { - for ch in ch.to_lowercase() { - let mut buf = [0u8; 4]; - let s = ch.encode_utf8(&mut buf); - out.0.extend(s.as_bytes()); - } - } -} - -// Handle context-sensitive sigma. -// -// CPython handles sigma as a special case. This is more efficient than using icu4x to scan the -// entire string with CaseMapper because CaseMapper would allocate to produce a new string. The -// icu4x crates are robust but CPython's capitalize() is NOT so we can skip the extra allocs. -fn handle_capital_sigma(s: &str, i: usize) -> char { - let (left, rest) = s.split_at(i); - let right = &rest['Σ'.len_utf8()..]; - - // Check if any chars before or after sigma are cased. - let before = left - .chars() - .rev() - .find(|&ch| !CaseIgnorable::for_char(ch)) - .is_some_and(Cased::for_char); - let after = right - .chars() - .find(|&ch| !CaseIgnorable::for_char(ch)) - .is_some_and(Cased::for_char); - if before && !after { 'ς' } else { 'σ' } -} - -fn swapcase_utf8(s: &str, out: &mut VecFmtWriter) { - for (i, ch) in s.char_indices() { - if ch.is_uppercase() { - lowercase_or_sigma(ch, s, i, out); - } else if ch.is_lowercase() { - for ch in ch.to_uppercase() { - let mut buf = [0u8; 4]; - let s = ch.encode_utf8(&mut buf); - out.0.extend(s.as_bytes()); - } - } else { - let mut buf = [0u8; 4]; - let s = ch.encode_utf8(&mut buf); - out.0.extend(s.as_bytes()); - } - } -} - impl PyRef { #[must_use] pub fn is_empty(&self) -> bool { @@ -2454,11 +2286,11 @@ impl AnyStr for str { } fn py_islower(&self) -> bool { - self.is_cased::() + self.is_cased(case::is_lowercase, case::is_uppercase) } fn py_isupper(&self) -> bool { - self.is_cased::() + self.is_cased(case::is_uppercase, case::is_lowercase) } } @@ -2574,11 +2406,11 @@ impl AnyStr for Wtf8 { } fn py_islower(&self) -> bool { - self.is_cased::() + self.is_cased(case::is_lowercase, case::is_uppercase) } fn py_isupper(&self) -> bool { - self.is_cased::() + self.is_cased(case::is_uppercase, case::is_lowercase) } } diff --git a/crates/vm/src/stdlib/_sre.rs b/crates/vm/src/stdlib/_sre.rs index 1f62b48b137..f7bc10e6640 100644 --- a/crates/vm/src/stdlib/_sre.rs +++ b/crates/vm/src/stdlib/_sre.rs @@ -23,7 +23,7 @@ mod _sre { use num_traits::ToPrimitive; use rustpython_sre_engine::{ Request, SearchIter, SreFlag, State, StrDrive, - string::{lower_ascii, lower_unicode, upper_unicode}, + string::{lower_ascii, lower_unicode}, }; #[pyattr] @@ -41,8 +41,7 @@ mod _sre { #[pyfunction] fn unicode_iscased(ch: i32) -> bool { - let ch = ch as u32; - ch != lower_unicode(ch) || ch != upper_unicode(ch) + char::from_u32(ch as u32).is_some_and(rustpython_unicode::case::is_cased) } #[pyfunction] diff --git a/crates/vm/src/utils.rs b/crates/vm/src/utils.rs index 51e27123fc8..b5117ddd8d1 100644 --- a/crates/vm/src/utils.rs +++ b/crates/vm/src/utils.rs @@ -1,5 +1,3 @@ -use core::fmt; - use rustpython_common::wtf8::{Wtf8, Wtf8Buf}; use crate::{ @@ -74,16 +72,3 @@ where Ok(repr) } - -/// Wrapper around a bytes vector that implements [`fmt::Write`]. -/// -/// # Safety -/// Don't assume the contents of the internal vector are valid UTF-8/WTF-8. -pub(crate) struct VecFmtWriter(pub Vec); - -impl fmt::Write for VecFmtWriter { - fn write_str(&mut self, s: &str) -> fmt::Result { - self.0.extend(s.bytes()); - Ok(()) - } -} From 5c36d5c367c56296b3027e17b1c07e230f77edc0 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:38:30 +0900 Subject: [PATCH 094/351] Titlecase the first-of-word code point without leading adjustment (#8241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit case::{title,capitalize}_wtf8 pass a single first-of-word code point to TitlecaseMapper::titlecase_segment. Under the default Auto leading adjustment the mapper looks for a Letter/Number/Symbol/Private_Use head and skips anything else, dropping the titlecase mapping of cased marks such as U+0345 (COMBINING GREEK YPOGEGRAMMENI) -> U+0399: 'ͅ'.title() returned 'ͅ' instead of 'Ι'. Use LeadingAdjustment::None so the given code point is titlecased directly, matching CPython str.title and str.capitalize. Assisted-by: Claude --- crates/unicode/src/case.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/unicode/src/case.rs b/crates/unicode/src/case.rs index 0d74133399a..c563db09339 100644 --- a/crates/unicode/src/case.rs +++ b/crates/unicode/src/case.rs @@ -19,6 +19,7 @@ use alloc::{ vec::Vec, }; +use icu_casemap::options::{LeadingAdjustment, TitlecaseOptions}; use icu_casemap::{CaseMapper, TitlecaseMapper}; use icu_locale::LanguageIdentifier; use icu_properties::props::{ @@ -236,8 +237,16 @@ fn titlecase_string(s: &str, out: &mut FmtWriter<'_>) { } fn titlecase_segment(s: &str, out: &mut FmtWriter<'_>) { + // Callers pass a single first-of-word code point, which Python titlecases + // unconditionally (applying its titlecase mapping). The default `Auto` + // leading adjustment looks for a head in Letter/Number/Symbol/Private_Use + // and skips anything else, dropping the titlecase mapping of cased marks + // such as U+0345 (`ͅ`, general category Mn) -> U+0399 (`Ι`). `None` + // titlecases the code point as given. + let mut options = TitlecaseOptions::default(); + options.leading_adjustment = Some(LeadingAdjustment::None); TitlecaseMapper::new() - .titlecase_segment(s, &LanguageIdentifier::UNKNOWN, Default::default()) + .titlecase_segment(s, &LanguageIdentifier::UNKNOWN, options) .write_to(out) .expect("writing to an in-memory buffer cannot fail"); } @@ -353,6 +362,23 @@ mod tests { assert_eq!(swapcase_str("Hello"), "hELLO"); } + #[test] + fn titlecase_first_of_word_takes_titlecase_mapping() { + // A leading cased combining mark still takes its titlecase mapping: + // U+0345 (ͅ, general category Mn) titlecases to U+0399 (Ι), even though + // it is not a Letter/Number/Symbol head. + assert_eq!(title_str("\u{0345}"), "\u{0399}"); + assert_eq!(capitalize_str("\u{0345}"), "\u{0399}"); + assert_eq!(title_str("\u{0345}a"), "\u{0399}a"); + // Full (one-to-many) titlecase mappings still apply to the first + // character of each word. + // cspell:ignore finnish NNISH dzungla Dzungla ßhello Sshello + assert_eq!(capitalize_str("finnish"), "Finnish"); + assert_eq!(title_str("fiNNISH"), "Finnish"); + assert_eq!(capitalize_str("dzungla"), "Dzungla"); + assert_eq!(capitalize_str("ßhello"), "Sshello"); + } + #[test] fn wtf8_passes_surrogates_through() { let mut buf = Wtf8Buf::from("ab cd"); From adb7d8a4a3c0c077ceb545bc4812b3f385a8877e Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:07:28 +0200 Subject: [PATCH 095/351] Add more build-info functions to c-api (#8216) * Add more build-info functions to c-api * Use CStr for COMPILER/COPYRIGHT/PLATFORM --- crates/capi/src/pylifecycle.rs | 26 +++++++++++++++++++++++++- crates/vm/src/builtins/str.rs | 8 ++++++++ crates/vm/src/stdlib/sys.rs | 22 ++++++++++++---------- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/crates/capi/src/pylifecycle.rs b/crates/capi/src/pylifecycle.rs index de673b33f0a..534c97956fe 100644 --- a/crates/capi/src/pylifecycle.rs +++ b/crates/capi/src/pylifecycle.rs @@ -4,7 +4,8 @@ use crate::pystate::ensure_thread_has_vm_attached; use alloc::ffi::CString; use core::ffi::{c_char, c_int, c_ulong}; use rustpython_vm::common::rc::PyRc; -use rustpython_vm::version::{MAJOR, MICRO, MINOR, VERSION_HEX}; +use rustpython_vm::stdlib::sys; +use rustpython_vm::version::{MAJOR, MICRO, MINOR, RUSTPYTHON_BUILD_INFO, VERSION_HEX}; use rustpython_vm::vm::thread::ThreadedVirtualMachine; use rustpython_vm::{Context, Interpreter}; use std::sync::{LazyLock, Mutex}; @@ -80,6 +81,29 @@ pub extern "C" fn Py_GetVersion() -> *const c_char { VERSION.as_ptr() } +#[unsafe(no_mangle)] +pub extern "C" fn Py_GetBuildInfo() -> *const c_char { + static BUILD_INFO: LazyLock = LazyLock::new(|| { + CString::new(RUSTPYTHON_BUILD_INFO).expect("build info must not contain interior NULs") + }); + BUILD_INFO.as_ptr() +} + +#[unsafe(no_mangle)] +pub extern "C" fn Py_GetCompiler() -> *const c_char { + c"[RUST]".as_ptr() +} + +#[unsafe(no_mangle)] +pub extern "C" fn Py_GetCopyright() -> *const c_char { + sys::COPYRIGHT.as_ptr() +} + +#[unsafe(no_mangle)] +pub extern "C" fn Py_GetPlatform() -> *const c_char { + sys::PLATFORM.as_ptr() +} + #[cfg(test)] mod tests { use pyo3::prelude::*; diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index b1a119f5ef2..bd5cd39ddb0 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -34,6 +34,7 @@ use crate::{ use alloc::{borrow::Cow, fmt}; use ascii::{AsciiChar, AsciiStr, AsciiString}; use bstr::ByteSlice; +use core::ffi::CStr; use core::{char, mem, ops::Range}; use itertools::Itertools; use num_traits::ToPrimitive; @@ -1700,6 +1701,13 @@ impl ToPyObject for &String { } } +impl ToPyObject for &CStr { + fn to_pyobject(self, vm: &VirtualMachine) -> PyObjectRef { + let s = self.to_str().expect("ToPyObject expects utf-8 CStr"); + vm.ctx.new_str(s).into() + } +} + impl ToPyObject for &Wtf8 { fn to_pyobject(self, vm: &VirtualMachine) -> PyObjectRef { vm.ctx.new_str(self).into() diff --git a/crates/vm/src/stdlib/sys.rs b/crates/vm/src/stdlib/sys.rs index 4e31075da45..31e10203684 100644 --- a/crates/vm/src/stdlib/sys.rs +++ b/crates/vm/src/stdlib/sys.rs @@ -4,6 +4,7 @@ use crate::{Py, PyPayload, PyResult, VirtualMachine, builtins::PyModule, convert #[cfg(all(not(feature = "host_env"), feature = "stdio"))] pub(crate) use sys::SandboxStdio; +pub use sys::{COPYRIGHT, PLATFORM}; pub(crate) use sys::{DOC, MAXSIZE, RUST_MULTIARCH, UnraisableHookArgsData, module_def, multiarch}; #[pymodule(name = "_jit")] @@ -50,6 +51,7 @@ pub mod sys { version, vm::{Settings, VirtualMachine}, }; + use core::ffi::CStr; use core::sync::atomic::Ordering; use num_traits::ToPrimitive; use std::{ @@ -222,7 +224,7 @@ pub mod sys { #[pyattr(name = "api_version")] const API_VERSION: u32 = 0x0; // what C api? #[pyattr(name = "copyright")] - const COPYRIGHT: &str = "Copyright (c) 2019 RustPython Team"; + pub const COPYRIGHT: &CStr = c"Copyright (c) 2019 RustPython Team"; #[pyattr(name = "float_repr_style")] const FLOAT_REPR_STYLE: &str = "short"; #[pyattr(name = "_framework")] @@ -235,14 +237,14 @@ pub mod sys { const MAXUNICODE: u32 = core::char::MAX as u32; #[pyattr(name = "platform")] - pub const PLATFORM: &str = cfg_select! { - target_os = "linux" => "linux", - target_os = "android" => "android", - target_os = "macos" => "darwin", - target_os = "ios" => "ios", - windows => "win32", - target_os = "wasi" => "wasi", - _ => "unknown" + pub const PLATFORM: &CStr = cfg_select! { + target_os = "linux" => c"linux", + target_os = "android" => c"android", + target_os = "macos" => c"darwin", + target_os = "ios" => c"ios", + windows => c"win32", + target_os = "wasi" => c"wasi", + _ => c"unknown" }; #[pyattr(name = "ps1")] @@ -1897,7 +1899,7 @@ pub(crate) fn sysconfigdata_name() -> String { format!( "_sysconfigdata_{}_{}_{}", sys::ABIFLAGS, - sys::PLATFORM, + sys::PLATFORM.to_string_lossy(), sys::multiarch() ) } From 6b1bd401d09b36a8f991e8ad8da7038bf0213c2b Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:08:23 +0200 Subject: [PATCH 096/351] Add garbage collection c-api (#8239) --- crates/capi/src/lib.rs | 1 + crates/capi/src/objimpl.rs | 82 ++++++++++++++++++++++++++++++++++++ crates/vm/src/object/core.rs | 2 +- 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 crates/capi/src/objimpl.rs diff --git a/crates/capi/src/lib.rs b/crates/capi/src/lib.rs index 15632648961..bf985c3ba26 100644 --- a/crates/capi/src/lib.rs +++ b/crates/capi/src/lib.rs @@ -25,6 +25,7 @@ pub mod longobject; pub mod methodobject; pub mod moduleobject; pub mod object; +pub mod objimpl; pub mod osmodule; pub mod pycapsule; pub mod pyerrors; diff --git a/crates/capi/src/objimpl.rs b/crates/capi/src/objimpl.rs new file mode 100644 index 00000000000..99b0be7cc68 --- /dev/null +++ b/crates/capi/src/objimpl.rs @@ -0,0 +1,82 @@ +use crate::PyObject; +use crate::pymem::{PyMem_Calloc, PyMem_Free, PyMem_Malloc, PyMem_Realloc}; +use crate::pystate::with_vm; +use core::ffi::{c_int, c_void}; +use rustpython_vm::gc_state; + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_GC_Track(op: *mut PyObject) { + with_vm(|_vm| { + let obj = unsafe { &*op }; + if !obj.is_gc_tracked() { + unsafe { gc_state::gc_state().track_object(obj.into()) }; + } + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_GC_UnTrack(op: *mut PyObject) { + with_vm(|_vm| { + let obj = unsafe { &*op }; + if obj.is_gc_tracked() { + unsafe { gc_state::gc_state().untrack_object(obj.into()) }; + } + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_GC_IsTracked(op: *mut PyObject) -> c_int { + with_vm(|_vm| unsafe { (&*op).is_gc_tracked() }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_GC_IsFinalized(op: *mut PyObject) -> c_int { + with_vm(|_vm| unsafe { (&*op).gc_finalized() }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyGC_Collect() -> isize { + let result = gc_state::gc_state().collect(2); + (result.collected + result.uncollectable) as isize +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyGC_Enable() -> c_int { + let gc = gc_state::gc_state(); + let was_enabled = gc.is_enabled(); + gc.enable(); + was_enabled.into() +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyGC_Disable() -> c_int { + let gc = gc_state::gc_state(); + let was_enabled = gc.is_enabled(); + gc.disable(); + was_enabled.into() +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyGC_IsEnabled() -> c_int { + gc_state::gc_state().is_enabled().into() +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_Malloc(size: usize) -> *mut c_void { + unsafe { PyMem_Malloc(size) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_Calloc(nelem: usize, elsize: usize) -> *mut c_void { + unsafe { PyMem_Calloc(nelem, elsize) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_Realloc(ptr: *mut c_void, new_size: usize) -> *mut c_void { + unsafe { PyMem_Realloc(ptr, new_size) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_Free(ptr: *mut c_void) { + unsafe { PyMem_Free(ptr) } +} diff --git a/crates/vm/src/object/core.rs b/crates/vm/src/object/core.rs index f643be7e1fa..2eb1b17cb44 100644 --- a/crates/vm/src/object/core.rs +++ b/crates/vm/src/object/core.rs @@ -1654,7 +1654,7 @@ impl PyObject { /// Check if the object has been finalized (__del__ already called). /// _PyGC_FINALIZED in Py_GIL_DISABLED mode. #[inline] - pub(crate) fn gc_finalized(&self) -> bool { + pub fn gc_finalized(&self) -> bool { GcBits::from_bits_retain(self.0.gc_bits.load(Ordering::Relaxed)).contains(GcBits::FINALIZED) } From bf9813155144e9fbfb0c7aefb2c1fc60d4adca9d Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:12:10 +0200 Subject: [PATCH 097/351] Add memoryview support to c-api (#8240) --- crates/capi/src/lib.rs | 1 + crates/capi/src/memoryobject.rs | 37 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 crates/capi/src/memoryobject.rs diff --git a/crates/capi/src/lib.rs b/crates/capi/src/lib.rs index bf985c3ba26..424074a1292 100644 --- a/crates/capi/src/lib.rs +++ b/crates/capi/src/lib.rs @@ -22,6 +22,7 @@ pub mod genericaliasobject; pub mod import; pub mod listobject; pub mod longobject; +pub mod memoryobject; pub mod methodobject; pub mod moduleobject; pub mod object; diff --git a/crates/capi/src/memoryobject.rs b/crates/capi/src/memoryobject.rs new file mode 100644 index 00000000000..11019524e6c --- /dev/null +++ b/crates/capi/src/memoryobject.rs @@ -0,0 +1,37 @@ +use crate::object::define_py_check; +use crate::{PyObject, pystate::with_vm}; +use rustpython_vm::PyPayload; +use rustpython_vm::builtins::PyMemoryView; + +define_py_check!(fn PyMemoryView_Check, types.memoryview_type); + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyMemoryView_FromObject(obj: *mut PyObject) -> *mut PyObject { + with_vm(|vm| { + let obj = unsafe { &*obj }; + Ok(PyMemoryView::from_object(obj, vm)?.into_ref(&vm.ctx)) + }) +} + +#[cfg(test)] +mod tests { + use pyo3::prelude::*; + use pyo3::types::{PyBytes, PyMemoryView}; + + #[test] + fn memoryview_from_bytes() { + Python::attach(|py| { + let bytes = PyBytes::new(py, b"hello"); + let view = PyMemoryView::from(&bytes).unwrap(); + + assert!(view.is_instance_of::()); + + let copied = view + .call_method1("tobytes", ()) + .unwrap() + .cast_into::() + .unwrap(); + assert_eq!(copied.as_bytes(), b"hello"); + }) + } +} From d3f0729fdff740c99dece6855d5ad342ef833157 Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:14:56 +0200 Subject: [PATCH 098/351] Add more eval functions to c-api (#8244) --- crates/capi/src/ceval.rs | 89 +++++++++++++++++++++++++++++++++++++- crates/capi/src/pyframe.rs | 22 +++++----- crates/capi/src/util.rs | 23 +++++++++- 3 files changed, 121 insertions(+), 13 deletions(-) diff --git a/crates/capi/src/ceval.rs b/crates/capi/src/ceval.rs index d137cb17dab..366cb0071bb 100644 --- a/crates/capi/src/ceval.rs +++ b/crates/capi/src/ceval.rs @@ -1,3 +1,4 @@ +use crate::pyframe::PyFrameObject; use crate::pystate::with_vm; use crate::unicodeobject::decode_fsdefault_and_size; use core::ffi::{CStr, c_char, c_int}; @@ -5,8 +6,8 @@ use core::ptr::NonNull; use rustpython_vm::builtins::{PyCode, PyDict}; use rustpython_vm::function::ArgMapping; use rustpython_vm::scope::Scope; -use rustpython_vm::version; use rustpython_vm::{AsObject, PyObject, TryFromObject}; +use rustpython_vm::{PyObjectRef, version}; #[unsafe(no_mangle)] pub unsafe extern "C" fn Py_CompileString( @@ -42,6 +43,16 @@ pub unsafe extern "C" fn PyEval_EvalCode( }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyEval_EvalFrame(f: *mut PyFrameObject) -> *mut PyObject { + unsafe { PyEval_EvalFrameEx(f, 0) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyEval_EvalFrameEx(f: *mut PyFrameObject, _exc: c_int) -> *mut PyObject { + with_vm(|vm| vm.run_frame(unsafe { &*f }.to_owned())) +} + #[unsafe(no_mangle)] pub extern "C" fn PyEval_GetBuiltins() -> *mut PyObject { with_vm(|vm| { @@ -52,6 +63,82 @@ pub extern "C" fn PyEval_GetBuiltins() -> *mut PyObject { }) } +#[unsafe(no_mangle)] +pub extern "C" fn PyEval_GetFrame() -> *mut PyFrameObject { + with_vm(|vm| -> *mut PyObject { + vm.current_frame() + .map(|frame| frame.as_object().as_raw().cast_mut()) + .unwrap_or_default() + }) + .cast() +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyEval_GetFrameBuiltins() -> *mut PyObject { + with_vm(|vm| { + vm.current_frame().map_or_else( + || vm.builtins.as_object().to_owned(), + |frame| frame.builtins.as_object().to_owned(), + ) + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyEval_GetFrameGlobals() -> *mut PyObject { + with_vm(|vm| { + vm.current_frame() + .map(|frame| frame.globals.as_object().to_owned().into_raw().as_ptr()) + .unwrap_or_default() + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyEval_GetFrameLocals() -> *mut PyObject { + with_vm(|vm| { + let Some(frame) = vm.current_frame() else { + return Ok(core::ptr::null_mut()); + }; + let locals: PyObjectRef = frame.locals(vm)?.into(); + Ok(locals.into_raw().as_ptr()) + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyEval_GetGlobals() -> *mut PyObject { + with_vm(|vm| { + vm.current_frame() + .map(|frame| frame.globals.as_object().as_raw()) + .unwrap_or_default() + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn PyEval_GetLocals() -> *mut PyObject { + with_vm(|vm| { + let Some(frame) = vm.current_frame() else { + return Ok(core::ptr::null_mut()); + }; + let _ = frame.locals(vm)?; + Ok(frame.locals.as_object(vm).as_raw().cast_mut()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyEval_GetFuncDesc(func: *mut PyObject) -> *const c_char { + with_vm(|vm| { + let func = unsafe { &*func }; + let cls = func.class(); + if cls.is(vm.ctx.types.bound_method_type) + || cls.is(vm.ctx.types.function_type) + || cls.is(vm.ctx.types.builtin_function_or_method_type) + { + c"()" + } else { + c" object" + } + }) +} + #[cfg(test)] mod tests { use pyo3::exceptions::PyException; diff --git a/crates/capi/src/pyframe.rs b/crates/capi/src/pyframe.rs index d8e9d124bb0..5c9ad371708 100644 --- a/crates/capi/src/pyframe.rs +++ b/crates/capi/src/pyframe.rs @@ -1,19 +1,21 @@ -use crate::PyObject; use crate::pystate::with_vm; +use core::ffi::c_int; +use rustpython_vm::Py; +use rustpython_vm::builtins::PyCode; use rustpython_vm::frame::Frame; +pub type PyFrameObject = Py; +pub type PyCodeObject = Py; + #[unsafe(no_mangle)] -pub unsafe extern "C" fn PyFrame_GetCode(frame: *mut PyObject) -> *mut PyObject { - with_vm(|vm| { - let frame = unsafe { &*frame }.try_downcast_ref::(vm)?; - Ok(frame.f_code()) - }) +pub unsafe extern "C" fn PyFrame_GetCode(frame: *mut PyFrameObject) -> *mut PyCodeObject { + with_vm(|_vm| Ok(unsafe { &*frame }.f_code())) } #[unsafe(no_mangle)] -pub unsafe extern "C" fn PyFrame_GetLineNumber(frame: *mut PyObject) -> core::ffi::c_int { - with_vm(|vm| { - let frame = unsafe { &*frame }.try_downcast_ref::(vm)?; - Ok(frame.f_lineno() as core::ffi::c_int) +pub unsafe extern "C" fn PyFrame_GetLineNumber(frame: *mut PyFrameObject) -> c_int { + with_vm(|_vm| { + let lineno = unsafe { &*frame }.f_lineno(); + Ok(lineno.try_into().unwrap_or(c_int::MAX)) }) } diff --git a/crates/capi/src/util.rs b/crates/capi/src/util.rs index ccb8ee254af..6bbda7654fe 100644 --- a/crates/capi/src/util.rs +++ b/crates/capi/src/util.rs @@ -1,7 +1,7 @@ use crate::PyObject; use core::convert::Infallible; -use core::ffi::{c_char, c_double, c_int, c_long, c_ulong, c_void}; -use rustpython_vm::{PyObjectRef, PyRef, PyResult, VirtualMachine}; +use core::ffi::{CStr, c_char, c_double, c_int, c_long, c_ulong, c_void}; +use rustpython_vm::{Py, PyObjectRef, PyRef, PyResult, VirtualMachine}; pub(crate) trait FfiResult { const ERR_VALUE: Output; @@ -36,6 +36,17 @@ where } } +impl FfiResult<*mut Py> for PyRef +where + Self: Into, +{ + const ERR_VALUE: *mut Py = core::ptr::null_mut(); + + fn into_output(self, _vm: &VirtualMachine) -> *mut Py { + self.into().into_raw().as_ptr().cast() + } +} + impl FfiResult<*mut PyObject> for PyObjectRef { const ERR_VALUE: *mut PyObject = core::ptr::null_mut(); @@ -92,6 +103,14 @@ impl FfiResult for *const c_char { } } +impl FfiResult<*const c_char> for &CStr { + const ERR_VALUE: *const c_char = core::ptr::null_mut(); + + fn into_output(self, _vm: &VirtualMachine) -> *const c_char { + self.as_ptr() + } +} + impl FfiResult for usize { const ERR_VALUE: isize = -1; From 5f9139a3f8e9d89b5eaaf74747ae3e8afb206641 Mon Sep 17 00:00:00 2001 From: Kwak Byoung Min <67214970+doma17@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:24:16 +0900 Subject: [PATCH 099/351] Support xml.sax pyexpat setup APIs (#8238) * Let xml.sax initialize pyexpat-backed parsers RustPython already exposes an Expat-like XML parser, but xml.sax expects a few setup APIs before parsing. Add compatible parser setup methods and constants without pretending to implement full Expat external entity behavior. Constraint: Lib/test changes only remove expectedFailure markers for tests that now pass; test bodies and assertions are unchanged. Rejected: Implementing full Expat parameter entity parsing | xml-rs does not expose that configuration and the immediate failure is the missing setup API. Confidence: high Scope-risk: narrow Directive: Keep SetParamEntityParsing as a compatibility shim unless the backend grows real Expat-style entity parsing support. Tested: PATH=/tmp/pyshim:$PATH prek run --all-files Tested: cargo run --quiet -- extra_tests/snippets/stdlib_xml.py Tested: cargo run --release -- -m test test_sax Tested: cargo run --release -- -m test test_pyexpat Tested: cargo test --workspace --exclude rustpython-capi --exclude rustpython_wasm --exclude rustpython-compiler-source --exclude rustpython-venvlauncher --features threading --no-default-features --features stdlib,importlib,stdio,encodings,sqlite,ssl-rustls-aws-lc,host_env && (cd crates/capi && cargo test) Tested: PYO3_CONFIG_FILE=$PWD/crates/capi/pyo3-rustpython.config cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher Tested: cargo build --release --features sqlite && cd extra_tests && /tmp/rustpython-extra-tests-venv/bin/python -m pytest -v Tested: cargo clippy --workspace --exclude rustpython-capi --exclude rustpython_wasm --exclude rustpython-compiler-source --exclude rustpython-venvlauncher --features threading --no-default-features --features stdlib,importlib,stdio,encodings,sqlite,ssl-rustls-aws-lc,host_env Assisted-by: Codex:gpt-5.5 * Keep XML follow-up tests aligned with passing behavior The pyexpat setup API compatibility fix also makes pulldom's basic parse smoke test pass, so keep RustPython expected-failure markers aligned with current behavior and apply the ruff import-spacing fix. Constraint: Test body and assertions remain unchanged. Confidence: high Scope-risk: narrow Tested: cargo run --release -- -m test test_pulldom -v Tested: cargo run --release -- -m test test_sax Tested: cargo run --release -- -m test test_pyexpat Tested: cargo run --quiet -- extra_tests/snippets/stdlib_xml.py Tested: PATH=/tmp/pyshim:/Users/kbm/.codex/tmp/arg0/codex-arg0ejsdGi:/Users/kbm/.omx-runs/run-20260703105454-4fab/.omx/runtime/bin:/Users/kbm/.nvm/versions/node/v24.15.0/bin:/Users/kbm/Library/Java/JavaVirtualMachines/temurin-21.0.9/Contents/Home/bin:/Users/kbm/.antigravity/antigravity/bin:/opt/homebrew/opt/mysql-client/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/Library/Apple/usr/bin:/var/folders/vq/1mhmtsbs2kj_03tsx74kd1mm0000gn/T//cmux-cli-shims/93BA47BD-630C-4ED3-8573-0FE6ABFAE824:/Applications/cmux.app/Contents/Resources/bin:/Users/kbm/.nvm/versions/node/v24.15.0/bin:/Users/kbm/Library/Java/JavaVirtualMachines/temurin-21.0.9/Contents/Home/bin:/Users/kbm/.antigravity/antigravity/bin:/opt/homebrew/opt/mysql-client/bin:/var/folders/vq/1mhmtsbs2kj_03tsx74kd1mm0000gn/T/cmux-cli-shims/93BA47BD-630C-4ED3-8573-0FE6ABFAE824:/Applications/Visual Studio Code.app/Contents/Resources/app/bin:/Applications/Visual Studio Code.app/Contents/Resources/app/bin:/Applications/Visual Studio Code.app/Contents/Resources/app/bin prek run --all-files Tested: cargo build --release --features sqlite && cd extra_tests && /tmp/rustpython-extra-tests-venv/bin/python -m pytest -v Tested: PYO3_CONFIG_FILE=/Users/kbm/IdeaProjects/RustPython/crates/capi/pyo3-rustpython.config cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher Tested: cargo clippy --workspace --exclude rustpython-capi --exclude rustpython_wasm --exclude rustpython-compiler-source --exclude rustpython-venvlauncher --features threading --no-default-features --features stdlib,importlib,stdio,encodings,sqlite,ssl-rustls-aws-lc,host_env Assisted-by: Codex:gpt-5.5 --- Lib/test/test_pulldom.py | 1 - Lib/test/test_sax.py | 8 ----- crates/stdlib/src/pyexpat.rs | 35 +++++++++++++++++++++- extra_tests/snippets/stdlib_xml.py | 47 ++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 extra_tests/snippets/stdlib_xml.py diff --git a/Lib/test/test_pulldom.py b/Lib/test/test_pulldom.py index f91fa1f8a0f..435b52d33eb 100644 --- a/Lib/test/test_pulldom.py +++ b/Lib/test/test_pulldom.py @@ -24,7 +24,6 @@ class PullDOMTestCase(unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON; FileNotFoundError: [Errno 2] No such file or directory (os error 2): 'xmltestdata/test.xml' -> 'None' def test_parse(self): """Minimal test of DOMEventStream.parse()""" diff --git a/Lib/test/test_sax.py b/Lib/test/test_sax.py index faaf4dd95b6..e9e6b604d0d 100644 --- a/Lib/test/test_sax.py +++ b/Lib/test/test_sax.py @@ -190,7 +190,6 @@ def test_parse_bytes(self): with self.assertRaises(SAXException): self.check_parse(f) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing' def test_parse_path_object(self): make_xml_file(self.data, 'utf-8', None) self.check_parse(FakePath(TESTFN)) @@ -1018,7 +1017,6 @@ def test_expat_external_dtd_enabled(self): resolver.entities, [(None, 'unsupported://non-existing')] ) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing' def test_expat_external_dtd_default(self): parser = create_parser() resolver = self.TestEntityRecorder() @@ -1084,7 +1082,6 @@ def startElement(self, name, attrs): def startElementNS(self, name, qname, attrs): self._attrs = attrs - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing' def test_expat_attrs_empty(self): parser = create_parser() gather = self.AttrGatherer() @@ -1095,7 +1092,6 @@ def test_expat_attrs_empty(self): self.verify_empty_attrs(gather._attrs) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing' def test_expat_attrs_wattr(self): parser = create_parser() gather = self.AttrGatherer() @@ -1106,7 +1102,6 @@ def test_expat_attrs_wattr(self): self.verify_attrs_wattr(gather._attrs) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing' def test_expat_nsattrs_empty(self): parser = create_parser(1) gather = self.AttrGatherer() @@ -1300,7 +1295,6 @@ def test_flush_reparse_deferral_disabled(self): # ===== Locator support - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing' def test_expat_locator_noinfo(self): result = BytesIO() xmlgen = XMLGenerator(result) @@ -1315,7 +1309,6 @@ def test_expat_locator_noinfo(self): self.assertEqual(parser.getPublicId(), None) self.assertEqual(parser.getLineNumber(), 1) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing' def test_expat_locator_withinfo(self): result = BytesIO() xmlgen = XMLGenerator(result) @@ -1326,7 +1319,6 @@ def test_expat_locator_withinfo(self): self.assertEqual(parser.getSystemId(), TEST_XMLFILE) self.assertEqual(parser.getPublicId(), None) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'xmlparser' object has no attribute 'SetParamEntityParsing' @requires_nonascii_filenames def test_expat_locator_withinfo_nonascii(self): fname = os_helper.TESTFN_UNICODE diff --git a/crates/stdlib/src/pyexpat.rs b/crates/stdlib/src/pyexpat.rs index 8323a4ea106..fab4e7b5e93 100644 --- a/crates/stdlib/src/pyexpat.rs +++ b/crates/stdlib/src/pyexpat.rs @@ -43,7 +43,7 @@ mod _pyexpat { Context, Py, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, VirtualMachine, builtins::{PyBytesRef, PyException, PyModule, PyStr, PyStrRef, PyType, PyUtf8StrRef}, extend_module, - function::{ArgBytesLike, Either, IntoFuncArgs, OptionalArg}, + function::{ArgBytesLike, ArgPrimitiveIndex, Either, IntoFuncArgs, OptionalArg}, types::Constructor, }; use rustpython_common::lock::PyRwLock; @@ -70,6 +70,13 @@ mod _pyexpat { #[pyattr(name = "version_info")] pub(super) const VERSION_INFO: (u32, u32, u32) = (2, 7, 1); + #[pyattr] + const XML_PARAM_ENTITY_PARSING_NEVER: i32 = 0; + #[pyattr] + const XML_PARAM_ENTITY_PARSING_UNLESS_STANDALONE: i32 = 1; + #[pyattr] + const XML_PARAM_ENTITY_PARSING_ALWAYS: i32 = 2; + #[pyattr] #[pyattr(name = "XMLParserType")] #[pyclass(name = "xmlparser", module = false, traverse)] @@ -77,6 +84,8 @@ mod _pyexpat { pub(super) struct PyExpatLikeXmlParser { #[pytraverse(skip)] namespace_separator: Option, + #[pytraverse(skip)] + base: PyRwLock>, start_element: MutableObject, end_element: MutableObject, character_data: MutableObject, @@ -128,6 +137,7 @@ mod _pyexpat { let intern_dict = intern.unwrap_or_else(|| vm.ctx.new_dict().into()); Self { namespace_separator, + base: PyRwLock::new(None), start_element: MutableObject::new(vm.ctx.none()), end_element: MutableObject::new(vm.ctx.none()), character_data: MutableObject::new(vm.ctx.none()), @@ -297,6 +307,29 @@ mod _pyexpat { .whitespace_to_characters(true) } + #[pymethod(name = "SetParamEntityParsing")] + fn set_param_entity_parsing(&self, _flag: ArgPrimitiveIndex) -> i32 { + // Compatibility shim: xml.sax requires this setup API, but xml-rs + // does not expose Expat parameter entity parsing configuration. + 1 + } + + #[pymethod(name = "SetBase")] + fn set_base(&self, base: PyStrRef) { + // Store-only compatibility state for xml.sax locator APIs. The + // xml-rs backend still does not perform Expat-style base URI + // resolution for external entities. + *self.base.write() = Some(AsRef::::as_ref(&base).to_owned()); + } + + #[pymethod(name = "GetBase")] + fn get_base(&self, vm: &VirtualMachine) -> PyObjectRef { + self.base.read().as_ref().map_or_else( + || vm.ctx.none(), + |base| vm.ctx.new_str(base.as_str()).into(), + ) + } + /// Construct element name with namespace if separator is set fn make_name(&self, name: &xml::name::OwnedName) -> String { match (&self.namespace_separator, &name.namespace) { diff --git a/extra_tests/snippets/stdlib_xml.py b/extra_tests/snippets/stdlib_xml.py new file mode 100644 index 00000000000..268cdf45abe --- /dev/null +++ b/extra_tests/snippets/stdlib_xml.py @@ -0,0 +1,47 @@ +import xml.sax +from xml.parsers import expat + +from testutils import assert_raises + +assert expat.XML_PARAM_ENTITY_PARSING_NEVER == 0 +assert expat.XML_PARAM_ENTITY_PARSING_UNLESS_STANDALONE == 1 +assert expat.XML_PARAM_ENTITY_PARSING_ALWAYS == 2 + +parser = expat.ParserCreate() +for value in (0, 1, 2, 3, -1, True): + assert parser.SetParamEntityParsing(value) == 1 + +for value in ("x", None): + with assert_raises(TypeError): + parser.SetParamEntityParsing(value) + +with assert_raises(OverflowError): + parser.SetParamEntityParsing(2**100) + +assert parser.GetBase() is None +assert parser.SetBase("example.xml") is None +assert parser.GetBase() == "example.xml" +for value in (b"example.xml", None, 123): + with assert_raises(TypeError): + parser.SetBase(value) + + +class Handler(xml.sax.handler.ContentHandler): + def __init__(self): + self.events = [] + + def startElement(self, name, attrs): + self.events.append(("start", name)) + + def endElement(self, name): + self.events.append(("end", name)) + + +handler = Handler() +xml.sax.parseString("
", handler) +assert handler.events == [ + ("start", "main"), + ("start", "child"), + ("end", "child"), + ("end", "main"), +] From 150990e8661699eceafa54a75a97da39458191a2 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:24:49 +0900 Subject: [PATCH 100/351] literal: reject internal whitespace in complex::parse_str (#8242) parse_str split the token then parsed each part with float::parse_str, which tolerates surrounding whitespace, so "1 +2j" parsed as (1+2j). Reject whitespace inside the token after stripping optional parentheses. Add unit tests and complex() snippet tests. Assisted-by: Claude --- crates/literal/src/complex.rs | 44 +++++++++++++++++++++++++ extra_tests/snippets/builtin_complex.py | 9 +++++ 2 files changed, 53 insertions(+) diff --git a/crates/literal/src/complex.rs b/crates/literal/src/complex.rs index bbfc88cb367..20d694f8edd 100644 --- a/crates/literal/src/complex.rs +++ b/crates/literal/src/complex.rs @@ -70,6 +70,14 @@ pub fn parse_str(s: &str) -> Option<(f64, f64)> { Some(s) => s.strip_suffix(')')?.trim(), }; + // Whitespace is only allowed around the whole string and the optional + // parentheses, never inside the numeric token. Reject it here so that + // `float::parse_str` (which tolerates surrounding whitespace on a part) + // does not let e.g. "1 +2j" through. + if s.contains(char::is_whitespace) { + return None; + } + let value = match s.strip_suffix(|c| c == 'j' || c == 'J') { None => (float::parse_str(s)?, 0.0), Some(mut s) => { @@ -96,3 +104,39 @@ pub fn parse_str(s: &str) -> Option<(f64, f64)> { }; Some(value) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_rejects_internal_whitespace() { + // Whitespace inside the numeric token is invalid, even where a bare + // `float::parse_str` on a fragment would tolerate it. + for s in [ + "1 +2j", "1 2j", "1 +2 j", "+ 1j", "1.5 j", "(1 +2j)", "2 -3j", + ] { + assert_eq!(parse_str(s), None, "{s:?} must not parse"); + } + } + + #[test] + fn parse_allows_surrounding_and_paren_whitespace() { + for s in [" 1+2j ", " (1+2j) ", "( 1+2j )"] { + assert_eq!(parse_str(s), Some((1.0, 2.0)), "{s:?}"); + } + } + + #[test] + fn parse_basic() { + assert_eq!(parse_str("1"), Some((1.0, 0.0))); + assert_eq!(parse_str("1j"), Some((0.0, 1.0))); + assert_eq!(parse_str("j"), Some((0.0, 1.0))); + assert_eq!(parse_str("-j"), Some((0.0, -1.0))); + assert_eq!(parse_str("1+2j"), Some((1.0, 2.0))); + assert_eq!(parse_str("1e5j"), Some((0.0, 1e5))); + assert_eq!(parse_str("1_000"), Some((1000.0, 0.0))); + assert_eq!(parse_str(""), None); + assert_eq!(parse_str("abc"), None); + } +} diff --git a/extra_tests/snippets/builtin_complex.py b/extra_tests/snippets/builtin_complex.py index 136f26ef001..55ab38c887b 100644 --- a/extra_tests/snippets/builtin_complex.py +++ b/extra_tests/snippets/builtin_complex.py @@ -168,6 +168,15 @@ def __eq__(self, other): assert_raises(TypeError, lambda: complex("5+2j", 1)) assert_raises(ValueError, lambda: complex("abc")) +# whitespace is allowed around the string and the optional parentheses, +# but not inside the numeric token +assert complex(" 1+2j ") == 1 + 2j +assert complex("(1+2j)") == 1 + 2j +assert complex(" ( 1+2j ) ") == 1 + 2j +assert_raises(ValueError, lambda: complex("1 +2j")) +assert_raises(ValueError, lambda: complex("1+ 2j")) +assert_raises(ValueError, lambda: complex("1 + 2j")) + assert complex("1+10j") == 1 + 10j assert complex(10) == 10 + 0j assert complex(10.0) == 10 + 0j From 238bed66fbb0102a83dd9b5c8244e0276fbe8eac Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:42:08 +0900 Subject: [PATCH 101/351] literal: round-half-even tie for normal-range float/complex repr (#8243) to_string's normal-magnitude branch and complex::component_to_string returned Rust's shortest formatting directly, which can pick the odd-digit neighbour of a rounding tie where repr() picks the even one (e.g. bits 0x42e26687db6b9b04 formats as 161852602146008.13 vs repr's ...08.12). Route both through prefer_cpython_tie_repr, generalized to fixed-notation strings. Add unit tests and float/complex repr snippet tests. Assisted-by: Claude --- crates/literal/src/complex.rs | 4 +-- crates/literal/src/float.rs | 39 ++++++++++++++++++------- extra_tests/snippets/builtin_complex.py | 7 +++++ extra_tests/snippets/builtin_float.py | 12 ++++++++ 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/crates/literal/src/complex.rs b/crates/literal/src/complex.rs index 20d694f8edd..bc193f0f204 100644 --- a/crates/literal/src/complex.rs +++ b/crates/literal/src/complex.rs @@ -19,9 +19,9 @@ fn component_to_string(value: f64) -> String { if exponent < 16 && exponent > -5 { // Normal magnitude — Rust's default Display emits "1" for 1.0, // "1.5" for 1.5, "1000000000000000" for 1e15, etc. - value.to_string() + float::prefer_cpython_tie_repr(value.to_string(), value) } else { - alloc::format!("{significand}e{exponent:+#03}") + float::prefer_cpython_tie_repr(alloc::format!("{significand}e{exponent:+#03}"), value) } } else { // nan / inf / -inf — `format!("{x:e}")` produces e.g. "NaN" with no diff --git a/crates/literal/src/float.rs b/crates/literal/src/float.rs index 56a2b542993..2db4fd084a4 100644 --- a/crates/literal/src/float.rs +++ b/crates/literal/src/float.rs @@ -209,11 +209,13 @@ pub fn format_general( } } -fn prefer_cpython_tie_repr(s: String, value: f64) -> String { - let Some(exponent_pos) = s.find('e') else { - return s; - }; - let Some(digit_pos) = s[..exponent_pos].bytes().rposition(|b| b.is_ascii_digit()) else { +pub(crate) fn prefer_cpython_tie_repr(s: String, value: f64) -> String { + // Rust's shortest float formatter can land on the odd-digit neighbour of a + // rounding tie where round-half-to-even (what `repr` uses) picks the even + // one. When the last significant digit is odd and its even neighbour still + // round-trips and is no further from the value, prefer the even neighbour. + let boundary = s.find('e').unwrap_or(s.len()); + let Some(digit_pos) = s[..boundary].bytes().rposition(|b| b.is_ascii_digit()) else { return s; }; @@ -258,11 +260,11 @@ fn checked_pow_u128(base: u128, exp: u32) -> Option { } fn parse_decimal_rational(s: &str) -> Option<(u128, u32)> { - let exponent_pos = s.find('e')?; - let exponent = s[exponent_pos + 1..].parse::().ok()?; - let significand = s[..exponent_pos] - .strip_prefix('-') - .unwrap_or(&s[..exponent_pos]); + let (mantissa, exponent) = match s.find('e') { + Some(pos) => (&s[..pos], s[pos + 1..].parse::().ok()?), + None => (s, 0), + }; + let significand = mantissa.strip_prefix('-').unwrap_or(mantissa); let dot_pos = significand.find('.'); let frac_digits = dot_pos .map(|pos| significand.len().saturating_sub(pos + 1)) @@ -325,7 +327,7 @@ pub fn to_string(value: f64) -> String { if is_integer(value) { format!("{value:.1?}") } else { - value.to_string() + prefer_cpython_tie_repr(value.to_string(), value) } } else { prefer_cpython_tie_repr(format!("{significand}e{exponent:+#03}"), value) @@ -351,6 +353,21 @@ mod tests { "6.1005353927612305e-05" ); } + + #[test] + fn repr_normal_range_uses_cpython_tie_digit() { + // Rust's shortest formatter yields "161852602146008.13" for this + // value; round-half-to-even (what `repr` uses) picks "…08.12". + assert_eq!( + to_string(f64::from_bits(0x42e26687db6b9b04)), + "161852602146008.12" + ); + // Non-tie values are left untouched. + assert_eq!(to_string(1.5), "1.5"); + assert_eq!(to_string(0.1), "0.1"); + assert_eq!(to_string(12.34), "12.34"); + assert_eq!(to_string(100.0), "100.0"); + } } pub fn from_hex(s: &str) -> Option { diff --git a/extra_tests/snippets/builtin_complex.py b/extra_tests/snippets/builtin_complex.py index 55ab38c887b..ae84cac53e6 100644 --- a/extra_tests/snippets/builtin_complex.py +++ b/extra_tests/snippets/builtin_complex.py @@ -277,3 +277,10 @@ class complex_subclass(complex): assert repr(float("-inf") + 1j) == "(-inf+1j)" assert repr(complex(1, float("nan"))) == "(1+nanj)" assert repr(complex(1, float("inf"))) == "(1+infj)" + +# Round-half-to-even ties: Rust's shortest formatter can land on the +# odd-digit neighbour where repr()'s tie-breaking picks the even one. +assert repr(161852602146008.12 + 1j) == "(161852602146008.12+1j)" +assert repr(-788830060729777.2 + 2j) == "(-788830060729777.2+2j)" +assert repr(complex(0.0, 1959276370239205.2)) == "1959276370239205.2j" +assert repr(complex(-1818262230632059.2, 0.0)) == "(-1818262230632059.2+0j)" diff --git a/extra_tests/snippets/builtin_float.py b/extra_tests/snippets/builtin_float.py index f0fcae5d103..1417c5ae174 100644 --- a/extra_tests/snippets/builtin_float.py +++ b/extra_tests/snippets/builtin_float.py @@ -549,3 +549,15 @@ def _check_msg(call, exc_type, expected_msg): lambda: INF.__int__(), OverflowError, "cannot convert float infinity to integer" ) _check_msg(lambda: NAN.__floor__(), ValueError, "cannot convert float NaN to integer") + +# repr round-half-to-even ties: Rust's shortest formatter can land on the +# odd-digit neighbour where repr()'s tie-breaking picks the even one. +assert repr(161852602146008.12) == "161852602146008.12" +assert repr(-788830060729777.2) == "-788830060729777.2" +assert repr(1959276370239205.2) == "1959276370239205.2" +assert repr(-1818262230632059.2) == "-1818262230632059.2" +assert str(161852602146008.12) == "161852602146008.12" +# non-tie values are unaffected +assert repr(1.5) == "1.5" +assert repr(0.1) == "0.1" +assert repr(100.0) == "100.0" From 9c064c1dcbc018bde16646a6caa7cc33fe66f862 Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:58:32 -0400 Subject: [PATCH 102/351] host_env: os.replace for Windows (#8212) `rename` and `replace` are the same for Unixes but different for Windows in Python. POSIX's `rename` atomically replaces its target; there is no `replace` in POSIX. `renameat2` with `RENAME_NOREPLACE` atomically checks if a file exists and renames if it doesn't, but Python's `rename` and `replace` predate `renameat2`. For Windows, `rename` acts like the `RENAME_NOREPLACE` flag while `replace` functions like `rename`. I implemented both using the Windows API. Both implementations follow CPython's code by using `MoveFileExW`. There are modern Windows APIs that may be worth using in the future. Rust's standard library prefers the modern APIs but falls back to `MoveFileExW` for deprecated Windows versions. --- crates/host_env/src/lib.rs | 2 + crates/host_env/src/os.rs | 37 -------- crates/host_env/src/posix.rs | 12 +-- crates/host_env/src/posix_unix_like.rs | 47 ++++++++++ crates/host_env/src/posix_wasi.rs | 11 +-- crates/host_env/src/posix_windows.rs | 85 +++++++++++++++++- crates/vm/src/stdlib/os.rs | 118 ++++++++++++++++++------- 7 files changed, 220 insertions(+), 92 deletions(-) create mode 100644 crates/host_env/src/posix_unix_like.rs diff --git a/crates/host_env/src/lib.rs b/crates/host_env/src/lib.rs index 4d123e7e0db..975ca21b626 100644 --- a/crates/host_env/src/lib.rs +++ b/crates/host_env/src/lib.rs @@ -56,6 +56,8 @@ pub mod posix; #[cfg(windows)] #[path = "posix_windows.rs"] pub mod posix; +#[cfg(any(unix, target_os = "wasi"))] +pub mod posix_unix_like; #[cfg(unix)] pub mod pwd; #[cfg(unix)] diff --git a/crates/host_env/src/os.rs b/crates/host_env/src/os.rs index bd2a5acb906..7d11d7bbec8 100644 --- a/crates/host_env/src/os.rs +++ b/crates/host_env/src/os.rs @@ -9,8 +9,6 @@ use core::ffi::CStr; use core::str::Utf8Error; #[cfg(windows)] use core::time::Duration; -#[cfg(unix)] -use rustix::fd::AsFd; use std::{ env, ffi::{OsStr, OsString}, @@ -267,41 +265,6 @@ pub fn copy_file_range( rustix::fs::copy_file_range(src, offset_src, dst, offset_dst, count) } -#[cfg(not(unix))] -pub fn rename( - from: impl AsRef, - from_fd: Option>, - to: impl AsRef, - to_fd: Option>, -) -> io::Result<()> { - if from_fd.is_none() && to_fd.is_none() { - // TODO: Rust's implementation always overwrites the file so ensure consistency between - // operating systems. We need to use windows-sys directly to distinguish between - // os.rename and os.replace. - std::fs::rename(from, to) - } else { - core::hint::cold_path(); - Err(io::Error::new( - io::ErrorKind::Unsupported, - "renameat is not available on this platform", - )) - } -} - -#[cfg(unix)] -pub fn rename( - from: impl AsRef, - from_fd: Option>, - to: impl AsRef, - to_fd: Option>, -) -> io::Result<()> { - let from = from.as_ref(); - let from_fd = from_fd.as_ref().map_or(rustix::fs::CWD, AsFd::as_fd); - let to = to.as_ref(); - let to_fd = to_fd.as_ref().map_or(rustix::fs::CWD, AsFd::as_fd); - rustix::fs::renameat(from_fd, from, to_fd, to).map_err(Into::into) -} - #[cfg(windows)] pub fn seek_fd( fd: crt_fd::Borrowed<'_>, diff --git a/crates/host_env/src/posix.rs b/crates/host_env/src/posix.rs index f15dc576d47..60b55ef42ce 100644 --- a/crates/host_env/src/posix.rs +++ b/crates/host_env/src/posix.rs @@ -10,7 +10,7 @@ use std::os::fd::FromRawFd; use std::os::fd::{AsFd, AsRawFd, BorrowedFd, IntoRawFd, OwnedFd}; use std::path::Path; -use crate::crt_fd; +pub use super::posix_unix_like::*; pub use libc::{c_char, pid_t}; @@ -178,16 +178,6 @@ pub fn fcopyfile(in_fd: i32, out_fd: i32, flags: u32) -> std::io::Result<()> { } } -#[cfg(any(unix, target_os = "wasi"))] -pub fn make_dir( - dir_fd: Option>, - path: &impl AsRef, - mode: libc::mode_t, -) -> std::io::Result<()> { - let dir_fd = dir_fd.as_ref().map_or(rustix::fs::CWD, AsFd::as_fd); - rustix::fs::mkdirat(dir_fd, path.as_ref(), mode.into()).map_err(Into::into) -} - #[cfg(unix)] pub fn link_paths(src: &CStr, dst: &CStr, follow_symlinks: bool) -> std::io::Result<()> { let flags = if follow_symlinks { diff --git a/crates/host_env/src/posix_unix_like.rs b/crates/host_env/src/posix_unix_like.rs new file mode 100644 index 00000000000..9bb29c41c84 --- /dev/null +++ b/crates/host_env/src/posix_unix_like.rs @@ -0,0 +1,47 @@ +//! Common POSIX implementations across Unix-likes. + +use std::{io, path::Path}; + +use rustix::{fd::AsFd, fs}; + +pub use rustix::fs::RawMode; + +use crate::crt_fd; + +/// https://pubs.opengroup.org/onlinepubs/9799919799/functions/mkdir.html +pub fn make_dir( + dir_fd: Option>, + path: impl AsRef, + mode: fs::RawMode, +) -> io::Result<()> { + let dir_fd = dir_fd.as_ref().map_or(fs::CWD, AsFd::as_fd); + fs::mkdirat(dir_fd, path.as_ref(), mode.into()).map_err(Into::into) +} + +/// https://pubs.opengroup.org/onlinepubs/9799919799/functions/rename.html +pub fn rename( + from: impl AsRef, + from_fd: Option>, + to: impl AsRef, + to_fd: Option>, +) -> io::Result<()> { + let from = from.as_ref(); + let from_fd = from_fd.as_ref().map_or(fs::CWD, AsFd::as_fd); + let to = to.as_ref(); + let to_fd = to_fd.as_ref().map_or(fs::CWD, AsFd::as_fd); + fs::renameat(from_fd, from, to_fd, to).map_err(Into::into) +} + +/// https://docs.python.org/3/library/os.html#os.replace +/// +/// Atomically replace `to` with `from`. +/// POSIX's rename already atomically replaces targets, so this function just forwards to [`rename`]. +#[inline] +pub fn replace( + from: impl AsRef, + from_fd: Option>, + to: impl AsRef, + to_fd: Option>, +) -> io::Result<()> { + rename(from, from_fd, to, to_fd) +} diff --git a/crates/host_env/src/posix_wasi.rs b/crates/host_env/src/posix_wasi.rs index f1883fccd58..23c3be2fb91 100644 --- a/crates/host_env/src/posix_wasi.rs +++ b/crates/host_env/src/posix_wasi.rs @@ -3,16 +3,9 @@ use core::{ffi::CStr, time::Duration}; use rustix::fd::AsFd; use std::{ffi::OsStr, io, path::Path}; -use crate::{crt_fd, os::CheckLibcResult}; +pub use super::posix_unix_like::*; -pub fn make_dir( - dir_fd: Option>, - path: &impl AsRef, - mode: libc::mode_t, -) -> std::io::Result<()> { - let dir_fd = dir_fd.as_ref().map_or(rustix::fs::CWD, AsFd::as_fd); - rustix::fs::mkdirat(dir_fd, path.as_ref(), mode.into()).map_err(Into::into) -} +use crate::{crt_fd, os::CheckLibcResult}; pub fn remove_dir_at(dir_fd: i32, path: &CStr) -> io::Result<()> { unsafe { libc::unlinkat(dir_fd, path.as_ptr(), libc::AT_REMOVEDIR) }.check_libc_neg()?; diff --git a/crates/host_env/src/posix_windows.rs b/crates/host_env/src/posix_windows.rs index ac9e2cfa808..e78bd8f743f 100644 --- a/crates/host_env/src/posix_windows.rs +++ b/crates/host_env/src/posix_windows.rs @@ -4,19 +4,96 @@ //! these syscalls, but they can be emulated with a mix of the Windows API and the Rust standard //! library, the latter of which calls the former. +use core::hint::cold_path; use std::{fs, io, path::Path}; +use widestring::WideCString; +use windows_sys::Win32::{ + Foundation::FALSE, + Storage::FileSystem::{MOVE_FILE_FLAGS, MOVEFILE_REPLACE_EXISTING, MoveFileExW}, +}; + use crate::crt_fd; -#[expect(non_camel_case_types)] -pub type mode_t = u32; +pub type RawMode = u32; pub fn make_dir( dir_fd: Option>, - path: &impl AsRef, - _mode: mode_t, + path: impl AsRef, + _mode: RawMode, ) -> io::Result<()> { debug_assert!(dir_fd.is_none()); // TODO: On Windows, Python has an override if the mode is 0o700 fs::create_dir(path) } + +/// https://pubs.opengroup.org/onlinepubs/9799919799/functions/rename.html +#[inline] +pub fn rename( + from: impl AsRef, + #[cfg_attr(not(debug_assertions), expect(unused_variables))] from_fd: Option< + crt_fd::Borrowed<'_>, + >, + to: impl AsRef, + #[cfg_attr(not(debug_assertions), expect(unused_variables))] to_fd: Option< + crt_fd::Borrowed<'_>, + >, +) -> io::Result<()> { + debug_assert!(from_fd.is_none()); + debug_assert!(to_fd.is_none()); + + rename_impl(from, to, 0) +} + +/// https://docs.python.org/3/library/os.html#os.replace +/// +/// Atomically replace `to` with `from`. +#[inline] +pub fn replace( + from: impl AsRef, + #[cfg_attr(not(debug_assertions), expect(unused_variables))] from_fd: Option< + crt_fd::Borrowed<'_>, + >, + to: impl AsRef, + #[cfg_attr(not(debug_assertions), expect(unused_variables))] to_fd: Option< + crt_fd::Borrowed<'_>, + >, +) -> io::Result<()> { + debug_assert!(from_fd.is_none()); + debug_assert!(to_fd.is_none()); + + rename_impl(from, to, MOVEFILE_REPLACE_EXISTING) +} + +fn rename_impl( + from: impl AsRef, + to: impl AsRef, + flags: MOVE_FILE_FLAGS, +) -> io::Result<()> { + let from = WideCString::from_os_str(from.as_ref()) + .map_err(io::Error::other)? + .into_vec_with_nul(); + let to = WideCString::from_os_str(to.as_ref()) + .map_err(io::Error::other)? + .into_vec_with_nul(); + + // SAFETY: + // * from and to are NUL terminated wide strings + let success = unsafe { + // Rust's [`std::fs::rename`] is more complicated than CPython's. Rust attempts to use modern APIs + // where available, such as `FileRenameInfoEx`, which better map to POSIX. CPython simply + // calls MoveFileExW so we'll do that for parity. However, it may be better to use the new + // APIs and fall back if possible, especially if they're faster. + // + // Unlike POSIX's rename, MoveFileExW does not automatically move between volumes. + // This is expected behavior in CPython. + MoveFileExW(from.as_ptr(), to.as_ptr(), flags) + }; + + if success != FALSE { + Ok(()) + } else { + cold_path(); + Err(io::Error::last_os_error()) + } +} diff --git a/crates/vm/src/stdlib/os.rs b/crates/vm/src/stdlib/os.rs index 94dd19f79d6..e6aea44c399 100644 --- a/crates/vm/src/stdlib/os.rs +++ b/crates/vm/src/stdlib/os.rs @@ -6,16 +6,11 @@ use crate::{ builtins::{PyModule, PySet}, convert::{IntoPyException, ToPyException, ToPyObject}, function::{ArgumentError, FromArgs, FuncArgs}, - host_env::crt_fd, + host_env::{crt_fd, posix::RawMode}, }; +use core::marker::PhantomData; use std::{io, path::Path}; -#[cfg(not(windows))] -use libc::mode_t; - -#[cfg(windows)] -use crate::host_env::posix::mode_t; - pub(crate) fn fs_metadata>( path: P, follow_symlink: bool, @@ -45,19 +40,44 @@ cfg_select! { const DEFAULT_DIR_FD: crt_fd::Borrowed<'static> = unsafe { crt_fd::Borrowed::borrow_raw(AT_FDCWD) }; +pub trait DirFdKeyword: Clone + Copy + Eq + PartialEq { + const NAME: &'static str; +} + +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct DefaultDirFd; +impl DirFdKeyword for DefaultDirFd { + const NAME: &'static str = "dir_fd"; +} + +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct SrcDirFd; +impl DirFdKeyword for SrcDirFd { + const NAME: &'static str = "src_dir_fd"; +} + +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct DstDirFd; +impl DirFdKeyword for DstDirFd { + const NAME: &'static str = "dst_dir_fd"; +} + // XXX: AVAILABLE should be a bool, but we can't yet have it as a bool and just cast it to usize -#[derive(Copy, Clone, PartialEq, Eq)] -pub struct DirFd<'fd, const AVAILABLE: usize>(pub(crate) [crt_fd::Borrowed<'fd>; AVAILABLE]); +#[derive(Clone, Copy, Eq, PartialEq)] +pub struct DirFd<'fd, const AVAILABLE: usize, KW: DirFdKeyword = DefaultDirFd>( + pub(crate) [crt_fd::Borrowed<'fd>; AVAILABLE], + PhantomData, +); -impl Default for DirFd<'_, AVAILABLE> { +impl Default for DirFd<'_, AVAILABLE, KW> { fn default() -> Self { - Self([DEFAULT_DIR_FD; AVAILABLE]) + Self([DEFAULT_DIR_FD; AVAILABLE], PhantomData) } } // not used on all platforms #[allow(unused)] -impl<'fd> DirFd<'fd, 1> { +impl<'fd, KW: DirFdKeyword> DirFd<'fd, 1, KW> { #[inline(always)] pub(crate) fn get_opt(self) -> Option> { let [fd] = self.0; @@ -76,9 +96,9 @@ impl<'fd> DirFd<'fd, 1> { } } -impl FromArgs for DirFd<'_, AVAILABLE> { +impl FromArgs for DirFd<'_, AVAILABLE, KW> { fn from_args(vm: &VirtualMachine, args: &mut FuncArgs) -> Result { - let fd = match args.take_keyword("dir_fd") { + let fd = match args.take_keyword(KW::NAME) { Some(o) if vm.is_none(&o) => Ok(DEFAULT_DIR_FD), None => Ok(DEFAULT_DIR_FD), Some(o) => { @@ -99,7 +119,7 @@ impl FromArgs for DirFd<'_, AVAILABLE> { .into()); } let fd = fd.map_err(|e| e.to_pyexception(vm))?; - Ok(Self([fd; AVAILABLE])) + Ok(Self([fd; AVAILABLE], PhantomData)) } } @@ -160,7 +180,7 @@ impl ToPyObject for crt_fd::Borrowed<'_> { #[pymodule(sub)] pub(super) mod _os { - use super::{DirFd, FollowSymlinks, SupportFunc, mode_t}; + use super::{DirFd, DstDirFd, FollowSymlinks, RawMode, SrcDirFd, SupportFunc}; use crate::host_env::fileutils::StatStruct; #[cfg(any(unix, windows))] use crate::utils::ToCString; @@ -180,6 +200,8 @@ pub(super) mod _os { types::{Destructor, IterNext, Iterable, PyStructSequence, Representable, SelfIter}, vm::VirtualMachine, }; + #[cfg(not(windows))] + use core::marker::PhantomData; use core::time::Duration; use crossbeam_utils::atomic::AtomicCell; use rustpython_common::wtf8::Wtf8Buf; @@ -195,7 +217,7 @@ pub(super) mod _os { const UTIME_DIR_FD: bool = cfg!(not(any(windows, target_os = "redox"))); pub(crate) const SYMLINK_DIR_FD: bool = cfg!(not(any(windows, target_os = "redox"))); pub(crate) const UNLINK_DIR_FD: bool = cfg!(not(any(windows, target_os = "redox"))); - const RENAME_DIR_FD: bool = cfg!(unix); + const RENAME_DIR_FD: bool = cfg!(any(unix, target_os = "wasi")); const RMDIR_DIR_FD: bool = cfg!(not(any(windows, target_os = "redox"))); const SCANDIR_FD: bool = cfg!(all(unix, not(target_os = "redox"))); @@ -347,7 +369,7 @@ pub(super) mod _os { #[pyfunction] fn mkdir( path: OsPath, - mode: OptionalArg, + mode: OptionalArg, #[cfg_attr(not(any(unix, target_os = "wasi")), expect(unused_variables))] dir_fd: DirFd< '_, { MKDIR_DIR_FD as usize }, @@ -625,7 +647,7 @@ pub(super) mod _os { // Safety: the fd came from os.open() and is borrowed for // the lifetime of this DirEntry reference. let borrowed = unsafe { crt_fd::Borrowed::borrow_raw(raw_fd) }; - return DirFd([borrowed; STAT_DIR_FD as usize]); + return DirFd([borrowed; STAT_DIR_FD as usize], PhantomData); } DirFd::default() } @@ -1382,14 +1404,15 @@ pub(super) mod _os { src: PyObjectRef, #[pyarg(positional)] dst: PyObjectRef, - #[pyarg(any, default)] - src_dir_fd: OptionalArg>, - #[pyarg(any, default)] - dst_dir_fd: OptionalArg>, + #[pyarg(flatten)] + #[cfg_attr(not(any(unix, target_os = "wasi")), expect(dead_code))] + src_dir_fd: DirFd<'fd, { RENAME_DIR_FD as usize }, SrcDirFd>, + #[pyarg(flatten)] + #[cfg_attr(not(any(unix, target_os = "wasi")), expect(dead_code))] + dst_dir_fd: DirFd<'fd, { RENAME_DIR_FD as usize }, DstDirFd>, } #[pyfunction] - #[pyfunction(name = "replace")] fn rename(args: RenameArgs<'_>, vm: &VirtualMachine) -> PyResult<()> { let src = PathConverter::new() .function("rename") @@ -1400,13 +1423,46 @@ pub(super) mod _os { .argument("dst") .try_path(args.dst, vm)?; - crate::host_env::os::rename( - &src, - args.src_dir_fd.into_option(), - &dst, - args.dst_dir_fd.into_option(), - ) - .map_err(|err| { + #[cfg(any(unix, target_os = "wasi"))] + let src_dir_fd = args.src_dir_fd.get_opt(); + #[cfg(not(any(unix, target_os = "wasi")))] + let src_dir_fd = None; + + #[cfg(any(unix, target_os = "wasi"))] + let dst_dir_fd = args.dst_dir_fd.get_opt(); + #[cfg(not(any(unix, target_os = "wasi")))] + let dst_dir_fd = None; + + crate::host_env::posix::rename(&src, src_dir_fd, &dst, dst_dir_fd).map_err(|err| { + let builder = err.to_os_error_builder(vm); + let builder = builder.filename(src.filename(vm)); + let builder = builder.filename2(dst.filename(vm)); + builder.build(vm).upcast() + }) + } + + #[pyfunction] + fn replace(args: RenameArgs<'_>, vm: &VirtualMachine) -> PyResult<()> { + let src = PathConverter::new() + .function("replace") + .argument("src") + .try_path(args.src, vm)?; + let dst = PathConverter::new() + .function("replace") + .argument("dst") + .try_path(args.dst, vm)?; + + #[cfg(any(unix, target_os = "wasi"))] + let src_dir_fd = args.src_dir_fd.get_opt(); + #[cfg(not(any(unix, target_os = "wasi")))] + let src_dir_fd = None; + + #[cfg(any(unix, target_os = "wasi"))] + let dst_dir_fd = args.dst_dir_fd.get_opt(); + #[cfg(not(any(unix, target_os = "wasi")))] + let dst_dir_fd = None; + + crate::host_env::posix::replace(&src, src_dir_fd, &dst, dst_dir_fd).map_err(|err| { let builder = err.to_os_error_builder(vm); let builder = builder.filename(src.filename(vm)); let builder = builder.filename2(dst.filename(vm)); From 492e41cfbbd69495fca728f555fa73ddb36bb672 Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:35:00 +0200 Subject: [PATCH 103/351] Fix clippy warning (#8249) * Fix clippy warning * Fix clippy warnings surfaced by newer clippy - exceptions.rs: {b:#02x} -> {b:#04x} (unused_format_specs); output unchanged for decode-error bytes (always >= 0x80) - binascii.rs: [b'\r', b'\n'] -> b"\r\n" (byte_str_slice) - host_env, _io.rs, ssl.rs, pyexpat.rs: expect std_instead_of_core where the suggested core::io items (ErrorKind, Cursor) are unstable (core_io); use expect so the suppression is flagged for removal once core::io stabilizes. build_posix_spawn_attrs co-gates the expect with the cfg block so it is not left unfulfilled on platforms compiling it out. Assisted-by: Claude * Fix more --------- Co-authored-by: Jeong YunWon --- crates/codegen/src/compile.rs | 22 +---- crates/codegen/src/symboltable.rs | 100 ++++------------------- crates/derive-impl/src/util.rs | 2 +- crates/host_env/src/fileutils.rs | 4 + crates/host_env/src/posix.rs | 8 ++ crates/stdlib/src/binascii.rs | 2 +- crates/stdlib/src/pyexpat.rs | 3 + crates/stdlib/src/ssl.rs | 3 + crates/vm/src/builtins/builtin_func.rs | 4 +- crates/vm/src/builtins/descriptor.rs | 4 +- crates/vm/src/builtins/memory.rs | 2 +- crates/vm/src/builtins/super.rs | 2 +- crates/vm/src/builtins/type.rs | 10 +-- crates/vm/src/exceptions.rs | 2 +- crates/vm/src/frame.rs | 17 ++-- crates/vm/src/object/core.rs | 2 +- crates/vm/src/object/payload.rs | 2 +- crates/vm/src/stdlib/_ast/argument.rs | 1 - crates/vm/src/stdlib/_ctypes/function.rs | 2 +- crates/vm/src/stdlib/_io.rs | 6 +- crates/vm/src/stdlib/_signal.rs | 4 + crates/vm/src/stdlib/_sysconfigdata.rs | 2 +- crates/vm/src/vm/vm_ops.rs | 2 +- src/lib.rs | 2 +- 24 files changed, 73 insertions(+), 135 deletions(-) diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index 0663375d339..ba2d41f12b3 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -1752,15 +1752,13 @@ impl<'warnings> Compiler<'warnings> { // Check if __class__ is available as a cell/free variable // The scope must be Free (from enclosing class) or have DEF_FREE_CLASS flag - if let Some(symbol) = table.lookup("__class__") { + { + let symbol = table.lookup("__class__")?; if symbol.scope != SymbolScope::Free && !symbol.flags.contains(SymbolFlags::DEF_FREE_CLASS) { return None; } - } else { - // __class__ not in symbol table, optimization not possible - return None; } Some(SuperCallType::ZeroArg) @@ -8260,12 +8258,7 @@ impl<'warnings> Compiler<'warnings> { self.compile_name(id, NameUsage::Load)?; AugAssignKind::Name { id } } - ast::Expr::Subscript(ast::ExprSubscript { - value, - slice, - ctx: _, - .. - }) => { + ast::Expr::Subscript(ast::ExprSubscript { value, slice, .. }) => { let use_slice_opt = self.should_apply_two_element_slice_optimization(slice); self.compile_expression(value)?; self.set_source_range(target_range); @@ -9225,14 +9218,7 @@ impl<'warnings> Compiler<'warnings> { let ast::Expr::Name(ast::ExprName { id, .. }) = func else { return None; }; - let [ - ast::Expr::Generator(ast::ExprGenerator { - elt: _, - generators: _, - .. - }), - ] = &args.args[..] - else { + let [ast::Expr::Generator(ast::ExprGenerator { .. })] = &args.args[..] else { return None; }; if !args.keywords.is_empty() || { diff --git a/crates/codegen/src/symboltable.rs b/crates/codegen/src/symboltable.rs index b1aadbe2c5d..bafd065ef93 100644 --- a/crates/codegen/src/symboltable.rs +++ b/crates/codegen/src/symboltable.rs @@ -1623,7 +1623,6 @@ impl SymbolTableBuilder { decorator_list, type_params, range, - node_index: _, .. }) => { let prev_class = self.class_name.clone(); @@ -1819,7 +1818,6 @@ impl SymbolTableBuilder { value, simple, range, - node_index: _, .. }) => { self.tables.last_mut().unwrap().annotations_used = true; @@ -2141,35 +2139,20 @@ impl SymbolTableBuilder { } match expression { - Expr::BinOp(ExprBinOp { - left, - right, - range: _, - .. - }) => { + Expr::BinOp(ExprBinOp { left, right, .. }) => { self.scan_expression(left, context)?; self.scan_expression(right, context)?; } - Expr::BoolOp(ExprBoolOp { - values, range: _, .. - }) => { + Expr::BoolOp(ExprBoolOp { values, .. }) => { self.scan_expressions(values, context)?; } Expr::Compare(ExprCompare { - left, - comparators, - range: _, - .. + left, comparators, .. }) => { self.scan_expression(left, context)?; self.scan_expressions(comparators, context)?; } - Expr::Subscript(ExprSubscript { - value, - slice, - range: _, - .. - }) => { + Expr::Subscript(ExprSubscript { value, slice, .. }) => { self.scan_expression(value, ExpressionContext::Load)?; self.scan_expression(slice, ExpressionContext::Load)?; } @@ -2179,12 +2162,7 @@ impl SymbolTableBuilder { self.check_name(attr.as_str(), context, *range)?; self.scan_expression(value, ExpressionContext::Load)?; } - Expr::Dict(ExprDict { - items, - node_index: _, - range: _, - .. - }) => { + Expr::Dict(ExprDict { items, .. }) => { for item in items { if let Some(key) = &item.key { self.scan_expression(key, context)?; @@ -2194,12 +2172,7 @@ impl SymbolTableBuilder { self.scan_expression(&item.value, context)?; } } - Expr::Await(ExprAwait { - value, - node_index: _, - range: _, - .. - }) => { + Expr::Await(ExprAwait { value, .. }) => { let current_scope = self.tables.last().unwrap().typ; if !self.allows_top_level_await() && !Self::is_function_like_scope(current_scope) @@ -2227,12 +2200,7 @@ impl SymbolTableBuilder { self.scan_expression(value, context)?; self.tables.last_mut().unwrap().is_coroutine = true; } - Expr::Yield(ExprYield { - value, - node_index: _, - range: _, - .. - }) => { + Expr::Yield(ExprYield { value, .. }) => { if let Some(expression) = value { self.scan_expression(expression, context)?; } @@ -2252,12 +2220,7 @@ impl SymbolTableBuilder { }); } } - Expr::YieldFrom(ExprYieldFrom { - value, - node_index: _, - range: _, - .. - }) => { + Expr::YieldFrom(ExprYieldFrom { value, .. }) => { self.scan_expression(value, context)?; self.tables.last_mut().unwrap().is_generator = true; if let Some(context_name) = self.comprehension_yield_context @@ -2275,28 +2238,19 @@ impl SymbolTableBuilder { }); } } - Expr::UnaryOp(ExprUnaryOp { - operand, range: _, .. - }) => { + Expr::UnaryOp(ExprUnaryOp { operand, .. }) => { self.scan_expression(operand, context)?; } - Expr::Starred(ExprStarred { - value, range: _, .. - }) => { + Expr::Starred(ExprStarred { value, .. }) => { self.scan_expression(value, context)?; } - Expr::Tuple(ExprTuple { elts, range: _, .. }) - | Expr::Set(ExprSet { elts, range: _, .. }) - | Expr::List(ExprList { elts, range: _, .. }) => { + Expr::Tuple(ExprTuple { elts, .. }) + | Expr::Set(ExprSet { elts, .. }) + | Expr::List(ExprList { elts, .. }) => { self.scan_expressions(elts, context)?; } Expr::Slice(ExprSlice { - lower, - upper, - step, - node_index: _, - range: _, - .. + lower, upper, step, .. }) => { if let Some(lower) = lower { self.scan_expression(lower, context)?; @@ -2326,7 +2280,6 @@ impl SymbolTableBuilder { elt, generators, range, - node_index: _, .. }) => { let was_in_iter_def_exp = self.in_iter_def_exp; @@ -2341,7 +2294,6 @@ impl SymbolTableBuilder { elt, generators, range, - node_index: _, .. }) => { let was_in_iter_def_exp = self.in_iter_def_exp; @@ -2357,7 +2309,6 @@ impl SymbolTableBuilder { value, generators, range, - node_index: _, .. }) => { let was_in_iter_def_exp = self.in_iter_def_exp; @@ -2377,11 +2328,7 @@ impl SymbolTableBuilder { self.in_iter_def_exp = was_in_iter_def_exp; } Expr::Call(ExprCall { - func, - arguments, - node_index: _, - range: _, - .. + func, arguments, .. }) => { match context { ExpressionContext::IterDefinitionExp => { @@ -2438,11 +2385,7 @@ impl SymbolTableBuilder { } } Expr::Lambda(ExprLambda { - body, - parameters, - node_index: _, - range: _, - .. + body, parameters, .. }) => { let was_in_iter_def_exp = self.in_iter_def_exp; if let Some(parameters) = parameters { @@ -2535,12 +2478,7 @@ impl SymbolTableBuilder { }); } Expr::If(ExprIf { - test, - body, - orelse, - node_index: _, - range: _, - .. + test, body, orelse, .. }) => { self.scan_expression(test, ExpressionContext::Load)?; self.scan_expression(body, ExpressionContext::Load)?; @@ -2551,7 +2489,6 @@ impl SymbolTableBuilder { target, value, range, - node_index: _, .. }) => { // named expressions are not allowed in the definition of @@ -2777,7 +2714,6 @@ impl SymbolTableBuilder { bound, range: type_var_range, default, - node_index: _, .. }) => { self.register_name(name.as_str(), SymbolUsage::TypeParam, *type_var_range)?; @@ -2821,7 +2757,6 @@ impl SymbolTableBuilder { name, range: param_spec_range, default, - node_index: _, .. }) => { self.register_name(name, SymbolUsage::TypeParam, *param_spec_range)?; @@ -2850,7 +2785,6 @@ impl SymbolTableBuilder { name, range: type_var_tuple_range, default, - node_index: _, .. }) => { self.register_name(name, SymbolUsage::TypeParam, *type_var_tuple_range)?; diff --git a/crates/derive-impl/src/util.rs b/crates/derive-impl/src/util.rs index a0708444691..1ee878c1313 100644 --- a/crates/derive-impl/src/util.rs +++ b/crates/derive-impl/src/util.rs @@ -63,7 +63,7 @@ impl ItemNursery { if !inserted { return Err(syn::Error::new( item.attr_name.span(), - format!("Duplicated #[py*] attribute found for {:?}", &item.py_names), + format!("Duplicated #[py*] attribute found for {:?}", item.py_names), )); } } diff --git a/crates/host_env/src/fileutils.rs b/crates/host_env/src/fileutils.rs index 5cfe3f1d757..d8895151671 100644 --- a/crates/host_env/src/fileutils.rs +++ b/crates/host_env/src/fileutils.rs @@ -457,6 +457,10 @@ pub unsafe fn fclose(fp: *mut CFile) -> core::ffi::c_int { // _Py_fopen_obj in cpython (Python/fileutils.c:1757-1835) // Open a file using std::fs::File and convert to FILE* // Automatically handles path encoding and EINTR retries +#[expect( + clippy::std_instead_of_core, + reason = "false positive: core::io::ErrorKind is unstable (core_io)" +)] pub fn fopen(path: &std::path::Path, mode: &str) -> std::io::Result<*mut CFile> { use alloc::ffi::CString; use std::fs::File; diff --git a/crates/host_env/src/posix.rs b/crates/host_env/src/posix.rs index 60b55ef42ce..150df505c42 100644 --- a/crates/host_env/src/posix.rs +++ b/crates/host_env/src/posix.rs @@ -305,6 +305,10 @@ pub fn fchown(fd: BorrowedFd<'_>, uid: Option, gid: Option) -> std::io } #[cfg(not(windows))] +#[expect( + clippy::std_instead_of_core, + reason = "false positive: core::io::ErrorKind is unstable (core_io)" +)] pub fn stat_path( path: &OsStr, dir_fd: Option, @@ -1431,6 +1435,10 @@ fn build_posix_spawn_attrs( target_os = "illumos", target_os = "hurd", )))] + #[expect( + clippy::std_instead_of_core, + reason = "false positive: core::io::ErrorKind is unstable (core_io); expect is co-gated with the usage so it is not left unfulfilled on platforms where this block is compiled out" + )] { return Err(std::io::Error::new( std::io::ErrorKind::Unsupported, diff --git a/crates/stdlib/src/binascii.rs b/crates/stdlib/src/binascii.rs index ac945235884..579a5081cc4 100644 --- a/crates/stdlib/src/binascii.rs +++ b/crates/stdlib/src/binascii.rs @@ -392,7 +392,7 @@ mod decl { // there are a few uuencodes out there that use // '`' as zero instead of space. if !(b' '..=(b' ' + 64)).contains(&c) { - if [b'\r', b'\n'].contains(&c) { + if b"\r\n".contains(&c) { return Ok(0); } return Err(super::new_binascii_error("Illegal char", vm)); diff --git a/crates/stdlib/src/pyexpat.rs b/crates/stdlib/src/pyexpat.rs index fab4e7b5e93..143d820d683 100644 --- a/crates/stdlib/src/pyexpat.rs +++ b/crates/stdlib/src/pyexpat.rs @@ -1,5 +1,8 @@ //! Pyexpat builtin module +// false positive: core::io::Cursor is unstable (core_io), unusable on stable +#![expect(clippy::std_instead_of_core)] + // spell-checker: ignore libexpat pub(crate) use _pyexpat::module_def; diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 81d69b8c64e..7b7e2127f48 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -13,6 +13,9 @@ //! //! Warning: This library contains AI-generated code and comments. Do not trust any code or comment without verification. Please have a qualified expert review the code and remove this notice after review. +// false positive: core::io::{Cursor, ErrorKind} are unstable (core_io), unusable on stable +#![expect(clippy::std_instead_of_core)] + // OID (Object Identifier) management module mod oid; diff --git a/crates/vm/src/builtins/builtin_func.rs b/crates/vm/src/builtins/builtin_func.rs index d3195aa0eab..eabe8d4ea27 100644 --- a/crates/vm/src/builtins/builtin_func.rs +++ b/crates/vm/src/builtins/builtin_func.rs @@ -157,7 +157,7 @@ impl PyNativeFunction { // m_self is an instance: use Py_TYPE(m_self).__qualname__ bound.class().name().to_string() }; - vm.ctx.new_str(format!("{}.{}", prefix, &zelf.value.name)) + vm.ctx.new_str(format!("{}.{}", prefix, zelf.value.name)) } else { vm.ctx.intern_str(zelf.value.name).to_owned() }; @@ -220,7 +220,7 @@ impl fmt::Debug for PyNativeMethod { f, "builtin method of {:?} with {:?}", &*self.class.name(), - &self.func + self.func ) } } diff --git a/crates/vm/src/builtins/descriptor.rs b/crates/vm/src/builtins/descriptor.rs index 350adf6d768..537c2e39c9a 100644 --- a/crates/vm/src/builtins/descriptor.rs +++ b/crates/vm/src/builtins/descriptor.rs @@ -127,7 +127,7 @@ impl PyMethodDescriptor { #[pygetset] fn __qualname__(&self) -> String { - format!("{}.{}", self.common.typ.name(), &self.common.name) + format!("{}.{}", self.common.typ.name(), self.common.name) } #[pygetset] @@ -164,7 +164,7 @@ impl Representable for PyMethodDescriptor { fn repr_str(zelf: &Py, _vm: &VirtualMachine) -> PyResult { Ok(format!( "", - &zelf.method.name, + zelf.method.name, zelf.common.typ.name() )) } diff --git a/crates/vm/src/builtins/memory.rs b/crates/vm/src/builtins/memory.rs index ec622896555..f40350982a8 100644 --- a/crates/vm/src/builtins/memory.rs +++ b/crates/vm/src/builtins/memory.rs @@ -198,7 +198,7 @@ impl PyMemoryView { let data = self.format_spec.pack(vec![value], vm).map_err(|_| { vm.new_type_error(format!( "memoryview: invalid type for format '{}'", - &self.desc.format + self.desc.format )) })?; bytes[pos..pos + self.desc.itemsize].copy_from_slice(&data); diff --git a/crates/vm/src/builtins/super.rs b/crates/vm/src/builtins/super.rs index c44b61d71e9..62036396603 100644 --- a/crates/vm/src/builtins/super.rs +++ b/crates/vm/src/builtins/super.rs @@ -237,7 +237,7 @@ impl Representable for PySuper { let obj = zelf.inner.read().obj.clone(); let repr = match obj { Some((_, ref ty)) => { - format!(", <{} object>>", &type_name, ty.name()) + format!(", <{} object>>", type_name, ty.name()) } None => format!(", NULL>"), }; diff --git a/crates/vm/src/builtins/type.rs b/crates/vm/src/builtins/type.rs index 1c98e6861bc..3d9228b805f 100644 --- a/crates/vm/src/builtins/type.rs +++ b/crates/vm/src/builtins/type.rs @@ -431,7 +431,7 @@ impl core::fmt::Display for PyType { impl core::fmt::Debug for PyType { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!(f, "[PyType {}]", &self.name()) + write!(f, "[PyType {}]", self.name()) } } @@ -1906,13 +1906,7 @@ impl PyType { .get(identifier!(vm, __module__)) .cloned() // We need to exclude this method from going into recursion: - .and_then(|found| { - if found.fast_isinstance(vm.ctx.types.getset_type) { - None - } else { - Some(found) - } - }) + .filter(|found| !found.fast_isinstance(vm.ctx.types.getset_type)) .unwrap_or_else(|| { // For non-heap types, extract module from tp_name (e.g. "typing.TypeAliasType" -> "typing") let slot_name = self.slot_name(); diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index 845b01c3816..f7e1b79aa5a 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -2652,7 +2652,7 @@ pub(super) mod types { Ok(vm.ctx.new_str(if start < object.len() && end <= object.len() && end == start + 1 { let b = object.borrow_buf()[start]; format!( - "'{encoding}' codec can't decode byte {b:#02x} in position {start}: {reason}" + "'{encoding}' codec can't decode byte {b:#04x} in position {start}: {reason}" ) } else { format!( diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index 40499efb110..ae7f2c06164 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -3801,16 +3801,15 @@ impl ExecutingFrame<'_> { } seen_keys.add(key.as_object().to_owned(), vm)?; // value = map.get(key, dummy) - match get_method.call((key.as_object(), dummy.clone()), vm) { - Ok(value) => { - // if value == dummy: key not in map! - if value.is(&dummy) { - all_match = false; - break; - } - values.push(value); + { + let value = + get_method.call((key.as_object(), dummy.clone()), vm)?; + // if value == dummy: key not in map! + if value.is(&dummy) { + all_match = false; + break; } - Err(e) => return Err(e), + values.push(value); } } } else { diff --git a/crates/vm/src/object/core.rs b/crates/vm/src/object/core.rs index 2eb1b17cb44..9fa236b87ff 100644 --- a/crates/vm/src/object/core.rs +++ b/crates/vm/src/object/core.rs @@ -454,7 +454,7 @@ impl PyInner { impl fmt::Debug for PyInner { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "[PyObject {:?}]", &self.payload) + write!(f, "[PyObject {:?}]", self.payload) } } diff --git a/crates/vm/src/object/payload.rs b/crates/vm/src/object/payload.rs index 36262607a1a..b6590239ee3 100644 --- a/crates/vm/src/object/payload.rs +++ b/crates/vm/src/object/payload.rs @@ -198,7 +198,7 @@ pub trait PyPayload: MaybeTraverse + PyThreadingConstraint + Sized + 'static { ) -> PyBaseExceptionRef { vm.new_type_error(format!( "'{}' is not a subtype of '{}'", - &cls.name(), + cls.name(), exact_class.name() )) } diff --git a/crates/vm/src/stdlib/_ast/argument.rs b/crates/vm/src/stdlib/_ast/argument.rs index 8bd1507cd39..5019c436624 100644 --- a/crates/vm/src/stdlib/_ast/argument.rs +++ b/crates/vm/src/stdlib/_ast/argument.rs @@ -151,7 +151,6 @@ pub(super) fn split_function_call_arguments( args, keywords, runtime_args, - runtime_bases: _, .. } = args; diff --git a/crates/vm/src/stdlib/_ctypes/function.rs b/crates/vm/src/stdlib/_ctypes/function.rs index 86b4ff59b3f..ebda717192a 100644 --- a/crates/vm/src/stdlib/_ctypes/function.rs +++ b/crates/vm/src/stdlib/_ctypes/function.rs @@ -771,7 +771,7 @@ impl Constructor for PyCFuncPtr { .as_bigint() .clone(), }; - let terminated = format!("{}\0", &name); + let terminated = format!("{name}\0"); let ptr_val = match rustpython_host_env::ctypes::lookup_function_symbol_addr( handle .to_usize() diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index 580add6471e..6707a254c4d 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -112,6 +112,10 @@ impl std::os::fd::AsRawFd for Fildes { } #[pymodule] +#[expect( + clippy::std_instead_of_core, + reason = "false positive: core::io items (Cursor, etc.) are unstable (core_io)" +)] mod _io { use super::*; use crate::{ @@ -3290,7 +3294,7 @@ mod _io { use crate::types::PyComparisonOp; if cookie.rich_compare_bool(vm.ctx.new_int(0).as_ref(), PyComparisonOp::Lt, vm)? { return Err( - vm.new_value_error(format!("negative seek position {}", &cookie.repr(vm)?)) + vm.new_value_error(format!("negative seek position {}", cookie.repr(vm)?)) ); } drop(textio); diff --git a/crates/vm/src/stdlib/_signal.rs b/crates/vm/src/stdlib/_signal.rs index 71063d8959b..d5083962bf2 100644 --- a/crates/vm/src/stdlib/_signal.rs +++ b/crates/vm/src/stdlib/_signal.rs @@ -335,6 +335,10 @@ pub(crate) mod _signal { } #[cfg(windows)] + #[expect( + clippy::std_instead_of_core, + reason = "false positive: core::io::ErrorKind is unstable (core_io)" + )] let is_socket = if fd != INVALID_WAKEUP { host_signal::wakeup_fd_is_socket(fd).map_err(|err| { if err.kind() == std::io::ErrorKind::InvalidInput { diff --git a/crates/vm/src/stdlib/_sysconfigdata.rs b/crates/vm/src/stdlib/_sysconfigdata.rs index a9871ec95dc..5a00a56aece 100644 --- a/crates/vm/src/stdlib/_sysconfigdata.rs +++ b/crates/vm/src/stdlib/_sysconfigdata.rs @@ -19,7 +19,7 @@ mod _sysconfigdata { let paths = &vm.state.config.paths; build_time_vars.set_item("prefix", paths.prefix.clone().to_pyobject(vm), vm)?; build_time_vars.set_item("exec_prefix", paths.exec_prefix.clone().to_pyobject(vm), vm)?; - let bindir = format!("{}/bin", &paths.exec_prefix); + let bindir = format!("{}/bin", paths.exec_prefix); build_time_vars.set_item("BINDIR", bindir.to_pyobject(vm), vm)?; module.set_attr("build_time_vars", build_time_vars, vm)?; diff --git a/crates/vm/src/vm/vm_ops.rs b/crates/vm/src/vm/vm_ops.rs index d25e7119df5..8cb00d4a10d 100644 --- a/crates/vm/src/vm/vm_ops.rs +++ b/crates/vm/src/vm/vm_ops.rs @@ -568,7 +568,7 @@ impl VirtualMachine { formatted.downcast().map_err(|result| { self.new_type_error(format!( "__format__ must return a str, not {}", - &result.class().name() + result.class().name() )) }) } diff --git a/src/lib.rs b/src/lib.rs index 9a5cede9bd4..dfede27fe23 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -303,7 +303,7 @@ fn run_rustpython(vm: &VirtualMachine, run_mode: RunMode) -> PyResult<()> { RunMode::InstallPip(installer) => install_pip(installer, scope.clone(), vm), RunMode::Script(script_path) => { // pymain_run_file_obj - debug!("Running script {}", &script_path); + debug!("Running script {}", script_path); run_file(vm, scope.clone(), &script_path) } RunMode::Repl => Ok(()), From 167edf6a4c09fa6e388096d4cbe3b37a21e32a5a Mon Sep 17 00:00:00 2001 From: YujinBae Date: Mon, 13 Jul 2026 14:10:23 +0900 Subject: [PATCH 104/351] _sre: fix Match.expand to use _compile_template (#8229) (#8256) Match.expand delegated to re._expand, which CPython removed in 3.12 during the template-expansion refactor. With the 3.14 stdlib that helper no longer exists, so expand raised AttributeError. Route expand through the compiled-template path (Template::compile -> re._compile_template) and share the template-filling logic with Pattern.sub via a Match::expand_template helper. Each caller still compiles the template once, so sub keeps its no-recompile-per-match fast path. Remove the expectedFailure marker on test_re.test_expand. Assisted-by: Claude Code:claude-opus-4-8 --- Lib/test/test_re.py | 1 - crates/vm/src/stdlib/_sre.rs | 47 ++++++++++++++++++++++++++---------- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/Lib/test/test_re.py b/Lib/test/test_re.py index 1d396e4f31c..eef63075431 100644 --- a/Lib/test/test_re.py +++ b/Lib/test/test_re.py @@ -726,7 +726,6 @@ def test_groupdict(self): 'first second').groupdict(), {'first':'first', 'second':'second'}) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_expand(self): self.assertEqual(re.match("(?Pfirst) (?Psecond)", "first second") diff --git a/crates/vm/src/stdlib/_sre.rs b/crates/vm/src/stdlib/_sre.rs index f7bc10e6640..c90ff4d4f6f 100644 --- a/crates/vm/src/stdlib/_sre.rs +++ b/crates/vm/src/stdlib/_sre.rs @@ -471,15 +471,7 @@ mod _sre { } FilterType::Template(template) => { let m = Match::new(&mut iter.state, zelf.clone(), string.clone()); - // template.expand(m)? - // let mut list = vec![template.literal.clone()]; - sub_list.push(template.literal.clone()); - for (index, literal) in template.items.iter().cloned() { - if let Some(item) = m.get_slice(index, s, vm) { - sub_list.push(item); - } - sub_list.push(literal); - } + m.expand_template(template, s, &mut sub_list, vm); } }; @@ -702,10 +694,19 @@ mod _sre { } #[pymethod] - fn expand(zelf: PyRef, template: PyStrRef, vm: &VirtualMachine) -> PyResult { - let re = vm.import("re", 0)?; - let func = re.get_attr("_expand", vm)?; - func.call((zelf.pattern.clone(), zelf, template), vm) + fn expand(zelf: PyRef, template: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let template = Template::compile(zelf.pattern.clone(), template, vm)?; + with_sre_str!(zelf.pattern, &zelf.string, vm, |s| { + let mut list: Vec = Vec::new(); + zelf.expand_template(&template, s, &mut list, vm); + + let join_type: PyObjectRef = if zelf.pattern.isbytes { + vm.ctx.new_bytes(vec![]).into() + } else { + vm.ctx.new_str(ascii!("")).into() + }; + vm.call_method(&join_type, "join", (PyList::from(list).into_pyobject(vm),)) + }) } #[pymethod] @@ -820,6 +821,26 @@ mod _sre { Some(str_drive.slice(start as usize, end as usize, vm)) } + /// Expand an already-compiled template against this match, appending the + /// resulting literal/group segments to `list`. Shared by `expand` and + /// `Pattern.sub` so the template-filling logic lives in one place; the + /// caller is responsible for compiling the template (once) beforehand. + fn expand_template( + &self, + template: &Template, + str_drive: S, + list: &mut Vec, + vm: &VirtualMachine, + ) { + list.push(template.literal.clone()); + for (index, literal) in template.items.iter().cloned() { + if let Some(item) = self.get_slice(index, str_drive, vm) { + list.push(item); + } + list.push(literal); + } + } + #[pyclassmethod] fn __class_getitem__( cls: PyTypeRef, From 67ddc1aa9e46d157205052bdea7cf2e47367acc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B4=91=ED=9A=A8?= <87641474+widehyo1@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:11:41 +0900 Subject: [PATCH 105/351] csv: fix csv escape fieldsep (#8260) * csv: apply dialect escapechar when reading ReaderBuilder only received escapechar when it was passed directly as a keyword argument. A dialect object's or registered dialect's escapechar was visible through reader.dialect but not used by csv_core. Pass escapechar through for named, object, and default dialect paths. This makes escaped delimiters inside quoted fields follow the configured dialect, for example '"abc\,def"' reads as 'abc,def'. Unmark TestQuotedEscapedExcel.test_read_escape_fieldsep. Assisted-by: Codex * csv: handle escaped delimiters with QUOTE_NONE csv_core applies escapechar while reading quoted fields, but its unquoted field state treats an escaped delimiter as a delimiter. Consequently, QUOTE_NONE with escapechar split 'abc\,def' into 'abc\' and 'def'. Add a small QUOTE_NONE record parser for dialects with escapechar. It keeps the byte following an escape character as field data, so escaped delimiters do not end a field while ordinary delimiters and record terminators retain their usual meaning. Unmark TestEscapedExcel.test_read_escape_fieldsep. Assisted-by: Codex * csv: respect skipinitialspace with QUOTE_NONE The QUOTE_NONE parser returned before the normal reader's whitespace preprocessing, so it ignored skipinitialspace when escapechar was set. Track whether parsing has just followed a delimiter and skip only ordinary spaces in that position. Escaped spaces remain field data. Add a stdlib_csv snippet regression test for QUOTE_NONE with escapechar and skipinitialspace. Assisted-by: Codex:gpt-5.6-terra * csv: deduplicate reader dialect configuration to_reader configured ReaderBuilder separately for named, object, and default dialects even though the configuration was identical. Resolve the PyDialect once, then apply delimiter, double_quote, escapechar, and quotechar in one place. Preserve the existing unregistered-name fallback and required excel dialect lookup behavior. Assisted-by: Codex:gpt-5.6-terra --- Lib/test/test_csv.py | 2 - crates/stdlib/src/csv.rs | 121 ++++++++++++++++++++--------- extra_tests/snippets/stdlib_csv.py | 13 ++++ 3 files changed, 98 insertions(+), 38 deletions(-) diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py index 494cce50a2a..6e00f8eb1d4 100644 --- a/Lib/test/test_csv.py +++ b/Lib/test/test_csv.py @@ -857,7 +857,6 @@ class TestEscapedExcel(TestCsvBase): def test_escape_fieldsep(self): self.writerAssertEqual([['abc,def']], 'abc\\,def\r\n') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_read_escape_fieldsep(self): self.readerAssertEqual('abc\\,def\r\n', [['abc,def']]) @@ -881,7 +880,6 @@ class TestQuotedEscapedExcel(TestCsvBase): def test_write_escape_fieldsep(self): self.writerAssertEqual([['abc,def']], '"abc,def"\r\n') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_read_escape_fieldsep(self): self.readerAssertEqual('"abc\\,def"\r\n', [['abc,def']]) diff --git a/crates/stdlib/src/csv.rs b/crates/stdlib/src/csv.rs index aaffab18252..48ad68d43ac 100644 --- a/crates/stdlib/src/csv.rs +++ b/crates/stdlib/src/csv.rs @@ -797,45 +797,27 @@ mod _csv { } fn to_reader(&self) -> csv_core::Reader { - let mut builder = csv_core::ReaderBuilder::new(); - let mut reader = match &self.dialect { - DialectItem::Str(name) => { + let dialect = match &self.dialect { + DialectItem::Str(name) => GLOBAL_HASHMAP.lock().get(name).copied(), + DialectItem::Obj(obj) => Some(*obj), + DialectItem::None => { let g = GLOBAL_HASHMAP.lock(); - if let Some(dialect) = g.get(name) { - let mut builder = builder - .delimiter(dialect.delimiter) - .double_quote(dialect.doublequote); - if let Some(t) = dialect.quotechar { - builder = builder.quote(t); - } - builder - // RustPython todo - // todo! Perfecting the remaining attributes. - } else { - &mut builder - } - } - DialectItem::Obj(obj) => { - let mut builder = builder - .delimiter(obj.delimiter) - .double_quote(obj.doublequote); - if let Some(t) = obj.quotechar { - builder = builder.quote(t); - } - builder + Some(*g.get("excel").unwrap()) } - _ => { - let name = "excel"; - let g = GLOBAL_HASHMAP.lock(); - let dialect = g.get(name).unwrap(); - let mut builder = builder - .delimiter(dialect.delimiter) - .double_quote(dialect.doublequote); - if let Some(quotechar) = dialect.quotechar { - builder = builder.quote(quotechar); - } - builder + }; + + let mut builder = csv_core::ReaderBuilder::new(); + let mut reader = if let Some(dialect) = dialect { + let mut builder = builder + .delimiter(dialect.delimiter) + .double_quote(dialect.doublequote) + .escape(dialect.escapechar); + if let Some(quotechar) = dialect.quotechar { + builder = builder.quote(quotechar); } + builder + } else { + &mut builder }; if let Some(t) = self.delimiter { @@ -972,6 +954,67 @@ mod _csv { impl SelfIter for Reader {} + fn read_quote_none_record( + input: &[u8], + dialect: PyDialect, + field_limit: isize, + vm: &VirtualMachine, + ) -> PyResult> { + let mut fields = vec![Vec::new()]; + let mut escaped = false; + let mut after_delimiter = false; + + for (index, &byte) in input.iter().enumerate() { + if escaped { + fields.last_mut().unwrap().push(byte); + escaped = false; + after_delimiter = false; + } else if dialect.skipinitialspace && after_delimiter && byte == b' ' { + continue; + } else if dialect.escapechar == Some(byte) { + escaped = true; + } else if byte == dialect.delimiter { + fields.push(Vec::new()); + after_delimiter = true; + } else if matches!(byte, b'\r' | b'\n') { + if !input[index..] + .iter() + .all(|&byte| matches!(byte, b'\r' | b'\n')) + { + return Err(new_csv_error( + vm, + concat!( + "new-line character seen in unquoted field", + " - do you need to open the file in universal-newline mode?" + ), + )); + } + break; + } else { + fields.last_mut().unwrap().push(byte); + after_delimiter = false; + } + } + + // CPython treats an escape character at the end of an iterator item + // as escaping the implicit newline at the end of that item. + if escaped { + fields.last_mut().unwrap().push(b'\n'); + } + + fields + .into_iter() + .map(|field| { + if field.len() > field_limit as usize { + return Err(new_csv_error(vm, "filed too long to read")); + } + let field = core::str::from_utf8(&field) + .map_err(|_| vm.new_unicode_decode_error("csv not utf8"))?; + Ok(vm.ctx.new_str(field).into()) + }) + .collect() + } + impl IterNext for Reader { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { let string = raise_if_stop!(zelf.iter.next(vm)?); @@ -1003,6 +1046,12 @@ mod _csv { let mut output_ends_offset = 0; let field_limit = GLOBAL_FIELD_LIMIT.lock().to_owned(); + if zelf.dialect.quoting == QuoteStyle::None && zelf.dialect.escapechar.is_some() { + let out = read_quote_none_record(input, zelf.dialect, field_limit, vm)?; + *line_num += 1; + return Ok(PyIterReturn::Return(vm.ctx.new_list(out).into())); + } + #[inline] fn trim_spaces(input: &[u8]) -> &[u8] { let trimmed_start = input.iter().position(|&x| x != b' ').unwrap_or(input.len()); diff --git a/extra_tests/snippets/stdlib_csv.py b/extra_tests/snippets/stdlib_csv.py index b9c741cbb16..dc2186d17ac 100644 --- a/extra_tests/snippets/stdlib_csv.py +++ b/extra_tests/snippets/stdlib_csv.py @@ -134,3 +134,16 @@ def test_quote_none_writer_without_quotechar(): test_quote_none_writer_without_quotechar() + + +def test_quote_none_reader_skipinitialspace_escapechar(): + reader = csv.reader( + ["a, b,\\ c,d"], + quoting=csv.QUOTE_NONE, + escapechar="\\", + skipinitialspace=True, + ) + assert list(reader) == [["a", "b", " c", "d"]] + + +test_quote_none_reader_skipinitialspace_escapechar() From 25813c20f349d69a8cb6d8e3aec0a9da44ef3311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9B=85?= Date: Mon, 13 Jul 2026 14:12:59 +0900 Subject: [PATCH 106/351] itertools: fix count() repr to show float step 1.0 (#8261) Assisted-by: Claude Code:claude-opus-4-8 --- Lib/test/test_itertools.py | 1 - crates/vm/src/stdlib/itertools.rs | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_itertools.py b/Lib/test/test_itertools.py index e7c764815d1..e865c9bf059 100644 --- a/Lib/test/test_itertools.py +++ b/Lib/test/test_itertools.py @@ -538,7 +538,6 @@ def test_count(self): #check proper internal error handling for large "step' sizes count(1, maxsize+5); sys.exc_info() - @unittest.expectedFailure # TODO: RUSTPYTHON; 'count(10.5)' != 'count(10.5, 1.0)' def test_count_with_step(self): self.assertEqual(lzip('abc',count(2,3)), [('a', 2), ('b', 5), ('c', 8)]) self.assertEqual(lzip('abc',count(start=2,step=3)), diff --git a/crates/vm/src/stdlib/itertools.rs b/crates/vm/src/stdlib/itertools.rs index 8a78c698ed3..041620298e7 100644 --- a/crates/vm/src/stdlib/itertools.rs +++ b/crates/vm/src/stdlib/itertools.rs @@ -225,7 +225,9 @@ mod decl { let step = &zelf.step; let mut result = Wtf8Buf::from("count("); result.push_wtf8(cur_repr.as_wtf8()); - if !vm.bool_eq(step, vm.ctx.new_int(1).as_object())? { + let step_is_int_one = step.fast_isinstance(vm.ctx.types.int_type) + && vm.bool_eq(step, vm.ctx.new_int(1).as_object())?; + if !step_is_int_one { result.push_str(", "); result.push_wtf8(step.repr(vm)?.as_wtf8()); } From 619d5b643719c54a7ce5231376dab5459d5b016c Mon Sep 17 00:00:00 2001 From: sijun-yang Date: Mon, 13 Jul 2026 14:13:48 +0900 Subject: [PATCH 107/351] Add shared JetBrains IDE settings (#8262) Assisted-by: Codex:gpt-5 --- .gitignore | 4 +++- .idea/icon.svg | 3 +++ .idea/vcs.xml | 13 +++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 .idea/icon.svg create mode 100644 .idea/vcs.xml diff --git a/.gitignore b/.gitignore index 09e1b97b9f8..92fc399bf75 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,9 @@ __pycache__/ .repl_history.txt .vscode/ wasm-pack.log -.idea/ +.idea/* +!.idea/icon.svg +!.idea/vcs.xml .envrc flame-graph.html diff --git a/.idea/icon.svg b/.idea/icon.svg new file mode 100644 index 00000000000..84e5f593a6d --- /dev/null +++ b/.idea/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000000..9bb3f97becc --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,13 @@ + + + + + + From 8ab076cd20798d674f17f5300fd776e6cd6d1576 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:31:25 +0900 Subject: [PATCH 108/351] Preserve Windows CRT errno and avoid bare-drive prefix fallback (#8255) getpath: on Windows, default_prefix falls back to the executable directory instead of a bare "C:" when landmark search fails. host_env: errno_io_error carries the raw CRT errno as an io::Error payload (CrtErrno) instead of translating it to a Win32 error code and back. The round-trip collapsed every errno outside the errno_to_winerror table to EINVAL and attached a spurious winerror to the OSError. posix_errno recovers the exact errno from the payload; ftruncate reuses the same path. fstat now reports ERROR_INVALID_HANDLE for an invalid fd. --- crates/host_env/src/crt_fd.rs | 5 ++-- crates/host_env/src/fileutils.rs | 14 ++++++---- crates/host_env/src/os.rs | 46 +++++++++++++++++++++++++++++--- crates/vm/src/getpath.rs | 39 ++++++++++++++++++--------- 4 files changed, 80 insertions(+), 24 deletions(-) diff --git a/crates/host_env/src/crt_fd.rs b/crates/host_env/src/crt_fd.rs index b681081be89..f06c5aad984 100644 --- a/crates/host_env/src/crt_fd.rs +++ b/crates/host_env/src/crt_fd.rs @@ -355,9 +355,8 @@ pub fn ftruncate(fd: Borrowed<'_>, len: Offset) -> io::Result<()> { cfg_select! { windows => { if ret != 0 { - // _chsize_s returns errno directly, convert to Windows error code - let winerror = crate::os::errno_to_winerror(ret); - return Err(io::Error::from_raw_os_error(winerror)); + // _chsize_s returns errno directly; preserve it exactly. + return Err(crate::os::io_error_from_errno(ret)); } } _ => cvt(ret)?, diff --git a/crates/host_env/src/fileutils.rs b/crates/host_env/src/fileutils.rs index d8895151671..1713370a942 100644 --- a/crates/host_env/src/fileutils.rs +++ b/crates/host_env/src/fileutils.rs @@ -94,11 +94,15 @@ pub mod windows { // _Py_fstat_noraise in cpython pub fn fstat(fd: crt_fd::Borrowed<'_>) -> std::io::Result { - let h = crt_fd::as_handle(fd); - if h.is_err() { - unsafe { SetLastError(ERROR_INVALID_HANDLE) }; - } - let h = h?; + let h = match crt_fd::as_handle(fd) { + Ok(h) => h, + Err(_) => { + // An invalid fd is reported as a Win32 handle error so the + // OSError carries winerror = ERROR_INVALID_HANDLE. + unsafe { SetLastError(ERROR_INVALID_HANDLE) }; + return Err(std::io::Error::last_os_error()); + } + }; let h = h.as_raw_handle(); // reset stat? diff --git a/crates/host_env/src/os.rs b/crates/host_env/src/os.rs index 7d11d7bbec8..e5868871e8d 100644 --- a/crates/host_env/src/os.rs +++ b/crates/host_env/src/os.rs @@ -340,11 +340,53 @@ impl ErrorExt for io::Error { } #[cfg(windows)] fn posix_errno(&self) -> i32 { + // A C runtime error carries its exact errno as the payload; report it + // directly instead of round-tripping through a Win32 error code. + if let Some(crt) = self.get_ref().and_then(|e| e.downcast_ref::()) { + return crt.0; + } let winerror = self.raw_os_error().unwrap_or(0); winerror_to_errno(winerror) } } +/// Wraps a raw C runtime `errno` inside an [`io::Error`]. +/// +/// CRT functions (`open`, `read`, `dup`, ...) report failures through `errno`, +/// not `GetLastError`. Translating that `errno` into a Win32 error code is +/// lossy — any value missing from [`errno_to_winerror`] collapses to `EINVAL` — +/// and also attaches a spurious `winerror` to the resulting `OSError`. Carrying +/// the `errno` as the error payload lets [`ErrorExt::posix_errno`] recover it +/// exactly while leaving `raw_os_error()` empty, so no `winerror` is reported. +#[cfg(windows)] +#[derive(Debug)] +struct CrtErrno(i32); + +#[cfg(windows)] +impl core::fmt::Display for CrtErrno { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match crate::errno::strerror_string(self.0) { + Some(msg) => f.write_str(&msg), + None => write!(f, "os error {}", self.0), + } + } +} + +#[cfg(windows)] +impl core::error::Error for CrtErrno {} + +/// Build an [`io::Error`] that preserves a raw C runtime `errno`. +/// +/// The [`io::ErrorKind`] is derived from the closest Win32 mapping so callers +/// matching on `kind()` keep working, while the exact `errno` is preserved for +/// [`ErrorExt::posix_errno`]. +#[cfg(windows)] +#[must_use] +pub fn io_error_from_errno(errno: i32) -> io::Error { + let kind = io::Error::from_raw_os_error(errno_to_winerror(errno)).kind(); + io::Error::new(kind, CrtErrno(errno)) +} + #[cfg(all(not(windows), not(target_arch = "wasm32")))] impl ErrorExt for rustix::io::Errno { fn posix_errno(&self) -> i32 { @@ -357,9 +399,7 @@ impl ErrorExt for rustix::io::Errno { #[cfg(windows)] #[must_use] pub fn errno_io_error() -> io::Error { - let errno: i32 = get_errno(); - let winerror = errno_to_winerror(errno); - io::Error::from_raw_os_error(winerror) + io_error_from_errno(get_errno()) } #[cfg(not(windows))] diff --git a/crates/vm/src/getpath.rs b/crates/vm/src/getpath.rs index 437db37835b..bd90a3d2725 100644 --- a/crates/vm/src/getpath.rs +++ b/crates/vm/src/getpath.rs @@ -180,17 +180,30 @@ pub fn init_path_config(settings: &Settings) -> Paths { paths } -/// Get default prefix value -fn default_prefix() -> String { - std::option_env!("RUSTPYTHON_PREFIX") - .map(String::from) - .unwrap_or_else(|| { - if cfg!(windows) { - "C:".to_owned() - } else { - "/usr/local".to_owned() - } - }) +/// Get default prefix value used when landmark search fails. +/// +/// A compile-time `RUSTPYTHON_PREFIX` always wins. Otherwise POSIX uses the +/// conventional install prefix, while Windows has no meaningful compile-time +/// prefix and falls back to the executable's directory (ref: getpath.py). +/// +/// A bare drive root must never be returned on Windows: pip walks up from +/// `/Lib/site-packages` and would otherwise probe the drive root for +/// writability, which fails for standard users (see issue #8246). +fn default_prefix(exe_dir: Option<&PathBuf>) -> String { + if let Some(prefix) = std::option_env!("RUSTPYTHON_PREFIX") { + return prefix.to_owned(); + } + + if cfg!(windows) { + if let Some(dir) = exe_dir { + return dir.to_string_lossy().into_owned(); + } + // Executable directory is unknown; use a valid absolute root as a last + // resort rather than a drive-relative bare "C:". + "C:\\".to_owned() + } else { + "/usr/local".to_owned() + } } /// Detect virtual environment by looking for pyvenv.cfg @@ -262,7 +275,7 @@ fn calculate_prefix(exe_dir: Option<&PathBuf>, build_prefix: Option<&PathBuf>) - } // 4. Fallback to default - default_prefix() + default_prefix(exe_dir) } /// Calculate exec_prefix @@ -410,7 +423,7 @@ mod tests { #[test] fn default_prefix_basic() { - let prefix = default_prefix(); + let prefix = default_prefix(None); assert!(!prefix.is_empty()); } } From a9c2c529b14199a7ae7893b9d82c8bebe6b17418 Mon Sep 17 00:00:00 2001 From: Kwak Byoung Min <67214970+doma17@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:40:18 +0900 Subject: [PATCH 109/351] Forward pyexpat lexical events (#8264) * Restore pyexpat lexical callback compatibility Forward processing instructions, comments, and CDATA boundaries already emitted by xml-rs so registered Python handlers observe CPython-compatible events. Constraint: Reuse existing xml-rs events without changing the dependency Confidence: high Scope-risk: narrow Tested: prek; test_pyexpat; test_sax; 402 extra tests; workspace tests; clippy Assisted-by: Codex:gpt-5.6-sol * Recognize XML compatibility restored by lexical events Remove stale expected-failure markers for CPython XML tests now satisfied by pyexpat lexical-event forwarding, and simplify parser event error propagation. Constraint: CPython test edits are limited to removing stale expectedFailure markers Confidence: high Scope-risk: narrow Directive: Keep parser event dispatch aligned with pyexpat handler behavior Tested: user-verified full suite; prek run --all-files; cargo run --release -- -m test test_pyexpat test_xml_etree test_xml_etree_c test_minidom test_sax Assisted-by: Codex:gpt-5.6-sol --- Lib/test/test_minidom.py | 5 ----- Lib/test/test_pyexpat.py | 1 - Lib/test/test_xml_etree.py | 2 -- crates/stdlib/src/pyexpat.rs | 29 ++++++++++++++++++++++------- extra_tests/snippets/stdlib_xml.py | 21 +++++++++++++++++++++ 5 files changed, 43 insertions(+), 15 deletions(-) diff --git a/Lib/test/test_minidom.py b/Lib/test/test_minidom.py index 26fd366355f..5a5404d100e 100644 --- a/Lib/test/test_minidom.py +++ b/Lib/test/test_minidom.py @@ -659,7 +659,6 @@ def testProcessingInstruction(self): self.assertIsNone(pi.localName) self.assertEqual(pi.namespaceURI, xml.dom.EMPTY_NAMESPACE) - @unittest.expectedFailure # TODO: RUSTPYTHON def testProcessingInstructionRepr(self): dom = parseString('') pi = dom.documentElement.firstChild @@ -957,11 +956,9 @@ def check_clone_pi(self, deep, testName): self.confirm(clone.target == pi.target and clone.data == pi.data) - @unittest.expectedFailure # TODO: RUSTPYTHON def testClonePIShallow(self): self.check_clone_pi(0, "testClonePIShallow") - @unittest.expectedFailure # TODO: RUSTPYTHON def testClonePIDeep(self): self.check_clone_pi(1, "testClonePIDeep") @@ -1219,7 +1216,6 @@ def testBug1433694(self): self.assertIsNone(node.childNodes[-1].nextSibling, "Final child's .nextSibling should be None") - @unittest.expectedFailure # TODO: RUSTPYTHON def testSiblings(self): doc = parseString("text?") root = doc.documentElement @@ -1792,7 +1788,6 @@ def test_toprettyxml_with_attributes_ordered(self): '\n' '\n') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_toprettyxml_with_cdata(self): xml_str = ']]>' doc = parseString(xml_str) diff --git a/Lib/test/test_pyexpat.py b/Lib/test/test_pyexpat.py index 682abc520b3..e7046b79f7e 100644 --- a/Lib/test/test_pyexpat.py +++ b/Lib/test/test_pyexpat.py @@ -560,7 +560,6 @@ def test6(self): ["", "1", "", "", "2", "", "", "345", ""], "buffered text not properly split") - @unittest.expectedFailure # TODO: RUSTPYTHON def test7(self): self.setHandlers(["CommentHandler", "EndElementHandler", "StartElementHandler"]) diff --git a/Lib/test/test_xml_etree.py b/Lib/test/test_xml_etree.py index 6a75126f260..18a845fe2d2 100644 --- a/Lib/test/test_xml_etree.py +++ b/Lib/test/test_xml_etree.py @@ -1800,7 +1800,6 @@ def test_events_comment(self): self._feed(parser, "\n") self.assert_events(parser, [('comment', (ET.Comment, ' text here '))]) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_events_pi(self): parser = ET.XMLPullParser(events=('start', 'pi', 'end')) self._feed(parser, "\n") @@ -3821,7 +3820,6 @@ class TreeBuilderSubclass(ET.TreeBuilder): a = parser.close() self.assertEqual(a.text, "texttail") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_late_tail_mix_pi_comments(self): # Issue #37399: The tail of an ignored comment could overwrite the text before it. # Test appending tails to comments/pis. diff --git a/crates/stdlib/src/pyexpat.rs b/crates/stdlib/src/pyexpat.rs index 143d820d683..f690abadeaf 100644 --- a/crates/stdlib/src/pyexpat.rs +++ b/crates/stdlib/src/pyexpat.rs @@ -305,8 +305,9 @@ mod _pyexpat { fn create_config(&self) -> xml::ParserConfig { xml::ParserConfig::new() - .cdata_to_characters(true) + .cdata_to_characters(false) .coalesce_characters(false) + .ignore_comments(false) .whitespace_to_characters(true) } @@ -350,10 +351,10 @@ mod _pyexpat { T: std::io::Read, { for e in parser { - match e { - Ok(XmlEvent::StartElement { + match e? { + XmlEvent::StartElement { name, attributes, .. - }) => { + } => { let dict = vm.ctx.new_dict(); for attribute in attributes { let attr_name = self.make_name(&attribute.name); @@ -368,15 +369,29 @@ mod _pyexpat { let name_str = PyStr::from(self.make_name(&name)).into_ref(&vm.ctx); invoke_handler(vm, &self.start_element, (name_str, dict)); } - Ok(XmlEvent::EndElement { name, .. }) => { + XmlEvent::EndElement { name, .. } => { let name_str = PyStr::from(self.make_name(&name)).into_ref(&vm.ctx); invoke_handler(vm, &self.end_element, (name_str,)); } - Ok(XmlEvent::Characters(chars)) => { + XmlEvent::Characters(chars) => { let str = PyStr::from(chars).into_ref(&vm.ctx); invoke_handler(vm, &self.character_data, (str,)); } - Err(e) => return Err(e), + XmlEvent::ProcessingInstruction { name, data } => { + let name = PyStr::from(name).into_ref(&vm.ctx); + let data = PyStr::from(data.unwrap_or_default()).into_ref(&vm.ctx); + invoke_handler(vm, &self.processing_instruction, (name, data)); + } + XmlEvent::Comment(comment) => { + let comment = PyStr::from(comment).into_ref(&vm.ctx); + invoke_handler(vm, &self.comment, (comment,)); + } + XmlEvent::CData(chars) => { + invoke_handler(vm, &self.start_cdata_section, ()); + let str = PyStr::from(chars).into_ref(&vm.ctx); + invoke_handler(vm, &self.character_data, (str,)); + invoke_handler(vm, &self.end_cdata_section, ()); + } _ => {} } } diff --git a/extra_tests/snippets/stdlib_xml.py b/extra_tests/snippets/stdlib_xml.py index 268cdf45abe..34f1a726e4c 100644 --- a/extra_tests/snippets/stdlib_xml.py +++ b/extra_tests/snippets/stdlib_xml.py @@ -45,3 +45,24 @@ def endElement(self, name): ("end", "child"), ("end", "main"), ] + +events = [] +parser = expat.ParserCreate() +parser.ProcessingInstructionHandler = lambda target, data: events.append( + ("processing-instruction", target, data) +) +parser.CommentHandler = lambda data: events.append(("comment", data)) +parser.StartCdataSectionHandler = lambda: events.append(("start-cdata",)) +parser.CharacterDataHandler = lambda data: events.append(("characters", data)) +parser.EndCdataSectionHandler = lambda: events.append(("end-cdata",)) +parser.Parse( + "", True +) +assert events == [ + ("processing-instruction", "target", "data"), + ("processing-instruction", "empty", ""), + ("comment", "comment"), + ("start-cdata",), + ("characters", "text"), + ("end-cdata",), +] From 415f3d33ea982c84ada1f664454990842e63a697 Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:16:42 +0900 Subject: [PATCH 110/351] Match CPython unsigned socket conversion errors (#8257) Assisted-by: Codex:gpt-5.4 --- Lib/test/test_lzma.py | 1 - Lib/test/test_memoryio.py | 8 -------- Lib/test/test_resource.py | 1 - Lib/test/test_socket.py | 2 -- crates/stdlib/src/array.rs | 2 +- crates/stdlib/src/socket.rs | 8 +++++--- crates/vm/src/builtins/int.rs | 16 ++++++++++------ crates/vm/src/stdlib/os.rs | 3 ++- 8 files changed, 18 insertions(+), 23 deletions(-) diff --git a/Lib/test/test_lzma.py b/Lib/test/test_lzma.py index eebe6370f5f..fff261d890e 100644 --- a/Lib/test/test_lzma.py +++ b/Lib/test/test_lzma.py @@ -656,7 +656,6 @@ def test_init_bad_check(self): with self.assertRaises(ValueError): LZMAFile(BytesIO(COMPRESSED_XZ), check=lzma.CHECK_UNKNOWN) - @unittest.expectedFailure # TODO: RUSTPYTHON; OverflowError: Python int too large to convert to Rust u32 def test_init_bad_preset(self): with self.assertRaises(TypeError): LZMAFile(BytesIO(), "w", preset=4.39) diff --git a/Lib/test/test_memoryio.py b/Lib/test/test_memoryio.py index 0ae48600529..f0c21e4ae11 100644 --- a/Lib/test/test_memoryio.py +++ b/Lib/test/test_memoryio.py @@ -934,10 +934,6 @@ def test_flags(self): def test_write(self): return super().test_write() - @unittest.expectedFailure # TODO: RUSTPYTHON; OverflowError: Python int too large to convert to Rust u64 - def test_seek(self): - return super().test_seek() - class CStringIOTest(PyStringIOTest): ioclass = io.StringIO UnsupportedOperation = io.UnsupportedOperation @@ -1030,10 +1026,6 @@ def test_flags(self): def test_newlines_property(self): return super().test_newlines_property() - @unittest.expectedFailure # TODO: RUSTPYTHON; OverflowError: Python int too large to convert to Rust u64 - def test_seek(self): - return super().test_seek() - @unittest.expectedFailure # TODO: RUSTPYTHON; d def test_newline_cr(self): return super().test_newline_cr() diff --git a/Lib/test/test_resource.py b/Lib/test/test_resource.py index e2226e1a69d..6c7145caa93 100644 --- a/Lib/test/test_resource.py +++ b/Lib/test/test_resource.py @@ -151,7 +151,6 @@ def expected(cur): resource.setrlimit(resource.RLIMIT_FSIZE, (2**64-5, max)) self.assertIn(resource.getrlimit(resource.RLIMIT_FSIZE), expected(2**64-5)) - @unittest.expectedFailure # TODO: RUSTPYTHON; OverflowError: Python int too large to convert to Rust u64 @unittest.skipIf(sys.platform == "vxworks", "setting RLIMIT_FSIZE is not supported on VxWorks") @unittest.skipUnless(hasattr(resource, 'RLIMIT_FSIZE'), 'requires resource.RLIMIT_FSIZE') diff --git a/Lib/test/test_socket.py b/Lib/test/test_socket.py index ccc06cebac8..ddeb868db7b 100644 --- a/Lib/test/test_socket.py +++ b/Lib/test/test_socket.py @@ -1181,7 +1181,6 @@ def testInterfaceNameIndex(self): self.assertIsInstance(_name, str) self.assertEqual(name, _name) - @unittest.expectedFailure # TODO: RUSTPYTHON; OverflowError: Python int too large to convert to Rust u32 @unittest.skipUnless(hasattr(socket, 'if_indextoname'), 'socket.if_indextoname() not available.') @support.skip_android_selinux('if_indextoname') @@ -1249,7 +1248,6 @@ def testNtoH(self): self.assertEqual(swapped & mask, mask) self.assertRaises(OverflowError, func, 1<<34) - @unittest.expectedFailure # TODO: RUSTPYTHON; OverflowError: Python int too large to convert to Rust u16 def testNtoHErrors(self): s_good_values = [0, 1, 2, 0xffff] l_good_values = s_good_values + [0xffffffff] diff --git a/crates/stdlib/src/array.rs b/crates/stdlib/src/array.rs index e9b389949c1..19f6c48f272 100644 --- a/crates/stdlib/src/array.rs +++ b/crates/stdlib/src/array.rs @@ -498,7 +498,7 @@ pub mod array { ($($t:ty,)*) => {$( impl ArrayElement for $t { fn try_into_from_object(vm: &VirtualMachine, obj: PyObjectRef) -> PyResult { - obj.try_index(vm)?.try_to_primitive(vm) + obj.try_index(vm)?.try_to_primitive_raw(vm) } fn byteswap(self) -> Self { <$t>::swap_bytes(self) diff --git a/crates/stdlib/src/socket.rs b/crates/stdlib/src/socket.rs index e7ee6c907db..9cb197ab94e 100644 --- a/crates/stdlib/src/socket.rs +++ b/crates/stdlib/src/socket.rs @@ -2230,7 +2230,7 @@ mod _socket { let addr = Self::from_tuple(tuple, vm)?; let flowinfo = tuple .get(2) - .map(|obj| u32::try_from_borrowed_object(vm, obj)) + .map(|obj| obj.clone().try_index(vm)?.try_to_primitive_raw(vm)) .transpose()? .unwrap_or(0); let scopeid = tuple @@ -3141,14 +3141,16 @@ mod _socket { #[cfg(all(unix, not(target_os = "redox")))] #[pyfunction(name = "CMSG_LEN")] - fn cmsg_len(length: usize, vm: &VirtualMachine) -> PyResult { + fn cmsg_len(length: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let length = length.try_index(vm)?.try_to_primitive_raw(vm)?; host_socket::checked_cmsg_len(length) .ok_or_else(|| vm.new_overflow_error("CMSG_LEN() argument out of range")) } #[cfg(all(unix, not(target_os = "redox")))] #[pyfunction(name = "CMSG_SPACE")] - fn cmsg_space(length: usize, vm: &VirtualMachine) -> PyResult { + fn cmsg_space(length: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let length = length.try_index(vm)?.try_to_primitive_raw(vm)?; host_socket::checked_cmsg_space(length) .ok_or_else(|| vm.new_overflow_error("CMSG_SPACE() argument out of range")) } diff --git a/crates/vm/src/builtins/int.rs b/crates/vm/src/builtins/int.rs index c5aa607d023..278a9cecbb1 100644 --- a/crates/vm/src/builtins/int.rs +++ b/crates/vm/src/builtins/int.rs @@ -343,13 +343,17 @@ impl PyInt { where I: PrimInt + TryFrom<&'a BigInt>, { - // TODO: Python 3.14+: ValueError for negative int to unsigned type - // See stdlib_socket.py socket.htonl(-1) - // - // if I::min_value() == I::zero() && self.as_bigint().sign() == Sign::Minus { - // return Err(vm.new_value_error("Cannot convert negative int".to_owned())); - // } + if I::min_value() == I::zero() && self.as_bigint().sign() == Sign::Minus { + return Err(vm.new_value_error("can't convert negative number to unsigned")); + } + + self.try_to_primitive_raw(vm) + } + pub fn try_to_primitive_raw<'a, I>(&'a self, vm: &VirtualMachine) -> PyResult + where + I: PrimInt + TryFrom<&'a BigInt>, + { I::try_from(self.as_bigint()).map_err(|_| { vm.new_overflow_error(format!( "Python int too large to convert to Rust {}", diff --git a/crates/vm/src/stdlib/os.rs b/crates/vm/src/stdlib/os.rs index e6aea44c399..5772cc46f5d 100644 --- a/crates/vm/src/stdlib/os.rs +++ b/crates/vm/src/stdlib/os.rs @@ -1870,7 +1870,8 @@ pub(super) mod _os { #[cfg(windows)] #[pyfunction] - fn waitstatus_to_exitcode(status: u64, vm: &VirtualMachine) -> PyResult { + fn waitstatus_to_exitcode(status: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let status = status.try_index(vm)?.try_to_primitive_raw::(vm)?; let exitcode = status >> 8; // ExitProcess() accepts an UINT type: // reject exit code which doesn't fit in an UINT From 4a4cef68eaa6ab5e19037de9d842fc8951007db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=9A=A8=EC=A2=85?= Date: Wed, 15 Jul 2026 23:18:03 +0900 Subject: [PATCH 111/351] Add 'w' (Py_UCS4) typecode support to array (#8266) * Support array 'w' (Py_UCS4) typecode Python 3.13 added the 'w' typecode holding fixed 4-byte unicode code points, unlike 'u' which is platform-dependent wchar_t. Add a Ucs4Char element type and route it through the existing unicode-array paths: constructor from str, fromunicode()/tounicode(), __reduce_ex__, repr, byteswap, and the machine-format code (Utf32). Also register 'w' as a struct format char in vm::buffer. Remove the now-passing expectedFailure markers for 'w' tests in test_array.py. Assisted-by: Claude * Reject non-native byte order for the 'w' format char FormatType::parse accepted 'w' after '<', '>', '!', or '=', but the non-native branch of FormatType::info has no Ucs4Char arm and hits unreachable!(), so struct.calcsize('>w') panicked the interpreter. Add Ucs4Char to the existing native-only filter in FormatCode::parse (alongside SSizeT/SizeT/VoidP) so those formats fail with the regular "bad char in struct format" error instead. Native 'w' and memoryview(array('w', ...)) are unaffected. Assisted-by: Claude * Unmark 'w'-typecode tests in test_re and test_csv test_re's test_empty_array and test_csv's test_char_write construct array('w', ...) and were marked expectedFailure while the typecode was unsupported. Now that 'w' works they pass, and regrtest counts the unexpected successes as failures in CI. Remove the two markers, which restores both tests to their CPython v3.14.6 form; test_re, test_csv, and test_array all pass. Assisted-by: Claude --- Lib/test/test_array.py | 198 ------------------------------------- Lib/test/test_csv.py | 1 - Lib/test/test_re.py | 1 - crates/stdlib/src/array.rs | 79 ++++++++++++--- crates/vm/src/buffer.rs | 9 +- 5 files changed, 70 insertions(+), 218 deletions(-) diff --git a/Lib/test/test_array.py b/Lib/test/test_array.py index d300337b915..13df6134882 100644 --- a/Lib/test/test_array.py +++ b/Lib/test/test_array.py @@ -189,7 +189,6 @@ def test_numbers(self): self.assertEqual(a, b, msg="{0!r} != {1!r}; testcase={2!r}".format(a, b, testcase)) - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' def test_unicode(self): teststr = "Bonne Journ\xe9e \U0002030a\U00020347" testcases = ( @@ -1180,7 +1179,6 @@ def test_sizeof_without_buffer(self): basesize = support.calcvobjsize('Pn2Pi') support.check_sizeof(self, a, basesize) - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' def test_initialize_with_unicode(self): if self.typecode not in ('u', 'w'): with self.assertRaises(TypeError) as cm: @@ -1267,207 +1265,11 @@ def test_empty_string_mem_leak_gh140474(self): self.assertEqual(len(a), 0) self.assertEqual(a.typecode, 'u') - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_add(self): - return super().test_add() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_extend(self): - return super().test_extend() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_iadd(self): - return super().test_iadd() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_setiadd(self): - return super().test_setiadd() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_setslice(self): - return super().test_setslice() - class UCS4Test(UnicodeTest): typecode = 'w' minitemsize = 4 - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_buffer(self): - return super().test_buffer() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_buffer_info(self): - return super().test_buffer_info() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_byteswap(self): - return super().test_byteswap() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_clear(self): - return super().test_clear() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_cmp(self): - return super().test_cmp() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_constructor(self): - return super().test_constructor() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_constructor_with_iterable_argument(self): - return super().test_constructor_with_iterable_argument() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_copy(self): - return super().test_copy() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_count(self): - return super().test_count() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_coveritertraverse(self): - return super().test_coveritertraverse() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_deepcopy(self): - return super().test_deepcopy() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_delitem(self): - return super().test_delitem() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_exhausted_iterator(self): - return super().test_exhausted_iterator() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_exhausted_reverse_iterator(self): - return super().test_exhausted_reverse_iterator() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_extended_getslice(self): - return super().test_extended_getslice() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_extended_set_del_slice(self): - return super().test_extended_set_del_slice() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_filewrite(self): - return super().test_filewrite() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_fromarray(self): - return super().test_fromarray() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_fromfile_ioerror(self): - return super().test_fromfile_ioerror() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_getitem(self): - return super().test_getitem() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_getslice(self): - return super().test_getslice() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_imul(self): - return super().test_imul() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_index(self): - return super().test_index() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_insert(self): - return super().test_insert() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_issue17223(self): - return super().test_issue17223() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_iterator_pickle(self): - return super().test_iterator_pickle() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_len(self): - return super().test_len() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_mul(self): - return super().test_mul() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_pickle(self): - return super().test_pickle() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_pickle_for_empty_array(self): - return super().test_pickle_for_empty_array() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_pop(self): - return super().test_pop() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_reduce_ex(self): - return super().test_reduce_ex() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_remove(self): - return super().test_remove() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_repr(self): - return super().test_repr() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_reverse(self): - return super().test_reverse() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_reverse_iterator(self): - return super().test_reverse_iterator() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_reverse_iterator_picking(self): - return super().test_reverse_iterator_picking() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_setitem(self): - return super().test_setitem() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_str(self): - return super().test_str() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_tofrombytes(self): - return super().test_tofrombytes() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_tofromfile(self): - return super().test_tofromfile() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_tofromlist(self): - return super().test_tofromlist() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_unicode(self): - return super().test_unicode() - - @unittest.expectedFailure # TODO: RUSTPYTHON; Add support for 'w' - def test_weakref(self): - return super().test_weakref() - class NumberTest(BaseTest): diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py index 6e00f8eb1d4..86f1cf9bcb9 100644 --- a/Lib/test/test_csv.py +++ b/Lib/test/test_csv.py @@ -1137,7 +1137,6 @@ def test_float_write(self): fileobj.seek(0) self.assertEqual(fileobj.read(), expected) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_char_write(self): import array, string a = array.array('w', string.ascii_letters) diff --git a/Lib/test/test_re.py b/Lib/test/test_re.py index eef63075431..1c52eb0dcb2 100644 --- a/Lib/test/test_re.py +++ b/Lib/test/test_re.py @@ -1754,7 +1754,6 @@ def test_bug_6561(self): for x in not_decimal_digits: self.assertIsNone(re.match(r'^\d$', x)) - @unittest.expectedFailure # TODO: RUSTPYTHON; a = array.array(typecode)\n ValueError: bad typecode (must be b, B, u, h, H, i, I, l, L, q, Q, f or d) @warnings_helper.ignore_warnings(category=DeprecationWarning) # gh-80480 array('u') def test_empty_array(self): # SF buf 1647541 diff --git a/crates/stdlib/src/array.rs b/crates/stdlib/src/array.rs index 19f6c48f272..f2a16d72356 100644 --- a/crates/stdlib/src/array.rs +++ b/crates/stdlib/src/array.rs @@ -60,7 +60,7 @@ pub mod array { match c { $($c => Ok(ArrayContentType::$n(Vec::new())),)* _ => Err( - "bad typecode (must be b, B, u, h, H, i, I, l, L, q, Q, f or d)".into() + "bad typecode (must be b, B, u, w, h, H, i, I, l, L, q, Q, f or d)".into() ), } } @@ -473,6 +473,7 @@ pub mod array { (SignedByte, i8, 'b', "b"), (UnsignedByte, u8, 'B', "B"), (PyUnicode, WideChar, 'u', "u"), + (PyUcs4, Ucs4Char, 'w', "w"), (SignedShort, raw::c_short, 'h', "h"), (UnsignedShort, raw::c_ushort, 'H', "H"), (SignedInt, raw::c_int, 'i', "i"), @@ -488,6 +489,11 @@ pub mod array { #[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Debug)] pub struct WideChar(wchar_t); + /// Element type for the 'w' typecode: always a 4-byte unicode code point + /// (Py_UCS4), unlike 'u' which is platform-dependent `wchar_t`. + #[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Debug)] + pub struct Ucs4Char(u32); + trait ArrayElement: Sized { fn try_into_from_object(vm: &VirtualMachine, obj: PyObjectRef) -> PyResult; fn byteswap(self) -> Self; @@ -574,6 +580,47 @@ pub mod array { } } + impl ArrayElement for Ucs4Char { + fn try_into_from_object(vm: &VirtualMachine, obj: PyObjectRef) -> PyResult { + let s = obj.downcast::().map_err(|obj| { + vm.new_type_error(format!( + "array item must be a unicode character, not {}", + obj.class().name() + )) + })?; + s.as_wtf8() + .code_points() + .exactly_one() + .map(|ch| Self(ch.to_u32())) + .map_err(|e| { + vm.new_type_error(format!( + "array item must be a unicode character, not a string of length {}", + e.count() + )) + }) + } + fn byteswap(self) -> Self { + Self(self.0.swap_bytes()) + } + fn to_object(self, _vm: &VirtualMachine) -> PyObjectRef { + unreachable!() + } + } + + impl ToPyResult for Ucs4Char { + fn to_pyresult(self, vm: &VirtualMachine) -> PyResult { + Ok(u32_to_char(self.0) + .map_err(|msg| vm.new_value_error(msg))? + .to_pyobject(vm)) + } + } + + impl fmt::Display for Ucs4Char { + fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> fmt::Result { + unreachable!("`repr(array('w'))` calls `PyStr::repr`") + } + } + fn u32_to_char(ch: u32) -> Result { CodePoint::from_u32(ch) .ok_or_else(|| format!("character U+{ch:4x} is not in range [U+0000; U+10ffff]")) @@ -663,7 +710,7 @@ pub mod array { let value = init.read().typecode(); match (spec, value) { (spec, ch) if spec == ch => array.frombytes(&init.get_bytes()), - (spec, 'u') => { + (spec, 'u' | 'w') if !matches!(spec, 'u' | 'w') => { return Err(vm.new_type_error(format!( "cannot use a unicode array to initialize an array with typecode '{spec}'" ))) @@ -675,7 +722,7 @@ pub mod array { } } } else if let Some(wtf8) = init.downcast_ref::() { - if spec == 'u' { + if matches!(spec, 'u' | 'w') { let bytes = Self::_unicode_to_wchar_bytes(wtf8.as_wtf8(), array.itemsize()); array.frombytes_move(bytes); } else { @@ -824,10 +871,10 @@ pub mod array { obj.class().name() )) })?; - if zelf.read().typecode() != 'u' { - return Err( - vm.new_value_error("fromunicode() may only be called on unicode type arrays") - ); + if !matches!(zelf.read().typecode(), 'u' | 'w') { + return Err(vm.new_value_error( + "fromunicode() may only be called on unicode type arrays ('u' or 'w')", + )); } let mut w = zelf.try_resizable(vm)?; let bytes = Self::_unicode_to_wchar_bytes(wtf8, w.itemsize()); @@ -838,10 +885,10 @@ pub mod array { #[pymethod] fn tounicode(&self, vm: &VirtualMachine) -> PyResult { let array = self.array.read(); - if array.typecode() != 'u' { - return Err( - vm.new_value_error("tounicode() may only be called on unicode type arrays") - ); + if !matches!(array.typecode(), 'u' | 'w') { + return Err(vm.new_value_error( + "tounicode() may only be called on unicode type arrays ('u' or 'w')", + )); } let bytes = array.get_bytes(); Self::_wchar_bytes_to_string(bytes, self.itemsize(), vm) @@ -1152,7 +1199,7 @@ pub mod array { let array = zelf.read(); let cls = zelf.class().to_owned(); let typecode = vm.ctx.new_str(array.typecode_str()); - let values = if array.typecode() == 'u' { + let values = if matches!(array.typecode(), 'u' | 'w') { let s = Self::_wchar_bytes_to_string(array.get_bytes(), array.itemsize(), vm)?; s.code_points().map(|x| x.to_pyobject(vm)).collect() } else { @@ -1266,13 +1313,14 @@ pub mod array { fn repr_str(zelf: &Py, vm: &VirtualMachine) -> PyResult { let class = zelf.class(); let class_name = class.name(); - if zelf.read().typecode() == 'u' { + let typecode = zelf.read().typecode(); + if matches!(typecode, 'u' | 'w') { if zelf.__len__() == 0 { - return Ok(format!("{class_name}('u')")); + return Ok(format!("{class_name}('{typecode}')")); } let to_unicode = zelf.tounicode(vm)?; let escape = crate::vm::literal::escape::UnicodeEscape::new_repr(&to_unicode); - return Ok(format!("{}('u', {})", class_name, escape.str_repr())); + return Ok(format!("{class_name}('{typecode}', {})", escape.str_repr())); } zelf.read().repr(&class_name, vm) } @@ -1524,6 +1572,7 @@ pub mod array { _ => None, }; } + 'w' => return Some(Self::Utf32 { big_endian }), 'f' => { // Copied from CPython const Y: f32 = 16711938.0; diff --git a/crates/vm/src/buffer.rs b/crates/vm/src/buffer.rs index a174e2fd613..84c0ac17f3b 100644 --- a/crates/vm/src/buffer.rs +++ b/crates/vm/src/buffer.rs @@ -85,6 +85,7 @@ pub(crate) enum FormatType { UByte = b'B', Char = b'c', WideChar = b'u', + Ucs4Char = b'w', Str = b's', Pascal = b'p', Short = b'h', @@ -189,6 +190,7 @@ impl FormatType { unpack: Some(unpack_char), }, Self::WideChar => native_info!(wchar_t), + Self::Ucs4Char => native_info!(u32), Self::Short => native_info!(raw::c_short), Self::UShort => native_info!(raw::c_ushort), Self::Int => native_info!(raw::c_int), @@ -344,9 +346,10 @@ impl FormatCode { let code = FormatType::try_from(c) .ok() .filter(|c| match c { - FormatType::SSizeT | FormatType::SizeT | FormatType::VoidP => { - endianness == Endianness::Native - } + FormatType::SSizeT + | FormatType::SizeT + | FormatType::VoidP + | FormatType::Ucs4Char => endianness == Endianness::Native, _ => true, }) .ok_or_else(|| "bad char in struct format".to_owned())?; From 859516cada241abe30eec9e2289dc3d7aa7eafe2 Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:19:41 +0200 Subject: [PATCH 112/351] Add more dict functions to c-api (#8269) * Add more dict functions to c-api * Review --- crates/capi/src/dictobject.rs | 112 +++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/crates/capi/src/dictobject.rs b/crates/capi/src/dictobject.rs index e326ba87e3a..6cea9ea343c 100644 --- a/crates/capi/src/dictobject.rs +++ b/crates/capi/src/dictobject.rs @@ -1,7 +1,7 @@ use crate::PyObject; use crate::object::define_py_check; use crate::pystate::with_vm; -use core::ffi::c_int; +use core::ffi::{CStr, c_char, c_int}; use core::ptr::NonNull; use rustpython_vm::AsObject; use rustpython_vm::PyPayload; @@ -18,6 +18,15 @@ pub extern "C" fn PyDict_New() -> *mut PyObject { with_vm(|vm| vm.ctx.new_dict()) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyDict_Clear(dict: *mut PyObject) { + with_vm(|vm| { + let dict = unsafe { &*dict }.try_downcast_ref::(vm)?; + dict.clear(); + Ok(()) + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyDict_SetItem( dict: *mut PyObject, @@ -32,6 +41,96 @@ pub unsafe extern "C" fn PyDict_SetItem( }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyDict_SetItemString( + dict: *mut PyObject, + key: *const c_char, + val: *mut PyObject, +) -> c_int { + with_vm(|vm| { + let dict = unsafe { &*dict }.try_downcast_ref::(vm)?; + let key = unsafe { CStr::from_ptr(key) } + .to_str() + .map_err(|_| vm.new_value_error("dictionary key must be valid UTF-8"))?; + let value = unsafe { &*val }.to_owned(); + dict.inner_setitem(key, value, vm) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyDict_GetItem(dict: *mut PyObject, key: *mut PyObject) -> *mut PyObject { + with_vm(|vm| { + let dict = unsafe { &*dict }.try_downcast_ref::(vm)?; + let key = unsafe { &*key }; + + match dict.inner_getitem_opt(key, vm) { + Ok(Some(value)) => Ok(value.as_object().as_raw().cast_mut()), + Ok(None) | Err(_) => Ok(core::ptr::null_mut()), + } + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyDict_GetItemWithError( + dict: *mut PyObject, + key: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| { + let dict = unsafe { &*dict }.try_downcast_ref::(vm)?; + let key = unsafe { &*key }; + + if let Some(value) = dict.inner_getitem_opt(key, vm)? { + Ok(value.as_object().as_raw().cast_mut()) + } else { + Ok(core::ptr::null_mut()) + } + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyDict_GetItemString( + dict: *mut PyObject, + key: *const c_char, +) -> *mut PyObject { + with_vm(|vm| { + let dict = unsafe { &*dict }.try_downcast_ref::(vm)?; + let key = unsafe { CStr::from_ptr(key) } + .to_str() + .map_err(|_| vm.new_unicode_decode_error("dictionary key must be valid UTF-8"))?; + + match dict.inner_getitem_opt(key, vm)? { + Some(value) => Ok(value.as_object().as_raw().cast_mut()), + None => Ok(core::ptr::null_mut()), + } + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyDict_GetItemStringRef( + dict: *mut PyObject, + key: *const c_char, + result: *mut *mut PyObject, +) -> c_int { + with_vm(|vm| { + unsafe { + *result = core::ptr::null_mut(); + } + let dict = unsafe { &*dict }.try_downcast_ref::(vm)?; + let key = unsafe { CStr::from_ptr(key) } + .to_str() + .map_err(|_| vm.new_value_error("dictionary key must be valid UTF-8"))?; + + if let Some(value) = dict.inner_getitem_opt(key, vm)? { + unsafe { + *result = value.into_raw().as_ptr(); + } + Ok(true) + } else { + Ok(false) + } + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyDict_GetItemRef( dict: *mut PyObject, @@ -128,6 +227,17 @@ pub unsafe extern "C" fn PyDict_DelItem(dict: *mut PyObject, key: *mut PyObject) }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyDict_DelItemString(dict: *mut PyObject, key: *const c_char) -> c_int { + with_vm(|vm| { + let dict = unsafe { &*dict }.try_downcast_ref::(vm)?; + let key = unsafe { CStr::from_ptr(key) } + .to_str() + .map_err(|_| vm.new_value_error("dictionary key must be valid UTF-8"))?; + dict.del_item(key, vm) + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyDict_Items(dict: *mut PyObject) -> *mut PyObject { with_vm(|vm| { From 0389207a4c34f7860bd037519a0ff8b7b6b8a09c Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:24:26 +0200 Subject: [PATCH 113/351] Add str compare functions to c-api (#8268) --- .cspell.dict/cpython.txt | 3 +++ crates/capi/src/lib.rs | 1 + crates/capi/src/pystrcmp.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 crates/capi/src/pystrcmp.rs diff --git a/.cspell.dict/cpython.txt b/.cspell.dict/cpython.txt index 64a2e7479fc..da85c312898 100644 --- a/.cspell.dict/cpython.txt +++ b/.cspell.dict/cpython.txt @@ -140,6 +140,8 @@ metavars miscompiles mult multibytecodec +mystricmp +mystrnicmp nameobj nameop nargsf @@ -198,6 +200,7 @@ pymain pymem pyrepl pystate +pystrcmp PYTHONTRACEMALLOC PYTHONUTF8 pythonw diff --git a/crates/capi/src/lib.rs b/crates/capi/src/lib.rs index 424074a1292..08bb09bf3b5 100644 --- a/crates/capi/src/lib.rs +++ b/crates/capi/src/lib.rs @@ -34,6 +34,7 @@ pub mod pyframe; pub mod pylifecycle; pub mod pymem; pub mod pystate; +pub mod pystrcmp; pub mod refcount; pub mod setobject; pub mod sliceobject; diff --git a/crates/capi/src/pystrcmp.rs b/crates/capi/src/pystrcmp.rs new file mode 100644 index 00000000000..8055f97cd5c --- /dev/null +++ b/crates/capi/src/pystrcmp.rs @@ -0,0 +1,28 @@ +use core::ffi::c_char; + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyOS_mystricmp(str1: *const c_char, str2: *const c_char) -> i32 { + unsafe { PyOS_mystrnicmp(str1, str2, isize::MAX) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyOS_mystrnicmp( + str1: *const c_char, + str2: *const c_char, + size: isize, +) -> i32 { + let Ok(limit) = usize::try_from(size) else { + return 0; + }; + let mut index = 0usize; + while index < limit { + let left = unsafe { *str1.add(index) } as u8; + let right = unsafe { *str2.add(index) } as u8; + let diff = left.to_ascii_lowercase() as i32 - right.to_ascii_lowercase() as i32; + if diff != 0 || left == 0 || right == 0 { + return diff; + } + index += 1; + } + 0 +} From 176a12c1895d601f76336a656490b20391af3568 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:27:56 +0900 Subject: [PATCH 114/351] Bump actions/cache from 5.0.5 to 6.1.0 (#8273) --- updated-dependencies: - dependency-name: actions/cache dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/upgrade-pylib.lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/upgrade-pylib.lock.yml b/.github/workflows/upgrade-pylib.lock.yml index f9176f8694d..e5eefd6b9fc 100644 --- a/.github/workflows/upgrade-pylib.lock.yml +++ b/.github/workflows/upgrade-pylib.lock.yml @@ -114,7 +114,7 @@ jobs: run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh # Cache configuration from frontmatter processed below - name: Cache (cpython-lib-${{ env.PYTHON_VERSION }}) - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: cpython-lib-${{ env.PYTHON_VERSION }} path: cpython From 35845d5cc1e6bd90644af71f90119451bd507d7a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:28:09 +0900 Subject: [PATCH 115/351] Bump taiki-e/install-action from 2.82.6 to 2.82.9 (#8274) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.82.6 to 2.82.9. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/9bcaee1dcae34154180f412e2fa69355a7cda9f6...4684b8405694ae9dd42c9f39ba901a70ae83f4a3) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.82.9 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cron-ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cron-ci.yaml b/.github/workflows/cron-ci.yaml index ec86ec95afe..aba818b4f8f 100644 --- a/.github/workflows/cron-ci.yaml +++ b/.github/workflows/cron-ci.yaml @@ -33,7 +33,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - uses: taiki-e/install-action@9bcaee1dcae34154180f412e2fa69355a7cda9f6 # v2.82.6 + - uses: taiki-e/install-action@4684b8405694ae9dd42c9f39ba901a70ae83f4a3 # v2.82.9 with: tool: cargo-llvm-cov From 268a264c305523b06cd2ffc2ee29d295f0ad489f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:28:18 +0900 Subject: [PATCH 116/351] Bump actions/setup-python from 6.2.0 to 6.3.0 (#8275) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 6.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 6 +++--- .github/workflows/cron-ci.yaml | 6 +++--- .github/workflows/lib-deps-check.yaml | 2 +- .github/workflows/update-doc-db.yml | 2 +- .github/workflows/upgrade-pylib.lock.yml | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a029776912d..1125c178bec 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -343,7 +343,7 @@ jobs: # Windows runners randomly crashes, https://github.com/actions/cache/issues/1754 continue-on-error: true - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - name: Install macOS dependencies uses: ./.github/actions/install-macos-deps @@ -531,7 +531,7 @@ jobs: with: persist-credentials: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - uses: dtolnay/rust-toolchain@stable with: @@ -674,7 +674,7 @@ jobs: mkdir geckodriver tar -xzf geckodriver-v0.36.0-linux64.tar.gz -C geckodriver - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - run: python -m pip install -r requirements.txt working-directory: ./wasm/tests diff --git a/.github/workflows/cron-ci.yaml b/.github/workflows/cron-ci.yaml index aba818b4f8f..2839cf375ca 100644 --- a/.github/workflows/cron-ci.yaml +++ b/.github/workflows/cron-ci.yaml @@ -37,7 +37,7 @@ jobs: with: tool: cargo-llvm-cov - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - run: sudo apt-get update && sudo apt-get -y install lcov @@ -111,7 +111,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - name: build rustpython run: cargo build --release --verbose @@ -174,7 +174,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - run: cargo install cargo-criterion diff --git a/.github/workflows/lib-deps-check.yaml b/.github/workflows/lib-deps-check.yaml index eb4561daa63..efdae480db3 100644 --- a/.github/workflows/lib-deps-check.yaml +++ b/.github/workflows/lib-deps-check.yaml @@ -98,7 +98,7 @@ jobs: - name: Setup Python if: steps.changed-files.outputs.modules != '' - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - name: Run deps check if: steps.changed-files.outputs.modules != '' diff --git a/.github/workflows/update-doc-db.yml b/.github/workflows/update-doc-db.yml index c7dad17d252..0d9c37a3256 100644 --- a/.github/workflows/update-doc-db.yml +++ b/.github/workflows/update-doc-db.yml @@ -36,7 +36,7 @@ jobs: sparse-checkout: | crates/doc - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ inputs.python-version }} diff --git a/.github/workflows/upgrade-pylib.lock.yml b/.github/workflows/upgrade-pylib.lock.yml index e5eefd6b9fc..9790f37c53b 100644 --- a/.github/workflows/upgrade-pylib.lock.yml +++ b/.github/workflows/upgrade-pylib.lock.yml @@ -107,7 +107,7 @@ jobs: with: persist-credentials: false - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.14' - name: Create gh-aw temp directory From 4cb21885ae0720df957df4f86312afb8c096b327 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:28:25 +0900 Subject: [PATCH 117/351] Bump marocchino/sticky-pull-request-comment from 3.0.4 to 3.0.5 (#8276) Bumps [marocchino/sticky-pull-request-comment](https://github.com/marocchino/sticky-pull-request-comment) from 3.0.4 to 3.0.5. - [Release notes](https://github.com/marocchino/sticky-pull-request-comment/releases) - [Commits](https://github.com/marocchino/sticky-pull-request-comment/compare/0ea0beb66eb9baf113663a64ec522f60e49231c0...5770ad5eb8f42dd2c4f34da00c94c5381e49af88) --- updated-dependencies: - dependency-name: marocchino/sticky-pull-request-comment dependency-version: 3.0.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/lib-deps-check.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lib-deps-check.yaml b/.github/workflows/lib-deps-check.yaml index efdae480db3..7461fa6a2eb 100644 --- a/.github/workflows/lib-deps-check.yaml +++ b/.github/workflows/lib-deps-check.yaml @@ -114,7 +114,7 @@ jobs: - name: Post comment if: steps.deps-check.outputs.deps_output != '' - uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 with: header: lib-deps-check number: ${{ github.event.pull_request.number }} @@ -131,7 +131,7 @@ jobs: - name: Remove comment if no Lib changes if: steps.changed-files.outputs.modules == '' - uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 with: header: lib-deps-check number: ${{ github.event.pull_request.number }} From a41dd41c466d133d6d102365efdeb632222395c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:28:31 +0900 Subject: [PATCH 118/351] Bump https://github.com/rbubley/mirrors-prettier from v3.9.1 to 3.9.4 (#8277) Bumps [https://github.com/rbubley/mirrors-prettier](https://github.com/rbubley/mirrors-prettier) from v3.9.1 to 3.9.4. - [Commits](https://github.com/rbubley/mirrors-prettier/compare/v3.9.1...v3.9.4) --- updated-dependencies: - dependency-name: https://github.com/rbubley/mirrors-prettier dependency-version: 3.9.4 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a3cfaac09a4..bc8046ab06f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -77,7 +77,7 @@ repos: priority: 0 - repo: https://github.com/rbubley/mirrors-prettier - rev: v3.9.1 + rev: v3.9.4 hooks: - id: prettier files: '^wasm/.*$' From e491e3a96a37ebbcc8db291d1def4f5f358af5b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:28:37 +0900 Subject: [PATCH 119/351] Bump memchr from 2.8.2 to 2.8.3 (#8278) Bumps [memchr](https://github.com/BurntSushi/memchr) from 2.8.2 to 2.8.3. - [Commits](https://github.com/BurntSushi/memchr/compare/2.8.2...2.8.3) --- updated-dependencies: - dependency-name: memchr dependency-version: 2.8.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3a0392b188e..9be0f5cdf9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2260,9 +2260,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" From e78e1c402d6ec4449366064c163066aef22e037a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:28:45 +0900 Subject: [PATCH 120/351] Bump der from 0.8.0 to 0.8.1 (#8279) Bumps [der](https://github.com/RustCrypto/formats) from 0.8.0 to 0.8.1. - [Commits](https://github.com/RustCrypto/formats/compare/der/v0.8.0...der/v0.8.1) --- updated-dependencies: - dependency-name: der dependency-version: 0.8.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9be0f5cdf9b..81012908bd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1106,9 +1106,9 @@ dependencies = [ [[package]] name = "der" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ "const-oid 0.10.2", "pem-rfc7468 1.0.0", @@ -2707,7 +2707,7 @@ dependencies = [ "aes", "aes-gcm", "cbc", - "der 0.8.0", + "der 0.8.1", "pbkdf2", "rand_core 0.10.1", "scrypt", @@ -2721,7 +2721,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der 0.8.0", + "der 0.8.1", "pkcs5", "rand_core 0.10.1", "spki 0.8.0", @@ -3652,7 +3652,7 @@ dependencies = [ "crc32fast", "crossbeam-utils", "csv-core", - "der 0.8.0", + "der 0.8.1", "digest 0.11.3", "dyn-clone", "flame", @@ -4158,7 +4158,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der 0.8.0", + "der 0.8.1", ] [[package]] From 1f27564415d791fc70d235cb0b1398e2dfb9bda6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:28:52 +0900 Subject: [PATCH 121/351] Bump libz-rs-sys from 0.6.5 to 0.6.6 (#8280) Bumps [libz-rs-sys](https://github.com/trifectatechfoundation/zlib-rs) from 0.6.5 to 0.6.6. - [Release notes](https://github.com/trifectatechfoundation/zlib-rs/releases) - [Changelog](https://github.com/trifectatechfoundation/zlib-rs/blob/main/docs/release.md) - [Commits](https://github.com/trifectatechfoundation/zlib-rs/compare/v0.6.5...v0.6.6) --- updated-dependencies: - dependency-name: libz-rs-sys dependency-version: 0.6.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 81012908bd5..491c5851c14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2114,9 +2114,9 @@ dependencies = [ [[package]] name = "libz-rs-sys" -version = "0.6.5" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c12cd6e7e66c601f22d849241e2257b38b4685a34b41401f4aefdd9c431c1c6" +checksum = "50474818739ccab820cd57bca432d6b02d090b47f9e85501d963cd05851f82eb" dependencies = [ "zlib-rs", ] @@ -5239,9 +5239,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.5" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5431d5661c32445236631278f27946e444ddafe4684cac70b185272d4f9c52d5" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" [[package]] name = "zmij" From fd8866b594eaa3fcbb4585adf0b7829ed5e8ddef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=A7=80=EC=9B=85?= Date: Wed, 15 Jul 2026 23:36:43 +0900 Subject: [PATCH 122/351] module: honor module-level __dir__ (PEP 562) (#8271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * module: honor module-level __dir__ (PEP 562) dir(module) ignored a module-level __dir__ function and returned the raw __dict__ keys. Look up __dir__ in the module dict and, when present, call it and return its result — matching CPython's module___dir___impl. The existing expectedFailure markers on test_module_dir/test_module_dir_errors are removed since both now pass. Fixes #8231 Assisted-by: Claude Code:claude-opus-4-8 * test: unmark zoneinfo test_dir_contains_all (PEP 562 __dir__) Honoring a module-level __dir__ makes dir(zoneinfo) contain everything in __all__, so test_dir_contains_all now passes. Remove its stale expectedFailure marker to avoid an unexpected-success CI failure. Assisted-by: Claude Code:claude-opus-4-8 * test: unmark test_support.test_check__all__ (PEP 562 __dir__) check__all__ iterates dir(module); honoring a module-level __dir__ fixes dir(unittest), so test_check__all__ now passes. Remove its stale expectedFailure marker to avoid an unexpected-success CI failure. Assisted-by: Claude Code:claude-opus-4-8 --- Lib/test/test_module/__init__.py | 2 -- Lib/test/test_support.py | 1 - Lib/test/test_zoneinfo/test_zoneinfo.py | 1 - crates/vm/src/builtins/module.rs | 4 ++++ 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_module/__init__.py b/Lib/test/test_module/__init__.py index d4ed61648dd..22132b01c8a 100644 --- a/Lib/test/test_module/__init__.py +++ b/Lib/test/test_module/__init__.py @@ -151,13 +151,11 @@ def test_module_getattr_errors(self): if 'test.test_module.bad_getattr2' in sys.modules: del sys.modules['test.test_module.bad_getattr2'] - @unittest.expectedFailure # TODO: RUSTPYTHON def test_module_dir(self): import test.test_module.good_getattr as gga self.assertEqual(dir(gga), ['a', 'b', 'c']) del sys.modules['test.test_module.good_getattr'] - @unittest.expectedFailure # TODO: RUSTPYTHON def test_module_dir_errors(self): import test.test_module.bad_getattr as bga from test.test_module import bad_getattr2 diff --git a/Lib/test/test_support.py b/Lib/test/test_support.py index 6b3aa466d06..19ea6fafcf7 100644 --- a/Lib/test/test_support.py +++ b/Lib/test/test_support.py @@ -458,7 +458,6 @@ def test_detect_api_mismatch__ignore(self): self.OtherClass, self.RefClass, ignore=ignore) self.assertEqual(set(), missing_items) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_check__all__(self): extra = {'tempdir'} not_exported = {'template'} diff --git a/Lib/test/test_zoneinfo/test_zoneinfo.py b/Lib/test/test_zoneinfo/test_zoneinfo.py index e9516c0b127..638b4c52a4a 100644 --- a/Lib/test/test_zoneinfo/test_zoneinfo.py +++ b/Lib/test/test_zoneinfo/test_zoneinfo.py @@ -1970,7 +1970,6 @@ def test_getattr_error(self): with self.assertRaises(AttributeError): self.module.NOATTRIBUTE - @unittest.expectedFailure # TODO: RUSTPYTHON; dir(self.module) should at least contain everything in __all__. def test_dir_contains_all(self): """dir(self.module) should at least contain everything in __all__.""" module_all_set = set(self.module.__all__) diff --git a/crates/vm/src/builtins/module.rs b/crates/vm/src/builtins/module.rs index e6296755870..8fa5259f705 100644 --- a/crates/vm/src/builtins/module.rs +++ b/crates/vm/src/builtins/module.rs @@ -303,6 +303,10 @@ impl PyModule { let dict = dict_attr .downcast::() .map_err(|_| vm.new_type_error(".__dict__ is not a dictionary"))?; + // PEP 562: honor a module-level __dir__ if one is defined + if let Some(dir_func) = dict.get_item_opt(identifier!(vm, __dir__), vm)? { + return dir_func.call((), vm)?.try_to_value(vm); + } let attrs = dict.into_iter().map(|(k, _v)| k).collect(); Ok(attrs) } From a1130dd8169c0f1439b7c1ba4f7efa18d45e05e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EB=8F=99=EC=95=88?= Date: Wed, 15 Jul 2026 23:39:59 +0900 Subject: [PATCH 123/351] Make SystemExit.code a writable attribute set at init (#8282) * Make SystemExit.code a writable attribute set at init * Read exit status from SystemExit.code in handle_exit_exception * Store SystemExit.code in a struct field instead of the instance dict * Create SystemExit through the full constructor path --- crates/vm/src/exceptions.rs | 94 +++++++++++++++++++++++--------- crates/vm/src/stdlib/_thread.rs | 2 +- crates/vm/src/stdlib/builtins.rs | 2 +- crates/vm/src/vm/mod.rs | 45 ++++++++------- 4 files changed, 91 insertions(+), 52 deletions(-) diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index f7e1b79aa5a..1db10274e89 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -959,9 +959,7 @@ impl ExceptionZoo { "exceptions" => ctx.new_readonly_getset("exceptions", excs.base_exception_group, make_arg_getter(1)), }); - extend_exception!(PySystemExit, ctx, excs.system_exit, { - "code" => ctx.new_readonly_getset("code", excs.system_exit, system_exit_code), - }); + extend_exception!(PySystemExit, ctx, excs.system_exit); extend_exception!(PyKeyboardInterrupt, ctx, excs.keyboard_interrupt); extend_exception!(PyGeneratorExit, ctx, excs.generator_exit); @@ -1100,19 +1098,6 @@ fn syntax_error_set_msg(exc: PyBaseExceptionRef, value: PySetterValue, vm: &Virt *args = PyTuple::new_ref(new_args, &vm.ctx); } -fn system_exit_code(exc: PyBaseExceptionRef) -> Option { - // SystemExit.code based on args length: - // - size == 0: code is None - // - size == 1: code is args[0] - // - size > 1: code is args (the whole tuple) - let args = exc.args.read(); - Some(match args.len() { - 0 => return None, - 1 => args.first().unwrap().clone(), - _ => args.as_object().to_owned(), - }) -} - #[cfg(feature = "serde")] pub struct SerializeException<'vm, 's> { vm: &'vm VirtualMachine, @@ -1593,7 +1578,7 @@ pub(super) mod types { tuple::IntoPyTuple, }, convert::ToPyResult, - function::{ArgBytesLike, FuncArgs, KwArgs}, + function::{ArgBytesLike, FuncArgs, KwArgs, PySetterValue}, set_attrs, types::{Constructor, Initializer}, }; @@ -1624,22 +1609,65 @@ pub(super) mod types { pub(super) args: PyRwLock, } - #[pyexception(name, base = PyBaseException, ctx = "system_exit")] - #[derive(Debug)] - #[repr(transparent)] - pub struct PySystemExit(PyBaseException); + #[pyexception(name, base = PyBaseException, ctx = "system_exit", traverse = "manual")] + #[repr(C)] + pub struct PySystemExit { + base: PyBaseException, + code: PyAtomicRef>, + } - // SystemExit_init: has its own __init__ that sets the code attribute - #[pyexception(with(Initializer))] - impl PySystemExit {} + impl crate::class::PySubclass for PySystemExit { + type Base = PyBaseException; + fn as_base(&self) -> &Self::Base { + &self.base + } + } + + unsafe impl Traverse for PySystemExit { + fn traverse(&self, tracer_fn: &mut TraverseFn<'_>) { + self.base.traverse(tracer_fn); + if let Some(obj) = self.code.deref() { + tracer_fn(obj); + } + } + } + + impl core::fmt::Debug for PySystemExit { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("PySystemExit").finish_non_exhaustive() + } + } + + #[pyexception(with(Constructor, Initializer))] + impl PySystemExit { + #[pygetset] + fn code(&self) -> Option { + self.code.to_owned() + } + + #[pygetset(setter)] + fn set_code(&self, value: PySetterValue, vm: &VirtualMachine) { + let code = match value { + PySetterValue::Assign(v) => Some(v), + PySetterValue::Delete => None, + }; + self.code.swap_to_temporary_refs(code, vm); + } + } impl Initializer for PySystemExit { type Args = FuncArgs; fn slot_init(zelf: PyObjectRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult<()> { // Call BaseException_init first (handles args) - PyBaseException::slot_init(zelf, args, vm) - // Note: code is computed dynamically via system_exit_code getter - // so we don't need to set it here explicitly + let code = match args.args.len() { + 0 => vm.ctx.none(), + 1 => args.args[0].clone(), + _ => vm.ctx.new_tuple(args.args.clone()).into(), + }; + PyBaseException::slot_init(zelf.clone(), args, vm)?; + let exc: &Py = zelf.downcast_ref::().unwrap(); + exc.code.swap_to_temporary_refs(Some(code), vm); + Ok(()) } fn init(_zelf: PyRef, _args: Self::Args, _vm: &VirtualMachine) -> PyResult<()> { @@ -1647,6 +1675,18 @@ pub(super) mod types { } } + impl Constructor for PySystemExit { + type Args = FuncArgs; + + fn py_new(_cls: &Py, args: FuncArgs, vm: &VirtualMachine) -> PyResult { + let base_exception = PyBaseException::new(args.args, vm); + Ok(Self { + base: base_exception, + code: None.into(), + }) + } + } + #[pyexception(name, base = PyBaseException, ctx = "generator_exit", impl)] #[derive(Debug)] #[repr(transparent)] diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index 99f9b1787ed..9caa15dbfee 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -623,7 +623,7 @@ pub(crate) mod _thread { #[pyfunction] fn exit(vm: &VirtualMachine) -> PyResult { - Err(vm.new_exception_empty(vm.ctx.exceptions.system_exit.to_owned())) + Err(vm.invoke_exception(vm.ctx.exceptions.system_exit.to_owned(), vec![])?) } thread_local!(static SENTINELS: RefCell>> = const { RefCell::new(Vec::new()) }); diff --git a/crates/vm/src/stdlib/builtins.rs b/crates/vm/src/stdlib/builtins.rs index 8ea849ab05b..27f30158b22 100644 --- a/crates/vm/src/stdlib/builtins.rs +++ b/crates/vm/src/stdlib/builtins.rs @@ -1041,7 +1041,7 @@ mod builtins { #[pyfunction] pub(super) fn exit(exit_code_arg: OptionalArg, vm: &VirtualMachine) -> PyResult { let code = exit_code_arg.unwrap_or_else(|| vm.ctx.new_int(0).into()); - Err(vm.new_exception(vm.ctx.exceptions.system_exit.to_owned(), vec![code])) + Err(vm.invoke_exception(vm.ctx.exceptions.system_exit.to_owned(), vec![code])?) } #[derive(Debug, Default, FromArgs)] diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 852e57cc0b4..0f2e6a46370 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -2161,7 +2161,7 @@ impl VirtualMachine { if self.state.finalizing.load(Ordering::Acquire) && !self.is_main_thread() { // once finalization starts, // non-main Python threads should stop running bytecode. - return Err(self.new_exception(self.ctx.exceptions.system_exit.to_owned(), vec![])); + return Err(self.invoke_exception(self.ctx.exceptions.system_exit.to_owned(), vec![])?); } // Suspend this thread if stop-the-world is in progress @@ -2303,29 +2303,28 @@ impl VirtualMachine { pub fn handle_exit_exception(&self, exc: PyBaseExceptionRef) -> u32 { if exc.fast_isinstance(self.ctx.exceptions.system_exit) { - let args = exc.args(); - let msg = match args.as_slice() { - [] => return 0, - [arg] => match_class!(match arg { - ref i @ PyInt => { - use num_traits::cast::ToPrimitive; - // Try u32 first, then i32 (for negative values), else -1 for overflow - let code = i - .as_bigint() - .to_u32() - .or_else(|| i.as_bigint().to_i32().map(|v| v as u32)) - .unwrap_or(-1i32 as u32); - return code; - } - arg => { - if self.is_none(arg) { - return 0; - } - arg.str(self).ok() + let code = exc + .as_object() + .get_attr("code", self) + .unwrap_or_else(|_| exc.as_object().to_owned()); + let msg = match_class!(match code { + ref i @ PyInt => { + use num_traits::cast::ToPrimitive; + // Try u32 first, then i32 (for negative values), else -1 for overflow + let code = i + .as_bigint() + .to_u32() + .or_else(|| i.as_bigint().to_i32().map(|v| v as u32)) + .unwrap_or(-1i32 as u32); + return code; + } + code => { + if self.is_none(&code) { + return 0; } - }), - _ => args.as_object().repr(self).ok(), - }; + code.str(self).ok() + } + }); if let Some(msg) = msg { // Write using Python's write() to use stderr's error handler (backslashreplace) if let Ok(stderr) = stdlib::sys::get_stderr(self) { From c85e31ede3bc3e150c61e3a92618ab808d4d1367 Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:41:18 +0200 Subject: [PATCH 124/351] Add descriptor support to c-api (#8283) * Add descriptor support to c-api * Review * Extract getset builder * Extract memberdef builder --- crates/capi/src/descrobject.rs | 249 ++++++++++++++++++++++++++++++++- crates/vm/src/builtins/mod.rs | 4 + 2 files changed, 251 insertions(+), 2 deletions(-) diff --git a/crates/capi/src/descrobject.rs b/crates/capi/src/descrobject.rs index b0d24667dc7..ecc0b53f82a 100644 --- a/crates/capi/src/descrobject.rs +++ b/crates/capi/src/descrobject.rs @@ -1,7 +1,201 @@ use crate::PyObject; +use crate::methodobject::{PyMethodDef, build_method_def}; +use crate::object::PyTypeObject; use crate::pystate::with_vm; -use rustpython_vm::PyPayload; -use rustpython_vm::builtins::PyMappingProxy; +use core::ffi::{CStr, c_char, c_int, c_void}; +use core::ptr::NonNull; +use rustpython_vm::builtins::{ + DescriptorMemberDef, MemberGetter, MemberKind, MemberSetter, PyDescriptorOwned, PyGetSet, + PyMappingProxy, PyMemberDescriptor, PyType, +}; +use rustpython_vm::common::lock::PyRwLock; +use rustpython_vm::function::PySetterValue; +use rustpython_vm::{Py, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine}; + +#[repr(C)] +pub struct PyGetSetDef { + pub name: *const c_char, + pub get: + Option *mut PyObject>, + pub set: Option< + unsafe extern "C" fn( + slf: *mut PyObject, + value: *mut PyObject, + closure: *mut c_void, + ) -> c_int, + >, + pub doc: *const c_char, + pub closure: *mut c_void, +} + +impl PyGetSetDef { + pub(crate) fn build( + &self, + ty: &'static Py, + vm: &VirtualMachine, + ) -> PyResult> { + let name = unsafe { CStr::from_ptr(self.name) } + .to_str() + .map_err(|_| vm.new_system_error("PyGetSetDef name was not valid UTF-8"))?; + let closure = self.closure as usize; + + let descriptor = match (self.get, self.set) { + (Some(get), Some(set)) => vm.ctx.new_static_getset( + name, + ty, + move |obj: PyObjectRef, vm: &VirtualMachine| -> PyResult { + unsafe { + let closure = closure as *mut c_void; + let ret_ptr = get(obj.as_raw().cast_mut(), closure); + let ret_ptr = NonNull::new(ret_ptr).ok_or_else(|| { + vm.take_raised_exception().unwrap_or_else(|| { + vm.new_system_error( + "Native function returned NULL, but there was no exception set", + ) + }) + })?; + Ok(PyObjectRef::from_raw(ret_ptr)) + } + }, + move |obj: PyObjectRef, value: PySetterValue, vm: &VirtualMachine| unsafe { + let closure = closure as *mut c_void; + let value = value.unwrap_or_none(vm); + let result = set(obj.as_raw().cast_mut(), value.as_raw().cast_mut(), closure); + if result == 0 { + Ok(()) + } else { + Err(vm.take_raised_exception().unwrap_or_else(|| { + vm.new_system_error( + "C setter returned error but did not set an exception", + ) + })) + } + }, + ), + (Some(get), None) => vm.ctx.new_readonly_getset( + name, + ty, + move |obj: PyObjectRef, vm: &VirtualMachine| -> PyResult { + unsafe { + let closure = closure as *mut c_void; + let ret_ptr = get(obj.as_raw().cast_mut(), closure); + let ret_ptr = NonNull::new(ret_ptr).ok_or_else(|| { + vm.take_raised_exception().unwrap_or_else(|| { + vm.new_system_error( + "Native function returned NULL, but there was no exception set", + ) + }) + })?; + Ok(PyObjectRef::from_raw(ret_ptr)) + } + }, + ), + (None, Some(set)) => vm.ctx.new_static_getset( + name, + ty, + move |_obj: PyObjectRef, vm: &VirtualMachine| -> PyResult { + Err(vm.new_attribute_error("unreadable attribute")) + }, + move |obj: PyObjectRef, value: PySetterValue, vm: &VirtualMachine| unsafe { + let closure = closure as *mut c_void; + let value = value.unwrap_or_none(vm); + let result = set(obj.as_raw().cast_mut(), value.as_raw().cast_mut(), closure); + if result == 0 { + Ok(()) + } else { + Err(vm.take_raised_exception().unwrap_or_else(|| { + vm.new_system_error( + "C setter returned error but did not set an exception", + ) + })) + } + }, + ), + (None, None) => vm.ctx.new_readonly_getset( + name, + ty, + move |_obj: PyObjectRef, vm: &VirtualMachine| -> PyResult { + Err(vm.new_attribute_error("unreadable attribute")) + }, + ), + }; + + Ok(descriptor) + } +} + +#[repr(C)] +pub struct PyMemberDef { + pub name: *const c_char, + pub type_code: c_int, + pub offset: isize, + pub flags: c_int, + pub doc: *const c_char, +} + +impl PyMemberDef { + const PY_READONLY: c_int = 1; + const PY_RELATIVE_OFFSET: c_int = 8; + + pub(crate) fn build( + &self, + ty: &Py, + vm: &VirtualMachine, + ) -> PyResult> { + let name = unsafe { CStr::from_ptr(self.name) } + .to_str() + .map_err(|_| vm.new_system_error("PyMemberDef name was not valid UTF-8"))?; + let kind = match self.type_code { + 6 => MemberKind::Object, + 16 => MemberKind::ObjectEx, + 14 => MemberKind::Bool, + _ => { + return Err(vm.new_system_error(format!( + "PyDescr_NewMember does not support member type code {}", + self.type_code + ))); + } + }; + if self.offset < 0 { + return Err(vm.new_system_error("PyDescr_NewMember does not support negative offsets")); + } + if self.flags & Self::PY_RELATIVE_OFFSET != 0 { + return Err( + vm.new_system_error("PyDescr_NewMember does not support Py_RELATIVE_OFFSET") + ); + } + + let doc = NonNull::new(self.doc.cast_mut()) + .map(|doc| { + unsafe { CStr::from_ptr(doc.as_ptr()) } + .to_str() + .map(|s| s.to_owned()) + .map_err(|_| vm.new_system_error("PyMemberDef doc was not valid UTF-8")) + }) + .transpose()?; + + let descriptor = PyMemberDescriptor { + common: PyDescriptorOwned { + typ: ty.to_owned(), + name: vm.ctx.intern_str(name), + qualname: PyRwLock::new(None), + }, + member: DescriptorMemberDef { + name: name.to_owned(), + kind, + getter: MemberGetter::Offset(self.offset as usize), + setter: if self.flags & Self::PY_READONLY != 0 { + MemberSetter::Setter(None) + } else { + MemberSetter::Offset(self.offset as usize) + }, + doc, + }, + }; + + Ok(descriptor.into_ref(&vm.ctx)) + } +} #[unsafe(no_mangle)] pub unsafe extern "C" fn PyDictProxy_New(mapping: *mut PyObject) -> *mut PyObject { @@ -11,6 +205,57 @@ pub unsafe extern "C" fn PyDictProxy_New(mapping: *mut PyObject) -> *mut PyObjec }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyDescr_NewMethod( + typ: *mut PyTypeObject, + method: *mut PyMethodDef, +) -> *mut PyObject { + with_vm(|vm| { + let method = build_method_def(vm, unsafe { &*method }, true)?; + Ok(method.build_method(unsafe { &*typ }, vm)) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyDescr_NewClassMethod( + typ: *mut PyTypeObject, + method: *mut PyMethodDef, +) -> *mut PyObject { + with_vm(|vm| { + let method = build_method_def(vm, unsafe { &*method }, true)?; + Ok(method.build_method(unsafe { &*typ }, vm)) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyDescr_NewGetSet( + typ: *mut PyTypeObject, + getset: *mut PyGetSetDef, +) -> *mut PyObject { + with_vm(|vm| unsafe { &*getset }.build(unsafe { &*typ }, vm)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyDescr_NewMember( + typ: *mut PyTypeObject, + member: *mut PyMemberDef, +) -> *mut PyObject { + with_vm(|vm| Ok(unsafe { &*member }.build(unsafe { &*typ }, vm))) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyWrapper_New(descr: *mut PyObject, obj: *mut PyObject) -> *mut PyObject { + with_vm(|vm| { + let descr = unsafe { &*descr }; + let obj = unsafe { &*obj }; + vm.call_special_method( + descr, + vm.ctx.names.__get__, + (obj.to_owned(), obj.class().to_owned()), + ) + }) +} + #[cfg(test)] mod tests { use pyo3::prelude::*; diff --git a/crates/vm/src/builtins/mod.rs b/crates/vm/src/builtins/mod.rs index ffc01b00f29..f08a2b46721 100644 --- a/crates/vm/src/builtins/mod.rs +++ b/crates/vm/src/builtins/mod.rs @@ -99,6 +99,10 @@ pub use zip::PyZip; pub(crate) mod union_; pub use union_::{PyUnion, make_union}; pub(crate) mod descriptor; +pub use descriptor::{ + MemberGetter, MemberKind, MemberSetter, PyDescriptorOwned, PyMemberDef as DescriptorMemberDef, + PyMemberDescriptor, +}; pub use float::float_from_string as parse_float_from_string; pub use float::try_to_bigint as try_f64_to_bigint; From 74b598ecac8282048b7bb72bfd13c337aa02cabf Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:41:36 +0200 Subject: [PATCH 125/351] Add AsyncIterator support to c-api (#8285) --- crates/capi/src/abstract_/iter.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/capi/src/abstract_/iter.rs b/crates/capi/src/abstract_/iter.rs index fbd1440e0d0..a0827f62537 100644 --- a/crates/capi/src/abstract_/iter.rs +++ b/crates/capi/src/abstract_/iter.rs @@ -9,6 +9,15 @@ pub unsafe extern "C" fn PyIter_Check(obj: *mut PyObject) -> c_int { with_vm(|_vm| Ok(PyIter::check(unsafe { &*obj }))) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyAIter_Check(obj: *mut PyObject) -> c_int { + with_vm(|vm| { + Ok(unsafe { &*obj } + .class() + .has_attr(rustpython_vm::identifier!(vm, __anext__))) + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyObject_GetIter(obj: *mut PyObject) -> *mut PyObject { with_vm(|vm| { @@ -17,6 +26,11 @@ pub unsafe extern "C" fn PyObject_GetIter(obj: *mut PyObject) -> *mut PyObject { }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyObject_GetAIter(obj: *mut PyObject) -> *mut PyObject { + with_vm(|vm| unsafe { &*obj }.get_aiter(vm)) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyIter_NextItem(iter: *mut PyObject, item: *mut *mut PyObject) -> c_int { with_vm(|vm| { From bb49898b7b04a7db8b0d69dfd5e61e103f9b5506 Mon Sep 17 00:00:00 2001 From: SeungJe Jeong <88067455+seungje0612@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:43:29 +0900 Subject: [PATCH 126/351] Fix overflow handling for inplace sequence repetition (#8270) --- Lib/test/test_list.py | 1 - crates/vm/src/sequence.rs | 6 ++++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_list.py b/Lib/test/test_list.py index e320a2008a8..40cec226183 100644 --- a/Lib/test/test_list.py +++ b/Lib/test/test_list.py @@ -106,7 +106,6 @@ def test_empty_slice(self): x[:] = x self.assertEqual(x, []) - @unittest.skip("TODO: RUSTPYTHON; crash") def test_list_resize_overflow(self): # gh-97616: test new_allocated * sizeof(PyObject*) overflow # check in list_resize() diff --git a/crates/vm/src/sequence.rs b/crates/vm/src/sequence.rs index 0bc12fd2631..0bc35181b38 100644 --- a/crates/vm/src/sequence.rs +++ b/crates/vm/src/sequence.rs @@ -122,6 +122,12 @@ where fn imul(&mut self, vm: &VirtualMachine, n: isize) -> PyResult<()> { let n = vm.check_repeat_or_overflow_error(self.as_ref().len(), n)?; + + if n > 1 && core::mem::size_of_val(self.as_ref()) >= MAX_MEMORY_SIZE / n { + // TODO: make a global static NoMemory shared exc object and return its reference. + return Err(vm.new_memory_error("")); + } + if n == 0 { self.as_vec_mut().clear(); } else if n != 1 { From c1bc99afbd16d71451d5f5abd1ebf15596df670a Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:45:51 +0900 Subject: [PATCH 127/351] Improve unhashable set element errors (#8286) Assisted-by: Codex:gpt-5.6 --- Lib/test/test_set.py | 1 - crates/vm/src/builtins/set.rs | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_set.py b/Lib/test/test_set.py index d88f2c598f1..a0b50df8f21 100644 --- a/Lib/test/test_set.py +++ b/Lib/test/test_set.py @@ -657,7 +657,6 @@ def test_set_membership(self): self.assertRaises(KeyError, myset.remove, set(range(1))) self.assertRaises(KeyError, myset.remove, set(range(3))) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_unhashable_element(self): myset = {'a'} elem = [1, 2, 3] diff --git a/crates/vm/src/builtins/set.rs b/crates/vm/src/builtins/set.rs index 44dc9a22180..a8f3e83c830 100644 --- a/crates/vm/src/builtins/set.rs +++ b/crates/vm/src/builtins/set.rs @@ -223,7 +223,9 @@ impl PySetInner { } fn contains(&self, needle: &PyObject, vm: &VirtualMachine) -> PyResult { - self.retry_op_with_frozenset(needle, vm, |needle, vm| self.content.contains(vm, needle)) + let result = self + .retry_op_with_frozenset(needle, vm, |needle, vm| self.content.contains(vm, needle)); + Self::wrap_unhashable_error(result, needle, vm) } fn compare(&self, other: &Self, op: PyComparisonOp, vm: &VirtualMachine) -> PyResult { @@ -327,15 +329,20 @@ impl PySetInner { } fn add(&self, item: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { - self.content.insert(vm, &*item, ()) + let result = self.content.insert(vm, &*item, ()); + Self::wrap_unhashable_error(result, &item, vm) } fn remove(&self, item: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { - self.retry_op_with_frozenset(&item, vm, |item, vm| self.content.delete(vm, item)) + let result = + self.retry_op_with_frozenset(&item, vm, |item, vm| self.content.delete(vm, item)); + Self::wrap_unhashable_error(result, &item, vm) } fn discard(&self, item: &PyObject, vm: &VirtualMachine) -> PyResult { - self.retry_op_with_frozenset(item, vm, |item, vm| self.content.delete_if_exists(vm, item)) + let result = self + .retry_op_with_frozenset(item, vm, |item, vm| self.content.delete_if_exists(vm, item)); + Self::wrap_unhashable_error(result, item, vm) } fn clear(&self) { @@ -488,6 +495,25 @@ impl PySetInner { }) }) } + + fn wrap_unhashable_error( + result: PyResult, + item: &PyObject, + vm: &VirtualMachine, + ) -> PyResult { + match result { + Err(cause) if cause.fast_isinstance(vm.ctx.exceptions.type_error) => { + let message = cause.as_object().str(vm)?; + let err = vm.new_type_error(format!( + "cannot use '{}' as a set element ({message})", + item.class().name() + )); + err.set___cause__(Some(cause)); + Err(err) + } + result => result, + } + } } fn extract_set(obj: &PyObject) -> Option<&PySetInner> { From e548114f5ba03c5d14b94ed9ad77998e1421c36c Mon Sep 17 00:00:00 2001 From: Sumi Jeong <125195487+sigmaith@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:39:48 +0900 Subject: [PATCH 128/351] `_queue`: check for pending signals during blocking `get()` (#8252) * _queue: check for pending signals during blocking get() Semaphore::acquire() waited on the condvar in a single uninterrupted call, so a Python-level signal handler never ran until the wait itself returned. check_signals() is only invoked from the bytecode dispatch loop, and this native wait never passed through it -- CPython chunks its equivalent blocking wait for the same reason. Cap each wait at a short interval and call check_signals() between chunks, only giving up once the caller's real deadline has actually elapsed. The mutex guard must be dropped before check_signals() runs: a signal handler that calls back into the same queue on the same thread would otherwise try to relock a mutex it's still holding and deadlock against itself. Add a regression snippet that arms a SIGALRM well before a helper thread unblocks the queue, and asserts the handler ran near the alarm instead of only once get() returned. Refs #8250 Assisted-by: Claude * _queue: correct SIGNAL_CHECK_INTERVAL doc comment The old comment claimed this mirrors CPython's PyThread_acquire_lock_timed, but CPython doesn't chunk its wait either -- it makes one sem_timedwait call and relies on the OS reporting EINTR. parking_lot's Condvar has no equivalent signal to surface, so polling is a workaround for that gap, not a mirror of CPython's approach. Assisted-by: Claude * Apply suggestions from code review Co-authored-by: Jeong, YunWon <69878+youknowone@users.noreply.github.com> * _queue: fix comment indentation (cargo fmt) Assisted-by: Claude --------- Co-authored-by: Jeong, YunWon <69878+youknowone@users.noreply.github.com> --- crates/stdlib/src/_queue.rs | 72 +++++++++++++++------ extra_tests/snippets/stdlib_queue_signal.py | 52 +++++++++++++++ 2 files changed, 103 insertions(+), 21 deletions(-) create mode 100644 extra_tests/snippets/stdlib_queue_signal.py diff --git a/crates/stdlib/src/_queue.rs b/crates/stdlib/src/_queue.rs index 65456bd355b..6b150e4c68b 100644 --- a/crates/stdlib/src/_queue.rs +++ b/crates/stdlib/src/_queue.rs @@ -31,6 +31,17 @@ mod _queue { const INITIAL_RING_BUF_CAPACITY: usize = 8; + /// `parking_lot`'s `Condvar` doesn't expose a mid-wait signal to us (unlike + /// CPython's raw `sem_timedwait`, which reports `EINTR`), so we poll instead. + // FIXME: interim stopgap. The signal already interrupts the wait with EINTR + // (SA_RESTART cleared via `siginterrupt`), but `parking_lot::Condvar` swallows + // it and re-parks, forcing this poll. Replace with a shared interruptible + // timed-wait that surfaces EINTR (`poll`/wakeup-fd, portable incl. macOS; or + // `sem_timedwait` where available) -- `_thread` lock and `Thread.join` share + // this defect. Then this constant and the chunking loop go away. + #[cfg(feature = "threading")] + const SIGNAL_CHECK_INTERVAL: Duration = Duration::from_millis(50); + #[pyattr] #[pyclass(module = "_queue", name = "Empty", base = PyException)] #[repr(transparent)] @@ -72,31 +83,50 @@ mod _queue { self.cond.notify_one(); } - /// Returns `true` if the semaphore was acquired, `false` on timeout. - #[must_use] - fn acquire(&self, block: bool, deadline: Option, vm: &VirtualMachine) -> bool { - let mut count = self.mutex.lock(); + /// `Ok(true)` if acquired, `Ok(false)` on timeout, `Err` if a signal + /// handler raised (e.g. `KeyboardInterrupt`) while we were waiting. + fn acquire( + &self, + block: bool, + deadline: Option, + vm: &VirtualMachine, + ) -> PyResult { loop { - if *count > 0 { - *count -= 1; - return true; - } + // Guard must be dropped before check_signals() below, since a + // signal handler may call back into this same queue. + { + let mut count = self.mutex.lock(); + + if *count > 0 { + *count -= 1; + return Ok(true); + } - if !block { - return false; - } + if !block { + return Ok(false); + } + + let now = Instant::now(); + let chunk_deadline = deadline.map_or_else( + || now + SIGNAL_CHECK_INTERVAL, + |dl| dl.min(now + SIGNAL_CHECK_INTERVAL), + ); - match deadline { - Some(dl) => { - let result = vm.allow_threads(|| self.cond.wait_until(&mut count, dl)); - if result.timed_out() && *count == 0 { - return false; - } + vm.allow_threads(|| self.cond.wait_until(&mut count, chunk_deadline)); + + if *count > 0 { + *count -= 1; + return Ok(true); } - None => { - vm.allow_threads(|| self.cond.wait(&mut count)); + + if let Some(dl) = deadline + && Instant::now() >= dl + { + return Ok(false); } } + + vm.check_signals()?; } } } @@ -227,7 +257,7 @@ mod _queue { #[cfg(feature = "threading")] { - if !self.sem.acquire(block, deadline, vm) { + if !self.sem.acquire(block, deadline, vm)? { return Err(empty_error(vm)); } } @@ -239,7 +269,7 @@ mod _queue { fn get_nowait(&self, vm: &VirtualMachine) -> PyResult { #[cfg(feature = "threading")] { - if !self.sem.acquire(false, None, vm) { + if !self.sem.acquire(false, None, vm)? { return Err(empty_error(vm)); } } diff --git a/extra_tests/snippets/stdlib_queue_signal.py b/extra_tests/snippets/stdlib_queue_signal.py new file mode 100644 index 00000000000..fce6bcbb102 --- /dev/null +++ b/extra_tests/snippets/stdlib_queue_signal.py @@ -0,0 +1,52 @@ +"""A blocking queue.SimpleQueue.get() must stay responsive to signals. + +SimpleQueue.get() waits on a Condvar. A single uninterrupted wait blocks +Python-level signal handlers (including the default KeyboardInterrupt) until +the wait itself returns, since signals are only delivered at bytecode +safepoints. A regression shows up as the handler firing only once get() +unblocks, instead of promptly when the signal actually arrives. +""" + +import queue +import signal +import sys +import threading +import time + +if sys.platform.startswith("win"): + print("skipped (no SIGALRM)") + raise SystemExit(0) + +q = queue.SimpleQueue() +start = time.time() +handled_at = [] + + +def handler(signum, frame): + handled_at.append(time.time() - start) + + +signal.signal(signal.SIGALRM, handler) +signal.setitimer(signal.ITIMER_REAL, 0.3) + + +def unblock_later(): + time.sleep(1.5) + q.put("unblock") + + +threading.Thread(target=unblock_later, daemon=True).start() + +item = q.get() # blocks until unblock_later() wakes us up +elapsed = time.time() - start + +assert item == "unblock", item +assert handled_at, "signal handler never ran" +# The handler should fire around t=0.3s, when the timer was started, not t=1.5s +# (when get() finally unblocked). +assert handled_at[0] < elapsed - 0.5, ( + f"signal handled at {handled_at[0]:.2f}s but get() only returned at " + f"{elapsed:.2f}s -- signal was not processed while blocked" +) + +print("ok") From 5154e7e5a269bd3945456f8669b66eb0dd11f4c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=A7=84=EB=AA=85?= Date: Thu, 16 Jul 2026 17:42:02 +0900 Subject: [PATCH 129/351] csv: apply dialect quoting and lineterminator in writer (#8254) * csv: map QUOTE_MINIMAL to Necessary quote style QuoteStyle::Minimal was mapped to csv_core's Always, which quoted every field even under QUOTE_MINIMAL. Map it to Necessary so only fields that require quoting are quoted. Unmarks Test_Csv.test_write_quoting. Assisted-by: Claude Code:claude-opus-4-8 * csv: apply dialect quoting and lineterminator in writer csv.writer ignored the quoting and lineterminator from a dialect (whether passed by name or object), always using the defaults. Resolve both from the dialect via get_quoting()/get_lineterminator(), mirroring get_delimiter(), so an explicit keyword argument still overrides the dialect. Unmarks TestDialectUnix.test_simple_writer. Assisted-by: Claude Code:claude-opus-4-8 --- Lib/test/test_csv.py | 2 -- crates/stdlib/src/csv.rs | 50 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py index 86f1cf9bcb9..13b68cc2255 100644 --- a/Lib/test/test_csv.py +++ b/Lib/test/test_csv.py @@ -213,7 +213,6 @@ def test_write_bigfield(self): self._write_test([bigstring,bigstring], '%s,%s' % \ (bigstring, bigstring)) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_write_quoting(self): self._write_test(['a',1,'p,q'], 'a,1,"p,q"') self._write_error_test(csv.Error, ['a',1,'p,q'], @@ -863,7 +862,6 @@ def test_read_escape_fieldsep(self): class TestDialectUnix(TestCsvBase): dialect = 'unix' - @unittest.expectedFailure # TODO: RUSTPYTHON def test_simple_writer(self): self.writerAssertEqual([[1, 'abc def', 'abc']], '"1","abc def","abc"\n') diff --git a/crates/stdlib/src/csv.rs b/crates/stdlib/src/csv.rs index 48ad68d43ac..3fbafab8dca 100644 --- a/crates/stdlib/src/csv.rs +++ b/crates/stdlib/src/csv.rs @@ -462,7 +462,7 @@ mod _csv { impl From for csv_core::QuoteStyle { fn from(val: QuoteStyle) -> Self { match val { - QuoteStyle::Minimal => Self::Always, + QuoteStyle::Minimal => Self::Necessary, QuoteStyle::All => Self::Always, QuoteStyle::Nonnumeric => Self::NonNumeric, QuoteStyle::None => Self::Never, @@ -796,6 +796,48 @@ mod _csv { delimiter } + fn get_lineterminator(&self) -> csv_core::Terminator { + let mut lineterminator = match &self.dialect { + DialectItem::Str(name) => { + let g = GLOBAL_HASHMAP.lock(); + if let Some(dialect) = g.get(name) { + dialect.lineterminator + } else { + Terminator::CRLF + } + } + DialectItem::Obj(obj) => obj.lineterminator, + _ => Terminator::CRLF, + }; + + if let Some(attr) = self.lineterminator { + lineterminator = attr + } + + lineterminator + } + + fn get_quoting(&self) -> QuoteStyle { + let mut quoting = match &self.dialect { + DialectItem::Str(name) => { + let g = GLOBAL_HASHMAP.lock(); + if let Some(dialect) = g.get(name) { + dialect.quoting + } else { + QuoteStyle::Minimal + } + } + DialectItem::Obj(obj) => obj.quoting, + _ => QuoteStyle::Minimal, + }; + + if let Some(attr) = self.quoting { + quoting = attr + } + + quoting + } + fn to_reader(&self) -> csv_core::Reader { let dialect = match &self.dialect { DialectItem::Str(name) => GLOBAL_HASHMAP.lock().get(name).copied(), @@ -900,15 +942,13 @@ mod _csv { writer = writer.double_quote(t); } - writer = writer.terminator(self.lineterminator.unwrap_or(Terminator::CRLF)); + writer = writer.terminator(self.get_lineterminator()); if let Some(e) = self.escapechar { writer = writer.escape(e); } - if let Some(e) = self.quoting { - writer = writer.quote_style(e.into()); - } + writer = writer.quote_style(self.get_quoting().into()); writer.build() } From 019a19655e6618eb33575dc866f661227492f0ad Mon Sep 17 00:00:00 2001 From: Lee Dogeon Date: Fri, 17 Jul 2026 00:18:26 +0900 Subject: [PATCH 130/351] Remove trailing whitespace from sequence TODO (#8291) Apply the maintainer review suggestion from RustPython/RustPython#8270. Codex inspected the review context, made the one-line whitespace-only edit, and ran validation. Assisted-by: Codex:gpt-5 --- crates/vm/src/sequence.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/vm/src/sequence.rs b/crates/vm/src/sequence.rs index 0bc35181b38..4e6ed97f21c 100644 --- a/crates/vm/src/sequence.rs +++ b/crates/vm/src/sequence.rs @@ -124,7 +124,7 @@ where let n = vm.check_repeat_or_overflow_error(self.as_ref().len(), n)?; if n > 1 && core::mem::size_of_val(self.as_ref()) >= MAX_MEMORY_SIZE / n { - // TODO: make a global static NoMemory shared exc object and return its reference. + // TODO: make a global static NoMemory shared exc object and return its reference. return Err(vm.new_memory_error("")); } From 0b4e5daa18f16068f87492594876300d0c0b2f43 Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:19:31 +0200 Subject: [PATCH 131/351] Add codecs support to c-api (#8267) * Add codecs support to c-apo * Reduce code duplication --- crates/capi/src/abstract_.rs | 7 +- crates/capi/src/abstract_/mapping.rs | 21 +-- crates/capi/src/codecs.rs | 210 +++++++++++++++++++++++++++ crates/capi/src/descrobject.rs | 20 +-- crates/capi/src/dictobject.rs | 19 +-- crates/capi/src/import.rs | 16 +- crates/capi/src/lib.rs | 1 + crates/capi/src/methodobject.rs | 15 +- crates/capi/src/object.rs | 27 +--- crates/capi/src/pycapsule.rs | 5 +- crates/capi/src/pyerrors.rs | 27 ++-- crates/capi/src/unicodeobject.rs | 37 +---- crates/capi/src/util.rs | 30 ++++ crates/capi/src/warnings.rs | 23 +-- 14 files changed, 305 insertions(+), 153 deletions(-) create mode 100644 crates/capi/src/codecs.rs diff --git a/crates/capi/src/abstract_.rs b/crates/capi/src/abstract_.rs index 36d949a3022..08b4e540029 100644 --- a/crates/capi/src/abstract_.rs +++ b/crates/capi/src/abstract_.rs @@ -1,6 +1,7 @@ +use crate::util::CStrExt; use crate::{PyObject, pystate::with_vm}; use alloc::slice; -use core::ffi::{CStr, c_char, c_int}; +use core::ffi::{c_char, c_int}; pub use iter::*; pub use mapping::*; pub use number::*; @@ -208,9 +209,7 @@ pub unsafe extern "C" fn PyObject_DelItem(obj: *mut PyObject, key: *mut PyObject pub unsafe extern "C" fn PyObject_DelItemString(obj: *mut PyObject, key: *const c_char) -> c_int { with_vm(|vm| { let obj = unsafe { &*obj }; - let key = unsafe { CStr::from_ptr(key) } - .to_str() - .map_err(|_| vm.new_value_error("mapping key must be valid UTF-8"))?; + let key = unsafe { key.try_as_str(vm) }?; obj.del_item(key, vm) }) } diff --git a/crates/capi/src/abstract_/mapping.rs b/crates/capi/src/abstract_/mapping.rs index 6fec18bffd6..840a9aed69c 100644 --- a/crates/capi/src/abstract_/mapping.rs +++ b/crates/capi/src/abstract_/mapping.rs @@ -1,5 +1,6 @@ +use crate::util::CStrExt; use crate::{PyObject, pystate::with_vm}; -use core::ffi::{CStr, c_char, c_int}; +use core::ffi::{c_char, c_int}; use rustpython_vm::AsObject; #[unsafe(no_mangle)] @@ -60,9 +61,7 @@ pub unsafe extern "C" fn PyMapping_GetItemString( ) -> *mut PyObject { with_vm(|vm| { let obj = unsafe { &*obj }; - let key = unsafe { CStr::from_ptr(key) } - .to_str() - .map_err(|_| vm.new_value_error("mapping key must be valid UTF-8"))?; + let key = unsafe { key.try_as_str(vm) }?; obj.get_item(key, vm) }) } @@ -104,9 +103,7 @@ pub unsafe extern "C" fn PyMapping_GetOptionalItemString( *result = core::ptr::null_mut(); } let obj = unsafe { &*obj }; - let key = unsafe { CStr::from_ptr(key) } - .to_str() - .map_err(|_| vm.new_value_error("mapping key must be valid UTF-8"))?; + let key = unsafe { key.try_as_str(vm) }?; match obj.get_item(key, vm) { Ok(value) => { @@ -134,7 +131,7 @@ pub unsafe extern "C" fn PyMapping_HasKey(obj: *mut PyObject, key: *mut PyObject pub unsafe extern "C" fn PyMapping_HasKeyString(obj: *mut PyObject, key: *const c_char) -> c_int { with_vm(|vm| { let obj = unsafe { &*obj }; - if let Ok(key) = unsafe { CStr::from_ptr(key) }.to_str() { + if let Ok(key) = unsafe { key.try_as_str(vm) } { obj.get_item(key, vm).is_ok() } else { false @@ -166,9 +163,7 @@ pub unsafe extern "C" fn PyMapping_HasKeyStringWithError( ) -> c_int { with_vm(|vm| { let obj = unsafe { &*obj }; - let key = unsafe { CStr::from_ptr(key) } - .to_str() - .map_err(|_| vm.new_value_error("mapping key must be valid UTF-8"))?; + let key = unsafe { key.try_as_str(vm) }?; match obj.get_item(key, vm) { Ok(_) => Ok(true), @@ -186,9 +181,7 @@ pub unsafe extern "C" fn PyMapping_SetItemString( ) -> c_int { with_vm(|vm| { let obj = unsafe { &*obj }; - let key = unsafe { CStr::from_ptr(key) } - .to_str() - .map_err(|_| vm.new_value_error("mapping key must be valid UTF-8"))?; + let key = unsafe { key.try_as_str(vm) }?; let value = unsafe { &*value }.to_owned(); obj.set_item(key, value, vm) }) diff --git a/crates/capi/src/codecs.rs b/crates/capi/src/codecs.rs new file mode 100644 index 00000000000..8433bf3baa4 --- /dev/null +++ b/crates/capi/src/codecs.rs @@ -0,0 +1,210 @@ +use crate::util::CStrExt; +use crate::{PyObject, pystate::with_vm}; +use core::ffi::{c_char, c_int}; +use rustpython_vm::{AsObject, VirtualMachine}; + +fn call_codec_error_handler( + vm: &VirtualMachine, + handler_name: &str, + exc: *mut PyObject, +) -> rustpython_vm::PyResult { + vm.state + .codec_registry + .lookup_error(handler_name, vm)? + .call((unsafe { &*exc }.to_owned(),), vm) +} + +fn codec_stream( + vm: &VirtualMachine, + encoding: *const c_char, + stream: *mut PyObject, + errors: *const c_char, + method: &str, +) -> rustpython_vm::PyResult { + let encoding = unsafe { encoding.try_as_str_opt(vm) }?.unwrap_or("utf-8"); + let errors = unsafe { errors.try_as_str_opt(vm) }?.map(|errors| vm.ctx.new_str(errors)); + let stream = unsafe { &*stream }.to_owned(); + let codec = vm.state.codec_registry.lookup(encoding, vm)?; + let args = match errors { + Some(errors) => vec![stream, errors.into()], + None => vec![stream], + }; + vm.call_method(codec.as_tuple().as_object(), method, args) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_Register(search_function: *mut PyObject) -> c_int { + with_vm(|vm| { + let search_function = unsafe { &*search_function }.to_owned(); + vm.state.codec_registry.register(search_function, vm) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_Unregister(search_function: *mut PyObject) -> c_int { + with_vm(|vm| { + let search_function = unsafe { &*search_function }.to_owned(); + vm.state.codec_registry.unregister(search_function); + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_KnownEncoding(encoding: *const c_char) -> c_int { + with_vm(|vm| { + let encoding = unsafe { encoding.try_as_str(vm) }?; + match vm.state.codec_registry.lookup(encoding, vm) { + Ok(_) => Ok(true), + Err(err) if err.fast_isinstance(vm.ctx.exceptions.lookup_error) => Ok(false), + Err(err) => Err(err), + } + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_Encode( + object: *mut PyObject, + encoding: *const c_char, + errors: *const c_char, +) -> *mut PyObject { + with_vm(|vm| { + let object = unsafe { &*object }.to_owned(); + let encoding = unsafe { encoding.try_as_str_opt(vm) }?.unwrap_or("utf-8"); + let errors = + unsafe { errors.try_as_str_opt(vm) }?.map(|errors| vm.ctx.new_utf8_str(errors)); + vm.state.codec_registry.encode(object, encoding, errors, vm) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_Decode( + object: *mut PyObject, + encoding: *const c_char, + errors: *const c_char, +) -> *mut PyObject { + with_vm(|vm| { + let object = unsafe { &*object }.to_owned(); + let encoding = unsafe { encoding.try_as_str_opt(vm) }?.unwrap_or("utf-8"); + let errors = + unsafe { errors.try_as_str_opt(vm) }?.map(|errors| vm.ctx.new_utf8_str(errors)); + vm.state.codec_registry.decode(object, encoding, errors, vm) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_Encoder(encoding: *const c_char) -> *mut PyObject { + with_vm(|vm| { + let encoding = unsafe { encoding.try_as_str(vm) }?; + vm.state + .codec_registry + .lookup(encoding, vm) + .map(|codec| codec.get_encode_func().to_owned()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_Decoder(encoding: *const c_char) -> *mut PyObject { + with_vm(|vm| { + let encoding = unsafe { encoding.try_as_str(vm) }?; + vm.state + .codec_registry + .lookup(encoding, vm) + .map(|codec| codec.get_decode_func().to_owned()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_IncrementalEncoder( + encoding: *const c_char, + errors: *const c_char, +) -> *mut PyObject { + with_vm(|vm| { + let encoding = unsafe { encoding.try_as_str(vm) }?; + let errors = unsafe { errors.try_as_str_opt(vm) }?.map(|s| vm.ctx.new_str(s)); + let codec = vm.state.codec_registry.lookup(encoding, vm)?; + codec.get_incremental_encoder(errors, vm) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_IncrementalDecoder( + encoding: *const c_char, + errors: *const c_char, +) -> *mut PyObject { + with_vm(|vm| { + let encoding = unsafe { encoding.try_as_str(vm) }?; + let errors = unsafe { errors.try_as_str_opt(vm) }?.map(|s| vm.ctx.new_str(s)); + let codec = vm.state.codec_registry.lookup(encoding, vm)?; + codec.get_incremental_decoder(errors, vm) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_StreamReader( + encoding: *const c_char, + stream: *mut PyObject, + errors: *const c_char, +) -> *mut PyObject { + with_vm(|vm| codec_stream(vm, encoding, stream, errors, "streamreader")) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_StreamWriter( + encoding: *const c_char, + stream: *mut PyObject, + errors: *const c_char, +) -> *mut PyObject { + with_vm(|vm| codec_stream(vm, encoding, stream, errors, "streamwriter")) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_RegisterError(name: *const c_char, error: *mut PyObject) -> c_int { + with_vm(|vm| { + let name = unsafe { name.try_as_str(vm) }?; + let error = unsafe { &*error }.to_owned(); + if !error.is_callable() { + return Err(vm.new_type_error("handler must be callable")); + } + vm.state + .codec_registry + .register_error(name.to_owned(), error); + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_LookupError(name: *const c_char) -> *mut PyObject { + with_vm(|vm| { + let name = unsafe { name.try_as_str(vm) }?; + vm.state.codec_registry.lookup_error(name, vm) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_StrictErrors(exc: *mut PyObject) -> *mut PyObject { + with_vm(|vm| call_codec_error_handler(vm, "strict", exc)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_IgnoreErrors(exc: *mut PyObject) -> *mut PyObject { + with_vm(|vm| call_codec_error_handler(vm, "ignore", exc)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_ReplaceErrors(exc: *mut PyObject) -> *mut PyObject { + with_vm(|vm| call_codec_error_handler(vm, "replace", exc)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_XMLCharRefReplaceErrors(exc: *mut PyObject) -> *mut PyObject { + with_vm(|vm| call_codec_error_handler(vm, "xmlcharrefreplace", exc)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_BackslashReplaceErrors(exc: *mut PyObject) -> *mut PyObject { + with_vm(|vm| call_codec_error_handler(vm, "backslashreplace", exc)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyCodec_NameReplaceErrors(exc: *mut PyObject) -> *mut PyObject { + with_vm(|vm| call_codec_error_handler(vm, "namereplace", exc)) +} diff --git a/crates/capi/src/descrobject.rs b/crates/capi/src/descrobject.rs index ecc0b53f82a..863f286e13a 100644 --- a/crates/capi/src/descrobject.rs +++ b/crates/capi/src/descrobject.rs @@ -2,7 +2,8 @@ use crate::PyObject; use crate::methodobject::{PyMethodDef, build_method_def}; use crate::object::PyTypeObject; use crate::pystate::with_vm; -use core::ffi::{CStr, c_char, c_int, c_void}; +use crate::util::CStrExt; +use core::ffi::{c_char, c_int, c_void}; use core::ptr::NonNull; use rustpython_vm::builtins::{ DescriptorMemberDef, MemberGetter, MemberKind, MemberSetter, PyDescriptorOwned, PyGetSet, @@ -34,9 +35,7 @@ impl PyGetSetDef { ty: &'static Py, vm: &VirtualMachine, ) -> PyResult> { - let name = unsafe { CStr::from_ptr(self.name) } - .to_str() - .map_err(|_| vm.new_system_error("PyGetSetDef name was not valid UTF-8"))?; + let name = unsafe { self.name.try_as_str(vm) }?; let closure = self.closure as usize; let descriptor = match (self.get, self.set) { @@ -142,9 +141,7 @@ impl PyMemberDef { ty: &Py, vm: &VirtualMachine, ) -> PyResult> { - let name = unsafe { CStr::from_ptr(self.name) } - .to_str() - .map_err(|_| vm.new_system_error("PyMemberDef name was not valid UTF-8"))?; + let name = unsafe { self.name.try_as_str(vm) }?; let kind = match self.type_code { 6 => MemberKind::Object, 16 => MemberKind::ObjectEx, @@ -165,14 +162,7 @@ impl PyMemberDef { ); } - let doc = NonNull::new(self.doc.cast_mut()) - .map(|doc| { - unsafe { CStr::from_ptr(doc.as_ptr()) } - .to_str() - .map(|s| s.to_owned()) - .map_err(|_| vm.new_system_error("PyMemberDef doc was not valid UTF-8")) - }) - .transpose()?; + let doc = unsafe { self.doc.try_as_str_opt(vm) }?.map(str::to_owned); let descriptor = PyMemberDescriptor { common: PyDescriptorOwned { diff --git a/crates/capi/src/dictobject.rs b/crates/capi/src/dictobject.rs index 6cea9ea343c..ed29c693463 100644 --- a/crates/capi/src/dictobject.rs +++ b/crates/capi/src/dictobject.rs @@ -1,7 +1,8 @@ use crate::PyObject; use crate::object::define_py_check; use crate::pystate::with_vm; -use core::ffi::{CStr, c_char, c_int}; +use crate::util::CStrExt; +use core::ffi::{c_char, c_int}; use core::ptr::NonNull; use rustpython_vm::AsObject; use rustpython_vm::PyPayload; @@ -49,9 +50,7 @@ pub unsafe extern "C" fn PyDict_SetItemString( ) -> c_int { with_vm(|vm| { let dict = unsafe { &*dict }.try_downcast_ref::(vm)?; - let key = unsafe { CStr::from_ptr(key) } - .to_str() - .map_err(|_| vm.new_value_error("dictionary key must be valid UTF-8"))?; + let key = unsafe { key.try_as_str(vm) }?; let value = unsafe { &*val }.to_owned(); dict.inner_setitem(key, value, vm) }) @@ -94,9 +93,7 @@ pub unsafe extern "C" fn PyDict_GetItemString( ) -> *mut PyObject { with_vm(|vm| { let dict = unsafe { &*dict }.try_downcast_ref::(vm)?; - let key = unsafe { CStr::from_ptr(key) } - .to_str() - .map_err(|_| vm.new_unicode_decode_error("dictionary key must be valid UTF-8"))?; + let key = unsafe { key.try_as_str(vm) }?; match dict.inner_getitem_opt(key, vm)? { Some(value) => Ok(value.as_object().as_raw().cast_mut()), @@ -116,9 +113,7 @@ pub unsafe extern "C" fn PyDict_GetItemStringRef( *result = core::ptr::null_mut(); } let dict = unsafe { &*dict }.try_downcast_ref::(vm)?; - let key = unsafe { CStr::from_ptr(key) } - .to_str() - .map_err(|_| vm.new_value_error("dictionary key must be valid UTF-8"))?; + let key = unsafe { key.try_as_str(vm) }?; if let Some(value) = dict.inner_getitem_opt(key, vm)? { unsafe { @@ -231,9 +226,7 @@ pub unsafe extern "C" fn PyDict_DelItem(dict: *mut PyObject, key: *mut PyObject) pub unsafe extern "C" fn PyDict_DelItemString(dict: *mut PyObject, key: *const c_char) -> c_int { with_vm(|vm| { let dict = unsafe { &*dict }.try_downcast_ref::(vm)?; - let key = unsafe { CStr::from_ptr(key) } - .to_str() - .map_err(|_| vm.new_value_error("dictionary key must be valid UTF-8"))?; + let key = unsafe { key.try_as_str(vm) }?; dict.del_item(key, vm) }) } diff --git a/crates/capi/src/import.rs b/crates/capi/src/import.rs index c6d5ce85ed6..3a8dae651c9 100644 --- a/crates/capi/src/import.rs +++ b/crates/capi/src/import.rs @@ -1,5 +1,6 @@ +use crate::util::CStrExt; use crate::{PyObject, pystate::with_vm}; -use core::ffi::{CStr, c_char}; +use core::ffi::c_char; use rustpython_vm::builtins::{PyCode, PyDict, PyModule, PyStr}; use rustpython_vm::import::import_code_obj; @@ -14,9 +15,7 @@ pub unsafe extern "C" fn PyImport_Import(name: *mut PyObject) -> *mut PyObject { #[unsafe(no_mangle)] pub unsafe extern "C" fn PyImport_AddModuleRef(name: *const c_char) -> *mut PyObject { with_vm(|vm| { - let name = unsafe { CStr::from_ptr(name) } - .to_str() - .map_err(|_| vm.new_system_error("PyImport_AddModuleRef called with non utf8 name"))?; + let name = unsafe { name.try_as_str(vm) }?; let sys_modules = vm .sys_module @@ -46,16 +45,11 @@ pub unsafe extern "C" fn PyImport_ExecCodeModuleEx( pathname: *const c_char, ) -> *mut PyObject { with_vm(|vm| { - let name = unsafe { CStr::from_ptr(name) }.to_str().map_err(|_| { - vm.new_system_error("PyImport_ExecCodeModuleEx called with non utf8 name") - })?; + let name = unsafe { name.try_as_str(vm) }?; let code = unsafe { &*co }.try_downcast_ref::(vm)?; let module = import_code_obj(vm, name, code.to_owned(), false)?; - if !pathname.is_null() { - let pathname = unsafe { CStr::from_ptr(pathname) }.to_str().map_err(|_| { - vm.new_system_error("PyImport_ExecCodeModuleEx called with non utf8 pathname") - })?; + if let Some(pathname) = unsafe { pathname.try_as_str_opt(vm) }? { module.set_attr("__file__", vm.ctx.new_str(pathname), vm)?; } diff --git a/crates/capi/src/lib.rs b/crates/capi/src/lib.rs index 08bb09bf3b5..374fd3301d6 100644 --- a/crates/capi/src/lib.rs +++ b/crates/capi/src/lib.rs @@ -13,6 +13,7 @@ pub mod boolobject; pub mod bytearrayobject; pub mod bytesobject; pub mod ceval; +pub mod codecs; pub mod complexobject; pub mod critical_section; pub mod descrobject; diff --git a/crates/capi/src/methodobject.rs b/crates/capi/src/methodobject.rs index b234ba76a9c..cc3676ef51a 100644 --- a/crates/capi/src/methodobject.rs +++ b/crates/capi/src/methodobject.rs @@ -2,7 +2,8 @@ use crate::PyObject; use crate::object::PyTypeObject; use crate::object::define_py_check; use crate::pystate::with_vm; -use core::ffi::{CStr, c_char, c_int}; +use crate::util::CStrExt; +use core::ffi::{c_char, c_int}; use core::ptr::NonNull; use rustpython_vm::function::{FuncArgs, HeapMethodDef, PosArgs, PyMethodFlags}; use rustpython_vm::{AsObject, PyObjectRef, PyRef, PyResult, VirtualMachine}; @@ -46,17 +47,9 @@ pub(crate) fn build_method_def( ml: &PyMethodDef, has_self: bool, ) -> PyResult> { - let name = unsafe { CStr::from_ptr(ml.ml_name) } - .to_str() - .map_err(|_| vm.new_system_error("Method name was not valid UTF-8"))?; + let name = unsafe { ml.ml_name.try_as_str(vm) }?; - let doc = NonNull::new(ml.ml_doc.cast_mut()) - .map(|doc| { - unsafe { CStr::from_ptr(doc.as_ptr()) } - .to_str() - .map_err(|_| vm.new_system_error("Method doc was not valid UTF-8")) - }) - .transpose()?; + let doc = unsafe { ml.ml_doc.try_as_str_opt(vm) }?; let flags = PyMethodFlags::from_bits(ml.ml_flags as u32) .ok_or_else(|| vm.new_system_error("PyMethodDef contains unknown flags"))?; diff --git a/crates/capi/src/object.rs b/crates/capi/src/object.rs index 27417a6ad33..eabfbef23a1 100644 --- a/crates/capi/src/object.rs +++ b/crates/capi/src/object.rs @@ -1,6 +1,7 @@ use crate::PyObject; use crate::pystate::with_vm; -use core::ffi::{CStr, c_char, c_int, c_uint, c_void}; +use crate::util::CStrExt; +use core::ffi::{c_char, c_int, c_uint, c_void}; use core::ptr::NonNull; pub use pytype::*; use rustpython_vm::builtins::{PyStr, object_generic_set_dict, object_get_dict}; @@ -81,11 +82,7 @@ pub unsafe extern "C" fn PyObject_GetAttrString( ) -> *mut PyObject { with_vm(|vm| { let obj = unsafe { &*obj }; - let name = unsafe { - CStr::from_ptr(attr_name) - .to_str() - .map_err(|_| vm.new_value_error("attribute name must be valid UTF-8"))? - }; + let name = unsafe { attr_name.try_as_str(vm) }?; obj.get_attr(name, vm) }) } @@ -134,9 +131,7 @@ pub unsafe extern "C" fn PyObject_GetOptionalAttrString( *result = core::ptr::null_mut(); } let obj = unsafe { &*obj }; - let name = unsafe { CStr::from_ptr(attr_name) } - .to_str() - .map_err(|_| vm.new_value_error("attribute name must be valid UTF-8"))?; + let name = unsafe { attr_name.try_as_str(vm) }?; if let Some(attr) = vm.get_attribute_opt(obj.to_owned(), name)? { unsafe { *result = attr.into_raw().as_ptr(); @@ -156,9 +151,7 @@ pub unsafe extern "C" fn PyObject_SetAttrString( ) -> c_int { with_vm(|vm| { let obj = unsafe { &*obj }; - let name = unsafe { CStr::from_ptr(attr_name) } - .to_str() - .map_err(|_| vm.new_value_error("attribute name must be valid UTF-8"))?; + let name = unsafe { attr_name.try_as_str(vm) }?; let value = unsafe { &*value }.to_owned(); obj.set_attr(name, value, vm) }) @@ -194,9 +187,7 @@ pub unsafe extern "C" fn PyObject_DelAttrString( ) -> c_int { with_vm(|vm| { let obj = unsafe { &*obj }; - let name = unsafe { CStr::from_ptr(attr_name) } - .to_str() - .map_err(|_| vm.new_value_error("attribute name must be valid UTF-8"))?; + let name = unsafe { attr_name.try_as_str(vm) }?; obj.del_attr(name, vm) }) } @@ -259,7 +250,7 @@ pub unsafe extern "C" fn PyObject_HasAttrString( ) -> c_int { with_vm(|vm| { let obj = unsafe { &*obj }; - let Ok(name) = unsafe { CStr::from_ptr(attr_name) }.to_str() else { + let Ok(name) = (unsafe { attr_name.try_as_str(vm) }) else { return false; }; @@ -280,9 +271,7 @@ pub unsafe extern "C" fn PyObject_HasAttrStringWithError( ) -> c_int { with_vm(|vm| { let obj = unsafe { &*obj }; - let name = unsafe { CStr::from_ptr(attr_name) } - .to_str() - .map_err(|_| vm.new_value_error("attribute name must be valid UTF-8"))?; + let name = unsafe { attr_name.try_as_str(vm) }?; obj.has_attr(name, vm) }) } diff --git a/crates/capi/src/pycapsule.rs b/crates/capi/src/pycapsule.rs index a1b5effd88c..b36dea3d946 100644 --- a/crates/capi/src/pycapsule.rs +++ b/crates/capi/src/pycapsule.rs @@ -1,5 +1,6 @@ use crate::PyObject; use crate::pystate::with_vm; +use crate::util::CStrExt; use core::ffi::{CStr, c_char, c_int, c_void}; use core::ptr::NonNull; use rustpython_vm::builtins::PyCapsule; @@ -93,9 +94,7 @@ pub unsafe extern "C" fn PyCapsule_IsValid(capsule: *mut PyObject, name: *const #[unsafe(no_mangle)] pub unsafe extern "C" fn PyCapsule_Import(name: *const c_char, _no_block: c_int) -> *mut c_void { with_vm(|vm| { - let capsule_name = unsafe { CStr::from_ptr(name) } - .to_str() - .map_err(|_| vm.new_system_error("capsule name is not valid UTF-8"))?; + let capsule_name = unsafe { name.try_as_str(vm) }?; let (module_name, attrs_path) = capsule_name.split_once('.').ok_or_else(|| { vm.new_import_error( "capsule name is missing attribute path", diff --git a/crates/capi/src/pyerrors.rs b/crates/capi/src/pyerrors.rs index d7efd1a0d6f..55428ee7604 100644 --- a/crates/capi/src/pyerrors.rs +++ b/crates/capi/src/pyerrors.rs @@ -1,7 +1,8 @@ use crate::object::define_py_check; +use crate::util::CStrExt; use crate::{PyObject, pystate::with_vm}; use core::convert::Infallible; -use core::ffi::{CStr, c_char, c_int}; +use core::ffi::{c_char, c_int}; use core::ptr::NonNull; use core::slice; use rustpython_vm::builtins::{PyBaseException, PyTuple, PyType}; @@ -144,10 +145,7 @@ pub unsafe extern "C" fn PyErr_SetObject(exception: *mut PyObject, value: *mut P pub unsafe extern "C" fn PyErr_SetString(exception: *mut PyObject, message: *const c_char) { with_vm::, _>(|vm| { let exc_type = unsafe { &*exception }.try_downcast_ref::(vm)?; - - let Ok(message) = unsafe { CStr::from_ptr(message) }.to_str() else { - return Err(vm.new_type_error("Exception message is not valid UTF-8")); - }; + let message = unsafe { message.try_as_str(vm) }?; let exc = vm.invoke_exception( exc_type.to_owned(), @@ -210,13 +208,10 @@ pub unsafe extern "C" fn PyErr_NewException( dict: *mut PyObject, ) -> *mut PyObject { with_vm(|vm| { - let (module, name) = unsafe { - CStr::from_ptr(name) - .to_str() - .expect("Exception name is not valid UTF-8") - .rsplit_once('.') - .expect("Exception name must be of the form 'module.ExceptionName'") - }; + let (module, name) = unsafe { name.try_as_str(vm) } + .expect("Exception name is not valid UTF-8") + .rsplit_once('.') + .expect("Exception name must be of the form 'module.ExceptionName'"); let bases = unsafe { base.as_ref() }.map(|bases| { if let Some(ty) = bases.downcast_ref::() { @@ -332,12 +327,8 @@ pub unsafe extern "C" fn PyUnicodeDecodeError_Create( reason: *const c_char, ) -> *mut PyObject { with_vm(|vm| { - let encoding = unsafe { CStr::from_ptr(encoding) } - .to_str() - .map_err(|_| vm.new_system_error("encoding must be valid UTF-8"))?; - let reason = unsafe { CStr::from_ptr(reason) } - .to_str() - .map_err(|_| vm.new_system_error("reason must be valid UTF-8"))?; + let encoding = unsafe { encoding.try_as_str(vm) }?; + let reason = unsafe { reason.try_as_str(vm) }?; let length: usize = length .try_into() .map_err(|_| vm.new_system_error("length must be non-negative"))?; diff --git a/crates/capi/src/unicodeobject.rs b/crates/capi/src/unicodeobject.rs index 787e31ea571..1a5e43c0e9d 100644 --- a/crates/capi/src/unicodeobject.rs +++ b/crates/capi/src/unicodeobject.rs @@ -1,4 +1,5 @@ use crate::object::define_py_check; +use crate::util::CStrExt; use crate::{PyObject, pystate::with_vm}; use core::ffi::{CStr, c_char, c_int}; use core::ptr::NonNull; @@ -70,21 +71,9 @@ pub unsafe extern "C" fn PyUnicode_AsEncodedString( let unicode = unsafe { &*unicode } .try_downcast_ref::(vm)? .to_owned(); - let encoding = if encoding.is_null() { - "utf-8" - } else { - unsafe { CStr::from_ptr(encoding) } - .to_str() - .expect("encoding must be valid UTF-8") - }; - let errors = if errors.is_null() { - None - } else { - let errors = unsafe { CStr::from_ptr(errors) } - .to_str() - .expect("errors must be valid UTF-8"); - Some(vm.ctx.new_utf8_str(errors)) - }; + let encoding = unsafe { encoding.try_as_str_opt(vm) }?.unwrap_or("utf-8"); + let errors = + unsafe { errors.try_as_str_opt(vm) }?.map(|errors| vm.ctx.new_utf8_str(errors)); vm.state .codec_registry .encode_text(unicode, encoding, errors, vm) @@ -177,21 +166,9 @@ pub unsafe extern "C" fn PyUnicode_FromEncodedObject( return Err(vm.new_type_error("decoding str is not supported")); } - let encoding = if encoding.is_null() { - "utf-8" - } else { - unsafe { CStr::from_ptr(encoding) } - .to_str() - .map_err(|_| vm.new_system_error("encoding must be valid UTF-8"))? - }; - let errors = if errors.is_null() { - None - } else { - let errors = unsafe { CStr::from_ptr(errors) } - .to_str() - .map_err(|_| vm.new_system_error("errors must be valid UTF-8"))?; - Some(vm.ctx.new_utf8_str(errors)) - }; + let encoding = unsafe { encoding.try_as_str_opt(vm) }?.unwrap_or("utf-8"); + let errors = + unsafe { errors.try_as_str_opt(vm) }?.map(|errors| vm.ctx.new_utf8_str(errors)); obj.try_bytes_like(vm, |b| { vm.state.codec_registry.decode_text( diff --git a/crates/capi/src/util.rs b/crates/capi/src/util.rs index 6bbda7654fe..32ff775676b 100644 --- a/crates/capi/src/util.rs +++ b/crates/capi/src/util.rs @@ -1,6 +1,7 @@ use crate::PyObject; use core::convert::Infallible; use core::ffi::{CStr, c_char, c_double, c_int, c_long, c_ulong, c_void}; +use core::ptr::NonNull; use rustpython_vm::{Py, PyObjectRef, PyRef, PyResult, VirtualMachine}; pub(crate) trait FfiResult { @@ -222,6 +223,35 @@ where } } +pub(crate) trait CStrExt<'a> { + unsafe fn try_as_str(self, vm: &VirtualMachine) -> PyResult<&'a str>; + unsafe fn try_as_str_opt(self, vm: &VirtualMachine) -> PyResult>; +} + +impl<'a> CStrExt<'a> for *mut c_char { + unsafe fn try_as_str(self, vm: &VirtualMachine) -> PyResult<&'a str> { + unsafe { self.try_as_str_opt(vm) }? + .ok_or_else(|| vm.new_system_error("argument must not be null")) + } + + unsafe fn try_as_str_opt(self, vm: &VirtualMachine) -> PyResult> { + NonNull::new(self) + .map(|ptr| unsafe { CStr::from_ptr(ptr.as_ptr()) }.to_str()) + .transpose() + .map_err(|_| vm.new_system_error("argument must be valid UTF-8")) + } +} + +impl<'a> CStrExt<'a> for *const c_char { + unsafe fn try_as_str(self, vm: &VirtualMachine) -> PyResult<&'a str> { + unsafe { self.cast_mut().try_as_str(vm) } + } + + unsafe fn try_as_str_opt(self, vm: &VirtualMachine) -> PyResult> { + unsafe { self.cast_mut().try_as_str_opt(vm) } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/capi/src/warnings.rs b/crates/capi/src/warnings.rs index 4966cd60d6d..f9ed82b9fa9 100644 --- a/crates/capi/src/warnings.rs +++ b/crates/capi/src/warnings.rs @@ -1,5 +1,6 @@ +use crate::util::CStrExt; use crate::{PyObject, pystate::with_vm}; -use core::ffi::{CStr, c_char, c_int}; +use core::ffi::{c_char, c_int}; use rustpython_vm::builtins::{PyType, PyTypeRef}; use rustpython_vm::warn::{warn, warn_explicit}; use rustpython_vm::{AsObject, PyResult}; @@ -32,9 +33,7 @@ pub unsafe extern "C" fn PyErr_WarnEx( stack_level: isize, ) -> c_int { with_vm(|vm| { - let message = unsafe { CStr::from_ptr(message) } - .to_str() - .map_err(|_| vm.new_system_error("warning message is not valid UTF-8"))?; + let message = unsafe { message.try_as_str(vm) }?; let category = resolve_warning_category(vm, category)?; @@ -58,17 +57,11 @@ pub unsafe extern "C" fn PyErr_WarnExplicit( registry: *mut PyObject, ) -> c_int { with_vm(|vm| { - let message = unsafe { CStr::from_ptr(message) } - .to_str() - .map_err(|_| vm.new_system_error("warning message is not valid UTF-8"))?; - let filename = unsafe { CStr::from_ptr(filename) } - .to_str() - .map_err(|_| vm.new_system_error("filename is not valid UTF-8"))?; - - let module = unsafe { module.as_ref().map(|ptr| CStr::from_ptr(ptr).to_str()) } - .transpose() - .map_err(|_| vm.new_system_error("module is not valid UTF-8"))? - .map(|module| vm.ctx.new_str(module).into()); + let message = unsafe { message.try_as_str(vm) }?; + let filename = unsafe { filename.try_as_str(vm) }?; + + let module = + unsafe { module.try_as_str_opt(vm) }?.map(|module| vm.ctx.new_str(module).into()); let category = resolve_warning_category(vm, category)?; From ea6880b72a34ac5eb8ed60fe3e3af3f50bdf69cc Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:33:12 +0900 Subject: [PATCH 132/351] Fix frozenset subclass keyword arguments (#8287) Assisted-by: Codex:gpt-5.6 --- Lib/test/test_set.py | 1 - crates/vm/src/builtins/set.rs | 47 ++++++++++++++++++++++++++--------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/Lib/test/test_set.py b/Lib/test/test_set.py index a0b50df8f21..4d062b42ded 100644 --- a/Lib/test/test_set.py +++ b/Lib/test/test_set.py @@ -828,7 +828,6 @@ class TestFrozenSetSubclass(TestFrozenSet): thetype = FrozenSetSubclass basetype = frozenset - @unittest.expectedFailure # TODO: RUSTPYTHON def test_keywords_in_subclass(self): class subclass(frozenset): pass diff --git a/crates/vm/src/builtins/set.rs b/crates/vm/src/builtins/set.rs index a8f3e83c830..1481bc1b391 100644 --- a/crates/vm/src/builtins/set.rs +++ b/crates/vm/src/builtins/set.rs @@ -958,31 +958,54 @@ impl Constructor for PyFrozenSet { type Args = Vec; fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { - let iterable: OptionalArg = args.bind(vm)?; + let is_exact_frozenset = cls.is(vm.ctx.types.frozenset_type); + let is_frozenset_init = { + let cls_init = cls.slots.init.load().map(|init| init as usize); + let frozenset_init = vm + .ctx + .types + .frozenset_type + .slots + .init + .load() + .map(|init| init as usize); + cls_init == frozenset_init + }; // Optimizations for exact frozenset type - if cls.is(vm.ctx.types.frozenset_type) { + let iterable_opt = if is_exact_frozenset || is_frozenset_init { + let iterable: OptionalArg = args.bind(vm)?; + // Return exact frozenset as-is - if let OptionalArg::Present(ref input) = iterable - && let Ok(fs) = input.clone().downcast_exact::(vm) + if is_exact_frozenset + && let OptionalArg::Present(input) = &iterable + && input.class().is(vm.ctx.types.frozenset_type) { - return Ok(fs.into_pyref().into()); + return Ok(input.clone()); } - // Return empty frozenset singleton - if iterable.is_missing() { - return Ok(vm.ctx.empty_frozenset.clone().into()); + iterable.into_option() + } else { + match &args.args[..] { + [] => None, + [iterable] => Some(iterable.clone()), + slice => { + return Err(vm.new_type_error(format!( + "frozenset expected at most 1 argument, got {}", + slice.len() + ))); + } } - } + }; - let elements: Vec = if let OptionalArg::Present(iterable) = iterable { + let elements = if let Some(iterable) = iterable_opt { iterable.try_to_value(vm)? } else { vec![] }; - // Return empty frozenset singleton for exact frozenset types (when iterable was empty) - if elements.is_empty() && cls.is(vm.ctx.types.frozenset_type) { + // Return empty frozenset singleton + if is_exact_frozenset && elements.is_empty() { return Ok(vm.ctx.empty_frozenset.clone().into()); } From 390ac656acf32ae0b89e72cdca7c737ca0a12d32 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:33:30 +0900 Subject: [PATCH 133/351] Bump ws from 8.18.1 to 8.21.1 in /wasm/demo (#8288) Bumps [ws](https://github.com/websockets/ws) from 8.18.1 to 8.21.1. - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/compare/8.18.1...8.21.1) --- updated-dependencies: - dependency-name: ws dependency-version: 8.21.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- wasm/demo/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wasm/demo/package-lock.json b/wasm/demo/package-lock.json index 33be8e6016c..5f5324b5e4a 100644 --- a/wasm/demo/package-lock.json +++ b/wasm/demo/package-lock.json @@ -5911,9 +5911,9 @@ } }, "node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "dev": true, "license": "MIT", "engines": { From 6fd177496d7f800ec7f940ca16211c96d9b04b46 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:33:37 +0900 Subject: [PATCH 134/351] Bump websocket-driver from 0.7.4 to 0.7.5 in /wasm/demo (#8289) Bumps [websocket-driver](https://github.com/faye/websocket-driver-node) from 0.7.4 to 0.7.5. - [Changelog](https://github.com/faye/websocket-driver-node/blob/main/CHANGELOG.md) - [Commits](https://github.com/faye/websocket-driver-node/compare/0.7.4...0.7.5) --- updated-dependencies: - dependency-name: websocket-driver dependency-version: 0.7.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- wasm/demo/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wasm/demo/package-lock.json b/wasm/demo/package-lock.json index 5f5324b5e4a..d26170de891 100644 --- a/wasm/demo/package-lock.json +++ b/wasm/demo/package-lock.json @@ -5787,9 +5787,9 @@ } }, "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", "dev": true, "license": "Apache-2.0", "dependencies": { From a02f9587e98477df8bb6c9470c0210fb163e0d5e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:33:43 +0900 Subject: [PATCH 135/351] Bump uuid from 1.23.4 to 1.23.5 (#8293) Bumps [uuid](https://github.com/uuid-rs/uuid) from 1.23.4 to 1.23.5. - [Release notes](https://github.com/uuid-rs/uuid/releases) - [Commits](https://github.com/uuid-rs/uuid/compare/v1.23.4...v1.23.5) --- updated-dependencies: - dependency-name: uuid dependency-version: 1.23.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 491c5851c14..a0284da1d23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4595,9 +4595,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.4" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "atomic", "js-sys", From a17ab48129dd77a02a37b00ea9ffceb58179067f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:33:49 +0900 Subject: [PATCH 136/351] Bump rustls from 0.23.41 to 0.23.42 (#8294) Bumps [rustls](https://github.com/rustls/rustls) from 0.23.41 to 0.23.42. - [Release notes](https://github.com/rustls/rustls/releases) - [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md) - [Commits](https://github.com/rustls/rustls/compare/v/0.23.41...v/0.23.42) --- updated-dependencies: - dependency-name: rustls dependency-version: 0.23.42 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a0284da1d23..ea84f11c334 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3248,9 +3248,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "aws-lc-rs", "once_cell", From 24390e439a4da7784ba990134704c43484b4fd30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:33:55 +0900 Subject: [PATCH 137/351] Bump x509-cert from 0.2.5 to 0.3.0 (#8295) Bumps [x509-cert](https://github.com/RustCrypto/formats) from 0.2.5 to 0.3.0. - [Commits](https://github.com/RustCrypto/formats/compare/x509-cert/v0.2.5...x509-cert/v0.3.0) --- updated-dependencies: - dependency-name: x509-cert dependency-version: 0.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 187 +++++++++++++---------------------------------------- Cargo.toml | 2 +- 2 files changed, 47 insertions(+), 142 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ea84f11c334..51e91550215 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,7 +20,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common 0.2.2", + "crypto-common", "inout", ] @@ -32,7 +32,7 @@ checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" dependencies = [ "cipher", "cpubits", - "cpufeatures 0.3.0", + "cpufeatures", ] [[package]] @@ -379,16 +379,7 @@ version = "0.11.0-rc.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690" dependencies = [ - "digest 0.11.3", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", + "digest", ] [[package]] @@ -514,7 +505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures", "rand_core 0.10.1", ] @@ -564,8 +555,8 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer 0.12.1", - "crypto-common 0.2.2", + "block-buffer", + "crypto-common", "inout", ] @@ -686,12 +677,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const-oid" version = "0.10.2" @@ -736,15 +721,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "cpufeatures" version = "0.3.0" @@ -1008,16 +984,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "crypto-common" version = "0.2.2" @@ -1091,27 +1057,16 @@ dependencies = [ "thiserror", ] -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid 0.9.6", - "der_derive", - "flagset", - "pem-rfc7468 0.7.0", - "zeroize", -] - [[package]] name = "der" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ - "const-oid 0.10.2", - "pem-rfc7468 1.0.0", + "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", "zeroize", ] @@ -1131,9 +1086,9 @@ dependencies = [ [[package]] name = "der_derive" -version = "0.7.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +checksum = "59600e2c2d636fde9b65e99cc6445ac770c63d3628195ff39932b8d6d7409903" dependencies = [ "proc-macro2", "quote", @@ -1157,25 +1112,15 @@ dependencies = [ "syn", ] -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", -] - [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.1", - "const-oid 0.10.2", - "crypto-common 0.2.2", + "block-buffer", + "const-oid", + "crypto-common", "ctutils", ] @@ -1425,16 +1370,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "get-size-derive2" version = "0.7.4" @@ -1611,7 +1546,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.11.3", + "digest", ] [[package]] @@ -1989,7 +1924,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures", ] [[package]] @@ -2255,7 +2190,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest 0.11.3", + "digest", ] [[package]] @@ -2589,19 +2524,10 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ - "digest 0.11.3", + "digest", "hmac", ] -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -2707,12 +2633,12 @@ dependencies = [ "aes", "aes-gcm", "cbc", - "der 0.8.1", + "der", "pbkdf2", "rand_core 0.10.1", "scrypt", "sha2", - "spki 0.8.0", + "spki", ] [[package]] @@ -2721,10 +2647,10 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der 0.8.1", + "der", "pkcs5", "rand_core 0.10.1", - "spki 0.8.0", + "spki", ] [[package]] @@ -2779,7 +2705,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" dependencies = [ "cpubits", - "cpufeatures 0.3.0", + "cpufeatures", "universal-hash", ] @@ -3652,8 +3578,8 @@ dependencies = [ "crc32fast", "crossbeam-utils", "csv-core", - "der 0.8.1", - "digest 0.11.3", + "der", + "digest", "dyn-clone", "flame", "flate2", @@ -3680,7 +3606,7 @@ dependencies = [ "parking_lot", "paste", "pbkdf2", - "pem-rfc7468 1.0.0", + "pem-rfc7468", "phf 0.14.0", "pkcs8", "pymath", @@ -3701,7 +3627,7 @@ dependencies = [ "rustpython-unicode", "rustpython-vm", "scopeguard", - "sha1 0.11.0", + "sha1", "sha2", "sha3", "shake", @@ -4001,17 +3927,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha1" version = "0.11.0" @@ -4019,8 +3934,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", + "cpufeatures", + "digest", ] [[package]] @@ -4030,8 +3945,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", + "cpufeatures", + "digest", ] [[package]] @@ -4040,7 +3955,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ - "digest 0.11.3", + "digest", "keccak", "sponge-cursor", ] @@ -4051,7 +3966,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" dependencies = [ - "digest 0.11.3", + "digest", "keccak", "sponge-cursor", ] @@ -4078,11 +3993,11 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signature" -version = "2.2.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ - "rand_core 0.6.4", + "rand_core 0.10.1", ] [[package]] @@ -4141,16 +4056,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der 0.7.10", -] - [[package]] name = "spki" version = "0.8.0" @@ -4158,7 +4063,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der 0.8.1", + "der", ] [[package]] @@ -4279,7 +4184,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4565,7 +4470,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common 0.2.2", + "crypto-common", "ctutils", ] @@ -5053,15 +4958,15 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "x509-cert" -version = "0.2.5" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +checksum = "105ef4642d9cb137ef83d623d0e4bf08b8adf69e9918ca904a174adb6d3d038b" dependencies = [ - "const-oid 0.9.6", - "der 0.7.10", - "sha1 0.10.6", + "const-oid", + "der", + "sha1", "signature", - "spki 0.7.3", + "spki", "tls_codec", ] diff --git a/Cargo.toml b/Cargo.toml index 540324a4aaf..114b5983d92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -320,7 +320,7 @@ wasm-bindgen-futures = "0.4" web-sys = "0.3" webpki-roots = "1.0" which = "8" -x509-cert = "0.2.5" +x509-cert = "0.3.0" x509-parser = "0.18" xml = "1.3" writeable = "0.6" From e7be07616255798accc6e4358840f24010e5e61b Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:35:19 -0400 Subject: [PATCH 138/351] host_env: Don't truncate two byte wchar_t (#8296) Casting a u32 to a u16 would truncate the value. It's safer to use Wtf::encode_wide which already handles converting a CodePoint to a u16. I removed vec_into_bytes because it was only used in two places. The function was unsound for T but sound in the way RustPython used it (POD to POD, less strict alignment). AI disclosure: I linted this code with AI. I found this issue by accident while working on another patch in which I introduced a similar mistake. AI caught that mistake, so I linted the original code to cross-check it. I wrote the code myself in both instances. Assisted-by: Codex:gpt-5.4 --- crates/host_env/src/ctypes.rs | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/host_env/src/ctypes.rs b/crates/host_env/src/ctypes.rs index 2dfc8cbe29d..bbb3e4afe60 100644 --- a/crates/host_env/src/ctypes.rs +++ b/crates/host_env/src/ctypes.rs @@ -440,7 +440,7 @@ pub fn read_pointer_from_buffer(buffer: &[u8]) -> usize { pub const WCHAR_SIZE: usize = core::mem::size_of::(); #[inline] -pub fn wchar_from_bytes(bytes: &[u8]) -> Option { +pub const fn wchar_from_bytes(bytes: &[u8]) -> Option { if bytes.len() < WCHAR_SIZE { return None; } @@ -524,20 +524,17 @@ pub fn encode_wtf8_to_wchar_padded(s: &Wtf8, size: usize) -> Vec { } pub fn wchar_null_terminated_bytes(s: &Wtf8) -> Vec { - let wchars: Vec = s - .code_points() - .map(|cp| cp.to_u32() as WChar) - .chain(core::iter::once(0)) - .collect(); - vec_into_bytes(wchars) -} - -pub fn vec_into_bytes(vec: Vec) -> Vec { - let len = vec.len() * core::mem::size_of::(); - let cap = vec.capacity() * core::mem::size_of::(); - let ptr = vec.as_ptr() as *mut u8; - core::mem::forget(vec); - unsafe { Vec::from_raw_parts(ptr, len, cap) } + if size_of::() == 2 { + // We can't cast u32 to WChar because it would truncate the value on platforms where WChar + // is two bytes. Wtf8::encode_wide does all of the hard work for us, so all we have to do + // is split the bytes. + utf16z_bytes(s) + } else { + s.code_points() + .flat_map(|cp| (cp.to_u32() as WChar).to_ne_bytes()) + .chain((0 as WChar).to_ne_bytes()) + .collect() + } } pub enum IntegerValue { @@ -1108,7 +1105,10 @@ pub fn simple_storage_value_to_bytes_endian( } pub fn utf16z_bytes(s: &Wtf8) -> Vec { - vec_into_bytes::(s.encode_wide().chain(core::iter::once(0)).collect()) + s.encode_wide() + .flat_map(|cp| cp.to_ne_bytes()) + .chain(0u16.to_ne_bytes()) + .collect() } pub fn null_terminated_bytes(bytes: &[u8]) -> Vec { From 1205fd2468fc1cbc836c33cbbb0e04b0df95b1cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9D=80=EB=B9=88?= Date: Fri, 17 Jul 2026 11:51:36 +0900 Subject: [PATCH 139/351] weakref: treat explicit None callback as no callback (#8298) --- Lib/test/test_weakref.py | 1 - crates/vm/src/builtins/weakref.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/test/test_weakref.py b/Lib/test/test_weakref.py index 2e8e679c8d7..224130110b8 100644 --- a/Lib/test/test_weakref.py +++ b/Lib/test/test_weakref.py @@ -380,7 +380,6 @@ def __imatmul__(self, other): # was not honored, and was broken in different ways for # PyWeakref_NewRef() and PyWeakref_NewProxy(). (Two tests.) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_shared_ref_without_callback(self): self.check_shared_without_callback(weakref.ref) diff --git a/crates/vm/src/builtins/weakref.rs b/crates/vm/src/builtins/weakref.rs index 11e21684724..9e88ffaa2e6 100644 --- a/crates/vm/src/builtins/weakref.rs +++ b/crates/vm/src/builtins/weakref.rs @@ -49,7 +49,7 @@ impl Constructor for PyWeak { let referent = positional .next() .ok_or_else(|| vm.new_type_error("__new__ expected at least 1 argument, got 0"))?; - let callback = positional.next(); + let callback = positional.next().filter(|callback| !vm.is_none(callback)); if let Some(_extra) = positional.next() { let got = positional.count() + 3; return Err( From d509daf5e183330f03ca4f4a8212e24bd188a699 Mon Sep 17 00:00:00 2001 From: Seonghun An <53287605+shAn-kor@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:39:23 +0900 Subject: [PATCH 140/351] Validate TextIOWrapper chunk size (#8265) * Validate TextIOWrapper chunk size Match CPython for non-positive, oversized, and index-compatible values. Assisted-by: Codex:GPT-5 * Use explicit AssertionError in io snippet Assisted-by: Codex:GPT-5 * Report original type in chunk size overflow Assisted-by: Codex:GPT-5 * Chain io snippet assertion error Assisted-by: Codex:GPT-5 * Match CPython chunk size setter behavior Assisted-by: Codex:GPT-5 * Format io snippet assertion Assisted-by: Codex:GPT-5.6 * Match CPython type name truncation Assisted-by: Codex:GPT-5 * Format non-ASCII chunk size test Assisted-by: Codex:GPT-5 --- crates/vm/src/stdlib/_io.rs | 50 +++++++++---- extra_tests/snippets/stdlib_io.py | 113 ++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 12 deletions(-) diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index 6707a254c4d..c7bbbaf9359 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -3206,19 +3206,45 @@ mod _io { } #[pygetset(setter, name = "_CHUNK_SIZE")] - fn set_chunksize( - &self, - chunk_size: PySetterValue, - vm: &VirtualMachine, - ) -> PyResult<()> { - let mut textio = self.lock(vm)?; - match chunk_size { - PySetterValue::Assign(chunk_size) => textio.chunk_size = chunk_size, - PySetterValue::Delete => Err(vm.new_attribute_error("cannot delete attribute"))?, + fn set_chunksize(&self, value: PySetterValue, vm: &VirtualMachine) -> PyResult<()> { + { + let textio = self.lock(vm)?; + if vm.is_none(&textio.buffer) { + return Err(vm.new_value_error("underlying buffer has been detached")); + } + } + + let chunk_size: isize = match value { + PySetterValue::Assign(object_value) => { + let integer = object_value.try_index(vm)?; + + integer.try_to_primitive::(vm).map_err(|_| { + let class = object_value.class(); + let type_name = class.name(); + let mut end = type_name.len().min(200); + while !type_name.is_char_boundary(end) { + end -= 1; + } + vm.new_value_error(format!( + "cannot fit '{}' into an index-sized integer", + &type_name[..end] + )) + })? + } + PySetterValue::Delete => { + return Err(vm.new_attribute_error("cannot delete attribute")); + } }; - // TODO: RUSTPYTHON - // Change chunk_size type, validate it manually and throws ValueError if invalid. - // https://github.com/python/cpython/blob/2e9da8e3522764d09f1d6054a2be567e91a30812/Modules/_io/textio.c#L3124-L3143 + + if chunk_size <= 0 { + return Err(vm.new_value_error("a strictly positive integer is required")); + } + + let chunk_size = usize::try_from(chunk_size) + .map_err(|_| vm.new_value_error("a strictly positive integer is required"))?; + + let mut textio = self.lock(vm)?; + textio.chunk_size = chunk_size; Ok(()) } diff --git a/extra_tests/snippets/stdlib_io.py b/extra_tests/snippets/stdlib_io.py index 93c083a90c1..f17eae5b172 100644 --- a/extra_tests/snippets/stdlib_io.py +++ b/extra_tests/snippets/stdlib_io.py @@ -84,3 +84,116 @@ def write(self, data): raw.textio = textio with assert_raises(AttributeError): textio.writelines(["x"]) + +textio = TextIOWrapper(BytesIO()) + +for invalid_chunk_size in (0, -1, 2**100): + try: + textio._CHUNK_SIZE = invalid_chunk_size + except ValueError: + pass + else: + raise AssertionError(f"expected ValueError for {invalid_chunk_size!r}") + +for invalid_chunk_size in (1.5, "4"): + try: + textio._CHUNK_SIZE = invalid_chunk_size + except TypeError: + pass + else: + raise AssertionError(f"expected TypeError for {invalid_chunk_size!r}") + + +class ChunkSize: + def __index__(self): + return 16 + + +textio._CHUNK_SIZE = ChunkSize() +assert textio._CHUNK_SIZE == 16 + + +class OversizedChunkSize: + def __index__(self): + return 2**100 + + +try: + textio._CHUNK_SIZE = OversizedChunkSize() +except ValueError as error: + expected = "cannot fit 'OversizedChunkSize' into an index-sized integer" + if str(error) != expected: + raise AssertionError(f"unexpected error message: {error}") from error +else: + raise AssertionError("expected ValueError for oversized indexable object") + + +def expect_value_error(expected, operation): + try: + operation() + except ValueError as error: + if str(error) != expected: + raise AssertionError(f"unexpected error message: {error}") from error + else: + raise AssertionError(f"expected ValueError: {expected}") + + +class UninitializedChunkSize: + def __init__(self): + self.called = False + + def __index__(self): + self.called = True + return 16 + + +uninitialized_textio = TextIOWrapper.__new__(TextIOWrapper) +uninitialized_chunk_size = UninitializedChunkSize() +expect_value_error( + "I/O operation on uninitialized object", + lambda: setattr(uninitialized_textio, "_CHUNK_SIZE", uninitialized_chunk_size), +) + +if uninitialized_chunk_size.called: + raise AssertionError( + "__index__ should not be called for uninitialized TextIOWrapper" + ) + + +detached_textio = TextIOWrapper(BytesIO()) +detached_textio.detach() +expect_value_error( + "underlying buffer has been detached", + lambda: setattr(detached_textio, "_CHUNK_SIZE", 16), +) +expect_value_error( + "underlying buffer has been detached", + lambda: delattr(detached_textio, "_CHUNK_SIZE"), +) + + +long_type_name = "X" * 250 +LongNamedChunkSize = type( + long_type_name, + (), + {"__index__": lambda self: 2**100}, +) +expect_value_error( + f"cannot fit '{long_type_name[:200]}' into an index-sized integer", + lambda: setattr(textio, "_CHUNK_SIZE", LongNamedChunkSize()), +) + + +non_ascii_type_name = "é" * 250 +NonAsciiNamedChunkSize = type( + non_ascii_type_name, + (), + {"__index__": lambda self: 2**100}, +) +truncated_non_ascii_type_name = non_ascii_type_name.encode("utf-8")[:200].decode( + "utf-8" +) +expect_value_error( + f"cannot fit '{truncated_non_ascii_type_name}' into an index-sized integer", + lambda: setattr(textio, "_CHUNK_SIZE", NonAsciiNamedChunkSize()), +) From b838989ab041a39b14db47eb1054ccb31436e58b Mon Sep 17 00:00:00 2001 From: Leesoo Ahn Date: Sat, 18 Jul 2026 16:42:12 +0900 Subject: [PATCH 141/351] Clean up deprecations (#8300) * vm: replace fetch_update() with try_update() fetch_update() in atomic namespace is in the deprecation stage. * host_env: replace libc::RLIM_NLIMITS with const RLIM_NLIMITS Define a new const, RLIM_NLIMITS instead of libc::RLIM_NLIMITS in order to clean up the deprecated const and to prevent breaking API where the const is still used. --- crates/host_env/src/resource.rs | 6 +++--- crates/vm/src/builtins/function.rs | 2 +- crates/vm/src/builtins/type.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/host_env/src/resource.rs b/crates/host_env/src/resource.rs index a0ba79b1b6b..fdf4e1e6288 100644 --- a/crates/host_env/src/resource.rs +++ b/crates/host_env/src/resource.rs @@ -7,9 +7,6 @@ pub use libc::{ RLIMIT_NOFILE, RLIMIT_NPROC, RLIMIT_RSS, RLIMIT_STACK, c_long, rlim_t, rlimit, timeval, }; -#[cfg(target_os = "android")] -pub use libc::RLIM_NLIMITS; - #[cfg(any(target_os = "linux", target_os = "android", target_os = "emscripten"))] pub use libc::{RLIMIT_MSGQUEUE, RLIMIT_NICE, RLIMIT_RTPRIO, RLIMIT_SIGPENDING}; @@ -36,6 +33,9 @@ pub use libc::RUSAGE_THREAD; #[cfg(not(any(target_os = "windows", target_os = "redox")))] pub use libc::{RUSAGE_CHILDREN, RUSAGE_SELF}; +#[cfg(target_os = "android")] +pub const RLIM_NLIMITS: libc::c_int = 16; + #[derive(Debug, Clone, Copy)] pub struct RUsage { pub ru_utime: libc::timeval, diff --git a/crates/vm/src/builtins/function.rs b/crates/vm/src/builtins/function.rs index 47fda299455..af359f11190 100644 --- a/crates/vm/src/builtins/function.rs +++ b/crates/vm/src/builtins/function.rs @@ -87,7 +87,7 @@ static FUNC_VERSION_COUNTER: AtomicU32 = AtomicU32::new(1); /// Once the counter wraps to 0, it stays at 0 permanently. fn next_func_version() -> u32 { FUNC_VERSION_COUNTER - .fetch_update(Relaxed, Relaxed, |v| (v != 0).then(|| v.wrapping_add(1))) + .try_update(Relaxed, Relaxed, |v| (v != 0).then(|| v.wrapping_add(1))) .unwrap_or(0) } diff --git a/crates/vm/src/builtins/type.rs b/crates/vm/src/builtins/type.rs index 3d9228b805f..c27c6475f42 100644 --- a/crates/vm/src/builtins/type.rs +++ b/crates/vm/src/builtins/type.rs @@ -680,7 +680,7 @@ impl PyType { let flags_bits = flags.bits(); let _ = self .abc_tpflags - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |old| { + .try_update(Ordering::AcqRel, Ordering::Acquire, |old| { Some((old & !collection_bits) | flags_bits) }); self.modified(); From 1877adc164562243a200d0abd896d54fe5008bce Mon Sep 17 00:00:00 2001 From: OkJa <151524504+name-of-okja@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:46:57 +0900 Subject: [PATCH 142/351] Implement pyexpat ordered_attributes (#8305) When `ordered_attributes` is set, report attributes to the StartElementHandler as a flat `[name, value, ...]` list matching CPython's my_StartElementHandler, instead of always passing a dict. `xml.etree.ElementTree` and `xml.dom.minidom` (via expatbuilder) both enable this flag and rely on the flat-list contract, so parsing any element with attributes previously failed: the dict made `_start` do integer indexing, raising KeyError which was silently swallowed by the handler invoker, leaving the tree empty ("missing toplevel element"). Reference (CPython 3.14): https://github.com/python/cpython/blob/3.14/Modules/pyexpat.c#L439-L443 Remove `@unittest.expectedFailure` from tests that now pass (23 in test_xml_etree, 15 in test_minidom, 1 in test_regrtest). Assisted-by: Claude:claude-opus-4-8 Co-authored-by: Claude Opus 4.8 --- Lib/test/test_minidom.py | 15 --------------- Lib/test/test_regrtest.py | 1 - Lib/test/test_xml_etree.py | 23 ----------------------- crates/stdlib/src/pyexpat.rs | 36 ++++++++++++++++++++++++------------ 4 files changed, 24 insertions(+), 51 deletions(-) diff --git a/Lib/test/test_minidom.py b/Lib/test/test_minidom.py index 5a5404d100e..c6b52ce331e 100644 --- a/Lib/test/test_minidom.py +++ b/Lib/test/test_minidom.py @@ -553,7 +553,6 @@ def testAttributeRepr(self): self.assertEqual(str(node), repr(node)) dom.unlink() - @unittest.expectedFailure # TODO: RUSTPYTHON def testWriteXML(self): str = '' dom = parseString(str) @@ -601,7 +600,6 @@ def test_toxml_quote_attrib(self): 'lflf=" " ' 'ws=" "/>') - @unittest.expectedFailure # TODO: RUSTPYTHON def testAltNewline(self): str = '\n\n' dom = parseString(str) @@ -705,7 +703,6 @@ def testAttrListKeys(self): pass def testAttrListKeysNS(self): pass - @unittest.expectedFailure # TODO: RUSTPYTHON def testRemoveNamedItem(self): doc = parseString("") e = doc.documentElement @@ -715,7 +712,6 @@ def testRemoveNamedItem(self): self.assertTrue(a1.isSameNode(a2)) self.assertRaises(xml.dom.NotFoundErr, attrs.removeNamedItem, "a") - @unittest.expectedFailure # TODO: RUSTPYTHON def testRemoveNamedItemNS(self): doc = parseString("") e = doc.documentElement @@ -788,7 +784,6 @@ def _setupCloneElement(self, deep): root.setAttribute("added", "VALUE") return dom, clone - @unittest.expectedFailure # TODO: RUSTPYTHON def testCloneElementShallow(self): dom, clone = self._setupCloneElement(0) self.assertEqual(len(clone.childNodes), 0) @@ -940,11 +935,9 @@ def check_clone_attribute(self, deep, testName): self.confirm(clone.specified, testName + ": cloned attribute must have specified == True") - @unittest.expectedFailure # TODO: RUSTPYTHON def testCloneAttributeShallow(self): self.check_clone_attribute(0, "testCloneAttributeShallow") - @unittest.expectedFailure # TODO: RUSTPYTHON def testCloneAttributeDeep(self): self.check_clone_attribute(1, "testCloneAttributeDeep") @@ -1336,7 +1329,6 @@ def checkRenameNodeSharedConstraints(self, doc, node): self.assertRaises(xml.dom.WrongDocumentErr, doc2.renameNode, node, xml.dom.EMPTY_NAMESPACE, "foo") - @unittest.expectedFailure # TODO: RUSTPYTHON def testRenameAttribute(self): doc = parseString("") elem = doc.documentElement @@ -1541,7 +1533,6 @@ def setup(): self.confirm(text is None and len(elem.childNodes) == 2) - @unittest.expectedFailure # TODO: RUSTPYTHON def testSchemaType(self): doc = parseString( "") e = doc.documentElement @@ -1607,7 +1597,6 @@ def testSetIdAttribute(self): self.confirm(e.isSameNode(doc.getElementById("w")) and a2.isId) - @unittest.expectedFailure # TODO: RUSTPYTHON def testSetIdAttributeNS(self): NS1 = "http://xml.python.org/ns1" NS2 = "http://xml.python.org/ns2" @@ -1644,7 +1633,6 @@ def testSetIdAttributeNS(self): self.confirm(e.isSameNode(doc.getElementById("w")) and a2.isId) - @unittest.expectedFailure # TODO: RUSTPYTHON def testSetIdAttributeNode(self): NS1 = "http://xml.python.org/ns1" NS2 = "http://xml.python.org/ns2" @@ -1766,7 +1754,6 @@ def testProcessingInstructionNameError(self): pi = doc.createProcessingInstruction("y", "z") pi.nodeValue = "crash" - @unittest.expectedFailure # TODO: RUSTPYTHON def test_minidom_attribute_order(self): xml_str = '' doc = parseString(xml_str) @@ -1774,13 +1761,11 @@ def test_minidom_attribute_order(self): doc.writexml(output) self.assertEqual(output.getvalue(), xml_str) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_toxml_with_attributes_ordered(self): xml_str = '' doc = parseString(xml_str) self.assertEqual(doc.toxml(), xml_str) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_toprettyxml_with_attributes_ordered(self): xml_str = '' doc = parseString(xml_str) diff --git a/Lib/test/test_regrtest.py b/Lib/test/test_regrtest.py index 85db8e14c10..cb9ae24ccf2 100644 --- a/Lib/test/test_regrtest.py +++ b/Lib/test/test_regrtest.py @@ -2355,7 +2355,6 @@ def test_pass(self): self.check_executed_tests(output, testname, stats=1, parallel=True) self.assertNotIn('SPAM SPAM SPAM', output) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: int() argument must be a string, a bytes-like object or a real number, not 'NoneType' def test_xml(self): code = textwrap.dedent(r""" import unittest diff --git a/Lib/test/test_xml_etree.py b/Lib/test/test_xml_etree.py index 18a845fe2d2..2b0777c1d23 100644 --- a/Lib/test/test_xml_etree.py +++ b/Lib/test/test_xml_etree.py @@ -392,7 +392,6 @@ def test_cdata(self): self.serialize_check(ET.XML(""), 'hello') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_file_init(self): stringfile = io.BytesIO(SAMPLE_XML.encode("utf-8")) tree = ET.ElementTree(file=stringfile) @@ -508,7 +507,6 @@ def test_makeelement(self): elem[:] = tuple([subelem]) self.serialize_check(elem, '') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_parsefile(self): # Test parsing from file. @@ -688,7 +686,6 @@ def test_initialize_parser_without_target(self): parser2 = ET.XMLParser() self.assertIsInstance(parser2.target, ET.TreeBuilder) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_children(self): # Test Element children iteration @@ -1091,7 +1088,6 @@ def test_entity(self): self.assertEqual(str(cm.exception), 'undefined entity &entity;: line 4, column 10') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_namespace(self): # Test namespace issues. @@ -1335,7 +1331,6 @@ def test_attlist_default(self): class IterparseTest(unittest.TestCase): # Test iterparse interface. - @unittest.expectedFailure # TODO: RUSTPYTHON def test_basic(self): iterparse = ET.iterparse @@ -1361,7 +1356,6 @@ def test_basic(self): ]) it.close() - @unittest.expectedFailure # TODO: RUSTPYTHON def test_external_file(self): with open(SIMPLE_XMLFILE, 'rb') as source: it = ET.iterparse(source) @@ -1374,7 +1368,6 @@ def test_external_file(self): ]) self.assertEqual(it.root.tag, 'root') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_events(self): iterparse = ET.iterparse @@ -1475,7 +1468,6 @@ def test_nonexistent_file(self): with self.assertRaises(FileNotFoundError): ET.iterparse("nonexistent") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_resource_warnings_not_exhausted(self): # Not exhausting the iterator still closes the underlying file (bpo-43292) it = ET.iterparse(SIMPLE_XMLFILE) @@ -1514,7 +1506,6 @@ def test_resource_warnings_exhausted(self): del it gc_collect() - @unittest.expectedFailure # TODO: RUSTPYTHON def test_close_not_exhausted(self): iterparse = ET.iterparse @@ -2041,7 +2032,6 @@ def _my_loader(self, href, parse): else: return None - @unittest.expectedFailure # TODO: RUSTPYTHON def test_xinclude_default(self): from xml.etree import ElementInclude doc = self.xinclude_loader('default.xml') @@ -2056,7 +2046,6 @@ def test_xinclude_default(self): '\n' '') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_xinclude(self): from xml.etree import ElementInclude @@ -2121,7 +2110,6 @@ def test_xinclude(self): ' \n' '') # C5 - @unittest.expectedFailure # TODO: RUSTPYTHON def test_xinclude_repeated(self): from xml.etree import ElementInclude @@ -2129,7 +2117,6 @@ def test_xinclude_repeated(self): ElementInclude.include(document, self.xinclude_loader) self.assertEqual(1+4*2, len(document.findall(".//p"))) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_xinclude_failures(self): from xml.etree import ElementInclude @@ -2234,7 +2221,6 @@ def check(elem): elem.set("123", 123) check(elem) # attribute value - @unittest.expectedFailure # TODO: RUSTPYTHON def test_bug_xmltoolkit25(self): # typo in ElementTree.findtext @@ -2258,7 +2244,6 @@ def test_bug_xmltoolkitX1(self): ET.dump(tree) self.assertEqual(stdout.getvalue(), '
\n') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_bug_xmltoolkit39(self): # non-ascii element and attribute names doesn't work @@ -2345,7 +2330,6 @@ def xmltoolkit63(): xmltoolkit63() self.assertEqual(sys.getrefcount(None), count) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_bug_200708_newline(self): # Preserve newlines in attributes. @@ -2461,7 +2445,6 @@ def test_issue6233(self): b"\n" b'tãg') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_issue6565(self): elem = ET.XML("") self.assertEqual(summarize_list(elem), ['tag']) @@ -2533,7 +2516,6 @@ def check_expat224_utf8_bug(self, text): root = ET.XML(xml) self.assertEqual(root.get('b'), text.decode('utf-8')) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_expat224_utf8_bug(self): # bpo-31170: Expat 2.2.3 had a bug in its UTF-8 decoder. # Check that Expat 2.2.4 fixed the bug. @@ -3355,7 +3337,6 @@ class MyElement(ET.Element): class ElementFindTest(unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON def test_find_simple(self): e = ET.XML(SAMPLE_XML) self.assertEqual(e.find('tag').tag, 'tag') @@ -3379,7 +3360,6 @@ def test_find_simple(self): # Issue #16922 self.assertEqual(ET.XML('').findtext('empty'), '') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_find_xpath(self): LINEAR_XML = ''' @@ -3402,7 +3382,6 @@ def test_find_xpath(self): self.assertRaisesRegex(SyntaxError, 'XPath', e.find, './tag[last()-0]') self.assertRaisesRegex(SyntaxError, 'XPath', e.find, './tag[last()+1]') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_findall(self): e = ET.XML(SAMPLE_XML) e[2] = ET.XML(SAMPLE_SECTION) @@ -3591,7 +3570,6 @@ def test_bad_find(self): with self.assertRaisesRegex(SyntaxError, 'cannot use absolute path'): e.findall('/tag') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_find_through_ElementTree(self): e = ET.XML(SAMPLE_XML) self.assertEqual(ET.ElementTree(e).find('tag').tag, 'tag') @@ -4576,7 +4554,6 @@ def test_correct_import_pyET(self): # -------------------------------------------------------------------- class BoolTest(unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON def test_warning(self): e = ET.fromstring('
') msg = ( diff --git a/crates/stdlib/src/pyexpat.rs b/crates/stdlib/src/pyexpat.rs index f690abadeaf..9b55bbb22f3 100644 --- a/crates/stdlib/src/pyexpat.rs +++ b/crates/stdlib/src/pyexpat.rs @@ -43,7 +43,8 @@ macro_rules! create_bool_property { #[pymodule(name = "pyexpat")] mod _pyexpat { use crate::vm::{ - Context, Py, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, VirtualMachine, + AsObject, Context, Py, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, + VirtualMachine, builtins::{PyBytesRef, PyException, PyModule, PyStr, PyStrRef, PyType, PyUtf8StrRef}, extend_module, function::{ArgBytesLike, ArgPrimitiveIndex, Either, IntoFuncArgs, OptionalArg}, @@ -355,19 +356,30 @@ mod _pyexpat { XmlEvent::StartElement { name, attributes, .. } => { - let dict = vm.ctx.new_dict(); - for attribute in attributes { - let attr_name = self.make_name(&attribute.name); - dict.set_item( - attr_name.as_str(), - vm.ctx.new_str(attribute.value).into(), - vm, - ) - .unwrap(); - } + let ordered = self.ordered_attributes.read().is(&vm.ctx.true_value); + // Build the container. + let attrs: PyObjectRef = if ordered { + let mut items = Vec::with_capacity(attributes.len() * 2); + for attribute in attributes { + items.push(vm.ctx.new_str(self.make_name(&attribute.name)).into()); + items.push(vm.ctx.new_str(attribute.value).into()); + } + vm.ctx.new_list(items).into() + } else { + let dict = vm.ctx.new_dict(); + for attribute in attributes { + dict.set_item( + self.make_name(&attribute.name).as_str(), + vm.ctx.new_str(attribute.value).into(), + vm, + ) + .unwrap(); + } + dict.into() + }; let name_str = PyStr::from(self.make_name(&name)).into_ref(&vm.ctx); - invoke_handler(vm, &self.start_element, (name_str, dict)); + invoke_handler(vm, &self.start_element, (name_str, attrs)); } XmlEvent::EndElement { name, .. } => { let name_str = PyStr::from(self.make_name(&name)).into_ref(&vm.ctx); From 732aca834aeb96356efb6b59f7322295a5498703 Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:48:32 +0200 Subject: [PATCH 143/351] Update `.idea/vcs.xml` (#8308) --- .idea/vcs.xml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 9bb3f97becc..82bc0911775 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -10,4 +10,7 @@ - + + + + \ No newline at end of file From 0bc109d6de9ed0af4d57b0964d2af0e01e5f32d1 Mon Sep 17 00:00:00 2001 From: sijun-yang Date: Sat, 18 Jul 2026 16:49:20 +0900 Subject: [PATCH 144/351] Remove obsolete _symtable APIs (#8309) stdlib/_symtable.rs previously provided the public Symbol API. When 7f1fc3602 imported Lib/symtable.py, responsibility for the public Symbol implementation moved to Python, but the native implementation remained. Remove it along with the unused identifiers getter. Assisted-by: Codex:gpt-5.6-sol --- crates/vm/src/stdlib/_symtable.rs | 115 +----------------------------- 1 file changed, 1 insertion(+), 114 deletions(-) diff --git a/crates/vm/src/stdlib/_symtable.rs b/crates/vm/src/stdlib/_symtable.rs index 38d63208d9e..c4a4a7a2051 100644 --- a/crates/vm/src/stdlib/_symtable.rs +++ b/crates/vm/src/stdlib/_symtable.rs @@ -9,9 +9,7 @@ mod _symtable { types::Representable, }; use alloc::fmt; - use rustpython_codegen::symboltable::{ - CompilerScope, Symbol, SymbolFlags, SymbolScope, SymbolTable, - }; + use rustpython_codegen::symboltable::{CompilerScope, SymbolFlags, SymbolScope, SymbolTable}; /// [CPython's `SCOPE_OFFSET`](https://github.com/python/cpython/blob/v3.14.6/Include/internal/pycore_symtable.h#L176) const SCOPE_OFFSET: i32 = 12; @@ -183,15 +181,6 @@ mod _symtable { self as *const Self as *const core::ffi::c_void as usize } - #[pygetset] - fn identifiers(&self, vm: &VirtualMachine) -> Vec { - self.symtable - .symbols - .keys() - .map(|s| vm.ctx.new_str(s.as_str()).into()) - .collect() - } - #[pygetset] fn symbols(&self, vm: &VirtualMachine) -> PyDictRef { let dict = vm.ctx.new_dict(); @@ -220,106 +209,4 @@ mod _symtable { )) } } - - #[pyattr] - #[pyclass(name = "Symbol")] - #[derive(PyPayload)] - struct PySymbol { - symbol: Symbol, - namespaces: Vec, - is_top_scope: bool, - } - - impl fmt::Debug for PySymbol { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Symbol()") - } - } - - #[pyclass] - impl PySymbol { - #[pymethod] - fn get_name(&self) -> String { - self.symbol.name.clone() - } - - #[pymethod] - const fn is_global(&self) -> bool { - self.symbol.is_global() || (self.is_top_scope && self.symbol.is_bound()) - } - - #[pymethod] - const fn is_declared_global(&self) -> bool { - matches!(self.symbol.scope, SymbolScope::GlobalExplicit) - } - - #[pymethod] - const fn is_local(&self) -> bool { - self.symbol.is_local() || (self.is_top_scope && self.symbol.is_bound()) - } - - #[pymethod] - const fn is_imported(&self) -> bool { - self.symbol.flags.contains(SymbolFlags::DEF_IMPORT) - } - - #[pymethod] - const fn is_nested(&self) -> bool { - // TODO - false - } - - #[pymethod] - const fn is_nonlocal(&self) -> bool { - self.symbol.flags.contains(SymbolFlags::DEF_NONLOCAL) - } - - #[pymethod] - const fn is_referenced(&self) -> bool { - self.symbol.flags.contains(SymbolFlags::USE) - } - - #[pymethod] - const fn is_assigned(&self) -> bool { - self.symbol.flags.contains(SymbolFlags::DEF_LOCAL) - } - - #[pymethod] - const fn is_parameter(&self) -> bool { - self.symbol.flags.contains(SymbolFlags::DEF_PARAM) - } - - #[pymethod] - const fn is_free(&self) -> bool { - matches!(self.symbol.scope, SymbolScope::Free) - } - - #[pymethod] - const fn is_namespace(&self) -> bool { - !self.namespaces.is_empty() - } - - #[pymethod] - const fn is_annotated(&self) -> bool { - self.symbol.flags.contains(SymbolFlags::DEF_ANNOT) - } - - #[pymethod] - fn get_namespaces(&self, vm: &VirtualMachine) -> Vec { - self.namespaces - .iter() - .map(|table| to_py_symbol_table(table.clone()).into_pyobject(vm)) - .collect() - } - - #[pymethod] - fn get_namespace(&self, vm: &VirtualMachine) -> PyResult { - if self.namespaces.len() != 1 { - return Err(vm.new_value_error("namespace is bound to multiple namespaces")); - } - Ok(to_py_symbol_table(self.namespaces.first().unwrap().clone()) - .into_ref(&vm.ctx) - .into()) - } - } } From c7c67fd4d64d068f9a9133ca702619f4f3390bfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9D=80=EB=B9=88?= Date: Sat, 18 Jul 2026 23:47:03 +0900 Subject: [PATCH 145/351] weakref: reuse proxy objects (#8307) * weakref: reuse callback-less proxy objects like CPython Make PyWeakProxy a #[repr(transparent)] view sharing PyWeak's payload (manual PyPayload with shared PAYLOAD_TYPE_ID + class check), so a proxy is the weakref-list node itself and add()'s reuse applies to it directly. Port CPython's insert_weakref/get_basic_refs slot discipline: genericref at head, generic proxy right after it, with callback/class re-verificationbefore reuse. Constructor moves to a slot_new override since reuse returns an existing object. Drops the hidden __weakproxy marker subclass and the proxy branch in PyWeakref_GetRef. Assisted-by: Claude Code:claude-opus-4-8 * weakref: always validate class in basic-proxy slot lookup * weakref: rustfmt find_generic_proxy_ptr * weakref: unify reuse check shape --- Lib/test/test_weakref.py | 2 - crates/capi/src/weakrefobject.rs | 2 - crates/vm/src/builtins/weakproxy.rs | 71 ++++++------ crates/vm/src/object/core.rs | 166 ++++++++++++++++++---------- 4 files changed, 139 insertions(+), 102 deletions(-) diff --git a/Lib/test/test_weakref.py b/Lib/test/test_weakref.py index 224130110b8..3a3a5a7c3a1 100644 --- a/Lib/test/test_weakref.py +++ b/Lib/test/test_weakref.py @@ -289,7 +289,6 @@ def test_ref_reuse(self): self.assertEqual(weakref.getweakrefcount(o), 1, "wrong weak ref count for object after deleting proxy") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_proxy_reuse(self): o = C() proxy1 = weakref.proxy(o) @@ -383,7 +382,6 @@ def __imatmul__(self, other): def test_shared_ref_without_callback(self): self.check_shared_without_callback(weakref.ref) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_shared_proxy_without_callback(self): self.check_shared_without_callback(weakref.proxy) diff --git a/crates/capi/src/weakrefobject.rs b/crates/capi/src/weakrefobject.rs index de095e5e594..ff4a8bcff13 100644 --- a/crates/capi/src/weakrefobject.rs +++ b/crates/capi/src/weakrefobject.rs @@ -20,8 +20,6 @@ pub unsafe extern "C" fn PyWeakref_GetRef( let reference = unsafe { &*reference }; let upgraded = if let Some(weak) = reference.downcast_ref::() { weak.upgrade() - } else if let Some(proxy) = reference.downcast_ref::() { - proxy.get_weak().upgrade() } else { return Err(vm.new_type_error("expected a weakref")); }; diff --git a/crates/vm/src/builtins/weakproxy.rs b/crates/vm/src/builtins/weakproxy.rs index 1bdd7721ffe..35871c07b12 100644 --- a/crates/vm/src/builtins/weakproxy.rs +++ b/crates/vm/src/builtins/weakproxy.rs @@ -4,7 +4,7 @@ use crate::{ Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, atomic_func, class::PyClassImpl, common::hash::PyHash, - function::{OptionalArg, PyArithmeticValue, PyComparisonValue, PySetterValue}, + function::{FuncArgs, OptionalArg, PyArithmeticValue, PyComparisonValue, PySetterValue}, protocol::{PyIter, PyIterReturn, PyMappingMethods, PyNumberMethods, PySequenceMethods}, stdlib::builtins::reversed, types::{ @@ -13,13 +13,22 @@ use crate::{ }, }; -#[pyclass(module = false, name = "weakproxy", unhashable = true, traverse)] +#[pyclass(module = false, name = "weakproxy", unhashable = true)] #[derive(Debug)] -pub struct PyWeakProxy { - weak: PyRef, -} +#[repr(transparent)] +pub struct PyWeakProxy(PyWeak); impl PyPayload for PyWeakProxy { + const PAYLOAD_TYPE_ID: core::any::TypeId = ::PAYLOAD_TYPE_ID; + + #[inline] + unsafe fn validate_downcastable_from(obj: &PyObject) -> bool { + ::BASICSIZE <= obj.class().slots.basicsize + && obj + .class() + .fast_issubclass(::static_type()) + } + #[inline] fn class(ctx: &Context) -> &'static Py { ctx.types.weakproxy_type @@ -37,52 +46,34 @@ pub struct WeakProxyNewArgs { impl Constructor for PyWeakProxy { type Args = WeakProxyNewArgs; - fn py_new( - _cls: &Py, - Self::Args { referent, callback }: Self::Args, - vm: &VirtualMachine, - ) -> PyResult { - let weak = Self::new_weak(referent.as_ref(), callback.into_option(), vm)?; - // TODO: PyWeakProxy should use the same payload as PyWeak - Ok(Self { weak }) + fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { + let _ = cls; + let Self::Args { referent, callback } = args.bind(vm)?; + let callback = callback + .into_option() + .filter(|callback| !vm.is_none(callback)); + let proxy = Self::new_weakproxy(referent.as_ref(), callback, vm)?; + Ok(proxy.into()) } -} -crate::common::static_cell! { - static WEAK_SUBCLASS: PyTypeRef; + fn py_new(_cls: &Py, _args: Self::Args, _vm: &VirtualMachine) -> PyResult { + unimplemented!("use slot_new") + } } impl PyWeakProxy { - fn new_weak( - referent: &PyObject, - callback: Option, - vm: &VirtualMachine, - ) -> PyResult> { - // using an internal subclass as the class prevents us from getting the generic weakref, - // which would mess up the weakref count - let weak_cls = WEAK_SUBCLASS.get_or_init(|| { - vm.ctx.new_class( - None, - "__weakproxy", - vm.ctx.types.weakref_type.to_owned(), - super::PyWeak::make_slots(), - ) - }); - referent.downgrade_with_typ(callback, weak_cls.clone(), vm) - } - pub fn new_weakproxy( referent: &PyObject, callback: Option, vm: &VirtualMachine, - ) -> PyResult> { - let weak = Self::new_weak(referent, callback, vm)?; - Ok(Self { weak }.into_ref(&vm.ctx)) + ) -> PyResult> { + let typ = vm.ctx.types.weakproxy_type.to_owned(); + referent.downgrade_with_typ(callback, typ, vm) } #[must_use] - pub fn get_weak(&self) -> &PyRef { - &self.weak + pub fn get_weak(&self) -> &PyWeak { + &self.0 } } @@ -99,7 +90,7 @@ impl PyWeakProxy { ))] impl PyWeakProxy { fn try_upgrade(&self, vm: &VirtualMachine) -> PyResult { - self.weak.upgrade().ok_or_else(|| new_reference_error(vm)) + self.0.upgrade().ok_or_else(|| new_reference_error(vm)) } #[pymethod] diff --git a/crates/vm/src/object/core.rs b/crates/vm/src/object/core.rs index 9fa236b87ff..228fe1290ea 100644 --- a/crates/vm/src/object/core.rs +++ b/crates/vm/src/object/core.rs @@ -582,6 +582,18 @@ unsafe fn unlink_weakref(wrl: &WeakRefList, node: NonNull>) { } } +// try_reuse_basic_ref +unsafe fn try_reuse_weakref(ptr: *mut Py) -> Option> { + if ptr.is_null() { + return None; + } + let node = unsafe { &*ptr }; + node.0 + .ref_count + .safe_inc() + .then(|| unsafe { PyRef::from_raw(ptr) }) +} + impl WeakRefList { pub(super) fn new() -> Self { Self { @@ -596,22 +608,25 @@ impl WeakRefList { obj: &PyObject, cls: PyTypeRef, cls_is_weakref: bool, + cls_is_weakproxy: bool, callback: Option, dict: Option, ) -> PyRef { let is_generic = cls_is_weakref && callback.is_none(); + let is_generic_proxy = cls_is_weakproxy && callback.is_none(); // Try reuse under lock first (fast path, no allocation) { let _lock = weakref_lock::lock(obj as *const PyObject as usize); - if is_generic { - let generic_ptr = self.generic.load(Ordering::Relaxed); - if !generic_ptr.is_null() { - let generic = unsafe { &*generic_ptr }; - if generic.0.ref_count.safe_inc() { - return unsafe { PyRef::from_raw(generic_ptr) }; - } - } + let existing = if is_generic { + unsafe { try_reuse_weakref(self.generic.load(Ordering::Relaxed)) } + } else if is_generic_proxy { + unsafe { try_reuse_weakref(self.find_generic_proxy_ptr()) } + } else { + None + }; + if let Some(existing) = existing { + return existing; } } @@ -629,64 +644,98 @@ impl WeakRefList { // Re-acquire lock for linked list insertion let _lock = weakref_lock::lock(obj as *const PyObject as usize); - // Re-check: another thread may have inserted a generic ref while we - // were allocating outside the lock. If so, reuse it and drop ours. - if is_generic { - let generic_ptr = self.generic.load(Ordering::Relaxed); - if !generic_ptr.is_null() { - let generic = unsafe { &*generic_ptr }; - if generic.0.ref_count.safe_inc() { - // Nullify wr_object so drop_inner won't unlink an - // un-inserted node (which would corrupt the list head). - weak.wr_object.store(ptr::null_mut(), Ordering::Relaxed); - return unsafe { PyRef::from_raw(generic_ptr) }; - } - } + // Re-check: another thread may have inserted a generic ref/proxy + // while we were allocating outside the lock. If so, reuse it and + // drop ours. + let existing = if is_generic { + unsafe { try_reuse_weakref(self.generic.load(Ordering::Relaxed)) } + } else if is_generic_proxy { + unsafe { try_reuse_weakref(self.find_generic_proxy_ptr()) } + } else { + None + }; + if let Some(existing) = existing { + // Nullify wr_object so drop_inner won't unlink an + // un-inserted node (which would corrupt the list head). + weak.wr_object.store(ptr::null_mut(), Ordering::Relaxed); + return existing; } // Insert into linked list under stripe lock + // (insert_weakref: generic ref at head, generic proxy right after it) let node_ptr = NonNull::from(&*weak); + let after = if is_generic { + None + } else if is_generic_proxy { + NonNull::new(self.generic.load(Ordering::Relaxed)) + } else { + NonNull::new(self.find_generic_proxy_ptr()) + .or_else(|| NonNull::new(self.generic.load(Ordering::Relaxed))) + }; + match after { + Some(after) => unsafe { self.insert_after(after, node_ptr) }, + None => unsafe { self.insert_at_head(node_ptr) }, + } + if is_generic { + self.generic.store(node_ptr.as_ptr(), Ordering::Relaxed); + } + + weak + } + + unsafe fn insert_at_head(&self, node_ptr: NonNull>) { unsafe { let mut ptrs = WeakLink::pointers(node_ptr); - if is_generic { - // Generic ref goes to head (insert_head for basic ref) - let old_head = self.head.load(Ordering::Relaxed); - ptrs.as_mut().set_next(NonNull::new(old_head)); - ptrs.as_mut().set_prev(None); - if let Some(old_head) = NonNull::new(old_head) { - WeakLink::pointers(old_head) - .as_mut() - .set_prev(Some(node_ptr)); - } - self.head.store(node_ptr.as_ptr(), Ordering::Relaxed); - self.generic.store(node_ptr.as_ptr(), Ordering::Relaxed); - } else { - // Non-generic refs go after generic ref (insert_after) - let generic_ptr = self.generic.load(Ordering::Relaxed); - if let Some(after) = NonNull::new(generic_ptr) { - let after_next = WeakLink::pointers(after).as_ref().get_next(); - ptrs.as_mut().set_prev(Some(after)); - ptrs.as_mut().set_next(after_next); - WeakLink::pointers(after).as_mut().set_next(Some(node_ptr)); - if let Some(next) = after_next { - WeakLink::pointers(next).as_mut().set_prev(Some(node_ptr)); - } + let old_head = self.head.load(Ordering::Relaxed); + ptrs.as_mut().set_next(NonNull::new(old_head)); + ptrs.as_mut().set_prev(None); + if let Some(old_head) = NonNull::new(old_head) { + WeakLink::pointers(old_head) + .as_mut() + .set_prev(Some(node_ptr)); + } + self.head.store(node_ptr.as_ptr(), Ordering::Relaxed); + } + } + + unsafe fn insert_after(&self, after: NonNull>, node_ptr: NonNull>) { + unsafe { + let mut ptrs = WeakLink::pointers(node_ptr); + let after_next = WeakLink::pointers(after).as_ref().get_next(); + ptrs.as_mut().set_prev(Some(after)); + ptrs.as_mut().set_next(after_next); + WeakLink::pointers(after).as_mut().set_next(Some(node_ptr)); + if let Some(next) = after_next { + WeakLink::pointers(next).as_mut().set_prev(Some(node_ptr)); + } + } + } + + // get_basic_refs + fn find_generic_proxy_ptr(&self) -> *mut Py { + let generic_ptr = self.generic.load(Ordering::Relaxed); + let candidate_ptr = if let Some(generic_node) = NonNull::new(generic_ptr) { + unsafe { WeakLink::pointers(generic_node).as_ref().get_next() } + .map_or(ptr::null_mut(), |n| n.as_ptr()) + } else { + self.head.load(Ordering::Relaxed) + }; + match NonNull::new(candidate_ptr) { + Some(candidate) => { + let node = unsafe { candidate.as_ref() }; + let has_callback = unsafe { (&*node.0.payload.callback.get()).is_some() }; + // PyWeakref_CheckProxy: the basic-proxy slot is reserved for + // the canonical proxy type; subclasses and callback-less ref + // subclasses must not be mistaken for it. + let is_proxy = node.class().is(crate::builtins::PyWeakProxy::static_type()); + if has_callback || !is_proxy { + ptr::null_mut() } else { - // No generic ref; insert at head - let old_head = self.head.load(Ordering::Relaxed); - ptrs.as_mut().set_next(NonNull::new(old_head)); - ptrs.as_mut().set_prev(None); - if let Some(old_head) = NonNull::new(old_head) { - WeakLink::pointers(old_head) - .as_mut() - .set_prev(Some(node_ptr)); - } - self.head.store(node_ptr.as_ptr(), Ordering::Relaxed); + candidate_ptr } } + None => ptr::null_mut(), } - - weak } /// Clear all weakrefs and call their callbacks. @@ -1445,7 +1494,7 @@ impl PyObject { typ: PyTypeRef, ) -> Option> { self.weak_ref_list() - .map(|wrl| wrl.add(self, typ, true, callback, None)) + .map(|wrl| wrl.add(self, typ, true, false, callback, None)) } pub(crate) fn downgrade_with_typ( @@ -1476,13 +1525,14 @@ impl PyObject { None }; let cls_is_weakref = typ.is(vm.ctx.types.weakref_type); + let cls_is_weakproxy = typ.is(vm.ctx.types.weakproxy_type); let wrl = self.weak_ref_list().ok_or_else(|| { vm.new_type_error(format!( "cannot create weak reference to '{}' object", self.class().name() )) })?; - Ok(wrl.add(self, typ, cls_is_weakref, callback, dict)) + Ok(wrl.add(self, typ, cls_is_weakref, cls_is_weakproxy, callback, dict)) } pub fn downgrade( From e68d9c266fe78cfef3ff72f0e53abcbd4c00b5ca Mon Sep 17 00:00:00 2001 From: Leesoo Ahn Date: Sun, 19 Jul 2026 13:32:46 +0900 Subject: [PATCH 146/351] interpreter: canonicalize when initializing search paths dynamically (#8299) Dual-Root Mismatch in RustPython: Unlike CPython, RustPython manages the standard library in two separate locations in the workspace: - Workspace 'Lib/': Contains the test suite ('Lib/test/...') and standard library files. - crates/pylib/Lib/: A helper directory used for packaging. 'crates/pylib/Lib' is a symlink pointing to the real workspace '../../Lib/'. Runtime Directory Discrepancy: - 'sys.path' contains 'crates/pylib/Lib' (the path to the symlink). - Thus, when 'test.support' is imported, its '__file__' is resolved relative to the symlink: '/opt/RustPython/crates/pylib/Lib/test/support/__init__.py'. - Consequently, 'STDLIB_DIR' resolves to the symlink root: '/opt/RustPython/crates/pylib/Lib'. - However, since 'test_program.py' is executed directly from the workspace, its 'pkg_dir' is: '/opt/RustPython/Lib/test/test_unittest/testmock'. - When 'os.path.relpath(pkg_dir, top_dir)' is called: --- os.path.relpath( "/opt/RustPython/Lib/test/test_unittest/testmock", "/opt/RustPython/crates/pylib/Lib" ) # Result: "../../../Lib/test/test_unittest/testmock" --- Because the relative path starts with '..', the assertion fails. Assisted-by: Gemini --- Lib/test/test_unittest/test_program.py | 1 - src/interpreter.rs | 18 +++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_unittest/test_program.py b/Lib/test/test_unittest/test_program.py index 26808dc00a5..8ed92373e5e 100644 --- a/Lib/test/test_unittest/test_program.py +++ b/Lib/test/test_unittest/test_program.py @@ -505,7 +505,6 @@ def testParseArgsSelectedTestNames(self): self.assertEqual(program.testNamePatterns, ['*foo*', '*bar*', '*pat*']) - @unittest.expectedFailureIf(sys.platform != "win32", "TODO: RUSTPYTHON") def testSelectedTestNamesFunctionalTest(self): def run_unittest(args): # Use -E to ignore PYTHONSAFEPATH env var diff --git a/src/interpreter.rs b/src/interpreter.rs index 230192d1e21..89e24c25b08 100644 --- a/src/interpreter.rs +++ b/src/interpreter.rs @@ -57,7 +57,23 @@ fn setup_dynamic_stdlib(vm: &mut crate::VirtualMachine) { use rustpython_vm::common::rc::PyRc; let state = PyRc::get_mut(&mut vm.state).unwrap(); - let paths = collect_stdlib_paths(); + let paths: Vec = collect_stdlib_paths() + .into_iter() + .map(|p| { + std::fs::canonicalize(&p) + .map(|canonical| { + let s = canonical.to_string_lossy(); + #[cfg(windows)] + { + if let Some(stripped) = s.strip_prefix(r"\\?\") { + return stripped.to_owned(); + } + } + s.into_owned() + }) + .unwrap_or(p) + }) + .collect(); // Set stdlib_dir to the first stdlib path if available if let Some(first_path) = paths.first() { From 39f1630bf652916ca154606913d1bc1abb56bf58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EB=8F=99=EC=95=88?= Date: Sun, 19 Jul 2026 13:50:13 +0900 Subject: [PATCH 147/351] Store StopIteration.value in a struct field instead of the instance dict (#8301) * Store StopIteration.value in a struct field like SystemExit * Unmark test_pydoc's test_member_descriptor * Use expect instead of unwrap in new_stop_iteration --- Lib/test/test_pydoc/test_pydoc.py | 1 - crates/vm/src/exceptions.rs | 74 ++++++++++++++++++++++++++----- crates/vm/src/vm/vm_new.rs | 12 ++--- 3 files changed, 67 insertions(+), 20 deletions(-) diff --git a/Lib/test/test_pydoc/test_pydoc.py b/Lib/test/test_pydoc/test_pydoc.py index 2a96ef4dd71..5c9059614de 100644 --- a/Lib/test/test_pydoc/test_pydoc.py +++ b/Lib/test/test_pydoc/test_pydoc.py @@ -1797,7 +1797,6 @@ def test_getset_descriptor(self): self.assertEqual(self._get_summary_line(Exception.args), "args") self.assertEqual(self._get_summary_line(memoryview.obj), "obj") - @unittest.expectedFailure # TODO: RUSTPYTHON @requires_docstrings def test_member_descriptor(self): # Currently these attributes are implemented as member descriptors diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index 1db10274e89..f76a2ad62ed 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -965,9 +965,7 @@ impl ExceptionZoo { extend_exception!(PyException, ctx, excs.exception_type); - extend_exception!(PyStopIteration, ctx, excs.stop_iteration, { - "value" => ctx.none(), - }); + extend_exception!(PyStopIteration, ctx, excs.stop_iteration); extend_exception!(PyStopAsyncIteration, ctx, excs.stop_async_iteration); extend_exception!(PyArithmeticError, ctx, excs.arithmetic_error); @@ -1702,18 +1700,57 @@ pub(super) mod types { #[repr(transparent)] pub struct PyException(PyBaseException); - #[pyexception(name, base = PyException, ctx = "stop_iteration")] - #[derive(Debug)] - #[repr(transparent)] - pub struct PyStopIteration(PyException); + #[pyexception(name, base = PyException, ctx = "stop_iteration", traverse = "manual")] + #[repr(C)] + pub struct PyStopIteration { + base: PyException, + value: PyAtomicRef>, + } - #[pyexception(with(Initializer))] - impl PyStopIteration {} + impl crate::class::PySubclass for PyStopIteration { + type Base = PyException; + fn as_base(&self) -> &Self::Base { + &self.base + } + } + + impl core::fmt::Debug for PyStopIteration { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("PyStopIteration").finish_non_exhaustive() + } + } + + unsafe impl Traverse for PyStopIteration { + fn traverse(&self, tracer_fn: &mut TraverseFn<'_>) { + self.base.0.traverse(tracer_fn); + if let Some(obj) = self.value.deref() { + tracer_fn(obj); + } + } + } + + impl Constructor for PyStopIteration { + type Args = FuncArgs; + + fn py_new(_cls: &Py, args: FuncArgs, vm: &VirtualMachine) -> PyResult { + let base_exception = PyBaseException::new(args.args, vm); + Ok(Self { + base: PyException(base_exception), + value: None.into(), + }) + } + } impl Initializer for PyStopIteration { type Args = FuncArgs; fn slot_init(zelf: PyObjectRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult<()> { - zelf.set_attr("value", vm.unwrap_or_none(args.args.first().cloned()), vm)?; + let value = match args.args.len() { + 0 => vm.ctx.none(), + _ => args.args[0].clone(), + }; + PyBaseException::slot_init(zelf.clone(), args, vm)?; + let exc: &Py = zelf.downcast_ref::().unwrap(); + exc.value.swap_to_temporary_refs(Some(value), vm); Ok(()) } @@ -1722,6 +1759,23 @@ pub(super) mod types { } } + #[pyexception(with(Constructor, Initializer))] + impl PyStopIteration { + #[pygetset] + fn value(&self) -> Option { + self.value.to_owned() + } + + #[pygetset(setter)] + fn set_value(&self, setter_value: PySetterValue, vm: &VirtualMachine) { + let value = match setter_value { + PySetterValue::Assign(v) => Some(v), + PySetterValue::Delete => None, + }; + self.value.swap_to_temporary_refs(value, vm); + } + } + #[pyexception(name, base = PyException, ctx = "stop_async_iteration", impl)] #[derive(Debug)] #[repr(transparent)] diff --git a/crates/vm/src/vm/vm_new.rs b/crates/vm/src/vm/vm_new.rs index ae728aaba67..857ecb33c72 100644 --- a/crates/vm/src/vm/vm_new.rs +++ b/crates/vm/src/vm/vm_new.rs @@ -894,21 +894,15 @@ impl VirtualMachine { } pub fn new_stop_iteration(&self, value: Option) -> PyBaseExceptionRef { - let dict = self.ctx.new_dict(); + let stop_iteration_error = self.ctx.exceptions.stop_iteration.to_owned(); let args = if let Some(value) = value { - // manually set `value` attribute like StopIteration.__init__ - dict.set_item("value", value.clone(), self) - .expect("dict.__setitem__ never fails"); vec![value] } else { Vec::new() }; + let exc = self.invoke_exception(stop_iteration_error, args); - PyRef::new_ref( - PyBaseException::new(args, self), - self.ctx.exceptions.stop_iteration.to_owned(), - Some(dict), - ) + exc.expect("StopIteration is a BaseException Subclass.") } fn new_downcast_error( From 0784694038163c3332696b544c019ccf8ea54b14 Mon Sep 17 00:00:00 2001 From: Leesoo Ahn Date: Sun, 19 Jul 2026 13:51:53 +0900 Subject: [PATCH 148/351] stdlib: remove unused warning on sock_wait() (#8303) The function is used in 'feature = ssl'. However, the current codebase defines it without a conditional compilation expression and always be compiled. This commit removes the unused warning by adding a conditional compilation expression, '#[cfg(feature = "ssl")]'. --- crates/stdlib/src/socket.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/stdlib/src/socket.rs b/crates/stdlib/src/socket.rs index 9cb197ab94e..283aa408339 100644 --- a/crates/stdlib/src/socket.rs +++ b/crates/stdlib/src/socket.rs @@ -2444,6 +2444,7 @@ mod _socket { } /// returns Ok(true) on timeout + #[cfg(feature = "ssl")] pub(crate) fn sock_wait( sock: &Socket, wait_kind: SockWaitKind, From 6f9e3a19aa5320533f294d8206fb6070431bdd41 Mon Sep 17 00:00:00 2001 From: Jiseok CHOI Date: Sun, 19 Jul 2026 13:56:02 +0900 Subject: [PATCH 149/351] sqlite3: fix isolation_level TypeError message to match CPython (#8313) Assisted-by: GitHub Copilot:claude-sonnet-4-6 --- Lib/test/test_sqlite3/test_regression.py | 1 - crates/stdlib/src/_sqlite3.rs | 31 +++++++++++++++++------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/Lib/test/test_sqlite3/test_regression.py b/Lib/test/test_sqlite3/test_regression.py index 2f59cf9ba4d..5560365fdc6 100644 --- a/Lib/test/test_sqlite3/test_regression.py +++ b/Lib/test/test_sqlite3/test_regression.py @@ -305,7 +305,6 @@ def test_convert_timestamp_microsecond_padding(self): datetime.datetime(2012, 4, 4, 15, 6, 0, 123456), ]) - @unittest.expectedFailure # TODO: RUSTPYTHON; error message mismatch def test_invalid_isolation_level_type(self): # isolation level is a string, not an integer regex = "isolation_level must be str or None" diff --git a/crates/stdlib/src/_sqlite3.rs b/crates/stdlib/src/_sqlite3.rs index cbe9b85c63c..f3eea603e83 100644 --- a/crates/stdlib/src/_sqlite3.rs +++ b/crates/stdlib/src/_sqlite3.rs @@ -54,7 +54,7 @@ mod _sqlite3 { use rustpython_vm::{ __exports::paste, AsObject, Py, PyAtomicRef, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, - TryFromBorrowedObject, VirtualMachine, atomic_func, + TryFromBorrowedObject, TryFromObject, VirtualMachine, atomic_func, builtins::{ PyBaseException, PyBaseExceptionRef, PyByteArray, PyBytes, PyDict, PyDictRef, PyFloat, PyInt, PyIntRef, PyModule, PySlice, PyStr, PyStrRef, PyTuple, PyTupleRef, PyType, @@ -330,6 +330,19 @@ mod _sqlite3 { } } + struct IsolationLevelArg(Option); + + impl TryFromObject for IsolationLevelArg { + fn try_from_object(vm: &VirtualMachine, obj: PyObjectRef) -> PyResult { + if vm.is_none(&obj) { + return Ok(Self(None)); + } + obj.downcast::() + .map(|s| Self(Some(s))) + .map_err(|_| vm.new_type_error("isolation_level must be str or None".to_owned())) + } + } + #[derive(FromArgs)] struct ConnectArgs { #[pyarg(any)] @@ -338,8 +351,8 @@ mod _sqlite3 { timeout: TimeoutSeconds, #[pyarg(any, default = 0)] detect_types: c_int, - #[pyarg(any, default = Some(vm.ctx.empty_str.to_owned()))] - isolation_level: Option, + #[pyarg(any, default = IsolationLevelArg(Some(vm.ctx.empty_str.to_owned())))] + isolation_level: IsolationLevelArg, #[pyarg(any, default = true)] check_same_thread: bool, #[pyarg(any, default = Connection::class(&vm.ctx).to_owned())] @@ -356,7 +369,7 @@ mod _sqlite3 { unsafe impl Traverse for ConnectArgs { fn traverse(&self, tracer_fn: &mut TraverseFn<'_>) { - self.isolation_level.traverse(tracer_fn); + self.isolation_level.0.traverse(tracer_fn); self.factory.traverse(tracer_fn); } } @@ -914,7 +927,7 @@ mod _sqlite3 { db: PyMutex::new(db), initialized: Radium::new(initialized), detect_types: Radium::new(args.detect_types), - isolation_level: PyAtomicRef::from(args.isolation_level), + isolation_level: PyAtomicRef::from(args.isolation_level.0), check_same_thread: Radium::new(args.check_same_thread), thread_ident: PyMutex::new(std::thread::current().id()), row_factory: PyAtomicRef::from(None), @@ -970,7 +983,7 @@ mod _sqlite3 { .store(check_same_thread, Ordering::Relaxed); *zelf.autocommit.lock() = autocommit; *zelf.thread_ident.lock() = std::thread::current().id(); - let _ = unsafe { zelf.isolation_level.swap(isolation_level) }; + let _ = unsafe { zelf.isolation_level.swap(isolation_level.0) }; let mut guard = zelf.db.lock(); *guard = Some(db); @@ -996,7 +1009,7 @@ mod _sqlite3 { let db = Sqlite::from(SqliteRaw::open(path.as_ptr(), args.uri, vm)?); let timeout = (args.timeout.to_secs_f64() * 1000.0) as c_int; db.busy_timeout(timeout); - if let Some(isolation_level) = &args.isolation_level { + if let Some(isolation_level) = &args.isolation_level.0 { begin_statement_ptr_from_isolation_level(isolation_level, vm)?; } Ok(db) @@ -1492,11 +1505,11 @@ mod _sqlite3 { #[pygetset(setter)] fn set_isolation_level( &self, - value: PySetterValue>, + value: PySetterValue, vm: &VirtualMachine, ) -> PyResult<()> { match value { - PySetterValue::Assign(value) => { + PySetterValue::Assign(IsolationLevelArg(value)) => { if let Some(val_str) = &value { begin_statement_ptr_from_isolation_level(val_str, vm)?; } From 875400413f9d966e055fba39184ec12340654edf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=A7=84=EB=AA=85?= Date: Sun, 19 Jul 2026 13:58:32 +0900 Subject: [PATCH 150/351] csv: quote embedded CR/LF under QUOTE_MINIMAL (#8315) * csv: quote embedded CR/LF under QUOTE_MINIMAL with custom lineterminator QUOTE_MINIMAL rows were serialized by csv-core, which only quotes bytes registered for the configured terminator, so fields containing '\r' or '\n' were left unquoted when the lineterminator was not CR/LF (e.g. '!' or '\0'). Route QUOTE_MINIMAL through a hand-written path that uses field_needs_quotes, which always treats '\r'/'\n' as needing quotes. A single empty field is quoted and an empty row emits only the terminator, matching CPython. QUOTE_ALL / QUOTE_NONNUMERIC still use csv-core. Assisted-by: Claude Code:claude-opus-4-8 * csv: unmark now-passing writer tests test_write_iterable and test_write_empty_fields pass with the QUOTE_MINIMAL writer fix (empty rows no longer emit an empty field). Assisted-by: Claude Code:claude-opus-4-8 --- Lib/test/test_csv.py | 2 -- crates/stdlib/src/csv.rs | 50 ++++++++++++++++++++++++++++++ extra_tests/snippets/stdlib_csv.py | 45 +++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py index 13b68cc2255..3e86af0f8c5 100644 --- a/Lib/test/test_csv.py +++ b/Lib/test/test_csv.py @@ -276,7 +276,6 @@ def test_write_lineterminator(self): f'1,2{lineterminator}' f'"\r","\n"{lineterminator}') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_write_iterable(self): self._write_test(iter(['a', 1, 'p,q']), 'a,1,"p,q"') self._write_test(iter(['a', 1, None]), 'a,1,') @@ -319,7 +318,6 @@ def test_writerows_with_none(self): self.assertEqual(fileobj.read(), 'a\r\n""\r\n') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_write_empty_fields(self): self._write_test((), '') self._write_test([''], '""') diff --git a/crates/stdlib/src/csv.rs b/crates/stdlib/src/csv.rs index 3fbafab8dca..cb7cecff416 100644 --- a/crates/stdlib/src/csv.rs +++ b/crates/stdlib/src/csv.rs @@ -1380,6 +1380,55 @@ mod _csv { self.write.call((s,), vm) } + fn writerow_minimal(&self, row: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let _state = self.state.lock(); + + let row: ArgIterable = ArgIterable::try_from_object(vm, row.clone()).map_err(|_e| { + new_csv_error( + vm, + format!("'{}' object is not iterable", row.class().name()), + ) + })?; + + let fields = row.iter(vm)?.collect::>>()?; + let single_field = fields.len() == 1; + let mut output = Vec::new(); + + for (index, field) in fields.into_iter().enumerate() { + if index > 0 { + output.push(self.dialect.delimiter); + } + + let stringified; + let data: &[u8] = match_class!(match field { + ref s @ PyStr => s.as_bytes(), + crate::builtins::PyNone => b"", + ref obj => { + stringified = obj.str(vm)?; + stringified.as_bytes() + } + }); + + // CPython quotes a QUOTE_MINIMAL field if it contains the + // delimiter, the quote character, '\r', '\n', or the line + // terminator, regardless of which line terminator is + // configured. A row with a single empty field is also quoted + // so that it is not read back as an empty line. + if field_needs_quotes(data, self.dialect) || (single_field && data.is_empty()) { + write_quoted_field(&mut output, data, self.dialect, vm)?; + } else { + output.extend_from_slice(data); + } + } + + write_lineterminator(&mut output, self.dialect.lineterminator); + + let s = core::str::from_utf8(&output) + .map_err(|_| vm.new_unicode_decode_error("csv not utf8"))?; + + self.write.call((s,), vm) + } + #[pymethod] fn writerow(&self, row: PyObjectRef, vm: &VirtualMachine) -> PyResult { match self.dialect.quoting { @@ -1387,6 +1436,7 @@ mod _csv { QuoteStyle::Strings | QuoteStyle::Notnull => { return self.writerow_quoted_strings(row, vm); } + QuoteStyle::Minimal => return self.writerow_minimal(row, vm), _ => {} } diff --git a/extra_tests/snippets/stdlib_csv.py b/extra_tests/snippets/stdlib_csv.py index dc2186d17ac..0664bfd8d92 100644 --- a/extra_tests/snippets/stdlib_csv.py +++ b/extra_tests/snippets/stdlib_csv.py @@ -147,3 +147,48 @@ def test_quote_none_reader_skipinitialspace_escapechar(): test_quote_none_reader_skipinitialspace_escapechar() + + +def test_quote_minimal_writer_lineterminator(): + # https://github.com/RustPython/RustPython/issues/8302 + # QUOTE_MINIMAL must quote '\r' and '\n' regardless of the line terminator. + buf = io.StringIO() + writer = csv.writer(buf, lineterminator="!") + writer.writerow(["a", "b"]) + writer.writerow([1, 2]) + writer.writerow(["\r", "\n"]) + assert buf.getvalue() == 'a,b!1,2!"\r","\n"!' + + nul = io.StringIO() + csv.writer(nul, lineterminator="\0").writerow(["\r", "\n"]) + assert nul.getvalue() == '"\r","\n"\0' + + crlf = io.StringIO() + csv.writer(crlf, lineterminator="!").writerow(["\r\n"]) + assert crlf.getvalue() == '"\r\n"!' + + # the terminator character itself still triggers quoting + term = io.StringIO() + csv.writer(term, lineterminator="!").writerow(["a!b", "c"]) + assert term.getvalue() == '"a!b",c!' + + # default terminator behavior is unchanged + default = io.StringIO() + csv.writer(default).writerow(["\r", "\n"]) + assert default.getvalue() == '"\r","\n"\r\n' + + +test_quote_minimal_writer_lineterminator() + + +def test_quote_minimal_writer_empty_fields(): + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow([""]) + writer.writerow([None]) + writer.writerow([]) + writer.writerow(["", ""]) + assert buf.getvalue() == '""\r\n""\r\n\r\n,\r\n' + + +test_quote_minimal_writer_empty_fields() From 3290f287fd796dedffdefd713916bb9fdb5633f6 Mon Sep 17 00:00:00 2001 From: Jiwoo Ahn Date: Sun, 19 Jul 2026 14:13:47 +0900 Subject: [PATCH 151/351] wasm: add stderr support (#8318) Assisted-by: Codex:gpt-5.5 Signed-off-by: Jiwoo Ahn --- crates/wasm/Lib/asyncweb.py | 3 +- crates/wasm/README.md | 3 ++ crates/wasm/src/lib.rs | 3 ++ crates/wasm/src/vm_class.rs | 58 +++++++++++++++++++++++--------- crates/wasm/src/wasm_builtins.rs | 15 +++++---- wasm/demo/src/index.js | 1 + wasm/tests/test_exec_mode.py | 14 ++++++++ 7 files changed, 73 insertions(+), 24 deletions(-) diff --git a/crates/wasm/Lib/asyncweb.py b/crates/wasm/Lib/asyncweb.py index 40bd843499b..f0e7983f775 100644 --- a/crates/wasm/Lib/asyncweb.py +++ b/crates/wasm/Lib/asyncweb.py @@ -64,8 +64,7 @@ async def _main_wrapper(coro): import traceback import sys - # TODO: sys.stderr on wasm - traceback.print_exc(file=sys.stdout) + traceback.print_exc(file=sys.stderr) def _resolve(prom): diff --git a/crates/wasm/README.md b/crates/wasm/README.md index 3a755009205..1878614be16 100644 --- a/crates/wasm/README.md +++ b/crates/wasm/README.md @@ -34,6 +34,9 @@ pyEval(code, options?); - `stdout?`: `"console" | ((out: string) => void) | null`: A function to replace the native print function, and it will be `console.log` when giving `undefined` or "console", and it will be a dumb function when giving null. +- `stderr?`: `"console" | ((out: string) => void) | null`: A function to replace + `sys.stderr`, and it will be `console.error` when giving `undefined` or + "console". ## License diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index ae4e52f21b0..041cb864ef2 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -66,6 +66,7 @@ pub mod eval { }; vm.set_stdout(Reflect::get(&options, &"stdout".into())?)?; + vm.set_stderr(Reflect::get(&options, &"stderr".into())?)?; if let Some(js_vars) = js_vars { vm.add_to_scope("js_vars".into(), js_vars.into())?; @@ -93,6 +94,8 @@ pub mod eval { /// - `stdout?`: `"console" | ((out: string) => void) | null`: A function to replace the /// native print native print function, and it will be `console.log` when giving /// `undefined` or "console", and it will be a dumb function when giving null. + /// - `stderr?`: `"console" | ((out: string) => void) | null`: A function to replace + /// `sys.stderr`, and it will be `console.error` when giving `undefined` or "console". #[wasm_bindgen(js_name = pyEval)] pub fn eval_py(source: &str, options: Option) -> Result { run_py(source, options, Mode::Eval) diff --git a/crates/wasm/src/vm_class.rs b/crates/wasm/src/vm_class.rs index cd0af10f6df..5e09af0ee95 100644 --- a/crates/wasm/src/vm_class.rs +++ b/crates/wasm/src/vm_class.rs @@ -249,31 +249,59 @@ impl WASMVirtualMachine { #[wasm_bindgen(js_name = setStdout)] pub fn set_stdout(&self, stdout: JsValue) -> Result<(), JsValue> { + self.set_stdstream( + "stdout", + "JSStdout", + stdout, + wasm_builtins::sys_stdout_write_console, + ) + } + + #[wasm_bindgen(js_name = setStderr)] + pub fn set_stderr(&self, stderr: JsValue) -> Result<(), JsValue> { + self.set_stdstream( + "stderr", + "JSStderr", + stderr, + wasm_builtins::sys_stderr_write_console, + ) + } + + fn set_stdstream( + &self, + attr: &'static str, + class_name: &'static str, + stream: JsValue, + console_write: fn(&str, &VirtualMachine) -> PyResult<()>, + ) -> Result<(), JsValue> { self.with_vm(|vm, _| { - fn error() -> JsValue { - TypeError::new("Unknown stdout option, please pass a function or 'console'").into() + fn error(attr: &str) -> JsValue { + TypeError::new(&format!( + "Unknown {attr} option, please pass a function or 'console'" + )) + .into() } - use wasm_builtins::make_stdout_object; - let stdout: PyObjectRef = if let Some(s) = stdout.as_string() { + use wasm_builtins::make_stdstream_object; + let stream: PyObjectRef = if let Some(s) = stream.as_string() { match s.as_str() { - "console" => make_stdout_object(vm, wasm_builtins::sys_stdout_write_console), - _ => return Err(error()), + "console" => make_stdstream_object(vm, class_name, console_write), + _ => return Err(error(attr)), } - } else if stdout.is_function() { - let func = js_sys::Function::from(stdout); - make_stdout_object(vm, move |data, vm| { + } else if stream.is_function() { + let func = js_sys::Function::from(stream); + make_stdstream_object(vm, class_name, move |data, vm| { func.call1(&JsValue::UNDEFINED, &data.into()) .map_err(|err| convert::js_py_typeerror(vm, err))?; Ok(()) }) - } else if stdout.is_null() { - make_stdout_object(vm, |_, _| Ok(())) - } else if stdout.is_undefined() { - make_stdout_object(vm, wasm_builtins::sys_stdout_write_console) + } else if stream.is_null() { + make_stdstream_object(vm, class_name, |_, _| Ok(())) + } else if stream.is_undefined() { + make_stdstream_object(vm, class_name, console_write) } else { - return Err(error()); + return Err(error(attr)); }; - vm.sys_module.set_attr("stdout", stdout, vm).unwrap(); + vm.sys_module.set_attr(attr, stream, vm).unwrap(); Ok(()) })? } diff --git a/crates/wasm/src/wasm_builtins.rs b/crates/wasm/src/wasm_builtins.rs index efbc03c39ce..ae2ffa63dbe 100644 --- a/crates/wasm/src/wasm_builtins.rs +++ b/crates/wasm/src/wasm_builtins.rs @@ -16,19 +16,20 @@ pub fn sys_stdout_write_console(data: &str, _vm: &VirtualMachine) -> PyResult<() Ok(()) } -pub fn make_stdout_object( +pub fn sys_stderr_write_console(data: &str, _vm: &VirtualMachine) -> PyResult<()> { + console::error_1(&data.into()); + Ok(()) +} + +pub fn make_stdstream_object( vm: &VirtualMachine, + name: &'static str, write_f: impl Fn(&str, &VirtualMachine) -> PyResult<()> + 'static, ) -> PyObjectRef { let ctx = &vm.ctx; // there's not really any point to storing this class so that there's a consistent type object, // we just want a half-decent repr() output - let cls = PyRef::leak(py_class!( - ctx, - "JSStdout", - vm.ctx.types.object_type.to_owned(), - {} - )); + let cls = PyRef::leak(py_class!(ctx, name, vm.ctx.types.object_type.to_owned(), {})); let write_method = vm.new_method( "write", cls, diff --git a/wasm/demo/src/index.js b/wasm/demo/src/index.js index 0b568fa1d9e..aeab8e716e9 100644 --- a/wasm/demo/src/index.js +++ b/wasm/demo/src/index.js @@ -159,6 +159,7 @@ function onReady() { terminalVM = rp.vmStore.init('term_vm'); terminalVM.setStdout((data) => readline.print(data)); + terminalVM.setStderr((data) => readline.print(data)); readPrompts().catch((err) => console.error(err)); // so that the test knows that we're ready diff --git a/wasm/tests/test_exec_mode.py b/wasm/tests/test_exec_mode.py index a2a55846f48..28a0cea7ca8 100644 --- a/wasm/tests/test_exec_mode.py +++ b/wasm/tests/test_exec_mode.py @@ -19,3 +19,17 @@ def test_exec_single_mode(wdriver): """ ) assert stdout == "2\n4\n" + + +def test_exec_stderr_option(wdriver): + stderr = wdriver.execute_script( + """ + let output = ""; + save_output = function(text) { + output += text + }; + window.rp.pyExec('import sys; print("err", file=sys.stderr)', {stderr: save_output}); + return output; + """ + ) + assert stderr == "err\n" From 4c9b5d8854ae188ffba91da1258d16b5ddfff31d Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:21:42 +0900 Subject: [PATCH 152/351] ctypes: fix Windows pip truststore (#8314) * ctypes: preserve result when errcheck returns args * ctypes: cover Windows pip truststore * capi: implement PyEval_SaveThread * capi: merge saved thread state into PyThreadState --- .github/workflows/ci.yaml | 12 +++++- crates/capi/src/pystate.rs | 40 ++++++++++++----- crates/stdlib/src/overlapped.rs | 3 +- crates/vm/src/stdlib/_ctypes/array.rs | 16 +++++++ crates/vm/src/stdlib/_ctypes/function.rs | 6 ++- crates/vm/src/vm/thread.rs | 55 ++++++++++++++++++++++++ extra_tests/snippets/stdlib_ctypes.py | 31 ++++++++++++- 7 files changed, 147 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1125c178bec..d03735192c8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -438,6 +438,17 @@ jobs: target/release/rustpython -m ensurepip target/release/rustpython -c "import pip" + - if: runner.os == 'Windows' + name: Check pip HTTPS with the Windows trust store + run: >- + target/release/rustpython -m pip download + --disable-pip-version-check + --no-cache-dir + --no-deps + --only-binary=:all: + --dest "$env:RUNNER_TEMP\rustpython-pip-smoke" + six + - if: runner.os != 'Windows' name: Check if pip inside venv is functional run: | @@ -829,4 +840,3 @@ jobs: - name: cargo doc run: cargo doc --locked - diff --git a/crates/capi/src/pystate.rs b/crates/capi/src/pystate.rs index 1a2f66de9f1..173c26088cf 100644 --- a/crates/capi/src/pystate.rs +++ b/crates/capi/src/pystate.rs @@ -2,9 +2,9 @@ use crate::get_main_interpreter; use crate::pylifecycle::request_vm_from_interpreter; use crate::util::FfiResult; use core::ffi::c_int; -use core::ptr; use rustpython_vm::vm::thread::{ - CurrentVmAttachState, attach_current_thread, release_current_thread, with_current_vm, + CurrentVmAttachState, SavedThreadState, attach_current_thread, release_current_thread, + restore_current_thread, save_current_thread, with_current_vm, }; use rustpython_vm::{Interpreter, VirtualMachine}; @@ -22,6 +22,7 @@ pub type PyInterpreterState = Interpreter; #[repr(C)] pub struct PyThreadState { pub interp: *mut PyInterpreterState, + vm: SavedThreadState, } /// Make sure this thread has a running vm attached. This only creates a new vm if we don't already @@ -47,11 +48,22 @@ pub extern "C" fn PyGILState_Release(state: PyGILState_STATE) { #[unsafe(no_mangle)] pub extern "C" fn PyEval_SaveThread() -> *mut PyThreadState { - ptr::null_mut() + let interp = PyInterpreterState_Get(); + let state = Box::new(PyThreadState { + interp, + vm: save_current_thread(), + }); + Box::into_raw(state) } #[unsafe(no_mangle)] -pub extern "C" fn PyEval_RestoreThread(_state: *mut PyThreadState) {} +pub unsafe extern "C" fn PyEval_RestoreThread(state: *mut PyThreadState) { + assert!(!state.is_null(), "PyEval_RestoreThread called with null"); + // SAFETY: PyEval_SaveThread returns this allocation and CPython's API + // requires callers to restore exactly that thread state once. + let state = unsafe { Box::from_raw(state) }; + restore_current_thread(state.vm); +} #[unsafe(no_mangle)] pub extern "C" fn PyInterpreterState_Get() -> *mut PyInterpreterState { @@ -81,7 +93,7 @@ mod tests { #[test] fn new_thread() { - Python::attach(|_py| { + Python::attach(|py| { with_current_vm(|_vm| { assert!( current_vm_is_set(), @@ -89,18 +101,24 @@ mod tests { ) }); - std::thread::spawn(move || { + let handle = std::thread::spawn(move || { Python::attach(|_py| { - with_current_vm(|_vm| { + with_current_vm(|vm| { assert!( current_vm_is_set(), "This thread did not have a vm attached" - ) + ); + vm.state.stop_the_world.stop_the_world(vm); + vm.state.stop_the_world.start_the_world(vm); }); }); - }) - .join() - .unwrap(); + }); + + py.detach(|| { + assert!(!current_vm_is_set()); + handle.join().unwrap(); + }); + assert!(current_vm_is_set()); }) } diff --git a/crates/stdlib/src/overlapped.rs b/crates/stdlib/src/overlapped.rs index 8610fadb3bf..86ac24e3a0f 100644 --- a/crates/stdlib/src/overlapped.rs +++ b/crates/stdlib/src/overlapped.rs @@ -1163,7 +1163,8 @@ mod _overlapped { #[pyfunction] fn GetQueuedCompletionStatus(port: isize, msecs: u32, vm: &VirtualMachine) -> PyResult { - match host_overlapped::get_queued_completion_status(port, msecs) + match vm + .allow_threads(|| host_overlapped::get_queued_completion_status(port, msecs)) .map_err(|err| set_from_windows_err(err.raw_os_error().unwrap_or(0) as u32, vm))? { host_overlapped::WaitResult::Timeout => Ok(vm.ctx.none()), diff --git a/crates/vm/src/stdlib/_ctypes/array.rs b/crates/vm/src/stdlib/_ctypes/array.rs index f7abc834564..a99fabc812d 100644 --- a/crates/vm/src/stdlib/_ctypes/array.rs +++ b/crates/vm/src/stdlib/_ctypes/array.rs @@ -641,6 +641,14 @@ impl PyCArray { let (kept_alive, ptr) = super::base::ensure_z_null_terminated(bytes, vm); zelf.0.keep_alive(index, kept_alive); (ptr, Some(value.to_owned())) + } else if let Some(simple) = value.downcast_ref::() + && value.class().type_code(vm).as_deref() == Some("z") + { + let buffer = simple.0.buffer.read(); + ( + rustpython_host_env::ctypes::read_pointer_from_buffer(&buffer), + None, + ) } else if let Ok(int_val) = value.try_index(vm) { (int_val.as_bigint().to_usize().unwrap_or(0), None) } else { @@ -667,6 +675,14 @@ impl PyCArray { } else if let Some(s) = value.downcast_ref::() { let (holder, ptr) = super::base::str_to_wchar_bytes(s.as_wtf8(), vm); (ptr, Some(holder)) + } else if let Some(simple) = value.downcast_ref::() + && value.class().type_code(vm).as_deref() == Some("Z") + { + let buffer = simple.0.buffer.read(); + ( + rustpython_host_env::ctypes::read_pointer_from_buffer(&buffer), + None, + ) } else if let Ok(int_val) = value.try_index(vm) { (int_val.as_bigint().to_usize().unwrap_or(0), None) } else { diff --git a/crates/vm/src/stdlib/_ctypes/function.rs b/crates/vm/src/stdlib/_ctypes/function.rs index ebda717192a..cf655191683 100644 --- a/crates/vm/src/stdlib/_ctypes/function.rs +++ b/crates/vm/src/stdlib/_ctypes/function.rs @@ -1554,7 +1554,11 @@ fn build_result( let args_tuple = PyTuple::new_ref(args.args.clone(), &vm.ctx); let func_obj = zelf.as_object().to_owned(); let result_obj = result.clone().unwrap_or_else(|| vm.ctx.none()); - result = Some(errcheck.call((result_obj, func_obj, args_tuple), vm)?); + let checked = errcheck.call((result_obj, func_obj, args_tuple.clone()), vm)?; + // Returning the original args tuple requests normal result processing. + if !checked.is(&args_tuple) { + result = Some(checked); + } } // Handle OUT parameter return values diff --git a/crates/vm/src/vm/thread.rs b/crates/vm/src/vm/thread.rs index 5009cb695c6..9948b936f9d 100644 --- a/crates/vm/src/vm/thread.rs +++ b/crates/vm/src/vm/thread.rs @@ -224,6 +224,61 @@ pub enum CurrentVmAttachState { Attached, } +/// State preserved while the current native thread is detached from its VM. +#[cfg(feature = "threading")] +pub struct SavedThreadState { + vm_stack: Vec>, + gilstate_vm: Option>, +} + +/// Detach the current native thread and preserve its VM context for restoration. +#[cfg(feature = "threading")] +#[must_use = "the saved thread state must be restored"] +pub fn save_current_thread() -> SavedThreadState { + let vm_stack = VM_STACK.with(|vms| core::mem::take(&mut *vms.borrow_mut())); + assert!( + !vm_stack.is_empty(), + "save_current_thread() called without an attached VM" + ); + let gilstate_vm = GILSTATE_VM.with(|gilstate_vm| gilstate_vm.borrow_mut().take()); + detach_thread(); + SavedThreadState { + vm_stack, + gilstate_vm, + } +} + +/// Restore a VM context previously returned by [`save_current_thread`]. +#[cfg(feature = "threading")] +pub fn restore_current_thread(state: SavedThreadState) { + assert!( + !current_vm_is_set(), + "restore_current_thread() called with an attached VM" + ); + let SavedThreadState { + vm_stack, + gilstate_vm, + } = state; + let vm = vm_stack + .last() + .copied() + .expect("saved thread state has no VM"); + + GILSTATE_VM.with(|current| { + let mut current = current.borrow_mut(); + assert!( + current.is_none(), + "restore_current_thread() called with a GILState VM" + ); + *current = gilstate_vm; + }); + + // SAFETY: borrowed VMs remain alive for the dynamic save/restore scope, + // while an owned GILState VM was restored above before this dereference. + attach_thread(unsafe { vm.as_ref() }); + VM_STACK.with(|vms| *vms.borrow_mut() = vm_stack); +} + /// Attach the current native thread to a RustPython VM until /// `release_current_thread()` is called. #[cfg(feature = "threading")] diff --git a/extra_tests/snippets/stdlib_ctypes.py b/extra_tests/snippets/stdlib_ctypes.py index 0a5d1387a8d..7cd3fcf2639 100644 --- a/extra_tests/snippets/stdlib_ctypes.py +++ b/extra_tests/snippets/stdlib_ctypes.py @@ -190,6 +190,10 @@ def __repr__(self): _check_size(c_char_p, "P") +char_pointer = c_char_p(b"1.3.6.1.5.5.7.3.1") +char_pointer_array = (c_char_p * 1)(char_pointer) +assert char_pointer_array[0] == b"1.3.6.1.5.5.7.3.1" + class c_void_p(_SimpleCData): _type_ = "P" @@ -344,7 +348,9 @@ def LoadLibrary(self, name): # print(libc.srand(i)) # print(test_byte_array) else: + import ctypes import os + from ctypes import wintypes libc = cdll.msvcrt libc.rand() @@ -356,6 +362,29 @@ def LoadLibrary(self, name): # print("start printf") # libc.printf(test_byte_array) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + get_current_process = kernel32.GetCurrentProcess + get_current_process.argtypes = () + get_current_process.restype = ctypes.c_void_p + + def preserve_result(_result, _func, args): + return args + + get_current_process.errcheck = preserve_result + process_handle = get_current_process() + assert isinstance(process_handle, int) + + get_process_id = kernel32.GetProcessId + get_process_id.argtypes = (ctypes.c_void_p,) + get_process_id.restype = wintypes.DWORD + assert get_process_id(process_handle) == os.getpid() + + def replace_result(_result, _func, _args): + return "replacement" + + get_current_process.errcheck = replace_result + assert get_current_process() == "replacement" + # windows pip support def get_win_folder_via_ctypes(csidl_name: str) -> str: @@ -364,8 +393,6 @@ def get_win_folder_via_ctypes(csidl_name: str) -> str: # Use 'CSIDL_PROFILE' (40) and append the default folder 'Downloads' instead. # https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid - import ctypes # noqa: PLC0415 - csidl_const = { "CSIDL_APPDATA": 26, "CSIDL_COMMON_APPDATA": 35, From a7f0496cee84ffb83e555847a2b63f02e32d08f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B4=91=ED=9A=A8?= <87641474+widehyo1@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:05:51 +0900 Subject: [PATCH 153/351] csv: reject reentrant reader advancement (#8324) * csv: reject reentrant reader advancement Snapshot the reader generation before calling the input iterator. If a nested call completes a record and advances the same reader, reject the outer call with csv.Error instead of continuing with stale parser state. Increment the generation on each successful record path, matching CPython's handling of the existing reentrant-reader regression. Enable that test by removing its expectedFailure marker. Assisted-by: Codex:gpt-5.6-sol * csv: invalidate outer reads after reentry Advance the reader generation immediately after the reentrancy check, before validating or parsing the returned input item. This invalidates the outer call even when a nested read later fails validation or parsing. Remove the per-return generation updates now that the common path advances it exactly once. Assisted-by: Codex:gpt-5.6-sol * csv: generation type conversion(usize to u64) On 32-bit targets (like WebAssembly, which RustPython supports), usize is 32-bit. Csv with over 32-bit rows can raise panic on these platform. type conversion from usize to u64. --- Lib/test/test_csv.py | 1 - crates/stdlib/src/csv.rs | 18 +++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py index 3e86af0f8c5..1108846f544 100644 --- a/Lib/test/test_csv.py +++ b/Lib/test/test_csv.py @@ -568,7 +568,6 @@ def test_roundtrip_escaped_unquoted_newlines(self): self.assertEqual(row, rows[i]) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Error not raised def test_reader_reentrant_iterator(self): # gh-145105: re-entering the reader from the iterator must not crash. class ReentrantIter: diff --git a/crates/stdlib/src/csv.rs b/crates/stdlib/src/csv.rs index cb7cecff416..f4ccda55412 100644 --- a/crates/stdlib/src/csv.rs +++ b/crates/stdlib/src/csv.rs @@ -411,6 +411,7 @@ mod _csv { skipinitialspace: options.get_skipinitialspace(), delimiter: options.get_delimiter(), line_num: 0, + generation: 0, }), dialect: options.result(vm)?, }) @@ -961,6 +962,7 @@ mod _csv { skipinitialspace: bool, delimiter: u8, line_num: u64, + generation: u64, } #[pyclass(no_attr, module = "_csv", name = "reader", traverse)] @@ -1057,8 +1059,18 @@ mod _csv { impl IterNext for Reader { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { - let string = raise_if_stop!(zelf.iter.next(vm)?); - let string = string.downcast::().map_err(|obj| { + let generation = zelf.state.lock().generation; + let string_obj = raise_if_stop!(zelf.iter.next(vm)?); + let mut state = zelf.state.lock(); + if state.generation != generation { + return Err(new_csv_error( + vm, + "iterator has already advanced the reader", + )); + } + state.generation += 1; + + let string = string_obj.downcast::().map_err(|obj| { new_csv_error( vm, format!( @@ -1071,7 +1083,6 @@ mod _csv { if input.is_empty() || input.starts_with(b"\n") { return Ok(PyIterReturn::Return(vm.ctx.new_list(vec![]).into())); } - let mut state = zelf.state.lock(); let ReadState { buffer, output_ends, @@ -1079,6 +1090,7 @@ mod _csv { skipinitialspace, delimiter, line_num, + generation: _, } = &mut *state; let mut input_offset = 0; From eb56114e795dd22f318a903ea026a14c88c110fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EB=8F=99=EC=95=88?= Date: Mon, 20 Jul 2026 17:06:44 +0900 Subject: [PATCH 154/351] Take a borrow in invoke_exception instead of an owned type (#8327) --- crates/capi/src/pyerrors.rs | 5 +---- crates/vm/src/exception_group.rs | 7 ++----- crates/vm/src/exceptions.rs | 10 +++++----- crates/vm/src/stdlib/_ctypes/function.rs | 2 +- crates/vm/src/stdlib/_io.rs | 6 +++--- crates/vm/src/stdlib/_thread.rs | 2 +- crates/vm/src/stdlib/builtins.rs | 2 +- crates/vm/src/stdlib/sys.rs | 2 +- crates/vm/src/vm/mod.rs | 2 +- crates/vm/src/vm/vm_new.rs | 2 +- 10 files changed, 17 insertions(+), 23 deletions(-) diff --git a/crates/capi/src/pyerrors.rs b/crates/capi/src/pyerrors.rs index 55428ee7604..25b76f33362 100644 --- a/crates/capi/src/pyerrors.rs +++ b/crates/capi/src/pyerrors.rs @@ -147,10 +147,7 @@ pub unsafe extern "C" fn PyErr_SetString(exception: *mut PyObject, message: *con let exc_type = unsafe { &*exception }.try_downcast_ref::(vm)?; let message = unsafe { message.try_as_str(vm) }?; - let exc = vm.invoke_exception( - exc_type.to_owned(), - vec![vm.ctx.new_str(message).into_object()], - )?; + let exc = vm.invoke_exception(exc_type, vec![vm.ctx.new_str(message).into_object()])?; Err(exc) }) diff --git a/crates/vm/src/exception_group.rs b/crates/vm/src/exception_group.rs index a2c76378ab3..c6d18cc6594 100644 --- a/crates/vm/src/exception_group.rs +++ b/crates/vm/src/exception_group.rs @@ -71,11 +71,8 @@ pub(super) mod types { vm: &VirtualMachine, ) -> PyResult { let message = zelf.get_arg(0).unwrap_or_else(|| vm.ctx.new_str("").into()); - vm.invoke_exception( - vm.ctx.exceptions.base_exception_group.to_owned(), - vec![message, excs], - ) - .map(|e| e.into()) + vm.invoke_exception(vm.ctx.exceptions.base_exception_group, vec![message, excs]) + .map(|e| e.into()) } #[pymethod] diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index f76a2ad62ed..cd1eb1adbcb 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -353,11 +353,11 @@ impl VirtualMachine { pub fn invoke_exception( &self, - cls: PyTypeRef, + cls: &Py, args: Vec, ) -> PyResult { // TODO: fast-path built-in exceptions by directly instantiating them? Is that really worth it? - let res = PyType::call(&cls, args.into_args(self), self)?; + let res = PyType::call(cls, args.into_args(self), self)?; res.downcast::().map_err(|obj| { self.new_type_error(format!( "calling {} should have returned an instance of BaseException, not {}", @@ -444,7 +444,7 @@ impl TryFromObject for ExceptionCtor { impl ExceptionCtor { pub fn instantiate(self, vm: &VirtualMachine) -> PyResult { match self { - Self::Class(cls) => vm.invoke_exception(cls, vec![]), + Self::Class(cls) => vm.invoke_exception(&cls, vec![]), Self::Instance(exc) => Ok(exc), } } @@ -472,7 +472,7 @@ impl ExceptionCtor { exc @ PyBaseException => exc.args().to_vec(), obj => vec![obj], }); - vm.invoke_exception(cls, args) + vm.invoke_exception(&cls, args) } } } @@ -2101,7 +2101,7 @@ pub(super) mod types { .downcast_ref::() .and_then(|errno| errno.try_to_primitive::(vm).ok()) .and_then(|errno| super::errno_to_exc_type(errno, vm)) - .and_then(|typ| vm.invoke_exception(typ.to_owned(), args_vec).ok()) + .and_then(|typ| vm.invoke_exception(typ, args_vec).ok()) { return error.to_pyresult(vm); } diff --git a/crates/vm/src/stdlib/_ctypes/function.rs b/crates/vm/src/stdlib/_ctypes/function.rs index cf655191683..676ee5be8eb 100644 --- a/crates/vm/src/stdlib/_ctypes/function.rs +++ b/crates/vm/src/stdlib/_ctypes/function.rs @@ -1361,7 +1361,7 @@ fn check_hresult(hresult: i32, zelf: &Py, vm: &VirtualMachine) -> Py .new_str(format!("HRESULT: 0x{:08X}", hresult as u32)) .into(); let details: PyObjectRef = vm.ctx.none(); - let exc = vm.invoke_exception(com_error_type, vec![text.clone(), details.clone()])?; + let exc = vm.invoke_exception(&com_error_type, vec![text.clone(), details.clone()])?; let _ = exc.as_object().set_attr("hresult", hresult_obj, vm); let _ = exc.as_object().set_attr("text", text, vm); let _ = exc.as_object().set_attr("details", details, vm); diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index c7bbbaf9359..4cffb909fa7 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -943,7 +943,7 @@ mod _io { None => { // BlockingIOError(errno, msg, characters_written=0) return Err(vm.invoke_exception( - vm.ctx.exceptions.blocking_io_error.to_owned(), + vm.ctx.exceptions.blocking_io_error, vec![ vm.new_pyobj(EAGAIN), vm.new_pyobj("write could not complete without blocking"), @@ -1154,7 +1154,7 @@ mod _io { self.write_end += avail as Offset; self.pos += avail as Offset; return Err(vm.invoke_exception( - vm.ctx.exceptions.blocking_io_error.to_owned(), + vm.ctx.exceptions.blocking_io_error, vec![ vm.new_pyobj(EAGAIN), vm.new_pyobj("write could not complete without blocking"), @@ -1200,7 +1200,7 @@ mod _io { // BlockingIOError(errno, msg, characters_written) let chars_written = written + buffer_len; return Err(vm.invoke_exception( - vm.ctx.exceptions.blocking_io_error.to_owned(), + vm.ctx.exceptions.blocking_io_error, vec![ vm.new_pyobj(EAGAIN), vm.new_pyobj("write could not complete without blocking"), diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index 9caa15dbfee..0079f15df2b 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -623,7 +623,7 @@ pub(crate) mod _thread { #[pyfunction] fn exit(vm: &VirtualMachine) -> PyResult { - Err(vm.invoke_exception(vm.ctx.exceptions.system_exit.to_owned(), vec![])?) + Err(vm.invoke_exception(vm.ctx.exceptions.system_exit, vec![])?) } thread_local!(static SENTINELS: RefCell>> = const { RefCell::new(Vec::new()) }); diff --git a/crates/vm/src/stdlib/builtins.rs b/crates/vm/src/stdlib/builtins.rs index 27f30158b22..e329763fa45 100644 --- a/crates/vm/src/stdlib/builtins.rs +++ b/crates/vm/src/stdlib/builtins.rs @@ -1041,7 +1041,7 @@ mod builtins { #[pyfunction] pub(super) fn exit(exit_code_arg: OptionalArg, vm: &VirtualMachine) -> PyResult { let code = exit_code_arg.unwrap_or_else(|| vm.ctx.new_int(0).into()); - Err(vm.invoke_exception(vm.ctx.exceptions.system_exit.to_owned(), vec![code])?) + Err(vm.invoke_exception(vm.ctx.exceptions.system_exit, vec![code])?) } #[derive(Debug, Default, FromArgs)] diff --git a/crates/vm/src/stdlib/sys.rs b/crates/vm/src/stdlib/sys.rs index 31e10203684..65917865d07 100644 --- a/crates/vm/src/stdlib/sys.rs +++ b/crates/vm/src/stdlib/sys.rs @@ -776,7 +776,7 @@ pub mod sys { } else { vec![status] }; - let exc = vm.invoke_exception(vm.ctx.exceptions.system_exit.to_owned(), args)?; + let exc = vm.invoke_exception(vm.ctx.exceptions.system_exit, args)?; Err(exc) } diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 0f2e6a46370..28eb111a949 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -2161,7 +2161,7 @@ impl VirtualMachine { if self.state.finalizing.load(Ordering::Acquire) && !self.is_main_thread() { // once finalization starts, // non-main Python threads should stop running bytecode. - return Err(self.invoke_exception(self.ctx.exceptions.system_exit.to_owned(), vec![])?); + return Err(self.invoke_exception(self.ctx.exceptions.system_exit, vec![])?); } // Suspend this thread if stop-the-world is in progress diff --git a/crates/vm/src/vm/vm_new.rs b/crates/vm/src/vm/vm_new.rs index 857ecb33c72..48909c1a41e 100644 --- a/crates/vm/src/vm/vm_new.rs +++ b/crates/vm/src/vm/vm_new.rs @@ -894,7 +894,7 @@ impl VirtualMachine { } pub fn new_stop_iteration(&self, value: Option) -> PyBaseExceptionRef { - let stop_iteration_error = self.ctx.exceptions.stop_iteration.to_owned(); + let stop_iteration_error = self.ctx.exceptions.stop_iteration; let args = if let Some(value) = value { vec![value] } else { From d16fd12bc7f090dfe7cf0920657e6f0d04a7d1cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B4=91=ED=9A=A8?= <87641474+widehyo1@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:57:38 +0900 Subject: [PATCH 155/351] csv: handle empty fields with skipinitialspace (#8304) * csv: handle empty fields with skipinitialspace skipinitialspace preprocessed records by trimming both ends of every split field. Fields containing only spaces could therefore produce an invalid slice, while trailing spaces were removed even though CPython preserves them. Trim only field prefixes in the csv-core path and make the remaining all-space trimming helper return an empty slice safely. QUOTE_NOTNULL and QUOTE_STRINGS add another distinction: an unquoted empty field becomes None, but a quoted empty field remains an empty string. Both forms have identical decoded bytes, so extend the existing QUOTE_NONE custom reader into one shared path that records whether each field started quoted. Use that metadata only for the null conversion while retaining QUOTE_NONE escape behavior. Unmark Test_Csv.test_read_skipinitialspace now that its standard, QUOTE_NOTNULL, and QUOTE_STRINGS cases pass. This keeps the existing one-item reader lifecycle and does not add the larger strict or multiline parser state machine. Assisted-by: Codex:gpt-5-sol * csv: make skipinitialspace quote-aware The csv-core preprocessing path split each raw iterator item on every delimiter before trimming field prefixes. Delimiters inside quoted fields were therefore treated as separators, so spaces after them could be removed before csv-core parsed the record. Extract the quote, escape, delimiter, and field-start transitions from read_quote_record() into a small per-item scanner. Reuse its events in the skipinitialspace preprocessor and discard only InitialSpace events while copying all other source bytes unchanged. This keeps the custom quote-mode reader and preprocessing logic aligned without introducing persistent multiline or strict parser state. Remove the delimiter cache that was only needed by the old split-and-join path and add a regression for spaces following a delimiter inside a quoted field. Assisted-by: Codex:gpt-5-sol --- Lib/test/test_csv.py | 1 - crates/stdlib/src/csv.rs | 227 ++++++++++++++++++++--------- extra_tests/snippets/stdlib_csv.py | 8 + 3 files changed, 163 insertions(+), 73 deletions(-) diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py index 1108846f544..379f4c9b799 100644 --- a/Lib/test/test_csv.py +++ b/Lib/test/test_csv.py @@ -471,7 +471,6 @@ def test_read_quoting(self): self._read_test(['1\\.5,\\.5,"\\.5"'], [[1.5, 0.5, ".5"]], quoting=csv.QUOTE_STRINGS, escapechar='\\') - @unittest.skip("TODO: RUSTPYTHON; slice index starts at 1 but ends at 0") def test_read_skipinitialspace(self): self._read_test(['no space, space, spaces,\ttab'], [['no space', 'space', 'spaces', '\ttab']], diff --git a/crates/stdlib/src/csv.rs b/crates/stdlib/src/csv.rs index f4ccda55412..a5203e8a373 100644 --- a/crates/stdlib/src/csv.rs +++ b/crates/stdlib/src/csv.rs @@ -14,7 +14,7 @@ mod _csv { }; use alloc::fmt; use csv_core::Terminator; - use itertools::{self, Itertools}; + use itertools::Itertools; use parking_lot::Mutex; use rustpython_common::{lock::LazyLock, wtf8::Wtf8Buf}; use rustpython_vm::{match_class, sliceable::SliceableSequenceOp}; @@ -409,7 +409,6 @@ mod _csv { output_ends: vec![0; 16], reader: options.to_reader(), skipinitialspace: options.get_skipinitialspace(), - delimiter: options.get_delimiter(), line_num: 0, generation: 0, }), @@ -774,29 +773,6 @@ mod _csv { skipinitialspace } - fn get_delimiter(&self) -> u8 { - let mut delimiter = match &self.dialect { - DialectItem::Str(name) => { - let g = GLOBAL_HASHMAP.lock(); - if let Some(dialect) = g.get(name) { - dialect.delimiter - // RustPython todo - // todo! Perfecting the remaining attributes. - } else { - b',' - } - } - DialectItem::Obj(obj) => obj.delimiter, - _ => b',', - }; - - if let Some(attr) = self.delimiter { - delimiter = attr - } - - delimiter - } - fn get_lineterminator(&self) -> csv_core::Terminator { let mut lineterminator = match &self.dialect { DialectItem::Str(name) => { @@ -960,7 +936,6 @@ mod _csv { output_ends: Vec, reader: csv_core::Reader, skipinitialspace: bool, - delimiter: u8, line_num: u64, generation: u64, } @@ -996,60 +971,146 @@ mod _csv { impl SelfIter for Reader {} - fn read_quote_none_record( + enum QuoteScanEvent { + InitialSpace, + StartQuotedField, + EndQuotedField, + Escaped(Option), + DoubleQuote(u8), + Delimiter, + RecordTerminator, + Data(u8), + } + + struct QuoteScanState { + at_field_start: bool, + in_quoted_field: bool, + } + + impl QuoteScanState { + const fn new() -> Self { + Self { + at_field_start: true, + in_quoted_field: false, + } + } + + fn scan( + &mut self, + input: &[u8], + index: usize, + dialect: PyDialect, + unquoted_escape: bool, + ) -> (QuoteScanEvent, usize) { + let byte = input[index]; + + if (self.in_quoted_field || unquoted_escape) && dialect.escapechar == Some(byte) { + self.at_field_start = false; + return match input.get(index + 1).copied() { + Some(escaped) => (QuoteScanEvent::Escaped(Some(escaped)), 2), + None => (QuoteScanEvent::Escaped(None), 1), + }; + } + + if self.in_quoted_field { + if dialect.quotechar == Some(byte) { + if dialect.doublequote && input.get(index + 1) == Some(&byte) { + return (QuoteScanEvent::DoubleQuote(byte), 2); + } + self.in_quoted_field = false; + return (QuoteScanEvent::EndQuotedField, 1); + } + return (QuoteScanEvent::Data(byte), 1); + } + + if self.at_field_start && dialect.skipinitialspace && byte == b' ' { + return (QuoteScanEvent::InitialSpace, 1); + } + + if self.at_field_start + && dialect.quoting != QuoteStyle::None + && dialect.quotechar == Some(byte) + { + self.at_field_start = false; + self.in_quoted_field = true; + return (QuoteScanEvent::StartQuotedField, 1); + } + + if byte == dialect.delimiter { + self.at_field_start = true; + return (QuoteScanEvent::Delimiter, 1); + } + + self.at_field_start = false; + if matches!(byte, b'\r' | b'\n') { + (QuoteScanEvent::RecordTerminator, 1) + } else { + (QuoteScanEvent::Data(byte), 1) + } + } + } + + fn read_quote_record( input: &[u8], dialect: PyDialect, field_limit: isize, vm: &VirtualMachine, ) -> PyResult> { - let mut fields = vec![Vec::new()]; - let mut escaped = false; - let mut after_delimiter = false; - - for (index, &byte) in input.iter().enumerate() { - if escaped { - fields.last_mut().unwrap().push(byte); - escaped = false; - after_delimiter = false; - } else if dialect.skipinitialspace && after_delimiter && byte == b' ' { - continue; - } else if dialect.escapechar == Some(byte) { - escaped = true; - } else if byte == dialect.delimiter { - fields.push(Vec::new()); - after_delimiter = true; - } else if matches!(byte, b'\r' | b'\n') { - if !input[index..] - .iter() - .all(|&byte| matches!(byte, b'\r' | b'\n')) - { - return Err(new_csv_error( - vm, - concat!( - "new-line character seen in unquoted field", - " - do you need to open the file in universal-newline mode?" - ), - )); + // QUOTE_NOTNULL and QUOTE_STRINGS map empty unquoted fields to None, + // but preserve quoted empty fields as strings, so retain quote provenance. + let mut fields = vec![(Vec::new(), false)]; + let mut scan_state = QuoteScanState::new(); + let mut dangling_escape = false; + let mut index = 0; + + while index < input.len() { + let (event, consumed) = scan_state.scan(input, index, dialect, true); + match event { + QuoteScanEvent::InitialSpace | QuoteScanEvent::EndQuotedField => {} + QuoteScanEvent::StartQuotedField => fields.last_mut().unwrap().1 = true, + QuoteScanEvent::Escaped(Some(byte)) | QuoteScanEvent::DoubleQuote(byte) => { + fields.last_mut().unwrap().0.push(byte); } - break; - } else { - fields.last_mut().unwrap().push(byte); - after_delimiter = false; + QuoteScanEvent::Escaped(None) => dangling_escape = true, + QuoteScanEvent::Delimiter => fields.push((Vec::new(), false)), + QuoteScanEvent::RecordTerminator => { + if !input[index..] + .iter() + .all(|&byte| matches!(byte, b'\r' | b'\n')) + { + return Err(new_csv_error( + vm, + concat!( + "new-line character seen in unquoted field", + " - do you need to open the file in universal-newline mode?" + ), + )); + } + break; + } + QuoteScanEvent::Data(byte) => fields.last_mut().unwrap().0.push(byte), } + index += consumed; } // CPython treats an escape character at the end of an iterator item // as escaping the implicit newline at the end of that item. - if escaped { - fields.last_mut().unwrap().push(b'\n'); + if dangling_escape { + fields.last_mut().unwrap().0.push(b'\n'); } fields .into_iter() - .map(|field| { + .map(|(field, was_quoted)| { if field.len() > field_limit as usize { return Err(new_csv_error(vm, "filed too long to read")); } + if matches!(dialect.quoting, QuoteStyle::Notnull | QuoteStyle::Strings) + && !was_quoted + && field.is_empty() + { + return Ok(vm.ctx.none()); + } let field = core::str::from_utf8(&field) .map_err(|_| vm.new_unicode_decode_error("csv not utf8"))?; Ok(vm.ctx.new_str(field).into()) @@ -1088,7 +1149,6 @@ mod _csv { output_ends, reader, skipinitialspace, - delimiter, line_num, generation: _, } = &mut *state; @@ -1098,26 +1158,49 @@ mod _csv { let mut output_ends_offset = 0; let field_limit = GLOBAL_FIELD_LIMIT.lock().to_owned(); - if zelf.dialect.quoting == QuoteStyle::None && zelf.dialect.escapechar.is_some() { - let out = read_quote_none_record(input, zelf.dialect, field_limit, vm)?; + let use_quote_record = matches!( + zelf.dialect.quoting, + QuoteStyle::Notnull | QuoteStyle::Strings + ) || (zelf.dialect.quoting == QuoteStyle::None + && zelf.dialect.escapechar.is_some()); + if use_quote_record { + let out = read_quote_record(input, zelf.dialect, field_limit, vm)?; *line_num += 1; return Ok(PyIterReturn::Return(vm.ctx.new_list(out).into())); } + #[inline] + fn trim_initial_spaces(input: &[u8], dialect: PyDialect) -> Vec { + let mut trimmed = Vec::with_capacity(input.len()); + let mut scan_state = QuoteScanState::new(); + let mut index = 0; + + // Delimiters inside quoted fields are data, so only skip spaces + // after delimiters encountered outside quotes. + while index < input.len() { + let (event, consumed) = scan_state.scan(input, index, dialect, false); + if !matches!(event, QuoteScanEvent::InitialSpace) { + trimmed.extend_from_slice(&input[index..index + consumed]); + } + index += consumed; + } + + trimmed + } + #[inline] fn trim_spaces(input: &[u8]) -> &[u8] { let trimmed_start = input.iter().position(|&x| x != b' ').unwrap_or(input.len()); let trimmed_end = input.iter().rposition(|&x| x != b' ').map_or(0, |i| i + 1); - &input[trimmed_start..trimmed_end] + if trimmed_start >= trimmed_end { + &input[input.len()..] + } else { + &input[trimmed_start..trimmed_end] + } } let input = if *skipinitialspace { - let t = input.split(|x| x == delimiter); - t.map(|x| { - let trimmed = trim_spaces(x); - String::from_utf8(trimmed.to_vec()).unwrap() - }) - .join(format!("{}", *delimiter as char).as_str()) + String::from_utf8(trim_initial_spaces(input, zelf.dialect)).unwrap() } else { String::from_utf8(input.to_vec()).unwrap() }; diff --git a/extra_tests/snippets/stdlib_csv.py b/extra_tests/snippets/stdlib_csv.py index 0664bfd8d92..61b82459a28 100644 --- a/extra_tests/snippets/stdlib_csv.py +++ b/extra_tests/snippets/stdlib_csv.py @@ -192,3 +192,11 @@ def test_quote_minimal_writer_empty_fields(): test_quote_minimal_writer_empty_fields() + + +def test_reader_skipinitialspace_preserves_quoted_spaces(): + reader = csv.reader(['a, "b, c", d'], skipinitialspace=True) + assert list(reader) == [["a", "b, c", "d"]] + + +test_reader_skipinitialspace_preserves_quoted_spaces() From 50d7bc8505b8fc63e11b17b34acc7c524b862dd6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:07:03 +0900 Subject: [PATCH 156/351] Bump https://github.com/rbubley/mirrors-prettier from v3.9.4 to 3.9.5 (#8331) Bumps [https://github.com/rbubley/mirrors-prettier](https://github.com/rbubley/mirrors-prettier) from v3.9.4 to 3.9.5. - [Commits](https://github.com/rbubley/mirrors-prettier/compare/v3.9.4...v3.9.5) --- updated-dependencies: - dependency-name: https://github.com/rbubley/mirrors-prettier dependency-version: 3.9.5 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bc8046ab06f..fbb7c22304e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -77,7 +77,7 @@ repos: priority: 0 - repo: https://github.com/rbubley/mirrors-prettier - rev: v3.9.4 + rev: v3.9.5 hooks: - id: prettier files: '^wasm/.*$' From 7f721ec5968d6636c33bf95f7a05b2592bc10114 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:07:17 +0900 Subject: [PATCH 157/351] Bump https://github.com/astral-sh/ruff-pre-commit (#8332) Bumps [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit) from v0.15.20 to 0.15.21. - [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases) - [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.20...v0.15.21) --- updated-dependencies: - dependency-name: https://github.com/astral-sh/ruff-pre-commit dependency-version: 0.15.21 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fbb7c22304e..45fb5f47b97 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: priority: 0 - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.20 + rev: v0.15.21 hooks: - id: ruff-format priority: 0 From 691ee06dee6913f83b02415c42d75bccd98da439 Mon Sep 17 00:00:00 2001 From: Kwak Byoung Min <67214970+doma17@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:30:10 +0900 Subject: [PATCH 158/351] bz2: return streaming output from BZ2Compressor.compress() (#8330) * bz2: return streaming output from BZ2Compressor.compress() Return output produced during compression instead of waiting for flush(). Constraint: Preserve encoder state without flushing the BZ2 stream Confidence: high Scope-risk: narrow Directive: Do not flush merely to retrieve partial output Tested: test_bz2; stdlib_bz2.py; prek run --all-files Assisted-by: Codex:gpt-5.6-sol * bz2: remove redundant streaming-output snippet --- Lib/test/test_bz2.py | 1 - crates/stdlib/src/bz2.rs | 12 ++++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_bz2.py b/Lib/test/test_bz2.py index a7e152fb7e7..bcd4e033b59 100644 --- a/Lib/test/test_bz2.py +++ b/Lib/test/test_bz2.py @@ -936,7 +936,6 @@ def testPickle(self): with self.assertRaises(TypeError): pickle.dumps(BZ2Decompressor(), proto) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 0 != 100 def testDecompressorChunksMaxsize(self): bzd = BZ2Decompressor() max_length = 100 diff --git a/crates/stdlib/src/bz2.rs b/crates/stdlib/src/bz2.rs index 575f33c4b8f..fcde54057e5 100644 --- a/crates/stdlib/src/bz2.rs +++ b/crates/stdlib/src/bz2.rs @@ -17,6 +17,7 @@ mod _bz2 { }; use alloc::fmt; use bzip2::{Decompress, Status, write::BzEncoder}; + use core::mem; use rustpython_vm::convert::ToPyException; use std::io::Write; @@ -155,7 +156,6 @@ mod _bz2 { } } - // TODO: return partial results from compress() instead of returning everything in flush() #[pyclass(with(Constructor))] impl BZ2Compressor { #[pymethod] @@ -165,12 +165,12 @@ mod _bz2 { return Err(vm.new_value_error("Compressor has been flushed")); } - // let CompressorState { flushed, encoder } = &mut *state; let CompressorState { encoder, .. } = &mut *state; - - // TODO: handle Err - data.with_ref(|input_bytes| encoder.as_mut().unwrap().write_all(input_bytes).unwrap()); - Ok(vm.ctx.new_bytes(Vec::new())) + let encoder = encoder.as_mut().unwrap(); + data.with_ref(|input_bytes| encoder.write_all(input_bytes).unwrap()); + // BzEncoder writes its pending output at the start of the next write. + assert_eq!(encoder.write(&[]).unwrap(), 0); + Ok(vm.ctx.new_bytes(mem::take(encoder.get_mut()))) } #[pymethod] From d6c99603f54f9bd1218f14c77e1893896177963c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:30:20 +0900 Subject: [PATCH 159/351] Bump actions/setup-node from 6.4.0 to 7.0.0 (#8335) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.4.0 to 7.0.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e...820762786026740c76f36085b0efc47a31fe5020) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d03735192c8..d1aa26fed3f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -690,7 +690,7 @@ jobs: - run: python -m pip install -r requirements.txt working-directory: ./wasm/tests - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: package-manager-cache: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd66ae84572..990322cc26f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -114,7 +114,7 @@ jobs: - name: install wasm-pack run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: package-manager-cache: false From e01203e58fc68ce58a89cb397c9d21eaa397b13a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:36:02 +0900 Subject: [PATCH 160/351] Bump which from 8.0.4 to 8.0.5 (#8333) Bumps [which](https://github.com/harryfei/which-rs) from 8.0.4 to 8.0.5. - [Release notes](https://github.com/harryfei/which-rs/releases) - [Changelog](https://github.com/harryfei/which-rs/blob/master/CHANGELOG.md) - [Commits](https://github.com/harryfei/which-rs/compare/8.0.4...8.0.5) --- updated-dependencies: - dependency-name: which dependency-version: 8.0.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 51e91550215..1b3a0c1603e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4653,9 +4653,9 @@ dependencies = [ [[package]] name = "which" -version = "8.0.4" +version = "8.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48d7cd18d4acb58fb3cdfe9ea54e6cd96a4e7d4cc45c56338b236e82dad47248" +checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" dependencies = [ "libc", ] From c22993c8a796b1047bad215063cff94fabf9f209 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:36:12 +0900 Subject: [PATCH 161/351] Bump taiki-e/install-action from 2.82.9 to 2.83.2 (#8334) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.82.9 to 2.83.2. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/4684b8405694ae9dd42c9f39ba901a70ae83f4a3...43aecc8d72668fbcfe75c31400bc4f890f1c5853) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.83.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cron-ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cron-ci.yaml b/.github/workflows/cron-ci.yaml index 2839cf375ca..37af28a4ee3 100644 --- a/.github/workflows/cron-ci.yaml +++ b/.github/workflows/cron-ci.yaml @@ -33,7 +33,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - uses: taiki-e/install-action@4684b8405694ae9dd42c9f39ba901a70ae83f4a3 # v2.82.9 + - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 with: tool: cargo-llvm-cov From e6b794d16cf3eb9b1d575b0b26190926cb4fba1a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:36:21 +0900 Subject: [PATCH 162/351] Bump bitflags from 2.13.0 to 2.13.1 (#8336) Bumps [bitflags](https://github.com/bitflags/bitflags) from 2.13.0 to 2.13.1. - [Release notes](https://github.com/bitflags/bitflags/releases) - [Changelog](https://github.com/bitflags/bitflags/blob/main/CHANGELOG.md) - [Commits](https://github.com/bitflags/bitflags/compare/2.13.0...2.13.1) --- updated-dependencies: - dependency-name: bitflags dependency-version: 2.13.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1b3a0c1603e..e2b62be38e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -315,7 +315,7 @@ version = "0.71.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -335,7 +335,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -357,9 +357,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bitflagset" @@ -2266,7 +2266,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -2279,7 +2279,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -2412,7 +2412,7 @@ version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "foreign-types", "libc", @@ -3011,7 +3011,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -3165,7 +3165,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -3294,7 +3294,7 @@ dependencies = [ name = "rustpython-capi" version = "0.5.0" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "itertools 0.15.0", "libc", "malachite-bigint", @@ -3309,7 +3309,7 @@ dependencies = [ name = "rustpython-codegen" version = "0.5.0" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "indexmap", "itertools 0.15.0", "log", @@ -3333,7 +3333,7 @@ name = "rustpython-common" version = "0.5.0" dependencies = [ "ascii", - "bitflags 2.13.0", + "bitflags 2.13.1", "getrandom 0.4.3", "itertools 0.15.0", "libc", @@ -3368,7 +3368,7 @@ dependencies = [ name = "rustpython-compiler-core" version = "0.5.0" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "bitflagset", "itertools 0.15.0", "lz4_flex", @@ -3421,7 +3421,7 @@ dependencies = [ name = "rustpython-host_env" version = "0.5.0" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "dns-lookup", "gethostname", "getrandom 0.4.3", @@ -3492,7 +3492,7 @@ version = "0.15.9" source = "git+https://github.com/RustPython/ruff.git?tag=0.15.19-rustpython#3c1ab2dd9987f190f3df94d28452ed5e65e106af" dependencies = [ "aho-corasick", - "bitflags 2.13.0", + "bitflags 2.13.1", "compact_str", "get-size2", "is-macro", @@ -3509,7 +3509,7 @@ name = "rustpython-ruff_python_parser" version = "0.15.9" source = "git+https://github.com/RustPython/ruff.git?tag=0.15.19-rustpython#3c1ab2dd9987f190f3df94d28452ed5e65e106af" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "bstr", "compact_str", "get-size2", @@ -3556,7 +3556,7 @@ dependencies = [ name = "rustpython-sre_engine" version = "0.5.0" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "criterion", "num_enum", "optional", @@ -3665,7 +3665,7 @@ name = "rustpython-vm" version = "0.5.0" dependencies = [ "ascii", - "bitflags 2.13.0", + "bitflags 2.13.1", "bstr", "chrono", "constant_time_eq", @@ -3759,7 +3759,7 @@ version = "18.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53f6a737db68eb1a8ccff86b584b2fc13eca6a7bb6f78ebc7c529547e3ab9684" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "clipboard-win", "home", @@ -3841,7 +3841,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4147,7 +4147,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] From cc30cd5e0313c520e9133c1b8c47326d41889e3b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:36:31 +0900 Subject: [PATCH 163/351] Bump syn from 2.0.118 to 2.0.119 (#8337) Bumps [syn](https://github.com/dtolnay/syn) from 2.0.118 to 2.0.119. - [Release notes](https://github.com/dtolnay/syn/releases) - [Commits](https://github.com/dtolnay/syn/compare/2.0.118...2.0.119) --- updated-dependencies: - dependency-name: syn dependency-version: 2.0.119 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e2b62be38e3..a0af67e9536 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4110,9 +4110,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", From ea798265cab1d9d5c29fe05b19f714936dcfd786 Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:27:19 -0400 Subject: [PATCH 164/351] Fix building against new libc (#8343) --- crates/host_env/src/posix.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/host_env/src/posix.rs b/crates/host_env/src/posix.rs index 150df505c42..e20accee715 100644 --- a/crates/host_env/src/posix.rs +++ b/crates/host_env/src/posix.rs @@ -1424,8 +1424,9 @@ fn build_posix_spawn_attrs( target_os = "hurd", ))] { + #[allow(clippy::useless_conversion)] flags.insert(nix::spawn::PosixSpawnFlags::from_bits_retain( - libc::POSIX_SPAWN_SETSID, + libc::POSIX_SPAWN_SETSID.into(), )); } #[cfg(not(any( From 72daf7cbbc335828fa43e5745c7c53b9461333cd Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:28:51 +0200 Subject: [PATCH 165/351] Add more unicode functions to the c-api (#8272) * Add more unicode functions Review Add unicode decode functions Add more functions * Remove * Add helper --- crates/capi/src/unicodeobject.rs | 334 +++++++++++++++++++++++++++++-- crates/vm/src/builtins/str.rs | 8 +- 2 files changed, 321 insertions(+), 21 deletions(-) diff --git a/crates/capi/src/unicodeobject.rs b/crates/capi/src/unicodeobject.rs index 1a5e43c0e9d..00ab1dcb8e2 100644 --- a/crates/capi/src/unicodeobject.rs +++ b/crates/capi/src/unicodeobject.rs @@ -5,8 +5,10 @@ use core::ffi::{CStr, c_char, c_int}; use core::ptr::NonNull; use core::slice; use core::str; -use rustpython_vm::builtins::{PyStr, PyStrRef}; -use rustpython_vm::{PyObjectRef, PyResult, VirtualMachine}; +use rustpython_vm::builtins::{PyBytesRef, PyStr, PyStrRef, PyUtf8StrRef}; +use rustpython_vm::common::wtf8::{CodePoint, Wtf8Buf}; +use rustpython_vm::convert::ToPyObject; +use rustpython_vm::{AsObject, PyObjectRef, PyResult, VirtualMachine}; define_py_check!(fn PyUnicode_Check, types.str_type); define_py_check!(exact fn PyUnicode_CheckExact, types.str_type); @@ -37,6 +39,36 @@ pub unsafe extern "C" fn PyUnicode_FromStringAndSize( }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_FromString(s: *const c_char) -> *mut PyObject { + with_vm(|vm| { + let s = unsafe { s.try_as_str(vm)? }; + Ok(vm.ctx.new_str(s)) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_FromObject(obj: *mut PyObject) -> *mut PyObject { + with_vm(|vm| { + Ok(unsafe { &*obj } + .try_downcast_ref::(vm)? + .as_object() + .str(vm)) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_FromOrdinal(ordinal: c_int) -> *mut PyObject { + with_vm(|vm| { + let ordinal: u32 = ordinal + .try_into() + .map_err(|_| vm.new_value_error("ordinal not in range(0x110000)"))?; + let code_point = CodePoint::from_u32(ordinal) + .ok_or_else(|| vm.new_value_error("ordinal not in range(0x110000)"))?; + Ok(vm.ctx.new_str(Wtf8Buf::from_iter([code_point]))) + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyUnicode_AsUTF8AndSize( obj: *mut PyObject, @@ -61,6 +93,52 @@ pub unsafe extern "C" fn PyUnicode_AsUTF8AndSize( }) } +fn encode_unicode( + vm: &VirtualMachine, + unicode: *mut PyObject, + encoding: &str, + errors: Option, +) -> PyResult { + let unicode = unsafe { &*unicode } + .try_downcast_ref::(vm)? + .to_owned(); + vm.state + .codec_registry + .encode_text(unicode, encoding, errors, vm) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_AsASCIIString(unicode: *mut PyObject) -> *mut PyObject { + with_vm(|vm| encode_unicode(vm, unicode, "ascii", None)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_AsLatin1String(unicode: *mut PyObject) -> *mut PyObject { + with_vm(|vm| encode_unicode(vm, unicode, "latin-1", None)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_AsRawUnicodeEscapeString( + unicode: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| encode_unicode(vm, unicode, "raw-unicode-escape", None)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_AsUTF16String(unicode: *mut PyObject) -> *mut PyObject { + with_vm(|vm| encode_unicode(vm, unicode, "utf-16", None)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_AsUTF32String(unicode: *mut PyObject) -> *mut PyObject { + with_vm(|vm| encode_unicode(vm, unicode, "utf-32", None)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_AsUnicodeEscapeString(unicode: *mut PyObject) -> *mut PyObject { + with_vm(|vm| encode_unicode(vm, unicode, "unicode-escape", None)) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyUnicode_AsEncodedString( unicode: *mut PyObject, @@ -68,30 +146,102 @@ pub unsafe extern "C" fn PyUnicode_AsEncodedString( errors: *const c_char, ) -> *mut PyObject { with_vm(|vm| { - let unicode = unsafe { &*unicode } - .try_downcast_ref::(vm)? - .to_owned(); let encoding = unsafe { encoding.try_as_str_opt(vm) }?.unwrap_or("utf-8"); let errors = unsafe { errors.try_as_str_opt(vm) }?.map(|errors| vm.ctx.new_utf8_str(errors)); - vm.state - .codec_registry - .encode_text(unicode, encoding, errors, vm) + encode_unicode(vm, unicode, encoding, errors) }) } #[unsafe(no_mangle)] pub unsafe extern "C" fn PyUnicode_AsUTF8String(unicode: *mut PyObject) -> *mut PyObject { + with_vm(|vm| encode_unicode(vm, unicode, "utf-8", None)) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_Decode( + s: *const c_char, + size: isize, + encoding: *const c_char, + errors: *const c_char, +) -> *mut PyObject { with_vm(|vm| { - let unicode = unsafe { &*unicode } - .try_downcast_ref::(vm)? - .to_owned(); + let size: usize = size + .try_into() + .map_err(|_| vm.new_system_error("size must be non-negative"))?; + + let bytes = if s.is_null() { + if size != 0 { + return Err(vm.new_system_error("decode called with null data and non-zero size")); + } + Vec::new() + } else { + unsafe { slice::from_raw_parts(s.cast::(), size) }.to_vec() + }; + + let encoding = unsafe { encoding.try_as_str_opt(vm)?.unwrap_or("utf-8") }; + let errors = + unsafe { errors.try_as_str_opt(vm) }?.map(|errors| vm.ctx.new_utf8_str(errors)); + vm.state .codec_registry - .encode_text(unicode, "utf-8", None, vm) + .decode_text(vm.ctx.new_bytes(bytes).into(), encoding, errors, vm) }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_DecodeASCII( + s: *const c_char, + size: isize, + errors: *const c_char, +) -> *mut PyObject { + unsafe { PyUnicode_Decode(s, size, c"ascii".as_ptr(), errors) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_DecodeLatin1( + s: *const c_char, + size: isize, + errors: *const c_char, +) -> *mut PyObject { + unsafe { PyUnicode_Decode(s, size, c"latin-1".as_ptr(), errors) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_DecodeRawUnicodeEscape( + s: *const c_char, + size: isize, + errors: *const c_char, +) -> *mut PyObject { + unsafe { PyUnicode_Decode(s, size, c"raw-unicode-escape".as_ptr(), errors) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_DecodeUTF7( + s: *const c_char, + size: isize, + errors: *const c_char, +) -> *mut PyObject { + unsafe { PyUnicode_Decode(s, size, c"utf-7".as_ptr(), errors) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_DecodeUTF8( + s: *const c_char, + size: isize, + errors: *const c_char, +) -> *mut PyObject { + unsafe { PyUnicode_Decode(s, size, c"utf-8".as_ptr(), errors) } +} +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_DecodeUnicodeEscape( + s: *const c_char, + size: isize, + errors: *const c_char, +) -> *mut PyObject { + unsafe { PyUnicode_Decode(s, size, c"unicode-escape".as_ptr(), errors) } +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyUnicode_DecodeFSDefaultAndSize( s: *const c_char, @@ -106,6 +256,89 @@ pub unsafe extern "C" fn PyUnicode_DecodeFSDefaultAndSize( }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_Concat( + left: *mut PyObject, + right: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| { + let left = unsafe { &*left }.try_downcast_ref::(vm)?; + let right = unsafe { &*right }.try_downcast_ref::(vm)?; + vm._add(left.as_object(), right.as_object()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_GetLength(unicode: *mut PyObject) -> isize { + with_vm(|vm| { + let unicode = unsafe { &*unicode }.try_downcast_ref::(vm)?; + Ok(unicode.char_len()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_GetDefaultEncoding() -> *const c_char { + c"utf-8".as_ptr() +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_InternFromString(s: *const c_char) -> *mut PyObject { + with_vm(|vm| { + let s = unsafe { s.try_as_str(vm)? }; + Ok(vm.ctx.intern_str(s).to_owned()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_Compare(left: *mut PyObject, right: *mut PyObject) -> c_int { + with_vm(|vm| { + let left = unsafe { &*left }.try_downcast_ref::(vm)?; + let right = unsafe { &*right }.try_downcast_ref::(vm)?; + Ok(match left.as_wtf8().cmp(right.as_wtf8()) { + core::cmp::Ordering::Less => -1, + core::cmp::Ordering::Equal => 0, + core::cmp::Ordering::Greater => 1, + }) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_CompareWithASCIIString( + left: *mut PyObject, + right: *const c_char, +) -> c_int { + with_vm(|vm| { + let left = unsafe { &*left }.try_downcast_ref::(vm)?; + let right = unsafe { right.try_as_str(vm)? }; + Ok(match left.as_wtf8().cmp(right.into()) { + core::cmp::Ordering::Less => -1, + core::cmp::Ordering::Equal => 0, + core::cmp::Ordering::Greater => 1, + }) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_Equal(left: *mut PyObject, right: *mut PyObject) -> c_int { + with_vm(|vm| { + let left = unsafe { &*left }.try_downcast_ref::(vm)?; + let right = unsafe { &*right }.try_downcast_ref::(vm)?; + Ok(left.as_wtf8() == right.as_wtf8()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_EqualToUTF8( + unicode: *mut PyObject, + string: *const c_char, +) -> c_int { + with_vm(|vm| { + let unicode = unsafe { &*unicode }.try_downcast_ref::(vm)?; + let other = unsafe { string.try_as_str(vm)? }; + Ok(unicode.to_str().is_some_and(|s| s == other)) + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyUnicode_DecodeFSDefault(s: *const c_char) -> *mut PyObject { with_vm(|vm| { @@ -141,14 +374,11 @@ pub(crate) fn decode_fsdefault_and_size( #[unsafe(no_mangle)] pub unsafe extern "C" fn PyUnicode_EncodeFSDefault(unicode: *mut PyObject) -> *mut PyObject { with_vm(|vm| { - let unicode = unsafe { &*unicode } - .try_downcast_ref::(vm)? - .to_owned(); - vm.state.codec_registry.encode_text( + encode_unicode( + vm, unicode, vm.fs_encoding().as_str(), Some(vm.fs_encode_errors().to_owned()), - vm, ) }) } @@ -181,6 +411,76 @@ pub unsafe extern "C" fn PyUnicode_FromEncodedObject( }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_Contains( + container: *mut PyObject, + element: *mut PyObject, +) -> c_int { + with_vm(|vm| { + let container = unsafe { &*container }.try_downcast_ref::(vm)?; + let element = unsafe { &*element }.try_downcast_ref::(vm)?; + Ok(container.as_wtf8().contains(element.as_wtf8())) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_Format( + format: *mut PyObject, + args: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| { + let format = unsafe { &*format }.try_downcast_ref::(vm)?; + let result = format.__mod__(unsafe { &*args }.to_owned(), vm)?; + Ok(result.to_pyobject(vm)) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_IsIdentifier(s: *mut PyObject) -> c_int { + with_vm(|vm| { + let s = unsafe { &*s }.try_downcast_ref::(vm)?; + Ok(s.isidentifier()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_Partition( + s: *mut PyObject, + sep: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| { + let s = unsafe { &*s }.try_downcast_ref::(vm)?; + let sep = unsafe { &*sep }.try_downcast_ref::(vm)?; + s.partition(sep.to_owned(), vm) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_RPartition( + s: *mut PyObject, + sep: *mut PyObject, +) -> *mut PyObject { + with_vm(|vm| { + let s = unsafe { &*s }.try_downcast_ref::(vm)?; + let sep = unsafe { &*sep }.try_downcast_ref::(vm)?; + s.rpartition(sep.to_owned(), vm) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn PyUnicode_Translate( + str_obj: *mut PyObject, + table: *mut PyObject, + _errors: *const c_char, +) -> *mut PyObject { + with_vm(|vm| { + let str_obj = unsafe { &*str_obj }.try_downcast_ref::(vm)?; + Ok(str_obj + .translate(unsafe { &*table }.to_owned(), vm)? + .to_pyobject(vm)) + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn PyUnicode_InternInPlace(string: *mut *mut PyObject) { with_vm(|vm| { diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index bd5cd39ddb0..b1c2c41973b 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -977,7 +977,7 @@ impl PyStr { !self.data.is_empty() && self.char_all(unicode::classify::is_decimal) } - fn __mod__(&self, values: PyObjectRef, vm: &VirtualMachine) -> PyResult { + pub fn __mod__(&self, values: PyObjectRef, vm: &VirtualMachine) -> PyResult { cformat_string(vm, self.as_wtf8(), values) } @@ -1192,7 +1192,7 @@ impl PyStr { } #[pymethod] - fn partition(&self, sep: PyStrRef, vm: &VirtualMachine) -> PyResult { + pub fn partition(&self, sep: PyStrRef, vm: &VirtualMachine) -> PyResult { let (front, has_mid, back) = self.as_wtf8().py_partition( sep.as_wtf8(), || self.as_wtf8().splitn(2, sep.as_wtf8()), @@ -1211,7 +1211,7 @@ impl PyStr { } #[pymethod] - fn rpartition(&self, sep: PyStrRef, vm: &VirtualMachine) -> PyResult { + pub fn rpartition(&self, sep: PyStrRef, vm: &VirtualMachine) -> PyResult { let (back, has_mid, front) = self.as_wtf8().py_partition( sep.as_wtf8(), || self.as_wtf8().rsplitn(2, sep.as_wtf8()), @@ -1344,7 +1344,7 @@ impl PyStr { // https://docs.python.org/3/library/stdtypes.html#str.translate #[pymethod] - fn translate(&self, table: PyObjectRef, vm: &VirtualMachine) -> PyResult { + pub fn translate(&self, table: PyObjectRef, vm: &VirtualMachine) -> PyResult { vm.get_method_or_type_error(table.clone(), identifier!(vm, __getitem__), || { format!("'{}' object is not subscriptable", table.class().name()) })?; From bc12a97a69676ba16e638304a29d3853373302e2 Mon Sep 17 00:00:00 2001 From: Yubin Kim <80163835+devyubin@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:31:31 +0900 Subject: [PATCH 166/351] bytes: support keyword arguments in hex() (#8312) * bytes: support keyword arguments in hex() * bytes: unmark test_memoryview_hex_separator --- Lib/test/test_bytes.py | 1 - Lib/test/test_memoryview.py | 1 - crates/vm/src/builtins/bytearray.rs | 16 ++++++---------- crates/vm/src/builtins/bytes.rs | 8 ++++---- crates/vm/src/builtins/memory.rs | 12 ++++-------- crates/vm/src/bytes_inner.rs | 8 ++++++++ 6 files changed, 22 insertions(+), 24 deletions(-) diff --git a/Lib/test/test_bytes.py b/Lib/test/test_bytes.py index 70af9af466d..7bddc02817c 100644 --- a/Lib/test/test_bytes.py +++ b/Lib/test/test_bytes.py @@ -518,7 +518,6 @@ def test_hex(self): self.assertEqual(self.type2test(b"\x1a\x2b\x30").hex(), '1a2b30') self.assertEqual(memoryview(b"\x1a\x2b\x30").hex(), '1a2b30') - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: Unexpected keyword argument sep def test_hex_separator_basics(self): three_bytes = self.type2test(b'\xb9\x01\xef') self.assertEqual(three_bytes.hex(), 'b901ef') diff --git a/Lib/test/test_memoryview.py b/Lib/test/test_memoryview.py index 7889fa88d00..12e3504e42e 100644 --- a/Lib/test/test_memoryview.py +++ b/Lib/test/test_memoryview.py @@ -664,7 +664,6 @@ def test_memoryview_hex(self): m2 = m1[::-1] self.assertEqual(m2.hex(), '30' * 200000) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: Unexpected keyword argument sep def test_memoryview_hex_separator(self): x = bytes(range(97, 102)) m1 = memoryview(x) diff --git a/crates/vm/src/builtins/bytearray.rs b/crates/vm/src/builtins/bytearray.rs index 63b10552f50..a649fe9d8d5 100644 --- a/crates/vm/src/builtins/bytearray.rs +++ b/crates/vm/src/builtins/bytearray.rs @@ -1,7 +1,7 @@ //! Implementation of the python bytearray object. use super::{ - PositionIterInternal, PyBytes, PyBytesRef, PyDictRef, PyGenericAlias, PyIntRef, PyStrRef, - PyTuple, PyTupleRef, PyType, PyTypeRef, iter::builtins_iter, + PositionIterInternal, PyBytes, PyDictRef, PyGenericAlias, PyIntRef, PyStrRef, PyTuple, + PyTupleRef, PyType, PyTypeRef, iter::builtins_iter, }; use crate::{ AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, @@ -10,8 +10,8 @@ use crate::{ atomic_func, byte::{bytes_from_object, value_from_object}, bytes_inner::{ - ByteInnerFindOptions, ByteInnerNewOptions, ByteInnerPaddingOptions, ByteInnerSplitOptions, - ByteInnerTranslateOptions, DecodeArgs, PyBytesInner, bytes_decode, + ByteInnerFindOptions, ByteInnerHexOptions, ByteInnerNewOptions, ByteInnerPaddingOptions, + ByteInnerSplitOptions, ByteInnerTranslateOptions, DecodeArgs, PyBytesInner, bytes_decode, }, class::PyClassImpl, common::{ @@ -321,12 +321,8 @@ impl PyByteArray { } #[pymethod] - fn hex( - &self, - sep: OptionalArg>, - bytes_per_sep: OptionalArg, - vm: &VirtualMachine, - ) -> PyResult { + fn hex(&self, options: ByteInnerHexOptions, vm: &VirtualMachine) -> PyResult { + let ByteInnerHexOptions { sep, bytes_per_sep } = options; self.inner().hex(sep, bytes_per_sep, vm) } diff --git a/crates/vm/src/builtins/bytes.rs b/crates/vm/src/builtins/bytes.rs index f28e8ddbbd8..ca40b3d3b6b 100644 --- a/crates/vm/src/builtins/bytes.rs +++ b/crates/vm/src/builtins/bytes.rs @@ -9,8 +9,8 @@ use crate::{ anystr::{self, AnyStr}, atomic_func, bytes_inner::{ - ByteInnerFindOptions, ByteInnerNewOptions, ByteInnerPaddingOptions, ByteInnerSplitOptions, - ByteInnerTranslateOptions, DecodeArgs, PyBytesInner, bytes_decode, + ByteInnerFindOptions, ByteInnerHexOptions, ByteInnerNewOptions, ByteInnerPaddingOptions, + ByteInnerSplitOptions, ByteInnerTranslateOptions, DecodeArgs, PyBytesInner, bytes_decode, }, class::PyClassImpl, common::{hash::PyHash, lock::PyMutex}, @@ -318,10 +318,10 @@ impl PyBytes { #[pymethod] pub(crate) fn hex( &self, - sep: OptionalArg>, - bytes_per_sep: OptionalArg, + options: ByteInnerHexOptions, vm: &VirtualMachine, ) -> PyResult { + let ByteInnerHexOptions { sep, bytes_per_sep } = options; self.inner.hex(sep, bytes_per_sep, vm) } diff --git a/crates/vm/src/builtins/memory.rs b/crates/vm/src/builtins/memory.rs index f40350982a8..ee5a071287b 100644 --- a/crates/vm/src/builtins/memory.rs +++ b/crates/vm/src/builtins/memory.rs @@ -1,13 +1,13 @@ use super::{ PositionIterInternal, PyBytes, PyBytesRef, PyGenericAlias, PyInt, PyListRef, PySlice, PyStr, - PyStrRef, PyTuple, PyTupleRef, PyType, PyTypeRef, PyUtf8StrRef, iter::builtins_iter, + PyTuple, PyTupleRef, PyType, PyTypeRef, PyUtf8StrRef, iter::builtins_iter, }; use crate::common::lock::LazyLock; use crate::{ AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, TryFromBorrowedObject, TryFromObject, VirtualMachine, atomic_func, buffer::FormatSpec, - bytes_inner::bytes_to_hex, + bytes_inner::{ByteInnerHexOptions, bytes_to_hex}, class::PyClassImpl, common::{ borrow::{BorrowedValue, BorrowedValueMut}, @@ -739,12 +739,8 @@ impl PyMemoryView { } #[pymethod] - fn hex( - &self, - sep: OptionalArg>, - bytes_per_sep: OptionalArg, - vm: &VirtualMachine, - ) -> PyResult { + fn hex(&self, options: ByteInnerHexOptions, vm: &VirtualMachine) -> PyResult { + let ByteInnerHexOptions { sep, bytes_per_sep } = options; self.try_not_released(vm)?; self.contiguous_or_collect(|x| bytes_to_hex(x, sep, bytes_per_sep, vm)) } diff --git a/crates/vm/src/bytes_inner.rs b/crates/vm/src/bytes_inner.rs index 9cfe4d7609f..51c659f587e 100644 --- a/crates/vm/src/bytes_inner.rs +++ b/crates/vm/src/bytes_inner.rs @@ -1113,6 +1113,14 @@ pub(crate) fn bytes_decode( .decode_text(zelf, encoding, errors, vm) } +#[derive(FromArgs)] +pub(crate) struct ByteInnerHexOptions { + #[pyarg(any, optional)] + pub sep: OptionalArg>, + #[pyarg(any, optional)] + pub bytes_per_sep: OptionalArg, +} + fn hex_impl_no_sep(bytes: &[u8]) -> String { let mut buf: Vec = vec![0; bytes.len() * 2]; hex::encode_to_slice(bytes, buf.as_mut_slice()).unwrap(); From d2da5a2edf94ec761abd9b89f5913a755c64cff4 Mon Sep 17 00:00:00 2001 From: cui fliter Date: Wed, 22 Jul 2026 19:32:39 +0800 Subject: [PATCH 167/351] Fix bytearray repr escaping for apostrophes (#8316) Signed-off-by: cuishuang --- Lib/test/test_bytes.py | 2 -- crates/vm/src/bytes_inner.rs | 57 +++++++++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/Lib/test/test_bytes.py b/Lib/test/test_bytes.py index 7bddc02817c..32a9ca7df87 100644 --- a/Lib/test/test_bytes.py +++ b/Lib/test/test_bytes.py @@ -2101,7 +2101,6 @@ def test_bytes_repr(self, f=repr): self.assertEqual(f(b"'\"'"), r"""b'\'"\''""") # '\'"\'' self.assertEqual(f(BytesSubclass(b"abc")), "b'abc'") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_bytearray_repr(self, f=repr): self.assertEqual(f(bytearray()), "bytearray(b'')") self.assertEqual(f(bytearray(b'abc')), "bytearray(b'abc')") @@ -2123,7 +2122,6 @@ def test_bytearray_repr(self, f=repr): def test_bytes_str(self): self.test_bytes_repr(str) - @unittest.expectedFailure # TODO: RUSTPYTHON @check_bytes_warnings def test_bytearray_str(self): self.test_bytearray_repr(str) diff --git a/crates/vm/src/bytes_inner.rs b/crates/vm/src/bytes_inner.rs index 51c659f587e..0bf6c13726a 100644 --- a/crates/vm/src/bytes_inner.rs +++ b/crates/vm/src/bytes_inner.rs @@ -228,6 +228,31 @@ impl ByteInnerTranslateOptions { pub(crate) type ByteInnerSplitOptions = anystr::SplitArgs; +fn bytearray_repr_char_len(ch: u8) -> usize { + match ch { + b'\'' | b'\\' | b'\t' | b'\r' | b'\n' => 2, + 0x20..=0x7e => 1, + _ => 4, // \xHH + } +} + +fn write_bytearray_repr_char(ch: u8, buf: &mut String) { + match ch { + b'\'' => buf.push_str(r#"\'"#), + b'\\' => buf.push_str(r#"\\"#), + b'\t' => buf.push_str(r#"\t"#), + b'\n' => buf.push_str(r#"\n"#), + b'\r' => buf.push_str(r#"\r"#), + 0x20..=0x7e => buf.push(ch as char), + ch => { + const HEX: &[u8; 16] = b"0123456789abcdef"; + buf.push_str(r#"\x"#); + buf.push(HEX[(ch >> 4) as usize] as char); + buf.push(HEX[(ch & 0x0f) as usize] as char); + } + } +} + impl PyBytesInner { #[inline] pub fn as_bytes(&self) -> &[u8] { @@ -251,17 +276,33 @@ impl PyBytesInner { } pub fn repr_with_name(&self, class_name: &str, vm: &VirtualMachine) -> PyResult { - const DECORATION_LEN: isize = 2 + 3; // 2 for (), 3 for b"" => bytearray(b"") - let escape = crate::literal::escape::AsciiEscape::new_repr(&self.elements); - let len = escape - .layout() - .len - .and_then(|len| (len as isize).checked_add(DECORATION_LEN + class_name.len() as isize)) - .ok_or_else(|| Self::new_repr_overflow_error(vm))? as usize; + const DECORATION_LEN: usize = 2 + 3; // 2 for (), 3 for b"" => bytearray(b"") + let quote = if self.elements.contains(&b'\'') && !self.elements.contains(&b'"') { + '"' + } else { + '\'' + }; + let body_len = self + .elements + .iter() + .try_fold(0usize, |len, &ch| { + len.checked_add(bytearray_repr_char_len(ch)) + }) + .ok_or_else(|| Self::new_repr_overflow_error(vm))?; + let len = class_name + .len() + .checked_add(DECORATION_LEN) + .and_then(|len| len.checked_add(body_len)) + .ok_or_else(|| Self::new_repr_overflow_error(vm))?; let mut buf = String::with_capacity(len); buf.push_str(class_name); buf.push('('); - escape.bytes_repr().write(&mut buf).unwrap(); + buf.push('b'); + buf.push(quote); + for &ch in &self.elements { + write_bytearray_repr_char(ch, &mut buf); + } + buf.push(quote); buf.push(')'); debug_assert_eq!(buf.len(), len); Ok(buf) From dd9bce501ccec8d2a269a3982b563134a20b242c Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:34:20 +0900 Subject: [PATCH 168/351] Fix thread UnraisableException object to be None (#8326) Assisted-by: Codex:gpt-5 --- Lib/test/test_thread.py | 1 - crates/vm/src/stdlib/_thread.rs | 30 +++++++++++++++++++----------- crates/vm/src/vm/mod.rs | 26 ++++++++++++++++---------- 3 files changed, 35 insertions(+), 22 deletions(-) diff --git a/Lib/test/test_thread.py b/Lib/test/test_thread.py index 22b19ee3d30..ebb193eabda 100644 --- a/Lib/test/test_thread.py +++ b/Lib/test/test_thread.py @@ -151,7 +151,6 @@ def task(): support.gc_collect() # For PyPy or other GCs. self.assertEqual(thread._count(), orig) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_unraisable_exception(self): def task(): started.release() diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index 0079f15df2b..91fec0a0232 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -20,7 +20,10 @@ pub(crate) mod _thread { use crate::{ AsObject, Py, PyPayload, PyRef, PyResult, VirtualMachine, - builtins::{PyDictRef, PyIntRef, PyStr, PyTupleRef, PyType, PyTypeRef, PyUtf8StrRef}, + builtins::{ + PyBaseExceptionRef, PyDictRef, PyIntRef, PyStr, PyTupleRef, PyType, PyTypeRef, + PyUtf8StrRef, + }, common::wtf8::Wtf8Buf, frame::FrameRef, function::{ArgCallable, FuncArgs, KwArgs, OptionalArg, PySetterValue, TimeoutSeconds}, @@ -558,6 +561,19 @@ pub(crate) mod _thread { .map_err(|_err| vm.new_runtime_error("can't start new thread")) } + fn report_unraisable_thread_exception( + exc: PyBaseExceptionRef, + func: &ArgCallable, + vm: &VirtualMachine, + ) { + let msg = func + .as_ref() + .repr(vm) + .ok() + .map(|repr| format!("Exception ignored in thread started by {}", repr.as_wtf8())); + vm.run_unraisable(exc, msg, vm.ctx.none()); + } + fn run_thread(func: ArgCallable, args: FuncArgs, vm: &VirtualMachine) { // Increment thread count when thread actually starts executing vm.state.thread_count.fetch_add(1); @@ -572,11 +588,7 @@ pub(crate) mod _thread { if let Err(exc) = func.invoke(args, vm) && !exc.fast_isinstance(vm.ctx.exceptions.system_exit) { - vm.run_unraisable( - exc, - Some("Exception ignored in thread started by".to_owned()), - func.into(), - ); + report_unraisable_thread_exception(exc, &func, vm); } } for lock in SENTINELS.take() { @@ -1755,11 +1767,7 @@ pub(crate) mod _thread { if let Err(exc) = func.invoke((), vm) && !exc.fast_isinstance(vm.ctx.exceptions.system_exit) { - vm.run_unraisable( - exc, - Some("Exception ignored in thread started by".to_owned()), - func.into(), - ); + report_unraisable_thread_exception(exc, &func, vm); } } })) diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 28eb111a949..3062ea07f98 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -1296,18 +1296,24 @@ impl VirtualMachine { } }; - let msg_str = if let Some(msg) = msg { - format!("{msg}: ") + if self.is_none(object) { + if let Some(msg) = msg { + write_to_stderr(&format!("{msg}:\n"), &stderr, self); + } } else { - "Exception ignored in: ".to_owned() - }; - write_to_stderr(&msg_str, &stderr, self); + let msg_str = if let Some(msg) = msg { + format!("{msg}: ") + } else { + "Exception ignored in: ".to_owned() + }; + write_to_stderr(&msg_str, &stderr, self); - let repr_result = object.repr(self); - let repr_wtf8 = repr_result - .as_ref() - .map_or_else(|_| "".as_ref(), |s| s.as_wtf8()); - write_to_stderr(&format!("{repr_wtf8}\n"), &stderr, self); + let repr_result = object.repr(self); + let repr_wtf8 = repr_result + .as_ref() + .map_or_else(|_| "".as_ref(), |s| s.as_wtf8()); + write_to_stderr(&format!("{repr_wtf8}\n"), &stderr, self); + } // Write exception type and message let exc_type_name = e.class().name(); From 8d7d9d931ce39bfd89d64c1df9237dfcd7558593 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:18:09 +0900 Subject: [PATCH 169/351] Use dict keys-version stamps and entry-index hints in LOAD_ATTR/STORE_ATTR specializations (#8350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use dict keys-version stamps and entry-index hints in attr specializations Add a keys-version stamp to Dict: a globally unique u32 assigned lazily and reset on any key-set change (new key, deletion, clear). Value-only updates keep the stamp. - LoadAttrMethodWithValues / LoadAttrNondescriptorWithValues: cache the instance dict's stamp in the pointer cache and skip the shadow probe while the stamp matches. - LoadAttrWithHint: cache the entry index at specialization time; a hit is an identity check on the entry key instead of a hash probe, with self-refresh on miss. - StoreAttrWithHint / StoreAttrInstanceValue: replace values through the cached entry index via set_item_with_hint. Fixes youknowone#42 Assisted-by: Claude * Share keys-version stamps between dicts with identical layouts Derive the keys-version stamp from a shape — the ordered interned-key sequence of a hole-free dict — instead of always allocating a unique stamp. Dicts with identical layouts (instances of the same class built by the same __init__) now carry equal stamps, so a LOAD_ATTR cache entry populated by one instance skips the shadow probe for every instance sharing the layout. Shapes are held in a fixed-size lock-free table keyed by interned key addresses; dicts with holes, non-interned keys, or more than 32 keys fall back to dict-unique stamps. Assisted-by: Claude * Address clippy and review feedback for keys-version stamps - Use BuildHasher::hash_one for shape hashing (clippy manual_hash_one) - Fix set_item_with_hint doc: a refreshed hint is returned on any hint miss, not only when the hinted slot was vacant - Route both holey-dict instances through a single LOAD_ATTR cache site in the snippet test Assisted-by: Claude * Restore try_read_cached_descriptor doc comment to its function The doc block and #[inline] were left attached to store_attr_dict_hinted when it was inserted above try_read_cached_descriptor. Assisted-by: Claude * Specialize LoadAttrWithHint even when the entry index exceeds u16 hint_for_key returns None both for an absent key and for a present key whose entry index does not fit in u16, so very large instance dicts stopped specializing entirely. Use get_item_opt_refresh_hint to decide presence, degrading an unrepresentable hint to 0: the handler then keeps taking its full-probe fallback path. Assisted-by: Claude --- crates/compiler-core/src/bytecode.rs | 9 +- crates/vm/src/builtins/dict.rs | 57 +++++ crates/vm/src/dict_inner.rs | 245 +++++++++++++++++++++- crates/vm/src/frame.rs | 137 ++++++++---- extra_tests/snippets/vm_specialization.py | 142 +++++++++++++ 5 files changed, 550 insertions(+), 40 deletions(-) diff --git a/crates/compiler-core/src/bytecode.rs b/crates/compiler-core/src/bytecode.rs index c01f2f05739..3a7eec439e3 100644 --- a/crates/compiler-core/src/bytecode.rs +++ b/crates/compiler-core/src/bytecode.rs @@ -776,12 +776,15 @@ impl CodeUnits { /// Store a pointer-sized value atomically in the pointer cache at `index`. /// /// Uses a single `AtomicUsize` store to prevent torn writes when - /// multiple threads specialize the same instruction concurrently. + /// multiple threads specialize the same instruction concurrently. The + /// tear-free width also makes this the right slot for non-pointer guard + /// values (e.g. dict keys-version stamps) that must never be observed + /// half-written. /// /// # Safety /// - `index` must be in bounds. - /// - `value` must be `0` or a valid `*const PyObject` encoded as `usize`. - /// - Callers must follow the cache invalidation/upgrade protocol: + /// - When the slot holds a `*const PyObject` encoded as `usize` (or `0`), + /// callers must follow the cache invalidation/upgrade protocol: /// invalidate the version guard before writing and publish the new /// version after writing. pub unsafe fn write_cache_ptr(&self, index: usize, value: usize) { diff --git a/crates/vm/src/builtins/dict.rs b/crates/vm/src/builtins/dict.rs index 06791410edf..d14536eef53 100644 --- a/crates/vm/src/builtins/dict.rs +++ b/crates/vm/src/builtins/dict.rs @@ -753,6 +753,63 @@ impl Py { } } + /// Lookup trying a cached entry index hint first. + /// + /// When the hint misses but the key is present, also returns a refreshed + /// hint (`None` when the hint hit or no hint is representable). + pub(crate) fn get_item_opt_refresh_hint( + &self, + key: &K, + hint: u16, + vm: &VirtualMachine, + ) -> PyResult)>> { + if self.exact_dict(vm) { + if let Some(value) = self.entries.get_hint(vm, key, usize::from(hint))? { + return Ok(Some((value, None))); + } + self.entries.get_with_hint(vm, key) + } else { + Ok(self.get_item_opt(key, vm)?.map(|value| (value, None))) + } + } + + /// Store using a cached entry index hint for the value-replace fast path. + /// + /// On a hint miss, returns a refreshed hint for the key (`None` when the + /// hint hit or no hint is representable). + pub(crate) fn set_item_with_hint( + &self, + key: &K, + hint: u16, + value: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult> { + if self.exact_dict(vm) { + self.entries + .insert_with_hint(vm, key, usize::from(hint), value) + } else { + self.as_object().set_item(key, value, vm)?; + Ok(None) + } + } + + /// Current keys-version stamp of the underlying storage (0 if unset). + pub(crate) fn keys_version(&self) -> u32 { + self.entries.keys_version() + } + + /// Current keys-version stamp, assigning one if none is set. + /// + /// Returns 0 for dict subclasses: their lookup can be overridden, so a + /// key-set attestation on the raw storage must never be cached for them. + pub(crate) fn assign_keys_version(&self, vm: &VirtualMachine) -> u32 { + if self.exact_dict(vm) { + self.entries.assign_keys_version() + } else { + 0 + } + } + pub fn get_item(&self, key: &K, vm: &VirtualMachine) -> PyResult { if self.exact_dict(vm) { self.inner_getitem(key, vm) diff --git a/crates/vm/src/dict_inner.rs b/crates/vm/src/dict_inner.rs index 8112bbe252b..3b5f5617f02 100644 --- a/crates/vm/src/dict_inner.rs +++ b/crates/vm/src/dict_inner.rs @@ -20,8 +20,8 @@ use alloc::fmt; use core::mem::size_of; use core::ops::ControlFlow; use core::sync::atomic::{ - AtomicU64, - Ordering::{Acquire, Release}, + AtomicU32, AtomicU64, + Ordering::{AcqRel, Acquire, Relaxed, Release}, }; use num_traits::ToPrimitive; @@ -40,6 +40,104 @@ type EntryIndex = usize; pub(crate) struct Dict { inner: PyRwLock>, version: AtomicU64, + /// Keys-version stamp, assigned lazily by `assign_keys_version` and + /// reset to 0 whenever the key set changes. Value-only updates keep it. + /// + /// A nonzero stamp identifies either a *shape* — an exact hole-free + /// entry sequence of interned string keys, shared by every dict with + /// that layout — or, when no shape is derivable, this dict's key set + /// frozen at assignment time. Either way a stamp match guarantees the + /// entry layout is exactly the one the stamp was issued for, so entry + /// indexes cached against a stamp stay valid wherever the stamp matches. + keys_version: AtomicU32, +} + +/// Source of keys-version stamps. Allocated globally so a shape stamp and a +/// dict-unique stamp can never collide. +static KEYS_VERSION: AtomicU32 = AtomicU32::new(0); + +/// Allocate a new keys-version stamp. Returns 0 once the stamp space is +/// exhausted; stamps are only allocated on specialization, so exhaustion is +/// unrealistic in practice. +fn next_keys_version() -> u32 { + KEYS_VERSION + .fetch_update(Relaxed, Relaxed, |v| v.checked_add(1)) + .map_or(0, |v| v + 1) +} + +/// Largest key count eligible for a shared shape stamp. +const SHAPE_MAX_KEYS: usize = 32; +/// Shape registry slot count (power of two). +const SHAPE_TABLE_SIZE: usize = 1 << 12; +/// Linear-probe limit before giving up on registering a shape. +const SHAPE_MAX_PROBE: usize = 8; + +/// A registered shape: the ordered interned-key-pointer sequence of a +/// hole-free dict, plus the stamp shared by every dict with that layout. +/// Interned strings are never freed, so the addresses are stable identities. +struct ShapeData { + keys: Box<[usize]>, + stamp: u32, +} + +/// Lock-free registry mapping shapes to shared stamps. Fixed-size open +/// addressing; slots are installed with CAS and never removed, so a stamp +/// permanently means "exactly this entry sequence". Registered `ShapeData` +/// is intentionally leaked (bounded by the table size). Lock-free makes the +/// registry safe across fork() without reinitialization. +static SHAPE_TABLE: std::sync::LazyLock]>> = + std::sync::LazyLock::new(|| { + (0..SHAPE_TABLE_SIZE) + .map(|_| core::sync::atomic::AtomicPtr::new(core::ptr::null_mut())) + .collect() + }); + +fn shape_stamp(shape: &[usize]) -> Option { + use core::hash::BuildHasher; + use core::sync::atomic::AtomicPtr; + // The hasher seed must be process-stable so equal shapes always probe + // the same slots. + static SHAPE_HASHER: std::sync::LazyLock = + std::sync::LazyLock::new(Default::default); + let hash = SHAPE_HASHER.hash_one(shape) as usize; + let mut candidate: *mut ShapeData = core::ptr::null_mut(); + let mut result = None; + for probe in 0..SHAPE_MAX_PROBE { + let slot: &AtomicPtr = &SHAPE_TABLE[(hash + probe) & (SHAPE_TABLE_SIZE - 1)]; + let mut installed = slot.load(Acquire); + if installed.is_null() { + if candidate.is_null() { + let stamp = next_keys_version(); + if stamp == 0 { + break; + } + candidate = Box::into_raw(Box::new(ShapeData { + keys: shape.into(), + stamp, + })); + } + match slot.compare_exchange(core::ptr::null_mut(), candidate, AcqRel, Acquire) { + Ok(_) => { + // SAFETY: candidate was just leaked into the table. + result = Some(unsafe { (*candidate).stamp }); + candidate = core::ptr::null_mut(); + break; + } + Err(current) => installed = current, + } + } + // SAFETY: non-null slots reference leaked ShapeData, never freed. + let data = unsafe { &*installed }; + if *data.keys == *shape { + result = Some(data.stamp); + break; + } + } + if !candidate.is_null() { + // SAFETY: the candidate lost the race and was never shared. + drop(unsafe { Box::from_raw(candidate) }); + } + result } unsafe impl Traverse for Dict { @@ -105,6 +203,7 @@ impl Clone for Dict { Self { inner: PyRwLock::new(self.inner.read().clone()), version: AtomicU64::new(0), + keys_version: AtomicU32::new(0), } } } @@ -119,6 +218,7 @@ impl Default for Dict { entries: Vec::new(), }), version: AtomicU64::new(0), + keys_version: AtomicU32::new(0), } } } @@ -272,6 +372,83 @@ impl Dict { self.version.fetch_add(1, Release); } + /// Current keys-version stamp, or 0 if none has been assigned since the + /// last key-set change. Equal nonzero stamps guarantee an unchanged key + /// set (values may differ). + pub(crate) fn keys_version(&self) -> u32 { + self.keys_version.load(Acquire) + } + + /// Return the current keys-version stamp, assigning one if none is set. + /// Returns 0 only if no stamp could be allocated. + /// + /// When the dict is hole-free and all keys are interned strings, the + /// stamp is the *shared shape stamp* for that exact key sequence, so + /// dicts with identical layouts (e.g. instances of the same class built + /// by the same `__init__`) carry equal stamps and one cached stamp or + /// entry index serves them all. Otherwise a dict-unique stamp is used. + /// + /// The shape inspection and the stamp install happen under the inner + /// read lock. Key-set changes reset the stamp under the write lock, so + /// an installed stamp always attests the layout it was derived from. + pub(crate) fn assign_keys_version(&self) -> u32 { + let version = self.keys_version.load(Acquire); + if version != 0 { + return version; + } + let inner = self.read(); + // Re-check under the lock: a concurrent assign may have won. + let version = self.keys_version.load(Acquire); + if version != 0 { + return version; + } + let new_version = Self::derive_shape_stamp(&inner).unwrap_or_else(next_keys_version); + if new_version == 0 { + return 0; + } + // Only install over 0 so an already-valid stamp is never replaced. + match self + .keys_version + .compare_exchange(0, new_version, AcqRel, Acquire) + { + Ok(_) => new_version, + Err(current) => current, + } + } + + /// Compute the shared shape stamp for the current layout, if it + /// qualifies: hole-free entries, bounded size, all keys interned strings. + fn derive_shape_stamp(inner: &DictInner) -> Option { + if inner.entries.len() != inner.used || inner.used > SHAPE_MAX_KEYS { + return None; + } + let shape = inner + .entries + .iter() + .map(|entry| { + let key = &entry.as_ref()?.key; + key.is_interned() + .then(|| key.as_ref() as *const PyObject as usize) + }) + .collect::>>()?; + shape_stamp(&shape) + } + + /// Reset the keys-version stamp on a key-set change (insert of a new + /// key, deletion, or clear). Value-only updates keep the stamp. + /// + /// Must be called while holding the write lock, *before* the key set is + /// modified: a lock-free stamp reader that still observes the old stamp + /// then provably ran before the change became visible, so acting on the + /// old key set is linearizable. A stamp assigned concurrently (between + /// this reset and the mutation) can only be trusted by a caller whose + /// subsequent probe serializes after the mutation through the inner + /// lock, which then reflects the new key set. Since stamps are never + /// reused, a cached stamp can never spuriously match again. + fn invalidate_keys_version(&self) { + self.keys_version.store(0, Release); + } + fn read(&self) -> PyRwLockReadGuard<'_, DictInner> { self.inner.read() } @@ -320,6 +497,7 @@ impl Dict { // Dict was resized since lookup, retry continue; } + self.invalidate_keys_version(); inner.unchecked_push(index_index, hash, key.to_pyobject(vm), value, entry_index); self.bump_version(); break None; @@ -366,6 +544,62 @@ impl Dict { Ok(u16::try_from(index).ok()) } + /// Retrieve a key along with its entry index, for hint caching. + /// + /// Same as [`Self::get`], but on a hit also returns the entry index + /// usable as a `hint` for [`Self::get_hint`] (`None` if it doesn't fit). + pub(crate) fn get_with_hint( + &self, + vm: &VirtualMachine, + key: &K, + ) -> PyResult)>> { + let hash = key.key_hash(vm)?; + let ret = loop { + let (entry, index_index) = self.lookup(vm, key, hash, None)?; + if let Some(index) = entry.index() { + let inner = self.read(); + if let Some(entry) = inner.get_entry_checked(index, index_index) { + // The dict was not changed since we did lookup + break Some((entry.value.clone(), u16::try_from(index).ok())); + } + // The dict was changed since we did lookup. Let's try again. + } else { + break None; + } + }; + Ok(ret) + } + + /// Replace the value at entry index `hint` if that entry's key is + /// identical to `key`, otherwise fall back to a full probing store. + /// + /// On a hint miss, returns a refreshed hint for the key (`None` when the + /// hint hit or no hint is representable). + pub(crate) fn insert_with_hint( + &self, + vm: &VirtualMachine, + key: &K, + hint: usize, + value: T, + ) -> PyResult> { + let value = { + let mut inner = self.write(); + match inner.entries.get_mut(hint) { + Some(Some(entry)) if key.key_is(&entry.key) => { + let removed = core::mem::replace(&mut entry.value, value); + self.bump_version(); + drop(inner); + // defer dec RC until after the lock is released + drop(removed); + return Ok(None); + } + _ => value, + } + }; + self.insert(vm, key, value)?; + self.hint_for_key(vm, key) + } + /// Fast path lookup using a cached entry index (`hint`). /// /// Returns `None` if the hint is stale or the key no longer matches. @@ -433,6 +667,7 @@ impl Dict { pub(crate) fn clear(&self) { let _removed = { let mut inner = self.write(); + self.invalidate_keys_version(); inner.indices.clear(); inner.indices.resize(8, IndexEntry::FREE); inner.used = 0; @@ -521,6 +756,7 @@ impl Dict { if inner.indices.get(index_index) != Some(&entry) { continue; } + self.invalidate_keys_version(); inner.unchecked_push(index_index, hash, key.to_owned(), value, entry); self.bump_version(); break None; @@ -551,6 +787,7 @@ impl Dict { let value = default .take() .expect("default must only be computed on insertion")(); + self.invalidate_keys_version(); inner.unchecked_push( index_index, hash, @@ -594,6 +831,7 @@ impl Dict { .expect("default must only be computed on insertion")(); let key_obj = key.to_pyobject(vm); let ret = (key_obj.clone(), value.clone()); + self.invalidate_keys_version(); inner.unchecked_push(index_index, hash, key_obj, value, index_entry); self.bump_version(); return Ok(ret); @@ -787,6 +1025,7 @@ impl Dict { // The dict was changed since we did lookup. Let's try again. _ => return Ok(ControlFlow::Continue(())), } + self.invalidate_keys_version(); *unsafe { // index_index is result of lookup inner.indices.get_unchecked_mut(index_index) @@ -822,6 +1061,7 @@ impl Dict { break entry; } }; + self.invalidate_keys_version(); inner.used -= 1; *unsafe { // entry.index always refers valid index @@ -843,6 +1083,7 @@ impl Dict { /// This is used for circular reference resolution in GC. /// Requires &mut self to avoid lock contention. pub(crate) fn drain_entries(&mut self) -> impl Iterator + '_ { + self.keys_version.store(0, Release); let inner = self.inner.get_mut(); inner.used = 0; inner.filled = 0; diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index ae7f2c06164..2743bf3d542 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -4430,18 +4430,11 @@ impl ExecutingFrame<'_> { let type_version = self.code.instructions.read_cache_u32(cache_base + 1); if type_version != 0 && owner.class().tp_version_tag.load(Acquire) == type_version { - // Check instance dict doesn't shadow the method - let shadowed = if let Some(dict) = owner.dict() { - match dict.get_item_opt(attr_name, vm) { - Ok(Some(_)) => true, - Ok(None) => false, - Err(_) => { - // Dict lookup error -> use safe path. - return self.load_attr_slow(vm, oparg); - } - } - } else { - false + // Check instance dict doesn't shadow the method. + let shadowed = match self.shadowing_instance_attr(cache_base, attr_name, vm) { + Ok(shadowed) => shadowed.is_some(), + // Dict lookup error -> use safe path. + Err(_) => return self.load_attr_slow(vm, oparg), }; if !shadowed @@ -4489,16 +4482,29 @@ impl ExecutingFrame<'_> { if type_version != 0 && owner.class().tp_version_tag.load(Acquire) == type_version && let Some(dict) = owner.dict() - && let Some(value) = dict.get_item_opt(attr_name, vm)? { - self.pop_value(); - if oparg.is_method() { - self.push_value(value); - self.push_value_opt(None); - } else { - self.push_value(value); + // Try the cached entry index first; a hit is an identity + // check on the entry key instead of a hash probe. + let hint = self.code.instructions.read_cache_u16(cache_base + 3); + if let Some((value, refreshed)) = + dict.get_item_opt_refresh_hint(attr_name, hint, vm)? + { + if let Some(new_hint) = refreshed { + unsafe { + self.code + .instructions + .write_cache_u16(cache_base + 3, new_hint); + } + } + self.pop_value(); + if oparg.is_method() { + self.push_value(value); + self.push_value_opt(None); + } else { + self.push_value(value); + } + return Ok(None); } - return Ok(None); } self.load_attr_slow(vm, oparg) @@ -4559,9 +4565,7 @@ impl ExecutingFrame<'_> { if type_version != 0 && owner.class().tp_version_tag.load(Acquire) == type_version { // Instance dict has priority — check if attr is shadowed - if let Some(dict) = owner.dict() - && let Some(value) = dict.get_item_opt(attr_name, vm)? - { + if let Some(value) = self.shadowing_instance_attr(cache_base, attr_name, vm)? { self.pop_value(); if oparg.is_method() { self.push_value(value); @@ -4725,7 +4729,10 @@ impl ExecutingFrame<'_> { { self.pop_value(); // owner let value = self.pop_value(); - dict.set_item(attr_name, value, vm)?; + // The key was absent at specialization time, but this + // very store inserts it; hint learning makes later + // executions replace by entry index. + self.store_attr_dict_hinted(&dict, attr_name, value, cache_base, vm)?; return Ok(None); } self.store_attr(vm, attr_idx) @@ -4744,7 +4751,7 @@ impl ExecutingFrame<'_> { { self.pop_value(); // owner let value = self.pop_value(); - dict.set_item(attr_name, value, vm)?; + self.store_attr_dict_hinted(&dict, attr_name, value, cache_base, vm)?; return Ok(None); } self.store_attr(vm, attr_idx) @@ -8055,6 +8062,61 @@ impl ExecutingFrame<'_> { Ok(None) } + /// Store an instance attribute through the cached entry index at + /// `cache_base + 3`, refreshing the cache when the hint missed. + fn store_attr_dict_hinted( + &mut self, + dict: &Py, + attr_name: &'static PyStrInterned, + value: PyObjectRef, + cache_base: usize, + vm: &VirtualMachine, + ) -> PyResult<()> { + let hint = self.code.instructions.read_cache_u16(cache_base + 3); + if let Some(new_hint) = dict.set_item_with_hint(attr_name, hint, value, vm)? { + unsafe { + self.code + .instructions + .write_cache_u16(cache_base + 3, new_hint); + } + } + Ok(()) + } + + /// Shadow check for method/nondescriptor loads: return the instance + /// attribute shadowing the cached class attr, or `None` if not shadowed. + /// + /// A keys-version stamp of the instance dict is kept in the pointer cache + /// at `cache_base + 3`. While the dict reports the same stamp, its key + /// set is unchanged since the name was last verified absent, so the probe + /// is skipped. On a verified-absent probe the current stamp is recorded + /// for the next execution. + fn shadowing_instance_attr( + &self, + cache_base: usize, + attr_name: &'static PyStrInterned, + vm: &VirtualMachine, + ) -> PyResult> { + let Some(dict) = self.top_value().dict() else { + return Ok(None); + }; + let stamp = self.code.instructions.read_cache_ptr(cache_base + 3); + if stamp != 0 && stamp == dict.keys_version() as usize { + return Ok(None); + } + // Take the stamp before probing so it attests the probed key set. + let stamp = dict.assign_keys_version(vm); + if let Some(value) = dict.get_item_opt(attr_name, vm)? { + return Ok(Some(value)); + } + unsafe { + self.code + .instructions + .write_cache_ptr(cache_base + 3, stamp as usize); + } + Ok(None) + } + /// Read a cached descriptor pointer and validate it against the expected /// type version, using a lock-free double-check pattern: /// 1. read pointer → incref (try_to_owned) @@ -8408,10 +8470,12 @@ impl ExecutingFrame<'_> { // attribute is missing on both the class and the current // instance, keep the generic opcode and just enter // cooldown instead of specializing a repeated miss path. - let has_instance_attr = if let Some(dict) = obj.dict() { - match dict.get_item_opt(attr_name, _vm) { - Ok(Some(_)) => true, - Ok(None) => false, + // A present attribute always specializes; when no entry + // index is representable the hint degrades to 0 and the + // handler simply keeps taking its full-probe fallback. + let instance_attr_hint = if let Some(dict) = obj.dict() { + match dict.get_item_opt_refresh_hint(attr_name, 0, _vm) { + Ok(present) => present.map(|(_, refreshed)| refreshed.unwrap_or(0)), Err(_) => { unsafe { self.code.instructions.write_adaptive_counter( @@ -8427,13 +8491,14 @@ impl ExecutingFrame<'_> { } } } else { - false + None }; - if has_instance_attr { + if let Some(hint) = instance_attr_hint { unsafe { self.code .instructions .write_cache_u32(cache_base + 1, type_version); + self.code.instructions.write_cache_u16(cache_base + 3, hint); } self.specialize_at(instr_idx, cache_base, Instruction::LoadAttrWithHint); } else { @@ -9951,9 +10016,8 @@ impl ExecutingFrame<'_> { } } } else if let Some(dict) = owner.dict() { - let use_hint = match dict.get_item_opt(attr_name, vm) { - Ok(Some(_)) => true, - Ok(None) => false, + let hint = match dict.hint_for_key(attr_name, vm) { + Ok(hint) => hint, Err(_) => { unsafe { self.code.instructions.write_adaptive_counter( @@ -9970,11 +10034,14 @@ impl ExecutingFrame<'_> { self.code .instructions .write_cache_u32(cache_base + 1, type_version); + self.code + .instructions + .write_cache_u16(cache_base + 3, hint.unwrap_or(0)); } self.specialize_at( instr_idx, cache_base, - if use_hint { + if hint.is_some() { Instruction::StoreAttrWithHint } else { Instruction::StoreAttrInstanceValue diff --git a/extra_tests/snippets/vm_specialization.py b/extra_tests/snippets/vm_specialization.py index 2c884cc2f6d..f2415b4b2e8 100644 --- a/extra_tests/snippets/vm_specialization.py +++ b/extra_tests/snippets/vm_specialization.py @@ -69,3 +69,145 @@ def check_latin1_subscr_singleton_after_warmup(): check_latin1_subscr_singleton_after_warmup() + + +## LOAD_ATTR_METHOD_WITH_VALUES: keys-version shadow check + + +class MethodHolder: + def m(self): + return "method" + + +def method_shadowed_after_specialization(): + obj = MethodHolder() + obj.pad = 1 + for _ in range(300): + assert obj.m() == "method" + # Shadowing after warmup must deopt the stamp-based shadow skip. + obj.m = lambda: "instance" + assert obj.m() == "instance" + del obj.m + assert obj.m() == "method" + obj.__dict__["m"] = lambda: "dict" + assert obj.m() == "dict" + del obj.__dict__["m"] + assert obj.m() == "method" + + +method_shadowed_after_specialization() + + +def method_with_value_only_updates(): + obj = MethodHolder() + obj.pad = 0 + for i in range(500): + obj.pad = i # value-only update keeps the keys-version stamp + assert obj.m() == "method" + + +method_with_value_only_updates() + + +## LOAD_ATTR_WITH_HINT / STORE_ATTR: entry-index hint invalidation + + +class Plain: + pass + + +def load_hint_survives_key_churn(): + obj = Plain() + obj.a = 1 + obj.b = 2 + obj.x = "first" + for _ in range(300): + assert obj.x == "first" + del obj.a + del obj.b + assert obj.x == "first" + del obj.x + try: + obj.x + except AttributeError: + pass + else: + raise AssertionError("expected AttributeError") + obj.x = "second" + assert obj.x == "second" + + +load_hint_survives_key_churn() + + +def store_hint_survives_dict_replacement(): + obj = Plain() + obj.v = 0 + for i in range(500): + obj.v = i + assert obj.v == i + obj.__dict__ = {"v": "fresh"} + for i in range(300): + obj.v = i + assert obj.v == i + obj.__dict__.clear() + obj.v = "back" + assert obj.v == "back" + + +store_hint_survives_dict_replacement() + + +## Shared shape stamps: same-layout instances share the shadow-check stamp + + +class ShapedCounter: + def __init__(self): + self.a = 1 + self.b = 2 + + def m(self): + return "method" + + +def shape_sharing_shadow_one_instance(): + objs = [ShapedCounter() for _ in range(30)] + for _ in range(300): + for o in objs: + assert o.m() == "method" + objs[13].m = lambda: "thirteen" + for i, o in enumerate(objs): + expected = "thirteen" if i == 13 else "method" + assert o.m() == expected + del objs[13].m + for o in objs: + assert o.m() == "method" + + +shape_sharing_shadow_one_instance() + + +def holey_dict_falls_back(): + class Holey: + def m(self): + return "g" + + def call_m(obj): + # single LOAD_ATTR cache site shared by both instances + return obj.m() + + g1, g2 = Holey(), Holey() + for o in (g1, g2): + o.x = 1 + o.y = 2 + o.z = 3 + del o.y # leaves a hole in the entries + for _ in range(300): + assert call_m(g1) == "g" + assert call_m(g2) == "g" + g2.m = lambda: "g2" + assert call_m(g1) == "g" + assert call_m(g2) == "g2" + + +holey_dict_falls_back() From c6b80e8f685740aac61ca622a1e7e11dfc24d798 Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:25:27 -0400 Subject: [PATCH 170/351] Unify nul errors (#8339) Part of #8245 to reduce the amount of work needed to review. I simplified the embedded nul errors by forwarding to the implementations in `vm::exceptions`. --- crates/stdlib/src/grp.rs | 2 +- crates/stdlib/src/multiprocessing.rs | 5 +-- crates/stdlib/src/openssl.rs | 8 ++--- crates/stdlib/src/ssl.rs | 5 +-- crates/vm/src/buffer.rs | 3 +- crates/vm/src/exceptions.rs | 49 ++++++++++++++++++++++++++-- crates/vm/src/function/fspath.rs | 2 +- crates/vm/src/stdlib/_codecs.rs | 8 ++--- crates/vm/src/stdlib/_io.rs | 8 ++--- crates/vm/src/stdlib/_winapi.rs | 4 +-- crates/vm/src/stdlib/nt.rs | 4 +-- crates/vm/src/stdlib/os.rs | 16 +++++---- crates/vm/src/stdlib/pwd.rs | 2 +- crates/vm/src/stdlib/time.rs | 11 +++---- crates/vm/src/stdlib/winsound.rs | 9 ++--- crates/vm/src/utils.rs | 4 +-- 16 files changed, 95 insertions(+), 45 deletions(-) diff --git a/crates/stdlib/src/grp.rs b/crates/stdlib/src/grp.rs index c2a231bef27..7e3dd8ef378 100644 --- a/crates/stdlib/src/grp.rs +++ b/crates/stdlib/src/grp.rs @@ -63,7 +63,7 @@ mod grp { fn getgrnam(name: PyUtf8StrRef, vm: &VirtualMachine) -> PyResult { let gr_name = name.as_str(); if gr_name.contains('\0') { - return Err(exceptions::cstring_error(vm)); + return Err(exceptions::nul_char_error(vm)); } let group = host_grp::getgrnam(gr_name).map_err(|err| err.into_pyexception(vm))?; let group = group.ok_or_else(|| { diff --git a/crates/stdlib/src/multiprocessing.rs b/crates/stdlib/src/multiprocessing.rs index 1eee2ec04e4..9883219dfb1 100644 --- a/crates/stdlib/src/multiprocessing.rs +++ b/crates/stdlib/src/multiprocessing.rs @@ -359,6 +359,7 @@ mod _multiprocessing { use rustpython_host_env::multiprocessing::{ self as host_multiprocessing, SemError, TryAcquireStatus, WaitStatus, }; + use rustpython_vm::exceptions; /// Error type for sem_timedwait operations #[cfg(target_vendor = "apple")] @@ -811,7 +812,7 @@ mod _multiprocessing { let (handle, name) = SemHandle::create(&args.name, value, args.unlink).map_err(|err| { if err == SemError::InvalidInput && args.name.contains('\0') { - vm.new_value_error("embedded null character") + exceptions::nul_char_error(vm) } else { os_error(vm, err) } @@ -835,7 +836,7 @@ mod _multiprocessing { fn sem_unlink(name: String, vm: &VirtualMachine) -> PyResult<()> { host_multiprocessing::sem_unlink(&name).map_err(|err| { if err == SemError::InvalidInput && name.contains('\0') { - vm.new_value_error("embedded null character") + exceptions::nul_char_error(vm) } else { os_error(vm, err) } diff --git a/crates/stdlib/src/openssl.rs b/crates/stdlib/src/openssl.rs index 3397fff8ef0..8b7d1de0639 100644 --- a/crates/stdlib/src/openssl.rs +++ b/crates/stdlib/src/openssl.rs @@ -1041,7 +1041,7 @@ mod _ssl { fn set_ciphers(&self, cipherlist: PyStrRef, vm: &VirtualMachine) -> PyResult<()> { let ciphers: &str = cipherlist.as_ref(); if ciphers.contains('\0') { - return Err(exceptions::cstring_error(vm)); + return Err(exceptions::nul_char_error(vm)); } self.builder() .set_cipher_list(ciphers) @@ -1097,12 +1097,12 @@ mod _ssl { Either::A(s) => { let s: &str = s.as_ref(); if s.contains('\0') { - return Err(exceptions::cstring_error(vm)); + return Err(exceptions::nul_char_error(vm)); } s.to_cstring(vm)? } Either::B(b) => std::ffi::CString::new(b.borrow_buf().to_vec()) - .map_err(|_| exceptions::cstring_error(vm))?, + .map_err(|_| exceptions::nul_char_error(vm))?, }; // Find the NID for the curve name using OBJ_sn2nid @@ -2038,7 +2038,7 @@ mod _ssl { )); } if hostname_str.contains('\0') { - return Err(vm.new_type_error("embedded null character")); + return Err(exceptions::nul_char_type_error(vm)); } let ip = hostname_str.parse::(); if ip.is_err() { diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 7b7e2127f48..5e6f35943bd 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -70,6 +70,7 @@ mod _ssl { sync::atomic::{AtomicUsize, Ordering}, time::Duration, }; + use rustpython_vm::exceptions; use std::{ collections::{HashMap, hash_map::DefaultHasher}, io::BufRead, @@ -392,7 +393,7 @@ mod _ssl { // SNI will not be sent for IP addresses if hostname.contains('\0') { - return Err(vm.new_type_error("embedded null character")); + return Err(exceptions::nul_char_type_error(vm)); } if hostname.len() > 253 { @@ -1869,7 +1870,7 @@ mod _ssl { // Check for NULL bytes if hostname.contains('\0') { - return Err(vm.new_type_error("embedded null character")); + return Err(exceptions::nul_char_error(vm)); } Some(hostname.to_string()) diff --git a/crates/vm/src/buffer.rs b/crates/vm/src/buffer.rs index 84c0ac17f3b..ae5ce6b0065 100644 --- a/crates/vm/src/buffer.rs +++ b/crates/vm/src/buffer.rs @@ -3,6 +3,7 @@ use crate::{ builtins::{PyBaseExceptionRef, PyBytesRef, PyTuple, PyTupleRef, PyTypeRef}, common::{static_cell, str::wchar_t}, convert::ToPyObject, + exceptions, function::{ArgBytesLike, ArgIntoBool, ArgIntoFloat}, }; @@ -282,7 +283,7 @@ impl FormatCode { // Check for embedded null character if c == 0 { - return Err("embedded null character".to_owned()); + return Err(exceptions::NulError.to_string()); } // PEP3118: Handle extended format specifiers diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index cd1eb1adbcb..fe07a7e3c9e 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -15,6 +15,7 @@ use crate::{ suggestion::offer_suggestions, types::{Callable, Constructor, Initializer, Representable}, }; +use core::fmt::{self, Display, Formatter}; use crossbeam_utils::atomic::AtomicCell; use itertools::Itertools; #[cfg(feature = "host_env")] @@ -1188,20 +1189,62 @@ impl serde::Serialize for SerializeException<'_, '_> { } } -pub fn cstring_error(vm: &VirtualMachine) -> PyBaseExceptionRef { +#[derive(Debug)] +pub struct NulError; + +impl Display for NulError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "embedded null character") + } +} + +pub fn nul_char_error(vm: &VirtualMachine) -> PyBaseExceptionRef { vm.new_value_error("embedded null character") } +pub fn nul_char_type_error(vm: &VirtualMachine) -> PyBaseExceptionRef { + vm.new_type_error("embedded null character") +} + +pub fn nul_byte_error(vm: &VirtualMachine) -> PyBaseExceptionRef { + vm.new_value_error("embedded null byte") +} + impl ToPyException for alloc::ffi::NulError { fn to_pyexception(&self, vm: &VirtualMachine) -> PyBaseExceptionRef { - cstring_error(vm) + nul_char_error(vm) + } +} + +impl ToPyException for alloc::ffi::FromVecWithNulError { + fn to_pyexception(&self, vm: &VirtualMachine) -> PyBaseExceptionRef { + nul_char_error(vm) + } +} + +impl ToPyException for core::ffi::FromBytesWithNulError { + fn to_pyexception(&self, vm: &VirtualMachine) -> PyBaseExceptionRef { + nul_char_error(vm) + } +} + +impl ToPyException for NulError { + fn to_pyexception(&self, vm: &VirtualMachine) -> PyBaseExceptionRef { + nul_char_error(vm) } } #[cfg(windows)] impl ToPyException for widestring::error::ContainsNul { fn to_pyexception(&self, vm: &VirtualMachine) -> PyBaseExceptionRef { - cstring_error(vm) + nul_char_error(vm) + } +} + +#[cfg(windows)] +impl ToPyException for widestring::error::MissingNulTerminator { + fn to_pyexception(&self, vm: &VirtualMachine) -> PyBaseExceptionRef { + vm.new_value_error(self.to_string()) } } diff --git a/crates/vm/src/function/fspath.rs b/crates/vm/src/function/fspath.rs index 5ec81ba63a6..053802285cd 100644 --- a/crates/vm/src/function/fspath.rs +++ b/crates/vm/src/function/fspath.rs @@ -40,7 +40,7 @@ impl FsPath { if !check_for_nul || memchr::memchr(b'\0', b).is_none() { Ok(()) } else { - Err(crate::exceptions::cstring_error(vm)) + Err(crate::exceptions::nul_char_error(vm)) } }; let match1 = |obj: PyObjectRef| { diff --git a/crates/vm/src/stdlib/_codecs.rs b/crates/vm/src/stdlib/_codecs.rs index a9402edc3a2..6052350159d 100644 --- a/crates/vm/src/stdlib/_codecs.rs +++ b/crates/vm/src/stdlib/_codecs.rs @@ -13,7 +13,7 @@ mod _codecs { AsObject, PyObjectRef, PyResult, VirtualMachine, builtins::{PyStrRef, PyUtf8StrRef}, codecs, - exceptions::cstring_error, + exceptions::nul_char_error, function::{ArgBytesLike, FuncArgs}, }; @@ -30,7 +30,7 @@ mod _codecs { #[pyfunction] fn lookup(encoding: PyUtf8StrRef, vm: &VirtualMachine) -> PyResult { if encoding.as_str().contains('\0') { - return Err(cstring_error(vm)); + return Err(nul_char_error(vm)); } vm.state .codec_registry @@ -106,7 +106,7 @@ mod _codecs { #[pyfunction] fn lookup_error(name: PyUtf8StrRef, vm: &VirtualMachine) -> PyResult { if name.as_str().contains('\0') { - return Err(cstring_error(vm)); + return Err(nul_char_error(vm)); } vm.state.codec_registry.lookup_error(name.as_str(), vm) } @@ -114,7 +114,7 @@ mod _codecs { #[pyfunction] fn _unregister_error(errors: PyUtf8StrRef, vm: &VirtualMachine) -> PyResult { if errors.as_str().contains('\0') { - return Err(cstring_error(vm)); + return Err(nul_char_error(vm)); } vm.state .codec_registry diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index 4cffb909fa7..eb2fa6cfc4f 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -132,7 +132,7 @@ mod _io { }, common::wtf8::{Wtf8, Wtf8Buf}, convert::ToPyObject, - exceptions::cstring_error, + exceptions::nul_char_error, function::{ ArgBytesLike, ArgIterable, ArgMemoryBuffer, ArgSize, Either, FsPath, FuncArgs, IntoFuncArgs, OptionalArg, OptionalOption, PySetterValue, @@ -2855,7 +2855,7 @@ mod _io { fn validate_errors(errors: &PyRef, vm: &VirtualMachine) -> PyResult<()> { if errors.as_str().contains('\0') { - return Err(cstring_error(vm)); + return Err(nul_char_error(vm)); } vm.state .codec_registry @@ -2896,7 +2896,7 @@ mod _io { }, Some(enc) => { if enc.as_str().contains('\0') { - return Err(cstring_error(vm)); + return Err(nul_char_error(vm)); } enc } @@ -2915,7 +2915,7 @@ mod _io { }, }; if encoding.as_str().contains('\0') { - return Err(cstring_error(vm)); + return Err(nul_char_error(vm)); } Ok(encoding) } diff --git a/crates/vm/src/stdlib/_winapi.rs b/crates/vm/src/stdlib/_winapi.rs index e1b86f2ef54..0d54530d4b2 100644 --- a/crates/vm/src/stdlib/_winapi.rs +++ b/crates/vm/src/stdlib/_winapi.rs @@ -235,12 +235,12 @@ mod _winapi { if let Some(ref name) = args.name && name.as_bytes().contains(&0) { - return Err(crate::exceptions::cstring_error(vm)); + return Err(crate::exceptions::nul_char_error(vm)); } if let Some(ref cmd) = args.command_line && cmd.as_bytes().contains(&0) { - return Err(crate::exceptions::cstring_error(vm)); + return Err(crate::exceptions::nul_char_error(vm)); } let wcstring = |s: PyStrRef| s.as_wtf8().to_wide_cstring(); diff --git a/crates/vm/src/stdlib/nt.rs b/crates/vm/src/stdlib/nt.rs index 0d423541b87..acc9f4ee67e 100644 --- a/crates/vm/src/stdlib/nt.rs +++ b/crates/vm/src/stdlib/nt.rs @@ -9,7 +9,7 @@ pub(crate) mod module { Py, PyResult, TryFromObject, VirtualMachine, builtins::{PyBytes, PyDictRef, PyListRef, PyStr, PyStrRef, PyTupleRef}, convert::ToPyException, - exceptions::OSErrorBuilder, + exceptions::{self, OSErrorBuilder}, function::{ArgMapping, Either, OptionalArg}, host_env::{crt_fd, windows::ToWideString}, ospath::{OsPath, OsPathOrFd}, @@ -552,7 +552,7 @@ pub(crate) mod module { // Validate: no null characters in key or value if key_str.contains('\0') || value_str.contains('\0') { - return Err(vm.new_value_error("embedded null character")); + return Err(exceptions::nul_char_error(vm)); } // Validate: empty key or '=' in key after position 0 // (search from index 1 because on Windows starting '=' is allowed diff --git a/crates/vm/src/stdlib/os.rs b/crates/vm/src/stdlib/os.rs index 5772cc46f5d..f885fb1db05 100644 --- a/crates/vm/src/stdlib/os.rs +++ b/crates/vm/src/stdlib/os.rs @@ -181,6 +181,8 @@ impl ToPyObject for crt_fd::Borrowed<'_> { #[pymodule(sub)] pub(super) mod _os { use super::{DirFd, DstDirFd, FollowSymlinks, RawMode, SrcDirFd, SupportFunc}; + #[cfg(not(windows))] + use crate::exceptions; use crate::host_env::fileutils::StatStruct; #[cfg(any(unix, windows))] use crate::utils::ToCString; @@ -531,7 +533,7 @@ pub(super) mod _os { let key = env_bytes_as_bytes(&key); let value = env_bytes_as_bytes(&value); if key.contains(&b'\0') || value.contains(&b'\0') { - return Err(vm.new_value_error("embedded null byte")); + return Err(exceptions::nul_byte_error(vm)); } if key.is_empty() || key.contains(&b'=') { return Err(vm.new_value_error("illegal environment variable name")); @@ -574,7 +576,7 @@ pub(super) mod _os { ) -> PyResult<()> { let key = env_bytes_as_bytes(&key); if key.contains(&b'\0') { - return Err(vm.new_value_error("embedded null byte")); + return Err(exceptions::nul_byte_error(vm)); } if key.is_empty() || key.contains(&b'=') { let x = vm.new_errno_error( @@ -817,7 +819,7 @@ pub(super) mod _os { FollowSymlinks(false), ) .map_err(|e| e.into_pyexception(vm))? - .ok_or_else(|| crate::exceptions::cstring_error(vm))?; + .ok_or_else(|| crate::exceptions::nul_char_error(vm))?; // On Windows, combine st_ino and st_ino_high into 128-bit value let ino: u128 = cfg_select! { windows => stat.st_ino as u128 | ((stat.st_ino_high as u128) << 64), @@ -1360,7 +1362,7 @@ pub(super) mod _os { ) -> PyResult { let stat = stat_inner(file.clone(), dir_fd, follow_symlinks) .map_err(|err| OSErrorBuilder::with_filename(&err, file, vm))? - .ok_or_else(|| crate::exceptions::cstring_error(vm))?; + .ok_or_else(|| crate::exceptions::nul_char_error(vm))?; Ok(StatResultData::from_stat(&stat, vm).to_pyobject(vm)) } @@ -1543,10 +1545,12 @@ pub(super) mod _os { #[cfg(unix)] { use std::os::unix::ffi::OsStrExt; + + use crate::convert::ToPyException; let src_cstr = alloc::ffi::CString::new(src.path.as_os_str().as_bytes()) - .map_err(|_| vm.new_value_error("embedded null byte"))?; + .map_err(|e| e.to_pyexception(vm))?; let dst_cstr = alloc::ffi::CString::new(dst.path.as_os_str().as_bytes()) - .map_err(|_| vm.new_value_error("embedded null byte"))?; + .map_err(|e| e.to_pyexception(vm))?; let follow = follow_symlinks.into_option().unwrap_or(true); if let Err(err) = diff --git a/crates/vm/src/stdlib/pwd.rs b/crates/vm/src/stdlib/pwd.rs index e2f987ce019..e181de240f6 100644 --- a/crates/vm/src/stdlib/pwd.rs +++ b/crates/vm/src/stdlib/pwd.rs @@ -52,7 +52,7 @@ mod pwd { fn getpwnam(name: PyUtf8StrRef, vm: &VirtualMachine) -> PyResult { let pw_name = name.as_str(); if pw_name.contains('\0') { - return Err(exceptions::cstring_error(vm)); + return Err(exceptions::nul_char_error(vm)); } let user = host_pwd::getpwnam(name.as_str()); let user = user.ok_or_else(|| { diff --git a/crates/vm/src/stdlib/time.rs b/crates/vm/src/stdlib/time.rs index 5c77afb4f5c..05894cdc2a1 100644 --- a/crates/vm/src/stdlib/time.rs +++ b/crates/vm/src/stdlib/time.rs @@ -571,8 +571,8 @@ mod decl { for codepoint in format.as_wtf8().code_points() { if codepoint.to_u32() == 0 { if !ascii.is_empty() { - let part = host_time::strftime_ascii(&ascii, &tm) - .map_err(|_| vm.new_value_error("embedded null character"))?; + let part = + host_time::strftime_ascii(&ascii, &tm).map_err(|e| e.to_pyexception(vm))?; out.extend(part.chars()); ascii.clear(); } @@ -587,16 +587,15 @@ mod decl { } if !ascii.is_empty() { - let part = host_time::strftime_ascii(&ascii, &tm) - .map_err(|_| vm.new_value_error("embedded null character"))?; + let part = + host_time::strftime_ascii(&ascii, &tm).map_err(|e| e.to_pyexception(vm))?; out.extend(part.chars()); ascii.clear(); } out.push(codepoint); } if !ascii.is_empty() { - let part = host_time::strftime_ascii(&ascii, &tm) - .map_err(|_| vm.new_value_error("embedded null character"))?; + let part = host_time::strftime_ascii(&ascii, &tm).map_err(|e| e.to_pyexception(vm))?; out.extend(part.chars()); } Ok(out.to_pyobject(vm)) diff --git a/crates/vm/src/stdlib/winsound.rs b/crates/vm/src/stdlib/winsound.rs index 67d7c8a7ffe..95032ad8970 100644 --- a/crates/vm/src/stdlib/winsound.rs +++ b/crates/vm/src/stdlib/winsound.rs @@ -6,7 +6,8 @@ pub(crate) use winsound::module_def; #[pymodule] mod winsound { use crate::builtins::{PyBaseExceptionRef, PyBytes, PyStr}; - use crate::convert::{IntoPyException, TryFromBorrowedObject}; + use crate::convert::{IntoPyException, ToPyException, TryFromBorrowedObject}; + use crate::exceptions; use crate::host_env::windows::ToWideString; use crate::protocol::PyBuffer; use crate::{AsObject, PyObjectRef, PyResult, VirtualMachine}; @@ -142,12 +143,12 @@ mod winsound { // Check for embedded null characters if path.as_bytes().contains(&0) { - return Err(vm.new_value_error("embedded null character")); + return Err(exceptions::nul_char_error(vm)); } let wide = path.to_wide_with_nul(); - let wide_cstr = widestring::WideCStr::from_slice_truncate(&wide) - .map_err(|_| vm.new_value_error("embedded null character"))?; + let wide_cstr = + widestring::WideCStr::from_slice_truncate(&wide).map_err(|e| e.to_pyexception(vm))?; play_sound(PlaySoundSource::Name(wide_cstr), flags).map_err(map_play_err(vm)) } diff --git a/crates/vm/src/utils.rs b/crates/vm/src/utils.rs index b5117ddd8d1..d23cbb689a6 100644 --- a/crates/vm/src/utils.rs +++ b/crates/vm/src/utils.rs @@ -4,7 +4,7 @@ use crate::{ PyObjectRef, PyResult, VirtualMachine, builtins::{PyStr, PyUtf8Str}, convert::{ToPyException, ToPyObject}, - exceptions::cstring_error, + exceptions::nul_char_error, }; pub fn hash_iter<'a, I: IntoIterator>( @@ -26,7 +26,7 @@ pub trait ToCString: AsRef { } fn ensure_no_nul(&self, vm: &VirtualMachine) -> PyResult<()> { if self.as_ref().as_bytes().contains(&b'\0') { - Err(cstring_error(vm)) + Err(nul_char_error(vm)) } else { Ok(()) } From 9062f02e6ff91e95867eceb4850d5ea4413f9646 Mon Sep 17 00:00:00 2001 From: Kwak Byoung Min <67214970+doma17@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:00:17 +0900 Subject: [PATCH 171/351] Fix StringIO newline splitting and character positions (#8351) * Keep StringIO text operations CPython-compatible Constraint: email.feedparser depends on StringIO(newline='') recognizing CR, LF, and CRLF boundaries. Rejected: an email-specific parser workaround | StringIO is the shared root cause. Confidence: high Scope-risk: moderate Directive: Keep internal storage byte-based; convert only at StringIO API boundaries. Tested: prek run --all-files; test_memoryio; test_email; full workspace verification by contributor Assisted-by: Codex:gpt-5.6-sol * Run restored StringIO conformance tests Constraint: upstream StringIO tests already cover the repaired behavior. Rejected: new RustPython-only regression tests | existing CPython coverage is sufficient. Confidence: high Scope-risk: narrow Directive: Remove expected-failure markers only while the inherited tests pass. Tested: prek run --all-files; test_memoryio; test_email Assisted-by: Codex:gpt-5.6-sol * Keep StringIO newline handling consistent Constraint: StringIO must apply its newline mode consistently when constructing, writing, and reading text.\nRejected: a readline-only fix | it leaves CR and CRLF modes internally inconsistent.\nConfidence: high\nScope-risk: moderate\nDirective: Keep StringIO buffer contents valid WTF-8 before using unchecked views.\nTested: prek run --all-files; cargo clippy -p rustpython-vm -- -D warnings; test_memoryio; test_shlex; test_email; workspace excluding rustpython-capi; full workspace manually verified by contributor\nNot-tested: local rustpython-capi workspace test crashes with a pre-existing macOS SIGSEGV\nAssisted-by: Codex:gpt-5.6-sol --- Lib/test/test_email/test_email.py | 4 - Lib/test/test_memoryio.py | 52 ----------- Lib/test/test_shlex.py | 4 - crates/vm/src/stdlib/_io.rs | 149 ++++++++++++++++++++++++------ 4 files changed, 121 insertions(+), 88 deletions(-) diff --git a/Lib/test/test_email/test_email.py b/Lib/test/test_email/test_email.py index 49cdc95021a..671bc487bbf 100644 --- a/Lib/test/test_email/test_email.py +++ b/Lib/test/test_email/test_email.py @@ -3812,7 +3812,6 @@ def test_typed_subpart_iterator_default_type(self): -Me """) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_pushCR_LF(self): '''FeedParser BufferedSubFile.push() assumed it received complete line endings. A CR ending one push() followed by a LF starting @@ -3843,7 +3842,6 @@ def test_pushCR_LF(self): self.assertEqual(len(om), nt) self.assertEqual(''.join([il for il, n in imt]), ''.join(om)) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_push_random(self): from email.feedparser import BufferedSubFile, NeedMoreData @@ -3877,7 +3875,6 @@ def test_empty_header_name_handled(self): self.assertEqual(msg['First'], 'val') self.assertEqual(msg['Second'], 'val') - @unittest.expectedFailure # TODO: RUSTPYTHON; Feedparser.feed -> Feedparser._input.push, Feedparser._call_parse -> Feedparser._parse does not keep _input state between calls def test_newlines(self): m = self.parse(['a:\nb:\rc:\r\nd:\n']) self.assertEqual(m.keys(), ['a', 'b', 'c', 'd']) @@ -3896,7 +3893,6 @@ def test_newlines(self): m = self.parse(['a:\r', 'b:\x85', 'c:\n']) self.assertEqual(m.items(), [('a', ''), ('b', '\x85c:')]) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_long_lines(self): # Expected peak memory use on 32-bit platform: 6*N*M bytes. M, N = 1000, 20000 diff --git a/Lib/test/test_memoryio.py b/Lib/test/test_memoryio.py index f0c21e4ae11..1683a71fc88 100644 --- a/Lib/test/test_memoryio.py +++ b/Lib/test/test_memoryio.py @@ -940,7 +940,6 @@ class CStringIOTest(PyStringIOTest): # XXX: For the Python version of io.StringIO, this is highly # dependent on the encoding used for the underlying buffer. - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 8 != 2 def test_widechar(self): buf = self.buftype("\U0002030a\U00020347") memio = self.ioclass(buf) @@ -965,7 +964,6 @@ def test_getstate(self): memio.close() self.assertRaises(ValueError, memio.__getstate__) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: ValueError not raised by __setstate__ def test_setstate(self): # This checks whether __setstate__ does proper input validation. memio = self.ioclass() @@ -1002,22 +1000,6 @@ def __str__(self): memio2.write(MyStr("world")) self.assertEqual(memio2.getvalue(), "hello world") - @unittest.expectedFailure # TODO: RUSTPYTHON; + - def test_issue5265(self): - return super().test_issue5265() - - @unittest.expectedFailure # TODO: RUSTPYTHON; ? ++++ - def test_newline_empty(self): - return super().test_newline_empty() - - @unittest.expectedFailure # TODO: RUSTPYTHON; ? ^^^^^ - def test_newline_none(self): - return super().test_newline_none() - - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: OSError not raised by seek - def test_relative_seek(self): - return super().test_relative_seek() - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: ValueError not raised by writable def test_flags(self): return super().test_flags() @@ -1026,15 +1008,6 @@ def test_flags(self): def test_newlines_property(self): return super().test_newlines_property() - @unittest.expectedFailure # TODO: RUSTPYTHON; d - def test_newline_cr(self): - return super().test_newline_cr() - - @unittest.expectedFailure # TODO: RUSTPYTHON; d - def test_newline_crlf(self): - return super().test_newline_crlf() - - class CStringIOPickleTest(PyStringIOPickleTest): UnsupportedOperation = io.UnsupportedOperation @@ -1044,34 +1017,9 @@ def __new__(cls, *args, **kwargs): def __init__(self, *args, **kwargs): pass - @unittest.expectedFailure # TODO: RUSTPYTHON; + - def test_issue5265(self): - return super().test_issue5265() - - @unittest.expectedFailure # TODO: RUSTPYTHON; ? ++++ - def test_newline_empty(self): - return super().test_newline_empty() - - @unittest.expectedFailure # TODO: RUSTPYTHON; ? ^^^^^ - def test_newline_none(self): - return super().test_newline_none() - - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: OSError not raised by seek - def test_relative_seek(self): - return super().test_relative_seek() - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'StringIO' object has no attribute 'newlines'. Did you mean: 'readlines'? def test_newlines_property(self): return super().test_newlines_property() - @unittest.expectedFailure # TODO: RUSTPYTHON; d - def test_newline_cr(self): - return super().test_newline_cr() - - @unittest.expectedFailure # TODO: RUSTPYTHON; d - def test_newline_crlf(self): - return super().test_newline_crlf() - - if __name__ == '__main__': unittest.main() diff --git a/Lib/test/test_shlex.py b/Lib/test/test_shlex.py index 7c41432b82f..2a355abdeeb 100644 --- a/Lib/test/test_shlex.py +++ b/Lib/test/test_shlex.py @@ -167,12 +167,10 @@ def testSplitNone(self): with self.assertRaises(ValueError): shlex.split(None) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: Error Retrieving Value def testSplitPosix(self): """Test data splitting with posix parser""" self.splitTest(self.posix_data, comments=True) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: Error Retrieving Value def testCompat(self): """Test compatibility interface""" for i in range(len(self.data)): @@ -313,7 +311,6 @@ def testEmptyStringHandling(self): s = shlex.shlex("'')abc", punctuation_chars=True) self.assertEqual(list(s), expected) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: Error Retrieving Value def testUnicodeHandling(self): """Test punctuation_chars and whitespace_split handle unicode.""" ss = "\u2119\u01b4\u2602\u210c\u00f8\u1f24" @@ -356,7 +353,6 @@ def testJoin(self): joined = shlex.join(split_command) self.assertEqual(joined, command) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: Error Retrieving Value def testJoinRoundtrip(self): all_data = self.data + self.posix_data for command, *split_command in all_data: diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index eb2fa6cfc4f..854a46d8cd6 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -2371,10 +2371,10 @@ mod _io { match memchr::memchr(b'\r', remaining) { Some(p) => match remaining.get(p + 1) { Some(&ch_after_cr) => { - let pos_after = p + 2; if ch_after_cr == b'\n' { - break Ok(searched + pos_after); + break Ok(searched + p + 2); } + let pos_after = p + 1; searched += pos_after; remaining = &remaining[pos_after..]; continue; @@ -4380,6 +4380,7 @@ mod _io { struct StringIO { _base: _TextIOBase, buffer: PyRwLock, + newline: AtomicCell, closed: AtomicCell, } @@ -4388,10 +4389,8 @@ mod _io { #[pyarg(positional, optional)] object: OptionalOption, - // TODO: use this #[pyarg(any, default)] - #[allow(dead_code)] - newline: Newlines, + newline: OptionalOption, } impl Constructor for StringIO { @@ -4401,6 +4400,7 @@ mod _io { Ok(Self { _base: Default::default(), buffer: PyRwLock::new(BufferedIO::new(Cursor::new(Vec::new()))), + newline: AtomicCell::new(Newlines::Lf), closed: AtomicCell::new(false), }) } @@ -4409,16 +4409,21 @@ mod _io { impl Initializer for StringIO { type Args = StringIONewArgs; - #[allow(unused_variables)] fn init( zelf: PyRef, Self::Args { object, newline }: Self::Args, _vm: &VirtualMachine, ) -> PyResult<()> { - let raw_bytes = object - .flatten() - .map_or_else(Vec::new, |v| v.as_bytes().to_vec()); + let newline = match newline { + OptionalArg::Missing => Newlines::Lf, + OptionalArg::Present(None) => Newlines::Universal, + OptionalArg::Present(Some(newline)) => newline, + }; + let raw_bytes = object.flatten().map_or_else(Vec::new, |v| { + Self::translate_newlines(v.as_wtf8(), newline).into_bytes() + }); *zelf.buffer.write() = BufferedIO::new(Cursor::new(raw_bytes)); + zelf.newline.store(newline); Ok(()) } } @@ -4431,6 +4436,46 @@ mod _io { Err(io_closed_error(vm)) } } + + fn translate_newlines(data: &Wtf8, newline: Newlines) -> Wtf8Buf { + match newline { + Newlines::Universal => data + .replace("\r\n".as_ref(), "\n".as_ref()) + .replace("\r".as_ref(), "\n".as_ref()), + Newlines::Cr => data.replace("\n".as_ref(), "\r".as_ref()), + Newlines::Crlf => data.replace("\n".as_ref(), "\r\n".as_ref()), + Newlines::Passthrough | Newlines::Lf => data.to_owned(), + } + } + + fn text(bytes: &[u8]) -> &Wtf8 { + // SAFETY: StringIO is populated only from PyStr values, which are valid WTF-8. + unsafe { Wtf8::from_bytes_unchecked(bytes) } + } + + fn char_offset_to_byte(bytes: &[u8], char_offset: usize) -> usize { + let text = Self::text(bytes); + crate::common::str::codepoint_range_end(text, char_offset) + .unwrap_or_else(|| bytes.len() + (char_offset - text.code_points().count())) + } + + fn byte_offset_to_char(bytes: &[u8], byte_offset: usize) -> usize { + let content_len = bytes.len(); + let in_content = byte_offset.min(content_len); + Self::text(&bytes[..in_content]).code_points().count() + + byte_offset.saturating_sub(content_len) + } + + fn read_size(buffer: &BufferedIO, size: Option, newline: Option) -> usize { + let position = buffer.tell() as usize; + let bytes = buffer.cursor.get_ref().get(position..).unwrap_or_default(); + let size_end = size + .and_then(|size| crate::common::str::codepoint_range_end(Self::text(bytes), size)) + .unwrap_or(bytes.len()); + newline + .and_then(|newline| newline.find_newline(Self::text(&bytes[..size_end])).ok()) + .unwrap_or(size_end) + } } #[pyclass(flags(BASETYPE, HAS_DICT, HAS_WEAKREF), with(Constructor, Initializer))] @@ -4463,10 +4508,11 @@ mod _io { // write string to underlying vector #[pymethod] fn write(&self, data: PyStrRef, vm: &VirtualMachine) -> PyResult { - let bytes = data.as_bytes(); + let bytes = Self::translate_newlines(data.as_wtf8(), self.newline.load()).into_bytes(); self.buffer(vm)? - .write(bytes) - .ok_or_else(|| vm.new_type_error("Error Writing String")) + .write(&bytes) + .ok_or_else(|| vm.new_type_error("Error Writing String"))?; + Ok(data.char_len() as u64) } // return the entire contents of the underlying @@ -4484,9 +4530,36 @@ mod _io { how: OptionalArg, vm: &VirtualMachine, ) -> PyResult { - self.buffer(vm)? - .seek(seekfrom(vm, offset, how)?) - .map_err(|err| os_err(vm, err)) + let offset: isize = ArgSize::try_from_object(vm, offset)?.into(); + let how = how.unwrap_or(0); + let mut buffer = self.buffer(vm)?; + let char_offset = match how { + 0 if offset >= 0 => offset as usize, + 0 => return Err(vm.new_value_error(format!("negative seek position {offset}"))), + 1 | 2 if offset != 0 => { + let kind = if how == 1 { "cur" } else { "end" }; + return Err(vm.new_os_error(format!("can't do nonzero {kind}-relative seeks"))); + } + 1 | 2 => { + let byte_offset = if how == 1 { + buffer.tell() as usize + } else { + buffer.cursor.get_ref().len() + }; + Self::byte_offset_to_char(buffer.cursor.get_ref(), byte_offset) + } + _ => { + return Err( + vm.new_value_error(format!("invalid whence ({how}, should be 0, 1 or 2)")) + ); + } + }; + + let byte_offset = Self::char_offset_to_byte(buffer.cursor.get_ref(), char_offset); + buffer + .seek(SeekFrom::Start(byte_offset as u64)) + .map_err(|err| os_err(vm, err))?; + Ok(char_offset as u64) } // Read k bytes from the object and return. @@ -4494,7 +4567,9 @@ mod _io { // This also increments the stream position by the value of k #[pymethod] fn read(&self, size: OptionalSize, vm: &VirtualMachine) -> PyResult { - let data = self.buffer(vm)?.read(size.to_usize()).unwrap_or_default(); + let mut buffer = self.buffer(vm)?; + let size = Self::read_size(&buffer, size.to_usize(), None); + let data = buffer.read(Some(size)).unwrap_or_default(); let value = Wtf8Buf::from_bytes(data) .map_err(|_| vm.new_value_error("Error Retrieving Value"))?; @@ -4503,22 +4578,28 @@ mod _io { #[pymethod] fn tell(&self, vm: &VirtualMachine) -> PyResult { - Ok(self.buffer(vm)?.tell()) + let buffer = self.buffer(vm)?; + Ok(Self::byte_offset_to_char(buffer.cursor.get_ref(), buffer.tell() as usize) as u64) } #[pymethod] fn readline(&self, size: OptionalSize, vm: &VirtualMachine) -> PyResult { - // TODO size should correspond to the number of characters, at the moments its the number of - // bytes. - let input = self.buffer(vm)?.readline(size.to_usize(), vm)?; + let mut buffer = self.buffer(vm)?; + let size = Self::read_size(&buffer, size.to_usize(), Some(self.newline.load())); + let input = buffer.read(Some(size)).unwrap_or_default(); Wtf8Buf::from_bytes(input).map_err(|_| vm.new_value_error("Error Retrieving Value")) } #[pymethod] fn truncate(&self, pos: OptionalSize, vm: &VirtualMachine) -> PyResult { let mut buffer = self.buffer(vm)?; - let pos = pos.try_usize(vm)?; - Ok(buffer.truncate(pos)) + let pos = match pos.try_usize(vm)? { + Some(pos) => pos, + None => Self::byte_offset_to_char(buffer.cursor.get_ref(), buffer.tell() as usize), + }; + let byte_pos = Self::char_offset_to_byte(buffer.cursor.get_ref(), pos); + buffer.truncate(Some(byte_pos)); + Ok(pos) } #[pygetset] @@ -4531,7 +4612,7 @@ mod _io { let buffer = zelf.buffer(vm)?; let content = Wtf8Buf::from_bytes(buffer.getvalue()) .map_err(|_| vm.new_value_error("Error Retrieving Value"))?; - let pos = buffer.tell(); + let pos = Self::byte_offset_to_char(buffer.cursor.get_ref(), buffer.tell() as usize); drop(buffer); // Get __dict__ if it exists and is non-empty @@ -4540,11 +4621,18 @@ mod _io { _ => vm.ctx.none(), }; + let newline = match zelf.newline.load() { + Newlines::Universal => vm.ctx.none(), + Newlines::Passthrough => vm.ctx.new_str("").into(), + Newlines::Lf => vm.ctx.new_str("\n").into(), + Newlines::Cr => vm.ctx.new_str("\r").into(), + Newlines::Crlf => vm.ctx.new_str("\r\n").into(), + }; + // Return (content, newline, position, dict) - // TODO: store actual newline setting when it's implemented Ok(vm.ctx.new_tuple(vec![ vm.ctx.new_str(content).into(), - vm.ctx.new_str("\n").into(), + newline, vm.ctx.new_int(pos).into(), dict_obj, ])) @@ -4564,18 +4652,23 @@ mod _io { } let content: PyStrRef = state[0].clone().try_into_value(vm)?; - // state[1] is newline - TODO: use when newline handling is implemented - let pos: u64 = state[2].clone().try_into_value(vm)?; + let newline = Newlines::try_from_object(vm, state[1].clone())?; + let pos: isize = ArgSize::try_from_object(vm, state[2].clone())?.into(); + if pos < 0 { + return Err(vm.new_value_error("negative seek position")); + } let dict = &state[3]; // Set content and position let raw_bytes = content.as_bytes().to_vec(); let mut buffer = zelf.buffer.write(); *buffer = BufferedIO::new(Cursor::new(raw_bytes)); + let byte_pos = Self::char_offset_to_byte(buffer.cursor.get_ref(), pos as usize); buffer - .seek(SeekFrom::Start(pos)) + .seek(SeekFrom::Start(byte_pos as u64)) .map_err(|err| os_err(vm, err))?; drop(buffer); + zelf.newline.store(newline); // Set __dict__ if provided if !vm.is_none(dict) { From 28454cc10ccbc1dacd55a050e89f9de861b36755 Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:43:31 +0200 Subject: [PATCH 172/351] Fix deadlock in c-api when a thread enters a stop_the_world (#8355) --- crates/capi/src/lib.rs | 7 ++++++- crates/capi/src/pylifecycle.rs | 8 +++++++- crates/capi/src/pystate.rs | 15 ++++++++------- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/crates/capi/src/lib.rs b/crates/capi/src/lib.rs index 374fd3301d6..2aff75fe15b 100644 --- a/crates/capi/src/lib.rs +++ b/crates/capi/src/lib.rs @@ -1,7 +1,8 @@ #![allow(clippy::missing_safety_doc)] use crate::pyerrors::init_exception_statics; -use crate::pylifecycle::MAIN_INTERP; +use crate::pylifecycle::{MAIN_INTERP, MAIN_INTERP_PTR}; +use core::sync::atomic::Ordering; pub use rustpython_vm::PyObject; use rustpython_vm::{Context, Interpreter}; use std::sync::MutexGuard; @@ -61,4 +62,8 @@ pub fn init_main_interpreter(interpreter: Interpreter) { // Safety: Interpreter was not initialized before, so we can safely assume the statics are not used unsafe { init_exception_statics(&Context::genesis().exceptions) }; *interp = Some(interpreter); + MAIN_INTERP_PTR.store( + interp.as_ref().unwrap() as *const _ as *mut _, + Ordering::Release, + ); } diff --git a/crates/capi/src/pylifecycle.rs b/crates/capi/src/pylifecycle.rs index 534c97956fe..7255a0e34a5 100644 --- a/crates/capi/src/pylifecycle.rs +++ b/crates/capi/src/pylifecycle.rs @@ -3,6 +3,7 @@ use crate::pyerrors::init_exception_statics; use crate::pystate::ensure_thread_has_vm_attached; use alloc::ffi::CString; use core::ffi::{c_char, c_int, c_ulong}; +use core::sync::atomic::{AtomicPtr, Ordering}; use rustpython_vm::common::rc::PyRc; use rustpython_vm::stdlib::sys; use rustpython_vm::version::{MAJOR, MICRO, MINOR, RUSTPYTHON_BUILD_INFO, VERSION_HEX}; @@ -11,6 +12,7 @@ use rustpython_vm::{Context, Interpreter}; use std::sync::{LazyLock, Mutex}; pub(crate) static MAIN_INTERP: Mutex> = Mutex::new(None); +pub(crate) static MAIN_INTERP_PTR: AtomicPtr = AtomicPtr::new(core::ptr::null_mut()); /// Request a thread local vm from the main interpreter pub(crate) fn request_vm_from_interpreter() -> ThreadedVirtualMachine { @@ -25,7 +27,7 @@ pub static Py_Version: c_ulong = VERSION_HEX as c_ulong; #[unsafe(no_mangle)] pub extern "C" fn Py_IsInitialized() -> c_int { - get_main_interpreter().is_some() as c_int + !MAIN_INTERP_PTR.load(Ordering::Acquire).is_null() as c_int } #[unsafe(no_mangle)] @@ -52,6 +54,10 @@ pub extern "C" fn Py_InitializeEx(_initsigs: c_int) { }) .build() .into(); + MAIN_INTERP_PTR.store( + interp.as_ref().unwrap() as *const _ as *mut _, + Ordering::Release, + ); drop(interp); ensure_thread_has_vm_attached(); } diff --git a/crates/capi/src/pystate.rs b/crates/capi/src/pystate.rs index 173c26088cf..865a116443b 100644 --- a/crates/capi/src/pystate.rs +++ b/crates/capi/src/pystate.rs @@ -1,7 +1,7 @@ -use crate::get_main_interpreter; -use crate::pylifecycle::request_vm_from_interpreter; +use crate::pylifecycle::{MAIN_INTERP_PTR, request_vm_from_interpreter}; use crate::util::FfiResult; use core::ffi::c_int; +use core::sync::atomic::Ordering; use rustpython_vm::vm::thread::{ CurrentVmAttachState, SavedThreadState, attach_current_thread, release_current_thread, restore_current_thread, save_current_thread, with_current_vm, @@ -67,11 +67,12 @@ pub unsafe extern "C" fn PyEval_RestoreThread(state: *mut PyThreadState) { #[unsafe(no_mangle)] pub extern "C" fn PyInterpreterState_Get() -> *mut PyInterpreterState { - get_main_interpreter() - .as_ref() - .map(|interp| interp as *const PyInterpreterState) - .expect("PyInterpreterState_Get called but no main interpreter was found") - .cast_mut() + let ptr = MAIN_INTERP_PTR.load(Ordering::Acquire); + assert!( + !ptr.is_null(), + "PyInterpreterState_Get() called but the interpreter is not initialized" + ); + ptr } #[unsafe(no_mangle)] From 63542a648058597732dbb3827e1014f40ac7c37a Mon Sep 17 00:00:00 2001 From: Jiseok CHOI Date: Sat, 25 Jul 2026 19:54:59 +0900 Subject: [PATCH 173/351] sqlite3: fix closed connection error message to match CPython (#8362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Connection is explicitly closed via con.close(), subsequent operations (cursor(), commit(), rollback(), create_function(), etc.) should raise ProgrammingError with 'Cannot operate on a closed database.' to match CPython behaviour. Previously, _db_lock() always returned 'Base Connection.__init__ not called.' when self.db was None, without distinguishing between a connection that was never initialised (subclass before __init__) and one that was initialised and then explicitly closed. Fix: inspect the initialized atomic flag — if True but db is None, the connection was closed; if False, it was never initialised. Assisted-by: GitHub Copilot:claude-sonnet-4-6 --- Lib/test/test_sqlite3/test_dbapi.py | 9 --------- crates/stdlib/src/_sqlite3.rs | 5 +++++ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/Lib/test/test_sqlite3/test_dbapi.py b/Lib/test/test_sqlite3/test_dbapi.py index 68faf0a2abb..9b654148423 100644 --- a/Lib/test/test_sqlite3/test_dbapi.py +++ b/Lib/test/test_sqlite3/test_dbapi.py @@ -1775,29 +1775,23 @@ def setUp(self): self.cur = self.con.cursor() self.con.close() - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for closed connection def test_closed_con_cursor(self): self.check(self.con.cursor) - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for closed connection def test_closed_con_commit(self): self.check(self.con.commit) - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for closed connection def test_closed_con_rollback(self): self.check(self.con.rollback) - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for closed connection def test_closed_cur_execute(self): self.check(self.cur.execute, "select 4") - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for closed connection def test_closed_create_function(self): def f(x): return 17 self.check(self.con.create_function, "foo", 1, f) - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for closed connection def test_closed_create_aggregate(self): class Agg: def __init__(self): @@ -1808,19 +1802,16 @@ def finalize(self): return 17 self.check(self.con.create_aggregate, "foo", 1, Agg) - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for closed connection def test_closed_set_authorizer(self): def authorizer(*args): return sqlite.DENY self.check(self.con.set_authorizer, authorizer) - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for closed connection def test_closed_set_progress_callback(self): def progress(): pass self.check(self.con.set_progress_handler, progress, 100) - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for closed connection def test_closed_call(self): self.check(self.con) diff --git a/crates/stdlib/src/_sqlite3.rs b/crates/stdlib/src/_sqlite3.rs index f3eea603e83..4e30f6204ec 100644 --- a/crates/stdlib/src/_sqlite3.rs +++ b/crates/stdlib/src/_sqlite3.rs @@ -1026,6 +1026,11 @@ mod _sqlite3 { Ok(PyMutexGuard::map(guard, |x| unsafe { x.as_mut().unwrap_unchecked() })) + } else if self.initialized.load(Ordering::Acquire) { + Err(new_programming_error( + vm, + "Cannot operate on a closed database.".to_owned(), + )) } else { Err(new_programming_error( vm, From d797f991922ca2595d006206caf72571b67b835b Mon Sep 17 00:00:00 2001 From: Jiseok CHOI Date: Sat, 25 Jul 2026 19:55:36 +0900 Subject: [PATCH 174/351] sqlite3: allow Row construction with cursor having no description (#8364) Row(cursor, data) raised ValueError when cursor.description was None. CPython allows this case and returns an empty key list. - Use empty tuple when description is None instead of raising - Include the key name in the IndexError when a string key is not found Assisted-by: GitHub Copilot:claude-sonnet-4-6 --- Lib/test/test_sqlite3/test_dbapi.py | 1 - crates/stdlib/src/_sqlite3.rs | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_sqlite3/test_dbapi.py b/Lib/test/test_sqlite3/test_dbapi.py index 9b654148423..c7ea5b59e9a 100644 --- a/Lib/test/test_sqlite3/test_dbapi.py +++ b/Lib/test/test_sqlite3/test_dbapi.py @@ -2045,7 +2045,6 @@ def test_row_equality(self): self.assertNotEqual(r1, r3) - @unittest.expectedFailure # TODO: RUSTPYTHON; Row with no description fails def test_row_no_description(self): cu = self.cx.cursor() self.assertIsNone(cu.description) diff --git a/crates/stdlib/src/_sqlite3.rs b/crates/stdlib/src/_sqlite3.rs index 4e30f6204ec..5f15b0cbcde 100644 --- a/crates/stdlib/src/_sqlite3.rs +++ b/crates/stdlib/src/_sqlite3.rs @@ -2255,7 +2255,7 @@ mod _sqlite3 { return self.data.getitem_by_index(vm, i); } } - Err(vm.new_index_error("No item with that key")) + Err(vm.new_index_error(format!("No item with key '{}'", name.to_string_lossy()))) } else if let Some(slice) = needle.downcast_ref::() { let list = self.data.getitem_by_slice(vm, slice.to_saturated(vm)?)?; Ok(vm.ctx.new_tuple(list).into()) @@ -2277,7 +2277,7 @@ mod _sqlite3 { .inner(vm)? .description .clone() - .ok_or_else(|| vm.new_value_error("no description in Cursor"))?; + .unwrap_or_else(|| vm.ctx.empty_tuple.clone()); Ok(Self { data, description }) } From 4a80f2e5b9a3f05da23de7935c54181af90eee37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B4=91=ED=9A=A8?= <87641474+widehyo1@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:58:05 +0900 Subject: [PATCH 175/351] ast: fix identifier interning (#8361) * Fix AST identifier interning Ensure parsed name and function identifier fields use interned strings, matching CPython behavior. Assisted-by: Codex:gpt-5.6-sol * Apply PEP 8 import ordering --- Lib/test/test_ast/test_ast.py | 3 --- crates/vm/src/stdlib/_ast/expression.rs | 3 ++- crates/vm/src/stdlib/_ast/statement.rs | 2 +- extra_tests/snippets/stdlib_ast.py | 20 ++++++++++++++++++++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_ast/test_ast.py b/Lib/test/test_ast/test_ast.py index 31fd6296451..1450f440dee 100644 --- a/Lib/test/test_ast/test_ast.py +++ b/Lib/test/test_ast/test_ast.py @@ -1331,7 +1331,6 @@ class MyNode(ast.AST): self.assertEqual(repl.x, 0) self.assertEqual(repl.y, y) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 'x' is not 'x' def test_replace_ignore_known_custom_instance_fields(self): node = ast.parse('x').body[0].value node.extra = extra = object() # add instance 'extra' field @@ -1401,7 +1400,6 @@ def test_replace_accept_missing_field_with_default(self): self.assertIs(node2.returns, None) self.assertEqual(node2.decorator_list, []) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: "Name\.__replace__\ got\ an\ unexpected\ keyword\ argument\ 'extra'\." does not match "replace() does not support Name objects" def test_replace_reject_known_custom_instance_fields_commits(self): node = ast.parse('x').body[0].value node.extra = extra = object() # add instance 'extra' field @@ -1417,7 +1415,6 @@ def test_replace_reject_known_custom_instance_fields_commits(self): self.assertIs(node.ctx, context) self.assertIs(node.extra, extra) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: "Name\.__replace__\ got\ an\ unexpected\ keyword\ argument\ 'unknown'\." does not match "replace() does not support Name objects" def test_replace_reject_unknown_instance_fields(self): node = ast.parse('x').body[0].value context = node.ctx diff --git a/crates/vm/src/stdlib/_ast/expression.rs b/crates/vm/src/stdlib/_ast/expression.rs index 39f42652cc7..10bdf526684 100644 --- a/crates/vm/src/stdlib/_ast/expression.rs +++ b/crates/vm/src/stdlib/_ast/expression.rs @@ -1376,7 +1376,8 @@ impl Node for ast::ExprName { .into_ref_with_type(vm, pyast::NodeExprName::static_type().to_owned()) .unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("id", id.to_pyobject(vm), vm).unwrap(); + dict.set_item("id", id.ast_to_object(vm, source_file), vm) + .unwrap(); dict.set_item("ctx", ctx.ast_to_object(vm, source_file), vm) .unwrap(); node_add_location(&dict, range, vm, source_file); diff --git a/crates/vm/src/stdlib/_ast/statement.rs b/crates/vm/src/stdlib/_ast/statement.rs index ad3306fce50..bf8b0347695 100644 --- a/crates/vm/src/stdlib/_ast/statement.rs +++ b/crates/vm/src/stdlib/_ast/statement.rs @@ -409,7 +409,7 @@ impl Node for ast::StmtFunctionDef { let node = NodeAst.into_ref_with_type(vm, cls).unwrap(); let dict = node.as_object().dict().unwrap(); - dict.set_item("name", vm.ctx.new_str(name.as_str()).to_pyobject(vm), vm) + dict.set_item("name", name.ast_to_object(vm, source_file), vm) .unwrap(); dict.set_item("args", parameters.ast_to_object(vm, source_file), vm) .unwrap(); diff --git a/extra_tests/snippets/stdlib_ast.py b/extra_tests/snippets/stdlib_ast.py index 7b5c69df49e..2dd2276723c 100644 --- a/extra_tests/snippets/stdlib_ast.py +++ b/extra_tests/snippets/stdlib_ast.py @@ -1,4 +1,5 @@ import ast +import copy print(ast) @@ -39,6 +40,25 @@ def foo(): assert i.names[0].asname is None +# Regression: parsed AST identifier fields are interned, matching CPython. +name_literal = "x" +name = ast.parse("x").body[0].value +assert name.id is name_literal + +name.extra = object() +replacement = copy.replace(name) +assert replacement.id is name.id +assert replacement.ctx is name.ctx +assert not hasattr(replacement, "extra") + +function_name = "f" +function = ast.parse("def f(): pass").body[0] +assert function.name is function_name + +async_function = ast.parse("async def f(): pass").body[0] +assert async_function.name is function_name + + # Regression test for issue #4862: # A cyclic AST fed to compile() used to overflow the Rust stack and SIGSEGV. # After the fix, the recursion guard in ast_from_object raises RecursionError, From e9f734712257b4374dd2b96de8ca4dccae1bf806 Mon Sep 17 00:00:00 2001 From: Lee Dogeon Date: Sat, 25 Jul 2026 19:59:05 +0900 Subject: [PATCH 176/351] Fix float overflow in struct packing (#8360) Raise OverflowError when a finite f64 value overflows while being converted for the struct f format. Remove the expected-failure marker from the corresponding regression test. Assisted-by: Codex:gpt-5 --- Lib/test/test_struct.py | 1 - crates/vm/src/buffer.rs | 16 ++++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_struct.py b/Lib/test/test_struct.py index f8d3a4be27d..31d2e58b108 100644 --- a/Lib/test/test_struct.py +++ b/Lib/test/test_struct.py @@ -365,7 +365,6 @@ def test_p_code(self): (got,) = struct.unpack(code, got) self.assertEqual(got, expectedback) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_705836(self): # SF bug 705836. "f" had a severe rounding bug, where a carry # from the low-order discarded bits could propagate into the exponent diff --git a/crates/vm/src/buffer.rs b/crates/vm/src/buffer.rs index ae5ce6b0065..dc3691b5421 100644 --- a/crates/vm/src/buffer.rs +++ b/crates/vm/src/buffer.rs @@ -616,14 +616,22 @@ make_pack_prim_int!(usize); make_pack_prim_int!(isize); macro_rules! make_pack_float { - ($T:ty) => { + ($T:ty, $fmt:literal) => { impl Packable for $T { fn pack( vm: &VirtualMachine, arg: PyObjectRef, data: &mut [u8], ) -> PyResult<()> { - let f = ArgIntoFloat::try_from_object(vm, arg)?.into_float() as $T; + let f_64 = ArgIntoFloat::try_from_object(vm, arg)?.into_float(); + let f = f_64 as $T; + if f.is_infinite() != f_64.is_infinite() { + return Err(vm.new_overflow_error(concat!( + "float too large to pack with ", + $fmt, + " format" + ))); + } f.to_bits().pack_int::(data); Ok(()) } @@ -636,8 +644,8 @@ macro_rules! make_pack_float { }; } -make_pack_float!(f32); -make_pack_float!(f64); +make_pack_float!(f32, "f"); +make_pack_float!(f64, "d"); impl Packable for f16 { fn pack(vm: &VirtualMachine, arg: PyObjectRef, data: &mut [u8]) -> PyResult<()> { From d155c93ae403d91a7fa73dadf265562357133fc5 Mon Sep 17 00:00:00 2001 From: sijun-yang Date: Sat, 25 Jul 2026 20:00:30 +0900 Subject: [PATCH 177/351] Fix b2a_qp CRLF boundary check (#8358) Assisted-by: Codex:gpt-5.6-sol --- crates/stdlib/src/binascii.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/stdlib/src/binascii.rs b/crates/stdlib/src/binascii.rs index 579a5081cc4..0cf0056285a 100644 --- a/crates/stdlib/src/binascii.rs +++ b/crates/stdlib/src/binascii.rs @@ -504,7 +504,7 @@ mod decl { } in_idx += 1; } - if buflen > 0 && in_idx < buflen && buf[in_idx - 1] == b'\r' { + if in_idx > 0 && in_idx < buflen && buf[in_idx - 1] == b'\r' { crlf = true; } From 1ee695e9a27fd98b5f808c88036cd93cf9a8658b Mon Sep 17 00:00:00 2001 From: lms0806 Date: Sat, 25 Jul 2026 20:03:52 +0900 Subject: [PATCH 178/351] time: use C altzone when available (#8357) * time: use C altzone when available * Modified to also check TARGET_CC/CC_target. --- Cargo.lock | 1 + crates/host_env/Cargo.toml | 3 ++ crates/host_env/build.rs | 72 ++++++++++++++++++++++++++++++++++++ crates/host_env/src/time.rs | 22 ++++++++++- crates/vm/src/stdlib/time.rs | 3 +- 5 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 crates/host_env/build.rs diff --git a/Cargo.lock b/Cargo.lock index a0af67e9536..463a7298460 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3422,6 +3422,7 @@ name = "rustpython-host_env" version = "0.5.0" dependencies = [ "bitflags 2.13.1", + "cc", "dns-lookup", "gethostname", "getrandom 0.4.3", diff --git a/crates/host_env/Cargo.toml b/crates/host_env/Cargo.toml index 3beda890bb4..07f7d0da60c 100644 --- a/crates/host_env/Cargo.toml +++ b/crates/host_env/Cargo.toml @@ -84,5 +84,8 @@ windows-sys = { workspace = true, features = [ "Win32_UI_WindowsAndMessaging", ] } +[build-dependencies] +cc = "1" + [lints] workspace = true diff --git a/crates/host_env/build.rs b/crates/host_env/build.rs new file mode 100644 index 00000000000..d04d48f1621 --- /dev/null +++ b/crates/host_env/build.rs @@ -0,0 +1,72 @@ +//! Like CPython's `HAVE_ALTZONE`, it detects the presence of `altzone` in `time.h` at build time. + +#![allow( + clippy::disallowed_methods, + reason = "build scripts cannot use rustpython-host_env" +)] + +use std::{env, fs, path::PathBuf}; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rustc-check-cfg=cfg(has_altzone)"); + + let target_env = env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); + + if target_env == "msvc" || target_arch == "wasm32" { + return; + } + + let host = env::var("HOST").unwrap_or_default(); + let target = env::var("TARGET").unwrap_or_default(); + // cc::Build resolves CC_, CC_, TARGET_CC, then CC. + if host != target && !has_target_c_compiler(&target) { + return; + } + + if probe_altzone() { + println!("cargo:rustc-cfg=has_altzone"); + } +} + +/// Whether any compiler env var that `cc::Build` would consult for the target is set. +fn has_target_c_compiler(target: &str) -> bool { + let underscored = target.replace(['-', '.'], "_"); + env::var_os(format!("CC_{target}")).is_some() + || env::var_os(format!("CC_{underscored}")).is_some() + || env::var_os("TARGET_CC").is_some() + || env::var_os("CC").is_some() +} + +/// Check corresponding to `AC_TRY_COMPILE(... altzone ...)` in CPython's `configure`. +fn probe_altzone() -> bool { + let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR")); + let src = out_dir.join("probe_altzone.c"); + let obj = out_dir.join("probe_altzone.o"); + + if fs::write( + &src, + "#include \nint main(void) { return (int)altzone; }\n", + ) + .is_err() + { + return false; + } + + let Ok(compiler) = cc::Build::new().try_get_compiler() else { + return false; + }; + + let mut cmd = compiler.to_command(); + if compiler.is_like_msvc() { + cmd.arg("/c").arg(&src).arg(format!("/Fo{}", obj.display())); + } else { + cmd.arg("-c").arg(&src).arg("-o").arg(&obj); + } + + match cmd.output() { + Ok(output) => output.status.success(), + Err(_) => false, + } +} diff --git a/crates/host_env/src/time.rs b/crates/host_env/src/time.rs index 3e3227d78de..dd97a936681 100644 --- a/crates/host_env/src/time.rs +++ b/crates/host_env/src/time.rs @@ -15,7 +15,7 @@ pub const SEC_TO_NS: i64 = SEC_TO_MS * MS_TO_NS; pub const NS_TO_MS: i64 = 1000 * 1000; pub const NS_TO_US: i64 = 1000; -/// Access to the C runtime's `tzset` / `timezone` / `daylight` / `tzname` +/// Access to the C runtime's `tzset` / `timezone` / `altzone` / `daylight` / `tzname` /// globals used by Python's `time` module. /// /// Not available under MSVC (which exposes these only via the @@ -28,6 +28,10 @@ pub mod tz { static c_daylight: core::ffi::c_int; #[link_name = "timezone"] static c_timezone: core::ffi::c_long; + // Set by `build.rs` when `time.h` exposes `altzone` (CPython `HAVE_ALTZONE`). + #[cfg(has_altzone)] + #[link_name = "altzone"] + static c_altzone: core::ffi::c_long; #[link_name = "tzname"] static c_tzname: [*const core::ffi::c_char; 2]; #[link_name = "tzset"] @@ -43,6 +47,22 @@ pub mod tz { unsafe { c_timezone } } + /// DST offset west of UTC in seconds, matching CPython's `time.altzone`. + /// + /// Uses the C `altzone` global when available; otherwise falls back to + /// `timezone - 3600` (same as CPython without `HAVE_ALTZONE`). + #[must_use] + pub fn altzone() -> core::ffi::c_long { + #[cfg(has_altzone)] + { + unsafe { c_altzone } + } + #[cfg(not(has_altzone))] + { + timezone() - 3600 + } + } + #[cfg(not(target_os = "freebsd"))] #[must_use] pub fn daylight() -> core::ffi::c_int { diff --git a/crates/vm/src/stdlib/time.rs b/crates/vm/src/stdlib/time.rs index 05894cdc2a1..aaac706bfac 100644 --- a/crates/vm/src/stdlib/time.rs +++ b/crates/vm/src/stdlib/time.rs @@ -210,8 +210,7 @@ mod decl { #[cfg(not(target_arch = "wasm32"))] #[pyattr] fn altzone(_vm: &VirtualMachine) -> core::ffi::c_long { - // TODO: RUSTPYTHON; Add support for using the C altzone - crate::host_env::time::tz::timezone() - 3600 + crate::host_env::time::tz::altzone() } #[cfg(target_env = "msvc")] From 0dc8f0fcadd587ba2dcbea35d7a2496c4812c38c Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers <7943856+bschoenmaeckers@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:07:44 +0200 Subject: [PATCH 179/351] Fix some pyo3 testcase against the c-api (#8349) --- crates/capi/src/import.rs | 14 +++++++++++++- crates/capi/src/methodobject.rs | 14 ++++++++++++++ crates/capi/src/object.rs | 16 ++++++++++++++-- crates/capi/src/pycapsule.rs | 23 +++++++++++++++++++++++ crates/vm/src/builtins/capsule.rs | 3 +++ 5 files changed, 67 insertions(+), 3 deletions(-) diff --git a/crates/capi/src/import.rs b/crates/capi/src/import.rs index 3a8dae651c9..37a4bbf8882 100644 --- a/crates/capi/src/import.rs +++ b/crates/capi/src/import.rs @@ -8,7 +8,11 @@ use rustpython_vm::import::import_code_obj; pub unsafe extern "C" fn PyImport_Import(name: *mut PyObject) -> *mut PyObject { with_vm(|vm| { let name = unsafe { (&*name).try_downcast_ref::(vm)? }; - vm.import(name, 0) + let _ = vm.import(name, 0)?; + + vm.sys_module + .get_attr(rustpython_vm::identifier!(vm, modules), vm)? + .get_item(name, vm) }) } @@ -74,4 +78,12 @@ mod tests { let _module = py.import("types").unwrap(); }) } + + #[test] + fn import_sub_module() { + Python::attach(|py| { + let module = py.import("collections.abc").unwrap(); + module.getattr("Sequence").unwrap(); + }) + } } diff --git a/crates/capi/src/methodobject.rs b/crates/capi/src/methodobject.rs index cc3676ef51a..2be54ca8939 100644 --- a/crates/capi/src/methodobject.rs +++ b/crates/capi/src/methodobject.rs @@ -53,6 +53,7 @@ pub(crate) fn build_method_def( let flags = PyMethodFlags::from_bits(ml.ml_flags as u32) .ok_or_else(|| vm.new_system_error("PyMethodDef contains unknown flags"))?; + let has_self = has_self && !flags.contains(PyMethodFlags::STATIC); let method = ml.ml_meth; @@ -359,4 +360,17 @@ mod tests { ); }) } + + #[test] + fn wrap_static_no_args_function() { + #[pyfunction()] + fn f() {} + + Python::attach(|py| { + let module = PyModule::new(py, "test_wrap_pyfunction_forms").unwrap(); + + let func = wrap_pyfunction!(f, &module).unwrap(); + func.call0().unwrap(); + }); + } } diff --git a/crates/capi/src/object.rs b/crates/capi/src/object.rs index eabfbef23a1..641dbe3ef9f 100644 --- a/crates/capi/src/object.rs +++ b/crates/capi/src/object.rs @@ -217,7 +217,7 @@ pub unsafe extern "C" fn PyObject_HasAttrWithError( with_vm(|vm| { let obj = unsafe { &*obj }; let name = unsafe { &*attr_name }.try_downcast_ref::(vm)?; - obj.has_attr(name, vm) + Ok(vm.get_attribute_opt(obj.to_owned(), name)?.is_some()) }) } @@ -272,7 +272,7 @@ pub unsafe extern "C" fn PyObject_HasAttrStringWithError( with_vm(|vm| { let obj = unsafe { &*obj }; let name = unsafe { attr_name.try_as_str(vm) }?; - obj.has_attr(name, vm) + Ok(vm.get_attribute_opt(obj.to_owned(), name)?.is_some()) }) } @@ -591,4 +591,16 @@ mod tests { assert!(dict.get_item("foo").is_ok()); }) } + + #[test] + fn hasattr() { + Python::attach(|py| { + let x = 5i32.into_pyobject(py).unwrap(); + assert!(x.is_instance_of::()); + + // spell-checker:ignore bbbbbbytes + assert!(x.hasattr("to_bytes").unwrap()); + assert!(!x.hasattr("bbbbbbytes").unwrap()); + }) + } } diff --git a/crates/capi/src/pycapsule.rs b/crates/capi/src/pycapsule.rs index b36dea3d946..f5b792eec4d 100644 --- a/crates/capi/src/pycapsule.rs +++ b/crates/capi/src/pycapsule.rs @@ -48,6 +48,11 @@ pub unsafe extern "C" fn PyCapsule_GetContext(capsule: *mut PyObject) -> *mut c_ let capsule = unsafe { &*capsule } .downcast_ref_if_exact::(vm) .ok_or_else(|| vm.new_value_error("Invalid capsule"))?; + + if capsule.pointer().is_null() { + return Err(vm.new_value_error("Capsule has null pointer")); + } + Ok(capsule.context()) }) } @@ -143,6 +148,7 @@ fn checked_capsule<'a>( #[cfg(test)] mod tests { + use pyo3::ffi; use pyo3::prelude::*; use pyo3::types::PyCapsule; @@ -156,4 +162,21 @@ mod tests { assert_eq!(unsafe { ptr.cast::().as_ref() }, "Some data"); }) } + + #[test] + fn capsule_context_on_invalid_capsule() { + Python::attach(|py| { + let cap = PyCapsule::new_with_value(py, 123u32, c"name").unwrap(); + + // Invalidate the capsule + // SAFETY: intentionally breaking the capsule for testing + unsafe { + ffi::PyCapsule_SetPointer(cap.as_ptr(), core::ptr::null_mut()); + } + + // context() on invalid capsule should fail + let result = cap.context(); + assert!(result.is_err()); + }); + } } diff --git a/crates/vm/src/builtins/capsule.rs b/crates/vm/src/builtins/capsule.rs index 43efa0fb214..19260dba82f 100644 --- a/crates/vm/src/builtins/capsule.rs +++ b/crates/vm/src/builtins/capsule.rs @@ -76,6 +76,9 @@ impl Representable for PyCapsule { impl Destructor for PyCapsule { fn del(zelf: &Py, _vm: &VirtualMachine) -> PyResult<()> { + if zelf.pointer().is_null() { + return Ok(()); + } if let Some(destructor) = zelf.destructor() { unsafe { destructor(zelf.as_object().as_raw().cast_mut()) }; } From 003ebec164b82a582f3f28e5ecfe27dab75b81ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:30:33 +0900 Subject: [PATCH 180/351] Bump fast-uri from 3.1.2 to 3.1.4 in /wasm/demo (#8365) Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.4. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.4) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- wasm/demo/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wasm/demo/package-lock.json b/wasm/demo/package-lock.json index d26170de891..e5ef3a70785 100644 --- a/wasm/demo/package-lock.json +++ b/wasm/demo/package-lock.json @@ -2559,9 +2559,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { From cc6c638b7553dceba41352d8fdf1919674918937 Mon Sep 17 00:00:00 2001 From: Jiseok CHOI Date: Sun, 26 Jul 2026 13:02:25 +0900 Subject: [PATCH 181/351] sqlite3: raise ProgrammingError when named param receives sequence (#8363) CPython raises ProgrammingError with "Binding N is a named parameter" when a query uses named placeholders (, , ) but the caller passes a sequence instead of a mapping. RustPython's bind_parameters_sequence() did not check whether each binding slot is a named parameter, so no error was raised. Fix: call sqlite3_bind_parameter_name() for each slot in bind_parameters_sequence(). If the first byte of the returned name is not '?' (i.e. it is a named placeholder), raise ProgrammingError before attempting to bind. Assisted-by: GitHub Copilot:claude-sonnet-4-6 --- .cspell.json | 1 + Lib/test/test_sqlite3/test_dbapi.py | 1 - crates/stdlib/src/_sqlite3.rs | 10 ++++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.cspell.json b/.cspell.json index ab3d229cf74..21d4015632b 100644 --- a/.cspell.json +++ b/.cspell.json @@ -83,6 +83,7 @@ "opargs", "pointee", "pyc", + "qmark", "reborrow", "reborrows", "reparenting", diff --git a/Lib/test/test_sqlite3/test_dbapi.py b/Lib/test/test_sqlite3/test_dbapi.py index c7ea5b59e9a..68f8969a00b 100644 --- a/Lib/test/test_sqlite3/test_dbapi.py +++ b/Lib/test/test_sqlite3/test_dbapi.py @@ -875,7 +875,6 @@ def __getitem__(slf, x): with self.assertRaises(ZeroDivisionError): self.cu.execute("select name from test where name=?", L()) - @unittest.expectedFailure # TODO: RUSTPYTHON; mixed named and positional parameters not validated def test_execute_named_param_and_sequence(self): dataset = ( ("select :a", (1,)), diff --git a/crates/stdlib/src/_sqlite3.rs b/crates/stdlib/src/_sqlite3.rs index 5f15b0cbcde..58d8ad7b391 100644 --- a/crates/stdlib/src/_sqlite3.rs +++ b/crates/stdlib/src/_sqlite3.rs @@ -3208,6 +3208,16 @@ mod _sqlite3 { } for i in 1..=num_needed { + let name = unsafe { sqlite3_bind_parameter_name(self.st, i) }; + if !name.is_null() && unsafe { *name } != b'?' as libc::c_char { + let name_str = ptr_to_str(name, vm)?; + return Err(new_programming_error( + vm, + format!( + "Binding {i} ('{name_str}') is a named parameter, but you supplied a sequence which requires nameless (qmark) placeholders." + ), + )); + } let val = seq.get_item(i as isize - 1, vm)?; self.bind_parameter(i, &val, vm)?; } From a5bb9fd59835e3e0b9f41ebf3262e9fb588b2613 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:02:39 +0900 Subject: [PATCH 182/351] build(deps-dev): bump postcss from 8.5.10 to 8.5.23 in /wasm/demo (#8367) Bumps [postcss](https://github.com/postcss/postcss) from 8.5.10 to 8.5.23. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.10...8.5.23) --- updated-dependencies: - dependency-name: postcss dependency-version: 8.5.23 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- wasm/demo/package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/wasm/demo/package-lock.json b/wasm/demo/package-lock.json index e5ef3a70785..43a56ca9145 100644 --- a/wasm/demo/package-lock.json +++ b/wasm/demo/package-lock.json @@ -3704,9 +3704,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -4069,9 +4069,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -4089,7 +4089,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, From 15099d08f9fd0760464d03169c9a3b321497a1b1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:02:50 +0900 Subject: [PATCH 183/351] build(deps-dev): bump http-proxy-middleware in /wasm/demo (#8368) Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.9 to 2.0.10. - [Release notes](https://github.com/chimurai/http-proxy-middleware/releases) - [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.10/CHANGELOG.md) - [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.9...v2.0.10) --- updated-dependencies: - dependency-name: http-proxy-middleware dependency-version: 2.0.10 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- wasm/demo/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wasm/demo/package-lock.json b/wasm/demo/package-lock.json index 43a56ca9145..59ce265c2d7 100644 --- a/wasm/demo/package-lock.json +++ b/wasm/demo/package-lock.json @@ -3026,9 +3026,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz", + "integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==", "dev": true, "license": "MIT", "dependencies": { From b2d7ae080d09e6fdf9b685b5cc3866753b86cf02 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:03:41 +0900 Subject: [PATCH 184/351] build(deps-dev): bump shell-quote from 1.8.4 to 1.10.0 in /wasm/demo (#8370) Bumps [shell-quote](https://github.com/ljharb/shell-quote) from 1.8.4 to 1.10.0. - [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.4...v1.10.0) --- updated-dependencies: - dependency-name: shell-quote dependency-version: 1.10.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- wasm/demo/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wasm/demo/package-lock.json b/wasm/demo/package-lock.json index 59ce265c2d7..a60d0056062 100644 --- a/wasm/demo/package-lock.json +++ b/wasm/demo/package-lock.json @@ -4849,9 +4849,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "dev": true, "license": "MIT", "engines": { From aea5fe3d9046c542e191aa2d19f46c226a62cbec Mon Sep 17 00:00:00 2001 From: Yubin Kim <80163835+devyubin@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:14:50 +0900 Subject: [PATCH 185/351] Replace malformed unicode error helper calls with the _real variants (#8359) * Replace malformed unicode error helper calls with the _real variants new_unicode_decode_error / new_unicode_encode_error build the exception from a bare message without running the initializer, so the result has none of the five attributes a unicode error must carry and str() renders as an empty string. Convert the non-Windows call sites in csv, socket, getlogin and the fs-path decoders to the _real constructors, passing the source bytes/str and the failing offset from the captured Utf8Error. The Windows-gated sites (nt, mbcs/oem codecs) and the sites whose source object is not reachable (uname, array) are left for follow-ups. Assisted-by: Claude Code:claude-fable-5 * Report the full invalid UTF-8 span in the converted decode errors The conversions used valid_up_to() + 1 for the decode-error end offset, which under-reports multi-byte invalid sequences. Take the span from the Utf8Error instead: valid_up_to() + error_len(), or the input length for a truncated sequence (error_len() == None), matching CPython. Assisted-by: Claude Code:claude-opus-4-8 --- crates/stdlib/src/csv.rs | 35 ++++++++++++++++++++++--------- crates/stdlib/src/socket.rs | 36 ++++++++++++++++++++++++++++---- crates/vm/src/function/fspath.rs | 11 ++++++++-- crates/vm/src/stdlib/os.rs | 11 ++++++++-- crates/vm/src/stdlib/posix.rs | 14 +++++++++---- 5 files changed, 85 insertions(+), 22 deletions(-) diff --git a/crates/stdlib/src/csv.rs b/crates/stdlib/src/csv.rs index a5203e8a373..91717801bc4 100644 --- a/crates/stdlib/src/csv.rs +++ b/crates/stdlib/src/csv.rs @@ -60,6 +60,21 @@ mod _csv { vm.new_exception_msg(super::_csv::error(vm), msg.into()) } + fn new_not_utf8_error( + vm: &VirtualMachine, + bytes: &[u8], + err: core::str::Utf8Error, + ) -> PyBaseExceptionRef { + vm.new_unicode_decode_error_real( + vm.ctx.new_str("utf-8"), + vm.ctx.new_bytes(bytes.to_vec()), + err.valid_up_to(), + err.error_len() + .map_or(bytes.len(), |n| err.valid_up_to() + n), + vm.ctx.new_str("csv not utf8"), + ) + } + #[pyattr] #[pyclass(module = "csv", name = "Dialect")] #[derive(Debug, PyPayload, Clone, Copy)] @@ -1111,8 +1126,8 @@ mod _csv { { return Ok(vm.ctx.none()); } - let field = core::str::from_utf8(&field) - .map_err(|_| vm.new_unicode_decode_error("csv not utf8"))?; + let field = + core::str::from_utf8(&field).map_err(|e| new_not_utf8_error(vm, &field, e))?; Ok(vm.ctx.new_str(field).into()) }) .collect() @@ -1248,7 +1263,7 @@ mod _csv { prev_end = end; let s = core::str::from_utf8(&buffer[range.clone()]) // not sure if this is possible - the input was all strings - .map_err(|_e| vm.new_unicode_decode_error("csv not utf8"))?; + .map_err(|e| new_not_utf8_error(vm, &buffer[range.clone()], e))?; // TODO: RUSTPYTHON; Incomplete implementation if let QuoteStyle::Nonnumeric = zelf.dialect.quoting { @@ -1423,8 +1438,8 @@ mod _csv { } write_lineterminator(&mut output, self.dialect.lineterminator); - let s = core::str::from_utf8(&output) - .map_err(|_| vm.new_unicode_decode_error("csv not utf8"))?; + let s = + core::str::from_utf8(&output).map_err(|e| new_not_utf8_error(vm, &output, e))?; self.write.call((s,), vm) } @@ -1469,8 +1484,8 @@ mod _csv { write_lineterminator(&mut output, self.dialect.lineterminator); - let s = core::str::from_utf8(&output) - .map_err(|_| vm.new_unicode_decode_error("csv not utf8"))?; + let s = + core::str::from_utf8(&output).map_err(|e| new_not_utf8_error(vm, &output, e))?; self.write.call((s,), vm) } @@ -1518,8 +1533,8 @@ mod _csv { write_lineterminator(&mut output, self.dialect.lineterminator); - let s = core::str::from_utf8(&output) - .map_err(|_| vm.new_unicode_decode_error("csv not utf8"))?; + let s = + core::str::from_utf8(&output).map_err(|e| new_not_utf8_error(vm, &output, e))?; self.write.call((s,), vm) } @@ -1593,7 +1608,7 @@ mod _csv { } let s = core::str::from_utf8(&buffer[..buffer_offset]) - .map_err(|_| vm.new_unicode_decode_error("csv not utf8"))?; + .map_err(|e| new_not_utf8_error(vm, &buffer[..buffer_offset], e))?; self.write.call((s,), vm) } diff --git a/crates/stdlib/src/socket.rs b/crates/stdlib/src/socket.rs index 283aa408339..968399ca782 100644 --- a/crates/stdlib/src/socket.rs +++ b/crates/stdlib/src/socket.rs @@ -2612,8 +2612,15 @@ mod _socket { } Some(ArgStrOrBytesLike::Buf(b)) => { let bytes = b.borrow_buf(); - let host_str = core::str::from_utf8(&bytes) - .map_err(|_| vm.new_unicode_decode_error("host bytes is not utf8"))?; + let host_str = core::str::from_utf8(&bytes).map_err(|e| { + vm.new_unicode_decode_error_real( + vm.ctx.new_str("utf-8"), + vm.ctx.new_bytes(bytes.to_vec()), + e.valid_up_to(), + e.error_len().map_or(bytes.len(), |n| e.valid_up_to() + n), + vm.ctx.new_str("host bytes is not utf8"), + ) + })?; Some(host_str.to_owned()) } None => None, @@ -2627,14 +2634,35 @@ mod _socket { ArgStrOrBytesLike::Str(s) => { // For str, check for surrogates and raise UnicodeEncodeError if found s.to_str() - .ok_or_else(|| vm.new_unicode_encode_error("surrogates not allowed"))? + .ok_or_else(|| { + let start = s + .as_wtf8() + .code_points() + .position(|c| c.to_char().is_none()) + .unwrap(); + vm.new_unicode_encode_error_real( + vm.ctx.new_str("utf-8"), + (*s).clone(), + start, + start + 1, + vm.ctx.new_str("surrogates not allowed"), + ) + })? .to_owned() } ArgStrOrBytesLike::Buf(b) => { // For bytes, check if it's valid UTF-8 let bytes = b.borrow_buf(); core::str::from_utf8(&bytes) - .map_err(|_| vm.new_unicode_decode_error("port is not utf8"))? + .map_err(|e| { + vm.new_unicode_decode_error_real( + vm.ctx.new_str("utf-8"), + vm.ctx.new_bytes(bytes.to_vec()), + e.valid_up_to(), + e.error_len().map_or(bytes.len(), |n| e.valid_up_to() + n), + vm.ctx.new_str("port is not utf8"), + ) + })? .to_owned() } }; diff --git a/crates/vm/src/function/fspath.rs b/crates/vm/src/function/fspath.rs index 053802285cd..cd3cd2276f7 100644 --- a/crates/vm/src/function/fspath.rs +++ b/crates/vm/src/function/fspath.rs @@ -125,8 +125,15 @@ impl FsPath { } pub fn bytes_as_os_str<'a>(b: &'a [u8], vm: &VirtualMachine) -> PyResult<&'a std::ffi::OsStr> { - rustpython_host_env::os::bytes_as_os_str(b) - .map_err(|_| vm.new_unicode_decode_error("can't decode path for utf-8")) + rustpython_host_env::os::bytes_as_os_str(b).map_err(|e| { + vm.new_unicode_decode_error_real( + vm.ctx.new_str("utf-8"), + vm.ctx.new_bytes(b.to_vec()), + e.valid_up_to(), + e.error_len().map_or(b.len(), |n| e.valid_up_to() + n), + vm.ctx.new_str("can't decode path for utf-8"), + ) + }) } } diff --git a/crates/vm/src/stdlib/os.rs b/crates/vm/src/stdlib/os.rs index f885fb1db05..73e4918ccd4 100644 --- a/crates/vm/src/stdlib/os.rs +++ b/crates/vm/src/stdlib/os.rs @@ -130,8 +130,15 @@ pub(super) struct FollowSymlinks( #[cfg(not(windows))] fn bytes_as_os_str<'a>(b: &'a [u8], vm: &VirtualMachine) -> PyResult<&'a std::ffi::OsStr> { - rustpython_host_env::os::bytes_as_os_str(b) - .map_err(|_| vm.new_unicode_decode_error("can't decode path for utf-8")) + rustpython_host_env::os::bytes_as_os_str(b).map_err(|e| { + vm.new_unicode_decode_error_real( + vm.ctx.new_str("utf-8"), + vm.ctx.new_bytes(b.to_vec()), + e.valid_up_to(), + e.error_len().map_or(b.len(), |n| e.valid_up_to() + n), + vm.ctx.new_str("can't decode path for utf-8"), + ) + }) } pub(crate) fn warn_if_bool_fd(obj: &PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { diff --git a/crates/vm/src/stdlib/posix.rs b/crates/vm/src/stdlib/posix.rs index 80245aed08f..c16da1ee703 100644 --- a/crates/vm/src/stdlib/posix.rs +++ b/crates/vm/src/stdlib/posix.rs @@ -1731,10 +1731,16 @@ pub mod module { let Some(login) = rustpython_host_env::posix::getlogin() else { return Err(vm.new_os_error("unable to determine login name")); }; - login - .to_str() - .map(|s| s.to_owned()) - .map_err(|e| vm.new_unicode_decode_error(format!("unable to decode login name: {e}"))) + login.to_str().map(|s| s.to_owned()).map_err(|e| { + vm.new_unicode_decode_error_real( + vm.ctx.new_str("utf-8"), + vm.ctx.new_bytes(login.as_bytes().to_vec()), + e.valid_up_to(), + e.error_len() + .map_or(login.as_bytes().len(), |n| e.valid_up_to() + n), + vm.ctx.new_str("unable to decode login name"), + ) + }) } // cfg from nix From 321158c4b4e839142d474c21ee3443e7ba93f8bf Mon Sep 17 00:00:00 2001 From: Sumi Jeong <125195487+sigmaith@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:30:48 +0900 Subject: [PATCH 186/351] Implement termios.tcgetwinsize/tcsetwinsize (#8347) * Implement termios.tcgetwinsize/tcsetwinsize Adds ioctl(TIOCGWINSZ/TIOCSWINSZ) wrappers in host_env and the corresponding Python-facing functions in the termios stdlib module, removing the associated expectedFailure markers in test_termios.py. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RbzcYLnX5tMfTM9BP7FDTT * Use ret < 0 convention for ioctl error checks in termios winsize POSIX ioctl only guarantees -1 on failure, not exactly 0 on success; matches the existing check_libc_neg convention used elsewhere in host_env (e.g. fcntl.rs, posix.rs::get_terminal_size). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RbzcYLnX5tMfTM9BP7FDTT * Reject non-sequence size in tcsetwinsize extract_elements_with also accepted dicts (treating keys as elements). Switch to try_sequence(), matching CPython's PySequence_Check, so only real sequences (tuple/list/etc.) are accepted. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RbzcYLnX5tMfTM9BP7FDTT * Use `core::mem::zeroed` instead of `std::mem::zeroed` `std::mem` just re-exports `core::mem`, but clippy prefers importing from core when there's no OS dependency. This was breaking the wasm CI build. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RbzcYLnX5tMfTM9BP7FDTT * Use try_index for tcsetwinsize row/col conversion CPython's PyLong_AsLong calls __index__ on non-int objects before converting, so downcast_ref:: was stricter than CPython. try_index matches that behavior and is more concise. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RbzcYLnX5tMfTM9BP7FDTT * Mark test_fork/test_spawn_doesnt_hang as expected failures Both were hidden by a skipIf on tty.tcgetwinsize, which now exists. Root cause: pty.fork() calls os.login_tty(), which isn't implemented, so the forked child crashes before the test body runs. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RbzcYLnX5tMfTM9BP7FDTT * Use unittest.skip instead of expectedFailure for pty.fork() tests expectedFailure still runs the test body, so pty.fork()'s real fork() still happens and the child crashes on missing os.login_tty inside the parallel test runner's worker process, corrupting its JSON reporting channel ("worker bug"). skip prevents the method from running at all, avoiding that. Verified locally with -j 2. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RbzcYLnX5tMfTM9BP7FDTT * Clarify skip reason for pty.fork() tests * Simplify tcgetwinsize/tcsetwinsize fd argument with Fildes destructuring * Explain why pty.fork() tests use skip instead of expectedFailure --------- Co-authored-by: Claude Sonnet 5 --- Lib/test/test_pty.py | 10 ++++++++++ Lib/test/test_termios.py | 4 ---- crates/host_env/src/termios.rs | 24 ++++++++++++++++++++++++ crates/stdlib/src/termios.rs | 22 ++++++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_pty.py b/Lib/test/test_pty.py index 1126404fee7..c68162c94ca 100644 --- a/Lib/test/test_pty.py +++ b/Lib/test/test_pty.py @@ -195,6 +195,11 @@ def test_openpty(self): s2 = _readline(master_fd) self.assertEqual(b'For my pet fish, Eric.\n', normalize_output(s2)) + # skip (not expectedFailure) because the test still forks a real child + # process, which crashes on missing os.login_tty() inside the parallel + # test runner's worker process, corrupting its JSON reporting channel + # ("worker bug", reproducible under --slow-ci -j N; not under plain -m test). + @unittest.skip("TODO: RUSTPYTHON; pty.fork() calls os.login_tty(), which is not implemented") def test_fork(self): debug("calling pty.fork()") pid, master_fd = pty.fork() @@ -296,6 +301,11 @@ def test_master_read(self): self.assertEqual(data, b"") + # skip (not expectedFailure) because the test still forks a real child + # process, which crashes on missing os.login_tty() inside the parallel + # test runner's worker process, corrupting its JSON reporting channel + # ("worker bug", reproducible under --slow-ci -j N; not under plain -m test). + @unittest.skip("TODO: RUSTPYTHON; pty.fork() calls os.login_tty(), which is not implemented") def test_spawn_doesnt_hang(self): # gh-140482: Do the test in a pty.fork() child to avoid messing # with the interactive test runner's terminal settings. diff --git a/Lib/test/test_termios.py b/Lib/test/test_termios.py index 216609719ac..1207855fa2d 100644 --- a/Lib/test/test_termios.py +++ b/Lib/test/test_termios.py @@ -221,7 +221,6 @@ def writer(): 'output was not resumed') self.assertEqual(os.read(rfd, 1024), b'def') - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: module 'termios' has no attribute 'tcgetwinsize' def test_tcgetwinsize(self): size = termios.tcgetwinsize(self.fd) self.assertIsInstance(size, tuple) @@ -230,7 +229,6 @@ def test_tcgetwinsize(self): self.assertIsInstance(size[1], int) self.assertEqual(termios.tcgetwinsize(self.stream), size) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: module 'termios' has no attribute 'tcgetwinsize' def test_tcgetwinsize_errors(self): self.assertRaisesTermiosError(errno.ENOTTY, termios.tcgetwinsize, self.bad_fd) self.assertRaises(ValueError, termios.tcgetwinsize, -1) @@ -238,14 +236,12 @@ def test_tcgetwinsize_errors(self): self.assertRaises(TypeError, termios.tcgetwinsize, object()) self.assertRaises(TypeError, termios.tcgetwinsize) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: module 'termios' has no attribute 'tcgetwinsize' def test_tcsetwinsize(self): size = termios.tcgetwinsize(self.fd) termios.tcsetwinsize(self.fd, size) termios.tcsetwinsize(self.fd, list(size)) termios.tcsetwinsize(self.stream, size) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: module 'termios' has no attribute 'tcgetwinsize' def test_tcsetwinsize_errors(self): size = termios.tcgetwinsize(self.fd) self.assertRaises(TypeError, termios.tcsetwinsize, self.fd, size[:-1]) diff --git a/crates/host_env/src/termios.rs b/crates/host_env/src/termios.rs index 0a96078be94..612f68be924 100644 --- a/crates/host_env/src/termios.rs +++ b/crates/host_env/src/termios.rs @@ -165,3 +165,27 @@ pub fn tcflush(fd: i32, queue: i32) -> std::io::Result<()> { pub fn tcflow(fd: i32, action: i32) -> std::io::Result<()> { ::termios::tcflow(fd, action) } + +pub fn tcgetwinsize(fd: i32) -> std::io::Result<(u16, u16)> { + let mut size: libc::winsize = unsafe { core::mem::zeroed() }; + let ret = unsafe { libc::ioctl(fd, TIOCGWINSZ as _, &mut size) }; + if ret < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok((size.ws_row, size.ws_col)) +} + +pub fn tcsetwinsize(fd: i32, row: u16, col: u16) -> std::io::Result<()> { + let mut size: libc::winsize = unsafe { core::mem::zeroed() }; + let ret = unsafe { libc::ioctl(fd, TIOCGWINSZ as _, &mut size) }; + if ret < 0 { + return Err(std::io::Error::last_os_error()); + } + size.ws_row = row; + size.ws_col = col; + let ret = unsafe { libc::ioctl(fd, TIOCSWINSZ as _, &size) }; + if ret < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} diff --git a/crates/stdlib/src/termios.rs b/crates/stdlib/src/termios.rs index 2207276f81e..67d382aa521 100644 --- a/crates/stdlib/src/termios.rs +++ b/crates/stdlib/src/termios.rs @@ -268,6 +268,28 @@ mod termios { Ok(()) } + #[pyfunction] + fn tcgetwinsize(Fildes(fd): Fildes, vm: &VirtualMachine) -> PyResult<(u16, u16)> { + let size = host_termios::tcgetwinsize(fd).map_err(|e| termios_error(e, vm))?; + Ok(size) + } + + #[pyfunction] + fn tcsetwinsize(Fildes(fd): Fildes, size: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { + let seq = size.try_sequence(vm)?; + if seq.length(vm)? != 2 { + return Err(vm.new_type_error("tcsetwinsize: size must be a 2 element sequence")); + } + let row = seq.get_item(0, vm)?; + let col = seq.get_item(1, vm)?; + + let row: u16 = row.try_index(vm)?.try_to_primitive(vm)?; + let col: u16 = col.try_index(vm)?.try_to_primitive(vm)?; + + host_termios::tcsetwinsize(fd, row, col).map_err(|e| termios_error(e, vm))?; + Ok(()) + } + fn termios_error(err: std::io::Error, vm: &VirtualMachine) -> PyBaseExceptionRef { vm.new_os_subtype_error( error_type(vm), From 44e5d84659798f7c31cc972750599151373ecb17 Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:47:35 +0900 Subject: [PATCH 187/351] _thread: initialize local subclasses per thread (#8353) Assisted-by: Codex:gpt-5.6 --- Lib/test/test_threading_local.py | 8 -- crates/vm/src/stdlib/_thread.rs | 147 +++++++++++++++++++++++++------ 2 files changed, 122 insertions(+), 33 deletions(-) diff --git a/Lib/test/test_threading_local.py b/Lib/test/test_threading_local.py index 0c805c5b055..17f031def7c 100644 --- a/Lib/test/test_threading_local.py +++ b/Lib/test/test_threading_local.py @@ -228,14 +228,6 @@ def __eq__(self, other): class ThreadLocalTest(unittest.TestCase, BaseLocalTest): _local = _thread._local - @unittest.expectedFailure # TODO: RUSTPYTHON - def test_cycle_collection(self): - return super().test_cycle_collection() - - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: TypeError not raised by _local - def test_arguments(self): - return super().test_arguments() - class PyThreadingLocalTest(unittest.TestCase, BaseLocalTest): _local = _threading_local.local diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index 91fec0a0232..a1942adc784 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -24,9 +24,10 @@ pub(crate) mod _thread { PyBaseExceptionRef, PyDictRef, PyIntRef, PyStr, PyTupleRef, PyType, PyTypeRef, PyUtf8StrRef, }, - common::wtf8::Wtf8Buf, + common::{lock::PyMutex, wtf8::Wtf8Buf}, frame::FrameRef, function::{ArgCallable, FuncArgs, KwArgs, OptionalArg, PySetterValue, TimeoutSeconds}, + object::{Traverse, TraverseFn}, types::{Constructor, GetAttr, Representable, SetAttr}, }; @@ -619,11 +620,10 @@ pub(crate) mod _thread { /// Clean up thread-local data for the current thread. /// This triggers __del__ on objects stored in thread-local variables. fn cleanup_thread_local_data() { - // Take all guards - this will trigger LocalGuard::drop for each, - // which removes the thread's dict from each Local instance - LOCAL_GUARDS.with(|guards| { - guards.borrow_mut().clear(); - }); + // Move all guards out before dropping them. A local dict's __del__ may + // re-enter thread-local access and borrow LOCAL_GUARDS again. + let guards = LOCAL_GUARDS.take(); + drop(guards); } #[cfg(all(not(target_arch = "wasm32"), feature = "host_env"))] @@ -929,15 +929,36 @@ pub(crate) mod _thread { if let Some(local_data) = self.local.upgrade() { // Remove from map while holding the lock, but drop the value // outside the lock to prevent deadlock if __del__ accesses _local - let removed = local_data.data.lock().remove(&self.thread_id); + let removed = local_data.state.lock().dicts.remove(&self.thread_id); drop(removed); } } } + struct LocalState { + init_args: FuncArgs, + dicts: std::collections::HashMap, + } + + unsafe impl Traverse for LocalState { + fn traverse(&self, tracer_fn: &mut TraverseFn<'_>) { + self.init_args.traverse(tracer_fn); + #[allow(clippy::iter_over_hash_type)] + for dict in self.dicts.values() { + dict.traverse(tracer_fn); + } + } + + fn clear(&mut self, out: &mut Vec) { + out.append(&mut self.init_args.args); + out.extend(self.init_args.kwargs.drain(..).map(|(_, value)| value)); + out.extend(self.dicts.drain().map(|(_, dict)| dict.into())); + } + } + // Shared data structure for Local struct LocalData { - data: parking_lot::Mutex>, + state: PyMutex, } impl fmt::Debug for LocalData { @@ -947,37 +968,62 @@ pub(crate) mod _thread { } #[pyattr] - #[pyclass(module = "_thread", name = "_local")] + #[pyclass(module = "_thread", name = "_local", traverse = "manual")] #[derive(Debug, PyPayload)] struct Local { inner: Arc, } + unsafe impl Traverse for Local { + fn traverse(&self, tracer_fn: &mut TraverseFn<'_>) { + self.inner.state.traverse(tracer_fn); + } + + fn clear(&mut self, out: &mut Vec) { + if let Some(mut state) = self.inner.state.try_lock() { + state.clear(out); + } + } + } + #[pyclass(with(GetAttr, SetAttr), flags(BASETYPE))] impl Local { - fn l_dict(&self, vm: &VirtualMachine) -> PyDictRef { + fn custom_init(cls: &Py, vm: &VirtualMachine) -> Option { + let cls_init = cls.slots.init.load()?; + let object_init = vm + .ctx + .types + .object_type + .slots + .init + .load() + .map(|init| init as usize); + (Some(cls_init as usize) != object_init).then_some(cls_init) + } + + fn create_dict(&self, vm: &VirtualMachine) -> (PyDictRef, bool) { let thread_id = current_thread_id(); // Fast path: check if dict exists under lock - let value = self.inner.data.lock().get(&thread_id).cloned(); + let value = self.inner.state.lock().dicts.get(&thread_id).cloned(); if let Some(dict) = value { - return dict; + return (dict, false); } // Slow path: allocate dict outside lock to reduce lock hold time let new_dict = vm.ctx.new_dict(); // Insert with double-check to handle races - let mut data = self.inner.data.lock(); + let mut state = self.inner.state.lock(); use std::collections::hash_map::Entry; - let (dict, need_guard) = match data.entry(thread_id) { + let (dict, need_guard) = match state.dicts.entry(thread_id) { Entry::Occupied(e) => (e.get().clone(), false), Entry::Vacant(e) => { e.insert(new_dict.clone()); (new_dict, true) } }; - drop(data); // Release lock before TLS access + drop(state); // Release lock before TLS access // Register cleanup guard only if we inserted a new entry if need_guard { @@ -990,29 +1036,80 @@ pub(crate) mod _thread { }); } - dict + (dict, need_guard) + } + + fn remove_current_dict(&self) { + let thread_id = current_thread_id(); + let guard = LOCAL_GUARDS.with(|guards| { + let mut guards = guards.borrow_mut(); + guards + .iter() + .rposition(|guard| { + guard.thread_id == thread_id + && guard.local.as_ptr() == Arc::as_ptr(&self.inner) + }) + .map(|position| guards.remove(position)) + }); + + if let Some(guard) = guard { + drop(guard); + } else { + let removed = self.inner.state.lock().dicts.remove(&thread_id); + drop(removed); + } + } + + fn l_dict(zelf: &Py, vm: &VirtualMachine) -> PyResult { + let (dict, created) = zelf.create_dict(vm); + if !created { + return Ok(dict); + } + + let Some(init) = Self::custom_init(zelf.class(), vm) else { + return Ok(dict); + }; + let init_args = zelf.inner.state.lock().init_args.clone(); + if let Err(err) = init(zelf.as_object().to_owned(), init_args, vm) { + zelf.remove_current_dict(); + return Err(err); + } + + Ok(dict) } #[pygetset(name = "__dict__")] - fn dict(zelf: PyRef, vm: &VirtualMachine) -> PyDictRef { - zelf.l_dict(vm) + fn dict(zelf: PyRef, vm: &VirtualMachine) -> PyResult { + Self::l_dict(&zelf, vm) } #[pyslot] - fn slot_new(cls: PyTypeRef, _args: FuncArgs, vm: &VirtualMachine) -> PyResult { - Self { + fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { + if !args.is_empty() && Self::custom_init(&cls, vm).is_none() { + return Err(vm.new_type_error("Initialization arguments are not supported")); + } + + let zelf = Self { inner: Arc::new(LocalData { - data: parking_lot::Mutex::new(std::collections::HashMap::new()), + state: PyMutex::new(LocalState { + init_args: args, + dicts: std::collections::HashMap::new(), + }), }), } - .into_ref_with_type(vm, cls) - .map(Into::into) + .into_ref_with_type(vm, cls)?; + + // type.__call__ invokes __init__ after __new__. Create this thread's + // dict first so assignments made by __init__ cannot recursively + // initialize the same local object. + zelf.create_dict(vm); + Ok(zelf.into()) } } impl GetAttr for Local { fn getattro(zelf: &Py, attr: &Py, vm: &VirtualMachine) -> PyResult { - let l_dict = zelf.l_dict(vm); + let l_dict = Self::l_dict(zelf, vm)?; if attr.as_bytes() == b"__dict__" { Ok(l_dict.into()) } else { @@ -1042,7 +1139,7 @@ pub(crate) mod _thread { zelf.class().name() ))) } else { - let dict = zelf.l_dict(vm); + let dict = Self::l_dict(zelf, vm)?; if let PySetterValue::Assign(value) = value { dict.set_item(attr, value, vm)?; } else { From 92fe3fd668d848454330bd947f80dd48ff4aaf97 Mon Sep 17 00:00:00 2001 From: sijun-yang Date: Sun, 26 Jul 2026 13:49:19 +0900 Subject: [PATCH 188/351] Propagate method wrapper repr errors (#8371) Assisted-by: Codex:gpt-5.6-sol --- crates/vm/src/builtins/classmethod.rs | 2 +- crates/vm/src/builtins/staticmethod.rs | 2 +- extra_tests/snippets/syntax_class.py | 23 +++++++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/vm/src/builtins/classmethod.rs b/crates/vm/src/builtins/classmethod.rs index a6c69eb50c7..eb0e15ece01 100644 --- a/crates/vm/src/builtins/classmethod.rs +++ b/crates/vm/src/builtins/classmethod.rs @@ -195,7 +195,7 @@ impl PyClassMethod { impl Representable for PyClassMethod { #[inline] fn repr_str(zelf: &Py, vm: &VirtualMachine) -> PyResult { - let callable = zelf.callable.lock().repr(vm).unwrap(); + let callable = zelf.callable.lock().repr(vm)?; let class = Self::class(&vm.ctx); let repr = match ( diff --git a/crates/vm/src/builtins/staticmethod.rs b/crates/vm/src/builtins/staticmethod.rs index 1ab697a0a1d..addfe8a4e2b 100644 --- a/crates/vm/src/builtins/staticmethod.rs +++ b/crates/vm/src/builtins/staticmethod.rs @@ -179,7 +179,7 @@ impl Callable for PyStaticMethod { impl Representable for PyStaticMethod { fn repr_str(zelf: &Py, vm: &VirtualMachine) -> PyResult { - let callable = zelf.callable.lock().repr(vm).unwrap(); + let callable = zelf.callable.lock().repr(vm)?; let class = Self::class(&vm.ctx); match ( diff --git a/extra_tests/snippets/syntax_class.py b/extra_tests/snippets/syntax_class.py index 4e80e7edf8c..4d1a99edfb0 100644 --- a/extra_tests/snippets/syntax_class.py +++ b/extra_tests/snippets/syntax_class.py @@ -163,6 +163,29 @@ def t1(self): cm = classmethod(lambda cls: cls) assert cm.__func__(int) is int + +class Callback: + def __init__(self, error): + self.error = error + + def __call__(self, *args, **kwargs): + pass + + def __repr__(self): + raise self.error + + +callback = Callback(RuntimeError("callback is unavailable")) + +with assert_raises(RuntimeError) as caught: + repr(staticmethod(callback)) +assert caught.exception is callback.error + +with assert_raises(RuntimeError) as caught: + repr(classmethod(callback)) +assert caught.exception is callback.error + + assert str(super(int, 5)) == ", >" class T5(int): From 9add522a441ae3de6799f35fa162907762673cf6 Mon Sep 17 00:00:00 2001 From: OkJa <151524504+name-of-okja@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:31:34 +0900 Subject: [PATCH 189/351] Implement fractional grouping in format specs (#8373) Support the CPython 3.13+ format spec syntax that puts a grouping option after the precision, so `format(1234.56789, '.6,f')` gives '1234.567,890'. The integer and fractional parts group independently and may use different separators (`,.6_f` -> '1,234.567_890'), so the spec carries a separate `frac_grouping_option`. `parse_precision` now consumes the separator that follows the precision digits and rejects a `,`/`_` mix. A repeated separator is deliberately left in the spec so the trailing-text check reports it, which is how CPython arrives at a different message there. A dot followed by neither digits nor a separator now raises "Format specifier missing precision" rather than an unrelated error. Fraction digits group away from the decimal point, so the last group may be short, and any exponent or percent tail is left intact. The separators count toward the field width, so zero padding of the integer part reserves room for them. 'n' takes its separators from the locale and so cannot carry one. The complex locale path rewrites 'n' to 'g' before delegating, so it has to validate first; that also makes `format(1+2j, ',n')` fail as it does in CPython, which it previously did not. Reference (CPython 3.14), Python/formatter_unicode.c: - parsing: https://github.com/python/cpython/blob/3.14/Python/formatter_unicode.c#L257-L299 - 'n' rejection: https://github.com/python/cpython/blob/3.14/Python/formatter_unicode.c#L361-L367 - number split: https://github.com/python/cpython/blob/3.14/Python/formatter_unicode.c#L488-L516 - zero-pad width: https://github.com/python/cpython/blob/3.14/Python/formatter_unicode.c#L604-L606 Remove `@unittest.expectedFailure` from tests that now pass (2 in test_format, 1 in test_float). Assisted-by: Claude:claude-opus-5 Co-authored-by: Claude Opus 5 --- Lib/test/test_float.py | 1 - Lib/test/test_format.py | 2 - crates/common/src/format.rs | 222 ++++++++++++++++++++++++++++++++---- crates/vm/src/format.rs | 1 + 4 files changed, 201 insertions(+), 25 deletions(-) diff --git a/Lib/test/test_float.py b/Lib/test/test_float.py index a514111c1b8..609a7164fa0 100644 --- a/Lib/test/test_float.py +++ b/Lib/test/test_float.py @@ -725,7 +725,6 @@ def test_serialized_float_rounding(self): class FormatTestCase(unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: Invalid format specifier def test_format(self): # these should be rewritten to use both format(x, spec) and # x.__format__(spec) diff --git a/Lib/test/test_format.py b/Lib/test/test_format.py index 6868c87171d..f6452341e1e 100644 --- a/Lib/test/test_format.py +++ b/Lib/test/test_format.py @@ -515,7 +515,6 @@ def test_with_two_underscore_in_format_specifier(self): with self.assertRaisesRegex(ValueError, error_msg): '{:__}'.format(1) - @unittest.expectedFailure # TODO: RUSTPYTHON; Wrong error message def test_with_a_commas_and_an_underscore_in_format_specifier(self): error_msg = re.escape("Cannot specify both ',' and '_'.") with self.assertRaisesRegex(ValueError, error_msg): @@ -523,7 +522,6 @@ def test_with_a_commas_and_an_underscore_in_format_specifier(self): with self.assertRaisesRegex(ValueError, error_msg): '{:.,_f}'.format(1.1) - @unittest.expectedFailure # TODO: RUSTPYTHON; Wrong error message def test_with_an_underscore_and_a_comma_in_format_specifier(self): error_msg = re.escape("Cannot specify both ',' and '_'.") with self.assertRaisesRegex(ValueError, error_msg): diff --git a/crates/common/src/format.rs b/crates/common/src/format.rs index 1f43335b73e..4218dca7b74 100644 --- a/crates/common/src/format.rs +++ b/crates/common/src/format.rs @@ -232,6 +232,7 @@ pub struct FormatSpec { width: Option, grouping_option: Option, precision: Option, + frac_grouping_option: Option, format_type: Option, } @@ -291,22 +292,49 @@ fn parse_zero(text: &Wtf8) -> (bool, &Wtf8) { } } -fn parse_precision(text: &Wtf8) -> Result<(Option, &Wtf8), FormatSpecError> { +fn parse_char(text: &Wtf8, expected: char) -> (bool, &Wtf8) { let mut chars = text.code_points(); - Ok(match chars.next().and_then(CodePoint::to_char) { - Some('.') => { - let (size, remaining) = parse_number(chars.as_wtf8())?; - if let Some(size) = size { - if size > i32::MAX as usize { - return Err(FormatSpecError::PrecisionTooBig); - } - (Some(size), remaining) - } else { - (None, text) - } + if chars.next().and_then(CodePoint::to_char) == Some(expected) { + (true, chars.as_wtf8()) + } else { + (false, text) + } +} + +fn parse_precision( + text: &Wtf8, +) -> Result<(Option, Option, &Wtf8), FormatSpecError> { + let (dot, text) = parse_char(text, '.'); + if !dot { + return Ok((None, None, text)); + } + let (precision, text) = parse_number(text)?; + if let Some(precision) = precision + && precision > i32::MAX as usize + { + return Err(FormatSpecError::PrecisionTooBig); + } + let mut frac_grouping = None; + let (comma, text) = parse_char(text, ','); + if comma { + frac_grouping = Some(FormatGrouping::Comma); + } + let (underscore, text) = parse_char(text, '_'); + if underscore { + if frac_grouping.is_some() { + return Err(FormatSpecError::ExclusiveFormat(',', '_')); } - _ => (None, text), - }) + frac_grouping = Some(FormatGrouping::Underscore); + } + let (trailing_comma, _) = parse_char(text, ','); + if trailing_comma && frac_grouping == Some(FormatGrouping::Underscore) { + return Err(FormatSpecError::ExclusiveFormat(',', '_')); + } + // Not having a precision or underscore/comma after a dot is an error. + if precision.is_none() && frac_grouping.is_none() { + return Err(FormatSpecError::PrecisionMissing); + } + Ok((precision, frac_grouping, text)) } impl FormatSpec { @@ -331,7 +359,7 @@ impl FormatSpec { if let Some(grouping) = grouping_option { Self::validate_separator(grouping, text)?; } - let (precision, text) = parse_precision(text)?; + let (precision, frac_grouping_option, text) = parse_precision(text)?; let (format_type, text) = FormatType::parse(text); if !text.is_empty() { return Err(FormatSpecError::InvalidFormatSpecifier); @@ -351,6 +379,7 @@ impl FormatSpec { width, grouping_option, precision, + frac_grouping_option, format_type, }) } @@ -463,7 +492,14 @@ impl FormatSpec { Err(FormatSpecError::UnspecifiedFormat('_', ch)) } _ => Ok(()), + }?; + if let Some(grouping) = self.frac_grouping_option + && matches!(format_type, FormatType::Number(_)) + { + let ch = char::from(format_type); + return Err(FormatSpecError::UnspecifiedFormat(char::from(grouping), ch)); } + Ok(()) } const fn get_separator_interval(&self) -> usize { @@ -491,7 +527,9 @@ impl FormatSpec { let disp_digit_cnt = if self.fill == Some('0'.into()) && self.align == Some(FormatAlign::AfterSign) { - let width = self.width.unwrap_or(magnitude_len) as i32 - prefix.len() as i32; + let width = self.width.unwrap_or(magnitude_len) as i32 + - prefix.len() as i32 + - self.frac_separator_count(&magnitude_str) as i32; cmp::max(width, magnitude_len as i32) } else { magnitude_len as i32 @@ -502,6 +540,41 @@ impl FormatSpec { } } + fn frac_digit_span(&self, magnitude_str: &str) -> Option<(FormatGrouping, usize, usize)> { + let grouping = self.frac_grouping_option?; + let start = magnitude_str.find('.')? + 1; + let end = magnitude_str[start..] + .bytes() + .position(|b| !b.is_ascii_digit()) + .map_or(magnitude_str.len(), |offset| start + offset); + (start < end).then_some((grouping, start, end)) + } + + fn frac_separator_count(&self, magnitude_str: &str) -> usize { + match self.frac_digit_span(magnitude_str) { + Some((_, start, end)) => (end - start - 1) / self.get_separator_interval(), + None => 0, + } + } + + fn add_frac_separators(&self, magnitude_str: String) -> String { + let Some((grouping, start, end)) = self.frac_digit_span(&magnitude_str) else { + return magnitude_str; + }; + let inter = self.get_separator_interval(); + let sep = char::from(grouping); + let mut result = magnitude_str[..start].to_string(); + let mut frac = &magnitude_str[start..end]; + while frac.len() > inter { + result.push_str(&frac[..inter]); + result.push(sep); + frac = &frac[inter..]; + } + result.push_str(frac); + result.push_str(&magnitude_str[end..]); + result + } + /// Returns true if this format spec uses the locale-aware 'n' format type. #[must_use] pub fn has_locale_format(&self) -> bool { @@ -664,6 +737,7 @@ impl FormatSpec { num: &Complex64, locale: &LocaleInfo, ) -> Result { + self.validate_format(FormatType::FixedPoint(Case::Lower))?; // Reuse format_complex_re_im with 'g' type to get the base formatted parts, // then apply locale grouping. This matches CPython's format_complex_internal: // 'n' → 'g', add_parens=0, skip_re=0. @@ -850,6 +924,7 @@ impl FormatSpec { } }; let magnitude_str = self.add_magnitude_separators(raw_magnitude_str?, sign_str); + let magnitude_str = self.add_frac_separators(magnitude_str); Ok( self.format_sign_and_align( &AsciiStr::new(&magnitude_str), @@ -1074,17 +1149,16 @@ impl FormatSpec { }, }, }?; - match &self.grouping_option { + let magnitude_str = match &self.grouping_option { Some(fg) => { let sep = char::from(fg); let inter = self.get_separator_interval().try_into().unwrap(); let len = magnitude_str.len() as i32; - let separated_magnitude = - Self::add_magnitude_separators_for_char(magnitude_str, inter, sep, len); - Ok(separated_magnitude) + Self::add_magnitude_separators_for_char(magnitude_str, inter, sep, len) } - None => Ok(magnitude_str), - } + None => magnitude_str, + }; + Ok(self.add_frac_separators(magnitude_str)) } fn format_sign_and_align( @@ -1175,6 +1249,7 @@ impl Deref for AsciiStr<'_> { pub enum FormatSpecError { DecimalDigitsTooMany, PrecisionTooBig, + PrecisionMissing, InvalidFormatSpecifier, UnspecifiedFormat(char, char), ExclusiveFormat(char, char), @@ -1539,6 +1614,7 @@ mod tests { width: Some(33), grouping_option: None, precision: None, + frac_grouping_option: None, format_type: None, }); assert_eq!(FormatSpec::parse("33"), expected); @@ -1555,6 +1631,7 @@ mod tests { width: Some(33), grouping_option: None, precision: None, + frac_grouping_option: None, format_type: None, }); assert_eq!(FormatSpec::parse("<>33"), expected); @@ -1571,6 +1648,7 @@ mod tests { width: Some(23), grouping_option: Some(FormatGrouping::Comma), precision: Some(11), + frac_grouping_option: None, format_type: Some(FormatType::Binary), }); assert_eq!(FormatSpec::parse("<>-#23,.11b"), expected); @@ -1738,6 +1816,106 @@ mod tests { assert_eq!(fmt_float("06,%", f64::INFINITY), "00inf%"); } + #[test] + fn format_float_fractional_grouping() { + // Fraction digits group away from the decimal point, so the last group + // may be shorter than the interval. + assert_eq!(fmt_float(".6,f", 1234.56789), "1234.567,890"); + assert_eq!(fmt_float(".7,f", 1234.56789), "1234.567,890,0"); + assert_eq!(fmt_float(".4,f", 1.1), "1.100,0"); + assert_eq!(fmt_float(".3,f", 1.1), "1.100"); + assert_eq!(fmt_float(".6_f", 1234.56789), "1234.567_890"); + // Omitting the precision keeps the type's default. + assert_eq!(fmt_float(".,f", 1.1), "1.100,000"); + // The two parts are independent and may use different separators. + assert_eq!(fmt_float(",.6,f", 1234.56789), "1,234.567,890"); + assert_eq!(fmt_float(",.6_f", 1234.56789), "1,234.567_890"); + assert_eq!(fmt_float("_.6,f", 1234.56789), "1_234.567,890"); + } + + #[test] + fn format_float_fractional_grouping_never_touches_tail() { + // Only the digits between the point and any tail are groupable: the + // exponent and a trailing percent sign must stay intact. + assert_eq!(fmt_float(".6,e", 12345678900.0), "1.234,568e+10"); + assert_eq!(fmt_float(".6,E", 1234.5678), "1.234,568E+03"); + assert_eq!(fmt_float(".8,%", 1.2345e-05), "0.001,234,50%"); + // Values with no point have nothing to group. + assert_eq!(fmt_float(".6,f", f64::INFINITY), "inf"); + assert_eq!(fmt_float(".6,f", f64::NAN), "nan"); + assert_eq!(fmt_float(".0,f", 1234.56789), "1235"); + } + + #[test] + fn format_float_fractional_grouping_counts_toward_width() { + // Separators are inserted before padding, so they consume width. + assert_eq!(fmt_float("020.6,f", 1234.56789), "000000001234.567,890"); + assert_eq!(fmt_float("015.6,f", 1.5), "0000001.500,000"); + assert_eq!(fmt_float("<20.6,f", 1234.56789), "1234.567,890 "); + // Zero padding of the integer part must reserve room for them too. + assert_eq!(fmt_float("020,.6,f", 1234.56789), "0,000,001,234.567,890"); + assert_eq!(fmt_float("+020,.6_f", 1e-10), "+000,000,000.000_000"); + assert_eq!(fmt_float("= 015,.6,E", 1234.0), " 01.234,000E+03"); + assert_eq!(fmt_float("-015_._e", 1.1), "001.100_000e+00"); + } + + #[test] + fn format_parse_fractional_grouping_errors() { + // Mixing the two separators is rejected wherever it appears. + assert_eq!( + FormatSpec::parse(".,_f"), + Err(FormatSpecError::ExclusiveFormat(',', '_')) + ); + assert_eq!( + FormatSpec::parse("._,f"), + Err(FormatSpecError::ExclusiveFormat(',', '_')) + ); + // A repeated separator is left in the spec and rejected as a whole. + assert_eq!( + FormatSpec::parse(".,,f"), + Err(FormatSpecError::InvalidFormatSpecifier) + ); + assert_eq!( + FormatSpec::parse(".__f"), + Err(FormatSpecError::InvalidFormatSpecifier) + ); + // A dot needs either digits or a separator after it. + assert_eq!( + FormatSpec::parse("."), + Err(FormatSpecError::PrecisionMissing) + ); + assert_eq!( + FormatSpec::parse(".f"), + Err(FormatSpecError::PrecisionMissing) + ); + // 'n' draws its separators from the locale. + assert_eq!( + FormatSpec::parse(".6,n").unwrap().format_float(1234.5678), + Err(FormatSpecError::UnspecifiedFormat(',', 'n')) + ); + assert_eq!( + FormatSpec::parse("._n").unwrap().format_float(1234.5678), + Err(FormatSpecError::UnspecifiedFormat('_', 'n')) + ); + // The integer separator is reported first when both are present. + assert_eq!( + FormatSpec::parse("_.6,n").unwrap().format_float(1234.5678), + Err(FormatSpecError::UnspecifiedFormat('_', 'n')) + ); + // The complex locale path rewrites 'n' to 'g', so it must validate first. + let locale = LocaleInfo { + thousands_sep: ",".to_owned(), + decimal_point: ".".to_owned(), + grouping: vec![3, 0], + }; + assert_eq!( + FormatSpec::parse(".6,n") + .unwrap() + .format_complex_locale(&Complex64::new(1.0, 2.345678), &locale), + Err(FormatSpecError::UnspecifiedFormat(',', 'n')) + ); + } + #[test] fn format_float_empty_type_with_precision() { // Empty presentation type with a precision is repr-like: precision is diff --git a/crates/vm/src/format.rs b/crates/vm/src/format.rs index 7e18dd75d2f..2f4652dccdd 100644 --- a/crates/vm/src/format.rs +++ b/crates/vm/src/format.rs @@ -48,6 +48,7 @@ impl IntoPyException for FormatSpecError { vm.new_value_error("Too many decimal digits in format string") } Self::PrecisionTooBig => vm.new_value_error("Precision too big"), + Self::PrecisionMissing => vm.new_value_error("Format specifier missing precision"), Self::InvalidFormatSpecifier => vm.new_value_error("Invalid format specifier"), Self::UnspecifiedFormat(c1, c2) => { let msg = format!("Cannot specify '{c1}' with '{c2}'."); From 5db61a009da659eca31012f48798231716d5ed09 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:31:55 +0900 Subject: [PATCH 190/351] build(deps-dev): bump webpack-dev-server in /wasm/demo (#8374) Bumps [webpack-dev-server](https://github.com/webpack/webpack-dev-server) from 5.2.5 to 5.2.6. - [Release notes](https://github.com/webpack/webpack-dev-server/releases) - [Changelog](https://github.com/webpack/webpack-dev-server/blob/v5.2.6/CHANGELOG.md) - [Commits](https://github.com/webpack/webpack-dev-server/compare/v5.2.5...v5.2.6) --- updated-dependencies: - dependency-name: webpack-dev-server dependency-version: 5.2.6 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- wasm/demo/package-lock.json | 10 +++++----- wasm/demo/package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/wasm/demo/package-lock.json b/wasm/demo/package-lock.json index a60d0056062..9cfd625aaa7 100644 --- a/wasm/demo/package-lock.json +++ b/wasm/demo/package-lock.json @@ -24,7 +24,7 @@ "serve": "^14.2.6", "webpack": "^5.105.0", "webpack-cli": "^6.0.1", - "webpack-dev-server": "^5.2.5" + "webpack-dev-server": "^5.2.6" } }, "node_modules/@codemirror/autocomplete": { @@ -5691,9 +5691,9 @@ } }, "node_modules/webpack-dev-server": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.5.tgz", - "integrity": "sha512-4wZtCquSuv9CKX8oybo+mqxtxZqWz47uM1Ch94lxowBztOhWCbhqvRbfC/mODOwxgV2brY+JGZpHq58/SuVFYg==", + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.6.tgz", + "integrity": "sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==", "dev": true, "license": "MIT", "dependencies": { @@ -5715,7 +5715,7 @@ "graceful-fs": "^4.2.6", "http-proxy-middleware": "^2.0.9", "ipaddr.js": "^2.1.0", - "launch-editor": "^2.6.1", + "launch-editor": "^2.14.1", "open": "^10.0.3", "p-retry": "^6.2.0", "schema-utils": "^4.2.0", diff --git a/wasm/demo/package.json b/wasm/demo/package.json index 04d7aa78c9f..2aa9e5867ae 100644 --- a/wasm/demo/package.json +++ b/wasm/demo/package.json @@ -19,7 +19,7 @@ "serve": "^14.2.6", "webpack": "^5.105.0", "webpack-cli": "^6.0.1", - "webpack-dev-server": "^5.2.5" + "webpack-dev-server": "^5.2.6" }, "scripts": { "dev": "webpack serve", From f0708f58293bea909e12af15221b156bc3ab0111 Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:35:01 -0400 Subject: [PATCH 191/351] Use memchr consistently for NUL checks (#8372) `memchr` is used throughout RustPython for searching through bytes expediently. However, for NUL checks, it's scantily used. Instead, our NUL checks either use `contains` or `memchr`. I switched all of the `contains(b'\0')` I could find to using memchr instead. I marked the failure paths as cold to hint to LLVM that interior NULs are truly exceptional. This should help branch prediction a bit which is nice for string/bytes functions since they are likely called a lot. --- Cargo.lock | 1 + crates/host_env/Cargo.toml | 1 + crates/host_env/src/multiprocessing.rs | 19 +++++++------ crates/host_env/src/time.rs | 7 ++--- crates/host_env/src/winapi.rs | 25 ++++++++--------- crates/stdlib/src/grp.rs | 6 ++-- crates/stdlib/src/mmap.rs | 9 +++--- crates/stdlib/src/multiprocessing.rs | 4 +-- crates/stdlib/src/openssl.rs | 20 ++++++++------ crates/stdlib/src/ssl.rs | 25 ++++------------- crates/vm/src/builtins/bytes.rs | 10 ++++++- crates/vm/src/builtins/str.rs | 10 ++++++- crates/vm/src/bytes_inner.rs | 4 +-- crates/vm/src/function/fspath.rs | 18 ++++++------ crates/vm/src/ospath.rs | 2 ++ crates/vm/src/stdlib/_codecs.rs | 11 ++++++-- crates/vm/src/stdlib/_io.rs | 17 ++++++------ crates/vm/src/stdlib/nt.rs | 4 ++- crates/vm/src/stdlib/os.rs | 38 ++++++++++++++++---------- crates/vm/src/stdlib/pwd.rs | 10 ++++--- crates/vm/src/utils.rs | 8 ------ 21 files changed, 134 insertions(+), 115 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 463a7298460..4f113adf091 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3431,6 +3431,7 @@ dependencies = [ "libffi", "libloading 0.9.0", "mac_address", + "memchr", "memmap2 0.9.11", "nix 0.31.3", "num-traits", diff --git a/crates/host_env/Cargo.toml b/crates/host_env/Cargo.toml index 07f7d0da60c..128afc59285 100644 --- a/crates/host_env/Cargo.toml +++ b/crates/host_env/Cargo.toml @@ -51,6 +51,7 @@ libffi = { workspace = true, features = ["system"] } system-configuration = { workspace = true } [target.'cfg(windows)'.dependencies] +memchr.workspace = true junction = { workspace = true } schannel = { workspace = true } widestring = { workspace = true } diff --git a/crates/host_env/src/multiprocessing.rs b/crates/host_env/src/multiprocessing.rs index 0030245dbb4..067a8630777 100644 --- a/crates/host_env/src/multiprocessing.rs +++ b/crates/host_env/src/multiprocessing.rs @@ -32,12 +32,13 @@ pub enum SemError { AlreadyExists, NotFound, InvalidInput, + InteriorNul, Other(i32), } #[cfg(unix)] impl SemError { - fn from_errno(err: Errno) -> Self { + const fn from_errno(err: Errno) -> Self { match err { Errno::EAGAIN => Self::WouldBlock, Errno::ETIMEDOUT => Self::TimedOut, @@ -49,14 +50,14 @@ impl SemError { } } - pub fn raw_os_error(self) -> i32 { + pub const fn raw_os_error(self) -> i32 { match self { Self::WouldBlock => Errno::EAGAIN as i32, Self::TimedOut => Errno::ETIMEDOUT as i32, Self::Interrupted => Errno::EINTR as i32, Self::AlreadyExists => Errno::EEXIST as i32, Self::NotFound => Errno::ENOENT as i32, - Self::InvalidInput => Errno::EINVAL as i32, + Self::InvalidInput | Self::InteriorNul => Errno::EINVAL as i32, Self::Other(code) => code, } } @@ -119,7 +120,7 @@ impl SemHandle { value: u32, unlink: bool, ) -> Result<(Self, Option), SemError> { - let cname = semaphore_name(name).map_err(|_| SemError::InvalidInput)?; + let cname = semaphore_name(name)?; let raw = unsafe { libc::sem_open(cname.as_ptr(), libc::O_CREAT | libc::O_EXCL, 0o600, value) }; if raw == libc::SEM_FAILED { @@ -141,7 +142,7 @@ impl SemHandle { } pub fn open_existing(name: &str) -> Result { - let cname = semaphore_name(name).map_err(|_| SemError::InvalidInput)?; + let cname = semaphore_name(name)?; let raw = unsafe { libc::sem_open(cname.as_ptr(), 0) }; if raw == libc::SEM_FAILED { Err(SemError::from_errno(Errno::last())) @@ -305,18 +306,18 @@ pub fn is_too_many_posts(err: u32) -> bool { } #[cfg(unix)] -pub fn semaphore_name(name: &str) -> Result { - let mut full = String::with_capacity(name.len() + 1); +pub fn semaphore_name(name: &str) -> Result { + let mut full = String::with_capacity(name.len() + 2); if !name.starts_with('/') { full.push('/'); } full.push_str(name); - CString::new(full) + CString::new(full).map_err(|_| SemError::InteriorNul) } #[cfg(unix)] pub fn sem_unlink(name: &str) -> Result<(), SemError> { - let cname = semaphore_name(name).map_err(|_| SemError::InvalidInput)?; + let cname = semaphore_name(name)?; let res = unsafe { libc::sem_unlink(cname.as_ptr()) }; if res < 0 { Err(SemError::from_errno(Errno::last())) diff --git a/crates/host_env/src/time.rs b/crates/host_env/src/time.rs index dd97a936681..451e4884098 100644 --- a/crates/host_env/src/time.rs +++ b/crates/host_env/src/time.rs @@ -638,10 +638,9 @@ unsafe extern "C" { #[cfg(windows)] pub fn strftime_ascii(fmt: &str, tm: &libc::tm) -> Result { - if fmt.contains('\0') { - return Err(CheckedTmError::EmbeddedNul); - } - let fmt_wide: Vec = fmt.encode_utf16().chain(core::iter::once(0)).collect(); + let fmt_wide = widestring::WideCString::from_str(fmt) + .map_err(|_| CheckedTmError::EmbeddedNul)? + .into_vec_with_nul(); let mut size = 1024usize; let max_scale = 256usize.saturating_mul(fmt.len().max(1)); loop { diff --git a/crates/host_env/src/winapi.rs b/crates/host_env/src/winapi.rs index 4e4536c3518..af53910089e 100644 --- a/crates/host_env/src/winapi.rs +++ b/crates/host_env/src/winapi.rs @@ -3,22 +3,20 @@ reason = "This module mirrors Win32 APIs with raw handle and pointer parameters." )] +use core::hint::cold_path; use std::{io, path::Path}; -use windows_sys::Win32::{ - Foundation::{HANDLE, HMODULE, WAIT_FAILED}, - System::Threading::PROCESS_INFORMATION, -}; use crate::windows::{CheckWin32Bool, CheckWin32Handle}; +use memchr::memchr; pub use windows_sys::Win32::{ Foundation::{ DUPLICATE_CLOSE_SOURCE, DUPLICATE_SAME_ACCESS, ERROR_ACCESS_DENIED, ERROR_ALREADY_EXISTS, ERROR_BROKEN_PIPE, ERROR_IO_PENDING, ERROR_MORE_DATA, ERROR_NETNAME_DELETED, ERROR_NO_DATA, ERROR_NO_SYSTEM_RESOURCES, ERROR_NOT_FOUND, ERROR_OPERATION_ABORTED, ERROR_PIPE_BUSY, ERROR_PIPE_CONNECTED, ERROR_PORT_UNREACHABLE, ERROR_PRIVILEGE_NOT_HELD, ERROR_SEM_TIMEOUT, - ERROR_SUCCESS, GENERIC_READ, GENERIC_WRITE, STILL_ACTIVE, WAIT_ABANDONED_0, WAIT_OBJECT_0, - WAIT_TIMEOUT, + ERROR_SUCCESS, GENERIC_READ, GENERIC_WRITE, HANDLE, HMODULE, STILL_ACTIVE, + WAIT_ABANDONED_0, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT, }, Globalization::{ LCMAP_FULLWIDTH, LCMAP_HALFWIDTH, LCMAP_HIRAGANA, LCMAP_KATAKANA, LCMAP_LINGUISTIC_CASING, @@ -57,12 +55,12 @@ pub use windows_sys::Win32::{ ABOVE_NORMAL_PRIORITY_CLASS, BELOW_NORMAL_PRIORITY_CLASS, CREATE_BREAKAWAY_FROM_JOB, CREATE_DEFAULT_ERROR_MODE, CREATE_NEW_CONSOLE, CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, DETACHED_PROCESS, HIGH_PRIORITY_CLASS, IDLE_PRIORITY_CLASS, - NORMAL_PRIORITY_CLASS, PROCESS_ALL_ACCESS, PROCESS_DUP_HANDLE, REALTIME_PRIORITY_CLASS, - STARTF_FORCEOFFFEEDBACK, STARTF_FORCEONFEEDBACK, STARTF_PREVENTPINNING, - STARTF_RUNFULLSCREEN, STARTF_TITLEISAPPID, STARTF_TITLEISLINKNAME, - STARTF_UNTRUSTEDSOURCE, STARTF_USECOUNTCHARS, STARTF_USEFILLATTRIBUTE, - STARTF_USEHOTKEY, STARTF_USEPOSITION, STARTF_USESHOWWINDOW, STARTF_USESIZE, - STARTF_USESTDHANDLES, + NORMAL_PRIORITY_CLASS, PROCESS_ALL_ACCESS, PROCESS_DUP_HANDLE, PROCESS_INFORMATION, + REALTIME_PRIORITY_CLASS, STARTF_FORCEOFFFEEDBACK, STARTF_FORCEONFEEDBACK, + STARTF_PREVENTPINNING, STARTF_RUNFULLSCREEN, STARTF_TITLEISAPPID, + STARTF_TITLEISLINKNAME, STARTF_UNTRUSTEDSOURCE, STARTF_USECOUNTCHARS, + STARTF_USEFILLATTRIBUTE, STARTF_USEHOTKEY, STARTF_USEPOSITION, STARTF_USESHOWWINDOW, + STARTF_USESIZE, STARTF_USESTDHANDLES, }, }, UI::WindowsAndMessaging::SW_HIDE, @@ -312,7 +310,8 @@ pub fn build_environment_block( let mut last_entry: HashMap> = HashMap::new(); for (key, value) in entries { - if key.contains('\0') || value.contains('\0') { + if memchr(b'\0', key.as_bytes()).is_some() || memchr(b'\0', value.as_bytes()).is_some() { + cold_path(); return Err(BuildEnvironmentBlockError::ContainsNul); } if key.is_empty() || key[1..].contains('=') { diff --git a/crates/stdlib/src/grp.rs b/crates/stdlib/src/grp.rs index 7e3dd8ef378..a237bd71043 100644 --- a/crates/stdlib/src/grp.rs +++ b/crates/stdlib/src/grp.rs @@ -10,6 +10,7 @@ mod grp { exceptions, types::PyStructSequence, }; + use core::hint::cold_path; use rustpython_host_env::grp as host_grp; #[pystruct_sequence_data] @@ -61,10 +62,11 @@ mod grp { #[pyfunction] fn getgrnam(name: PyUtf8StrRef, vm: &VirtualMachine) -> PyResult { - let gr_name = name.as_str(); - if gr_name.contains('\0') { + if name.as_pystr().contains_nuls() { + cold_path(); return Err(exceptions::nul_char_error(vm)); } + let gr_name = name.as_str(); let group = host_grp::getgrnam(gr_name).map_err(|err| err.into_pyexception(vm))?; let group = group.ok_or_else(|| { vm.new_key_error( diff --git a/crates/stdlib/src/mmap.rs b/crates/stdlib/src/mmap.rs index dac24b053c2..2a07cf7d86a 100644 --- a/crates/stdlib/src/mmap.rs +++ b/crates/stdlib/src/mmap.rs @@ -24,9 +24,9 @@ mod mmap { use core::ops::{Deref, DerefMut}; use crossbeam_utils::atomic::AtomicCell; use num_traits::Signed; - #[cfg(windows)] - use std::io; use std::io::Write; + #[cfg(windows)] + use {core::hint::cold_path, memchr::memchr, rustpython_vm::exceptions, std::io}; #[cfg(unix)] use rustpython_host_env::crt_fd; @@ -460,8 +460,9 @@ mod mmap { let s = obj .try_to_value::(vm) .map_err(|_| vm.new_type_error("tagname must be a string or None"))?; - if s.contains('\0') { - return Err(vm.new_value_error("tagname must not contain null characters")); + if memchr(b'\0', s.as_bytes()).is_some() { + cold_path(); + return Err(exceptions::nul_char_error(vm)); } Some(s) } diff --git a/crates/stdlib/src/multiprocessing.rs b/crates/stdlib/src/multiprocessing.rs index 9883219dfb1..c79af002b25 100644 --- a/crates/stdlib/src/multiprocessing.rs +++ b/crates/stdlib/src/multiprocessing.rs @@ -811,7 +811,7 @@ mod _multiprocessing { let value = args.value as u32; let (handle, name) = SemHandle::create(&args.name, value, args.unlink).map_err(|err| { - if err == SemError::InvalidInput && args.name.contains('\0') { + if err == SemError::InteriorNul { exceptions::nul_char_error(vm) } else { os_error(vm, err) @@ -835,7 +835,7 @@ mod _multiprocessing { #[pyfunction] fn sem_unlink(name: String, vm: &VirtualMachine) -> PyResult<()> { host_multiprocessing::sem_unlink(&name).map_err(|err| { - if err == SemError::InvalidInput && name.contains('\0') { + if err == SemError::InteriorNul { exceptions::nul_char_error(vm) } else { os_error(vm, err) diff --git a/crates/stdlib/src/openssl.rs b/crates/stdlib/src/openssl.rs index 8b7d1de0639..16ef8fdb19c 100644 --- a/crates/stdlib/src/openssl.rs +++ b/crates/stdlib/src/openssl.rs @@ -53,6 +53,8 @@ fn probe() -> &'static ProbeResult { #[cfg(ossl111)] ossl111, #[cfg(windows)] windows))] mod _ssl { + use core::hint::cold_path; + use super::{bio, probe}; // Import error types and helpers used in this module (others are exposed via pymodule(with(...))) @@ -85,6 +87,7 @@ mod _ssl { }; use crossbeam_utils::atomic::AtomicCell; use foreign_types_shared::{ForeignType, ForeignTypeRef}; + use memchr::memchr; use openssl::{ asn1::{Asn1Object, Asn1ObjectRef}, error::ErrorStack, @@ -1039,12 +1042,13 @@ mod _ssl { #[pymethod] fn set_ciphers(&self, cipherlist: PyStrRef, vm: &VirtualMachine) -> PyResult<()> { - let ciphers: &str = cipherlist.as_ref(); - if ciphers.contains('\0') { + if cipherlist.contains_nuls() { + cold_path(); return Err(exceptions::nul_char_error(vm)); } + self.builder() - .set_cipher_list(ciphers) + .set_cipher_list(cipherlist.as_ref()) .map_err(|_| new_ssl_error(vm, "No cipher can be selected.")) } @@ -1096,9 +1100,6 @@ mod _ssl { let name_cstr = match name { Either::A(s) => { let s: &str = s.as_ref(); - if s.contains('\0') { - return Err(exceptions::nul_char_error(vm)); - } s.to_cstring(vm)? } Either::B(b) => std::ffi::CString::new(b.borrow_buf().to_vec()) @@ -2031,15 +2032,16 @@ mod _ssl { // Configure server hostname if let Some(hostname) = &server_hostname { + if hostname.contains_nuls() { + cold_path(); + return Err(exceptions::nul_char_type_error(vm)); + } let hostname_str: &str = hostname.as_ref(); if hostname_str.is_empty() || hostname_str.starts_with('.') { return Err(vm.new_value_error( "server_hostname cannot be an empty string or start with a leading dot.", )); } - if hostname_str.contains('\0') { - return Err(exceptions::nul_char_type_error(vm)); - } let ip = hostname_str.parse::(); if ip.is_err() { ssl.set_hostname(hostname_str) diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 5e6f35943bd..7e2b4c124d2 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -67,9 +67,11 @@ mod _ssl { use alloc::sync::Arc; use core::{ hash::{Hash, Hasher}, + hint::cold_path, sync::atomic::{AtomicUsize, Ordering}, time::Duration, }; + use memchr::memchr; use rustpython_vm::exceptions; use std::{ collections::{HashMap, hash_map::DefaultHasher}, @@ -392,7 +394,8 @@ mod _ssl { // IP addresses are allowed as server_hostname // SNI will not be sent for IP addresses - if hostname.contains('\0') { + if memchr(b'\0', hostname.as_bytes()).is_some() { + cold_path(); return Err(exceptions::nul_char_type_error(vm)); } @@ -1854,25 +1857,7 @@ mod _ssl { let hostname = match args.server_hostname.into_option().flatten() { Some(hostname_str) => { let hostname = hostname_str.as_str(); - - // Validate hostname - if hostname.is_empty() { - return Err(vm.new_value_error("server_hostname cannot be an empty string")); - } - - // Check if it starts with a dot - if hostname.starts_with('.') { - return Err(vm.new_value_error("server_hostname cannot start with a dot")); - } - - // IP addresses are allowed - // SNI will not be sent for IP addresses - - // Check for NULL bytes - if hostname.contains('\0') { - return Err(exceptions::nul_char_error(vm)); - } - + validate_hostname(hostname, vm)?; Some(hostname.to_string()) } None => None, diff --git a/crates/vm/src/builtins/bytes.rs b/crates/vm/src/builtins/bytes.rs index ca40b3d3b6b..d4c30a7e94d 100644 --- a/crates/vm/src/builtins/bytes.rs +++ b/crates/vm/src/builtins/bytes.rs @@ -31,6 +31,7 @@ use crate::{ }; use bstr::ByteSlice; use core::{mem::size_of, ops::Deref}; +use memchr::memchr; #[pyclass(module = false, name = "bytes")] #[derive(Clone, Debug)] @@ -169,6 +170,13 @@ impl PyBytes { .map(|x| vm.ctx.new_bytes(x).into()), } } + + /// Check bytes for interior NULs. + #[inline] + #[must_use] + pub fn contains_nuls(&self) -> bool { + memchr(b'\0', self.as_bytes()).is_some() + } } impl PyRef { @@ -218,7 +226,7 @@ impl PyBytes { #[inline] #[must_use] - pub fn as_bytes(&self) -> &[u8] { + pub const fn as_bytes(&self) -> &[u8] { self.inner.as_bytes() } diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index b1c2c41973b..07325159a39 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -37,6 +37,7 @@ use bstr::ByteSlice; use core::ffi::CStr; use core::{char, mem, ops::Range}; use itertools::Itertools; +use memchr::memchr; use num_traits::ToPrimitive; use rustpython_common::{ ascii, @@ -545,6 +546,13 @@ impl PyStr { } } + /// Check string bytes for interior NULs. + #[inline] + #[must_use] + pub fn contains_nuls(&self) -> bool { + memchr(b'\0', self.as_bytes()).is_some() + } + pub fn to_string_lossy(&self) -> Cow<'_, str> { self.to_str() .map_or_else(|| self.as_wtf8().to_string_lossy(), Cow::Borrowed) @@ -2150,7 +2158,7 @@ impl PyUtf8Str { impl Py { /// Upcast to PyStr. - pub fn as_pystr(&self) -> &Py { + pub const fn as_pystr(&self) -> &Py { unsafe { // Safety: PyUtf8Str is a wrapper around PyStr, so this cast is safe. &*(self as *const Self as *const Py) diff --git a/crates/vm/src/bytes_inner.rs b/crates/vm/src/bytes_inner.rs index 0bf6c13726a..d144004d66f 100644 --- a/crates/vm/src/bytes_inner.rs +++ b/crates/vm/src/bytes_inner.rs @@ -255,8 +255,8 @@ fn write_bytearray_repr_char(ch: u8, buf: &mut String) { impl PyBytesInner { #[inline] - pub fn as_bytes(&self) -> &[u8] { - &self.elements + pub const fn as_bytes(&self) -> &[u8] { + self.elements.as_slice() } fn new_repr_overflow_error(vm: &VirtualMachine) -> PyBaseExceptionRef { diff --git a/crates/vm/src/function/fspath.rs b/crates/vm/src/function/fspath.rs index cd3cd2276f7..50feef86dd0 100644 --- a/crates/vm/src/function/fspath.rs +++ b/crates/vm/src/function/fspath.rs @@ -6,6 +6,7 @@ use crate::{ protocol::PyBuffer, }; use alloc::borrow::Cow; +use core::hint::cold_path; use std::{ffi::OsStr, path::PathBuf}; /// Helper to implement os.fspath() @@ -36,21 +37,20 @@ impl FsPath { msg: &'static str, vm: &VirtualMachine, ) -> PyResult { - let check_nul = |b: &[u8]| { - if !check_for_nul || memchr::memchr(b'\0', b).is_none() { - Ok(()) - } else { - Err(crate::exceptions::nul_char_error(vm)) - } - }; let match1 = |obj: PyObjectRef| { let pathlike = match_class!(match obj { s @ PyStr => { - check_nul(s.as_bytes())?; + if check_for_nul && s.contains_nuls() { + cold_path(); + return Err(crate::exceptions::nul_char_error(vm)); + } Self::Str(s) } b @ PyBytes => { - check_nul(&b)?; + if check_for_nul && b.contains_nuls() { + cold_path(); + return Err(crate::exceptions::nul_char_error(vm)); + } Self::Bytes(b) } obj => return Ok(Err(obj)), diff --git a/crates/vm/src/ospath.rs b/crates/vm/src/ospath.rs index f2368a28826..05f7b061159 100644 --- a/crates/vm/src/ospath.rs +++ b/crates/vm/src/ospath.rs @@ -7,6 +7,7 @@ use crate::{ convert::{IntoPyException, ToPyException, ToPyObject, TryFromObject}, function::FsPath, }; +use core::hint::cold_path; use std::path::{Path, PathBuf}; /// path_converter @@ -149,6 +150,7 @@ impl PathConverter { if self.non_strict || memchr::memchr(b'\0', b).is_none() { Ok(()) } else { + cold_path(); Err(vm.new_value_error(format!( "{}embedded null character in {}", self.error_prefix(), diff --git a/crates/vm/src/stdlib/_codecs.rs b/crates/vm/src/stdlib/_codecs.rs index 6052350159d..69d9e0e4fde 100644 --- a/crates/vm/src/stdlib/_codecs.rs +++ b/crates/vm/src/stdlib/_codecs.rs @@ -6,6 +6,8 @@ use crate::common::static_cell::StaticCell; #[pymodule(with(#[cfg(windows)] _codecs_windows))] mod _codecs { + use core::hint::cold_path; + use crate::codecs::{ErrorsHandler, PyDecodeContext, PyEncodeContext}; use crate::common::encodings; use crate::common::wtf8::Wtf8Buf; @@ -29,7 +31,8 @@ mod _codecs { #[pyfunction] fn lookup(encoding: PyUtf8StrRef, vm: &VirtualMachine) -> PyResult { - if encoding.as_str().contains('\0') { + if encoding.as_pystr().contains_nuls() { + cold_path(); return Err(nul_char_error(vm)); } vm.state @@ -105,7 +108,8 @@ mod _codecs { #[pyfunction] fn lookup_error(name: PyUtf8StrRef, vm: &VirtualMachine) -> PyResult { - if name.as_str().contains('\0') { + if name.as_pystr().contains_nuls() { + cold_path(); return Err(nul_char_error(vm)); } vm.state.codec_registry.lookup_error(name.as_str(), vm) @@ -113,7 +117,8 @@ mod _codecs { #[pyfunction] fn _unregister_error(errors: PyUtf8StrRef, vm: &VirtualMachine) -> PyResult { - if errors.as_str().contains('\0') { + if errors.as_pystr().contains_nuls() { + cold_path(); return Err(nul_char_error(vm)); } vm.state diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index 854a46d8cd6..ce41a942891 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -150,6 +150,7 @@ mod _io { use alloc::borrow::Cow; use bstr::ByteSlice; use core::{ + hint::cold_path, ops::Range, sync::atomic::{AtomicBool, Ordering}, }; @@ -2854,7 +2855,8 @@ mod _io { } fn validate_errors(errors: &PyRef, vm: &VirtualMachine) -> PyResult<()> { - if errors.as_str().contains('\0') { + if errors.as_pystr().contains_nuls() { + cold_path(); return Err(nul_char_error(vm)); } vm.state @@ -2894,12 +2896,7 @@ mod _io { } Err(err) => return Err(err), }, - Some(enc) => { - if enc.as_str().contains('\0') { - return Err(nul_char_error(vm)); - } - enc - } + Some(enc) => enc, _ => match vm.import("locale", 0) { Ok(locale) => locale .get_attr("getencoding", vm)? @@ -2914,7 +2911,8 @@ mod _io { Err(err) => return Err(err), }, }; - if encoding.as_str().contains('\0') { + if encoding.as_pystr().contains_nuls() { + cold_path(); return Err(nul_char_error(vm)); } Ok(encoding) @@ -3067,7 +3065,8 @@ mod _io { let mut write_through = None; if let Some(enc) = args.encoding { - if enc.as_str().contains('\0') && enc.as_str().starts_with("locale") { + if enc.as_pystr().contains_nuls() && enc.as_str().starts_with("locale") { + cold_path(); return Err(vm.new_lookup_error(format!("unknown encoding: {enc}"))); } let resolved = Self::resolve_encoding(Some(enc), vm)?; diff --git a/crates/vm/src/stdlib/nt.rs b/crates/vm/src/stdlib/nt.rs index acc9f4ee67e..31a08195c58 100644 --- a/crates/vm/src/stdlib/nt.rs +++ b/crates/vm/src/stdlib/nt.rs @@ -15,6 +15,7 @@ pub(crate) mod module { ospath::{OsPath, OsPathOrFd}, stdlib::os::{_os, DirFd, SupportFunc, TargetIsDirectory}, }; + use core::hint::cold_path; use libc::intptr_t; use rustpython_common::wtf8::Wtf8Buf; use rustpython_host_env::nt as host_nt; @@ -551,7 +552,8 @@ pub(crate) mod module { let value_str = value.expect_str(); // Validate: no null characters in key or value - if key_str.contains('\0') || value_str.contains('\0') { + if key.contains_nuls() || value.contains_nuls() { + cold_path(); return Err(exceptions::nul_char_error(vm)); } // Validate: empty key or '=' in key after position 0 diff --git a/crates/vm/src/stdlib/os.rs b/crates/vm/src/stdlib/os.rs index 73e4918ccd4..a41e9990f12 100644 --- a/crates/vm/src/stdlib/os.rs +++ b/crates/vm/src/stdlib/os.rs @@ -211,7 +211,7 @@ pub(super) mod _os { }; #[cfg(not(windows))] use core::marker::PhantomData; - use core::time::Duration; + use core::{hint::cold_path, time::Duration}; use crossbeam_utils::atomic::AtomicCell; use rustpython_common::wtf8::Wtf8Buf; #[cfg(windows)] @@ -487,10 +487,16 @@ pub(super) mod _os { } #[cfg(not(windows))] - fn env_bytes_as_bytes(obj: &crate::function::Either) -> &[u8] { + fn env_bytes_as_bytes_checked( + obj: &crate::function::Either, + ) -> Option<&[u8]> { match obj { - crate::function::Either::A(s) => s.as_bytes(), - crate::function::Either::B(b) => b.as_bytes(), + crate::function::Either::A(s) if !s.contains_nuls() => Some(s.as_bytes()), + crate::function::Either::B(b) if !b.contains_nuls() => Some(b.as_bytes()), + _ => { + cold_path(); + None + } } } @@ -515,9 +521,10 @@ pub(super) mod _os { // defining hidden environment variables. if key_str.is_empty() || key_str.get(1..).is_some_and(|s| s.contains('=')) - || key_str.contains('\0') - || value_str.contains('\0') + || key.contains_nuls() + || value.contains_nuls() { + cold_path(); return Err(vm.new_value_error("illegal environment variable name")); } let env_str = format!("{key_str}={value_str}"); @@ -537,11 +544,13 @@ pub(super) mod _os { value: crate::function::Either, vm: &VirtualMachine, ) -> PyResult<()> { - let key = env_bytes_as_bytes(&key); - let value = env_bytes_as_bytes(&value); - if key.contains(&b'\0') || value.contains(&b'\0') { + let (Some(key), Some(value)) = ( + env_bytes_as_bytes_checked(&key), + env_bytes_as_bytes_checked(&value), + ) else { + cold_path(); return Err(exceptions::nul_byte_error(vm)); - } + }; if key.is_empty() || key.contains(&b'=') { return Err(vm.new_value_error("illegal environment variable name")); } @@ -560,8 +569,9 @@ pub(super) mod _os { // defining hidden environment variables. if key_str.is_empty() || key_str.get(1..).is_some_and(|s| s.contains('=')) - || key_str.contains('\0') + || key.contains_nuls() { + cold_path(); return Err(vm.new_value_error("illegal environment variable name")); } // "key=" to unset (empty value removes the variable) @@ -581,10 +591,10 @@ pub(super) mod _os { key: crate::function::Either, vm: &VirtualMachine, ) -> PyResult<()> { - let key = env_bytes_as_bytes(&key); - if key.contains(&b'\0') { + let Some(key) = env_bytes_as_bytes_checked(&key) else { + cold_path(); return Err(exceptions::nul_byte_error(vm)); - } + }; if key.is_empty() || key.contains(&b'=') { let x = vm.new_errno_error( 22, diff --git a/crates/vm/src/stdlib/pwd.rs b/crates/vm/src/stdlib/pwd.rs index e181de240f6..cfd571e4c17 100644 --- a/crates/vm/src/stdlib/pwd.rs +++ b/crates/vm/src/stdlib/pwd.rs @@ -11,6 +11,7 @@ mod pwd { exceptions, types::PyStructSequence, }; + use core::hint::cold_path; use rustpython_host_env::pwd as host_pwd; #[cfg(not(target_os = "android"))] @@ -50,15 +51,16 @@ mod pwd { #[pyfunction] fn getpwnam(name: PyUtf8StrRef, vm: &VirtualMachine) -> PyResult { - let pw_name = name.as_str(); - if pw_name.contains('\0') { + if name.as_pystr().contains_nuls() { + cold_path(); return Err(exceptions::nul_char_error(vm)); } - let user = host_pwd::getpwnam(name.as_str()); + let name = name.as_str(); + let user = host_pwd::getpwnam(name); let user = user.ok_or_else(|| { vm.new_key_error( vm.ctx - .new_str(format!("getpwnam(): name not found: {pw_name}")) + .new_str(format!("getpwnam(): name not found: {name}")) .into(), ) })?; diff --git a/crates/vm/src/utils.rs b/crates/vm/src/utils.rs index d23cbb689a6..80402480cfd 100644 --- a/crates/vm/src/utils.rs +++ b/crates/vm/src/utils.rs @@ -4,7 +4,6 @@ use crate::{ PyObjectRef, PyResult, VirtualMachine, builtins::{PyStr, PyUtf8Str}, convert::{ToPyException, ToPyObject}, - exceptions::nul_char_error, }; pub fn hash_iter<'a, I: IntoIterator>( @@ -24,13 +23,6 @@ pub trait ToCString: AsRef { fn to_cstring(&self, vm: &VirtualMachine) -> PyResult { alloc::ffi::CString::new(self.as_ref().as_bytes()).map_err(|err| err.to_pyexception(vm)) } - fn ensure_no_nul(&self, vm: &VirtualMachine) -> PyResult<()> { - if self.as_ref().as_bytes().contains(&b'\0') { - Err(nul_char_error(vm)) - } else { - Ok(()) - } - } } impl ToCString for &str {} From f8c66d02b6f81e6716ccd9db7adbac21dbac16d7 Mon Sep 17 00:00:00 2001 From: YujinBae Date: Mon, 27 Jul 2026 21:36:27 +0900 Subject: [PATCH 192/351] dict iterator __reduce__ to resume from current position (#8384) * Fix dict iterator __reduce__ to resume from current position Pickling a partially-consumed dict / dict-view iterator restarted from the beginning because __reduce__ materialized every entry and ignored the iterator's position. Walk from the current position (mirroring next) so only the not-yet-yielded entries are captured, matching CPython, which reduces both directions to iter(remaining). Fixing the reverse iterator also uncovered two pre-existing bugs in reverse iteration itself, both from prev_entry saturating at 0: a hole just above index 0 made the entry at 0 yield twice, and deleting the first-inserted key made reversed() loop forever. prev_entry now returns the found entry's actual index and stops cleanly at index 0, and the reverse iterator/reduce detect exhaustion from that index. Verified against CPython 3.14 across all pickle protocols, dict states (including deletions), iterator kinds, and consumption counts. No regressions in test_dict, test_dictviews, test_ordered_dict, test_collections, test_userdict, test_iter, or test_copy. Co-Authored-By: Claude Opus 4.8 (1M context) * Apply cargo fmt to dict.rs imports Collapse the builtins import block left multi-line after removing the now-unused builtins_reversed import. Co-Authored-By: Claude Opus 4.8 (1M context) * Unmark TestSyncManagerTypes.test_dict as expectedFailure The dict iterator __reduce__ fix makes SyncManager dict proxies pickle correctly, so this test now passes under spawn and forkserver. Remove the stale expectedFailure marker that was causing an UNEXPECTED SUCCESS CI failure. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- Lib/test/_test_multiprocessing.py | 1 - crates/vm/src/builtins/dict.rs | 48 +++++++++++++++++++------------ crates/vm/src/dict_inner.rs | 5 +++- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/Lib/test/_test_multiprocessing.py b/Lib/test/_test_multiprocessing.py index c3a9805988f..9cbe6dc641e 100644 --- a/Lib/test/_test_multiprocessing.py +++ b/Lib/test/_test_multiprocessing.py @@ -6791,7 +6791,6 @@ def _test_dict(cls, obj): obj.clear() case.assertEqual(len(obj), 0) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_dict(self): o = self.manager.dict() o['foo'] = 5 diff --git a/crates/vm/src/builtins/dict.rs b/crates/vm/src/builtins/dict.rs index d14536eef53..af74a259157 100644 --- a/crates/vm/src/builtins/dict.rs +++ b/crates/vm/src/builtins/dict.rs @@ -7,11 +7,7 @@ use crate::object::{Traverse, TraverseFn}; use crate::{ AsObject, Context, Py, PyExact, PyObject, PyObjectRef, PyPayload, PyRef, PyRefExact, PyResult, TryFromObject, atomic_func, - builtins::{ - PyTuple, - iter::{builtins_iter, builtins_reversed}, - type_::PyAttributes, - }, + builtins::{PyTuple, iter::builtins_iter, type_::PyAttributes}, class::{PyClassDef, PyClassImpl}, common::ascii, dict_inner::{self, DictKey}, @@ -1118,10 +1114,17 @@ macro_rules! dict_view { let iter = builtins_iter(vm); let internal = self.internal.lock(); let entries = match &internal.status { - IterStatus::Active(dict) => dict - .into_iter() - .map(|(key, value)| ($result_fn)(vm, key, value)) - .collect::>(), + IterStatus::Active(dict) => { + let mut position = internal.position; + let mut entries = Vec::new(); + while let Some((next_position, key, value)) = + dict.entries.next_entry(position) + { + entries.push(($result_fn)(vm, key, value)); + position = next_position; + } + entries + } IterStatus::Exhausted => vec![], }; vm.new_tuple((iter, (vm.ctx.new_list(entries),))) @@ -1184,14 +1187,23 @@ macro_rules! dict_view { #[pymethod] fn __reduce__(&self, vm: &VirtualMachine) -> PyTupleRef { - let iter = builtins_reversed(vm); + let iter = builtins_iter(vm); let internal = self.internal.lock(); - // TODO: entries must be reversed too let entries = match &internal.status { - IterStatus::Active(dict) => dict - .into_iter() - .map(|(key, value)| ($result_fn)(vm, key, value)) - .collect::>(), + IterStatus::Active(dict) => { + let mut position = internal.position; + let mut entries = Vec::new(); + while let Some((found_index, key, value)) = + dict.entries.prev_entry(position) + { + entries.push(($result_fn)(vm, key, value)); + if found_index == 0 { + break; + } + position = found_index - 1; + } + entries + } IterStatus::Exhausted => vec![], }; vm.new_tuple((iter, (vm.ctx.new_list(entries),))) @@ -1218,11 +1230,11 @@ macro_rules! dict_view { ); } match dict.entries.prev_entry(internal.position) { - Some((position, key, value)) => { - if internal.position == position { + Some((found_index, key, value)) => { + if found_index == 0 { internal.status = IterStatus::Exhausted; } else { - internal.position = position; + internal.position = found_index - 1; } PyIterReturn::Return(($result_fn)(vm, key, value)) } diff --git a/crates/vm/src/dict_inner.rs b/crates/vm/src/dict_inner.rs index 3b5f5617f02..cc26383d9b8 100644 --- a/crates/vm/src/dict_inner.rs +++ b/crates/vm/src/dict_inner.rs @@ -861,10 +861,13 @@ impl Dict { let inner = self.read(); loop { let entry = inner.entries.get(position)?; - position = position.saturating_sub(1); if let Some(entry) = entry { break Some((position, entry.key.clone(), entry.value.clone())); } + if position == 0 { + break None; + } + position -= 1; } } From d62a3916f535f7c862c2da58ea12dad8c6156242 Mon Sep 17 00:00:00 2001 From: Chanho Lee Date: Mon, 27 Jul 2026 21:42:19 +0900 Subject: [PATCH 193/351] Fix sqlite autocommit lifecycle (#8387) * fix: open disabled sqlite autocommit transactions Assisted-by: Codex:gpt-5.6-sol * fix: reopen disabled sqlite transactions after commit Assisted-by: Codex:gpt-5.6-sol * fix: reopen disabled sqlite transactions after rollback Assisted-by: Codex:gpt-5.6-sol * fix: roll back disabled sqlite connections on close Assisted-by: Codex:gpt-5.6-sol --- Lib/test/test_sqlite3/test_transactions.py | 4 -- crates/stdlib/src/_sqlite3.rs | 63 +++++++++++++++------- 2 files changed, 45 insertions(+), 22 deletions(-) diff --git a/Lib/test/test_sqlite3/test_transactions.py b/Lib/test/test_sqlite3/test_transactions.py index db58938b9db..64a1621d42b 100644 --- a/Lib/test/test_sqlite3/test_transactions.py +++ b/Lib/test/test_sqlite3/test_transactions.py @@ -395,7 +395,6 @@ def test_autocommit_setget_invalid(self): with self.assertRaisesRegex(ValueError, msg): sqlite.connect(":memory:", autocommit=mode) - @unittest.expectedFailure # TODO: RUSTPYTHON; autocommit behavior differs def test_autocommit_disabled(self): expected = [ "SELECT 1", @@ -411,7 +410,6 @@ def test_autocommit_disabled(self): cx.commit() cx.rollback() - @unittest.expectedFailure # TODO: RUSTPYTHON; autocommit behavior differs def test_autocommit_disabled_implicit_rollback(self): expected = ["ROLLBACK"] with memory_database(autocommit=False) as cx: @@ -438,7 +436,6 @@ def test_autocommit_enabled_txn_ctl(self): meth() # expect this to pass silently self.assertFalse(cx.in_transaction) - @unittest.expectedFailure # TODO: RUSTPYTHON; autocommit behavior differs def test_autocommit_disabled_then_enabled(self): expected = ["COMMIT"] with memory_database(autocommit=False) as cx: @@ -472,7 +469,6 @@ def test_autocommit_enabled_ctx_mgr(self): self.assertFalse(cx.in_transaction) self.assertFalse(cx.in_transaction) - @unittest.expectedFailure # TODO: RUSTPYTHON; autocommit behavior differs def test_autocommit_disabled_ctx_mgr(self): expected = ["COMMIT", "BEGIN"] with memory_database(autocommit=False) as cx: diff --git a/crates/stdlib/src/_sqlite3.rs b/crates/stdlib/src/_sqlite3.rs index 58d8ad7b391..e2b06adc465 100644 --- a/crates/stdlib/src/_sqlite3.rs +++ b/crates/stdlib/src/_sqlite3.rs @@ -963,7 +963,7 @@ mod _sqlite3 { zelf.reset_factories(vm); if was_initialized { - zelf.drop_db(); + zelf.drop_db(vm)?; } // Attempt to open the new database before mutating other state so failures leave @@ -994,8 +994,20 @@ mod _sqlite3 { #[pyclass(with(Constructor, Callable, Initializer), flags(BASETYPE, HAS_WEAKREF))] impl Connection { - fn drop_db(&self) { - self.db.lock().take(); + fn drop_db(&self, vm: &VirtualMachine) -> PyResult<()> { + let mut guard = self.db.lock(); + let rollback_result = if let Some(db) = guard.as_ref() + && *self.autocommit.lock() == AutocommitMode::Disabled + && !db.is_autocommit() + { + db._exec(b"ROLLBACK\0", vm) + } else { + Ok(()) + }; + let db = guard.take(); + drop(guard); + drop(db); + rollback_result } fn reset_factories(&self, vm: &VirtualMachine) { @@ -1012,6 +1024,9 @@ mod _sqlite3 { if let Some(isolation_level) = &args.isolation_level.0 { begin_statement_ptr_from_isolation_level(isolation_level, vm)?; } + if args.autocommit == AutocommitMode::Disabled { + db._exec(b"BEGIN\0", vm)?; + } Ok(db) } @@ -1108,8 +1123,7 @@ mod _sqlite3 { #[pymethod] fn close(&self, vm: &VirtualMachine) -> PyResult<()> { self.check_thread(vm)?; - self.drop_db(); - Ok(()) + self.drop_db(vm) } fn is_closed(&self) -> bool { @@ -1118,16 +1132,35 @@ mod _sqlite3 { #[pymethod] fn commit(&self, vm: &VirtualMachine) -> PyResult<()> { - self.db_lock(vm)?.implicit_commit(vm) + let db = self.db_lock(vm)?; + let mode = *self.autocommit.lock(); + match mode { + AutocommitMode::Legacy => db.implicit_commit(vm), + AutocommitMode::Enabled => Ok(()), + AutocommitMode::Disabled => { + db._exec(b"COMMIT\0", vm)?; + db._exec(b"BEGIN\0", vm) + } + } } #[pymethod] fn rollback(&self, vm: &VirtualMachine) -> PyResult<()> { let db = self.db_lock(vm)?; - if !db.is_autocommit() { - db._exec(b"ROLLBACK\0", vm) - } else { - Ok(()) + let mode = *self.autocommit.lock(); + match mode { + AutocommitMode::Legacy => { + if db.is_autocommit() { + Ok(()) + } else { + db._exec(b"ROLLBACK\0", vm) + } + } + AutocommitMode::Enabled => Ok(()), + AutocommitMode::Disabled => { + db._exec(b"ROLLBACK\0", vm)?; + db._exec(b"BEGIN\0", vm) + } } } @@ -1521,11 +1554,7 @@ mod _sqlite3 { // If setting isolation_level to None (auto-commit mode), commit any pending transaction if value.is_none() { - let db = self.db_lock(vm)?; - if !db.is_autocommit() { - // Keep the lock and call implicit_commit directly to avoid race conditions - db.implicit_commit(vm)?; - } + self.commit(vm)?; } let _ = unsafe { self.isolation_level.swap(value) }; Ok(()) @@ -1549,6 +1578,7 @@ mod _sqlite3 { fn set_autocommit(&self, val: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { let mode = AutocommitMode::try_from_borrowed_object(vm, &val)?; let db = self.db_lock(vm)?; + *self.autocommit.lock() = mode; // Handle transaction state based on mode change match mode { @@ -1568,9 +1598,6 @@ mod _sqlite3 { // Legacy mode doesn't change transaction state } } - - drop(db); - *self.autocommit.lock() = mode; Ok(()) } From 59e903d807b5e314089070b977a997551bfa7ea0 Mon Sep 17 00:00:00 2001 From: Kwak Byoung Min <67214970+doma17@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:43:15 +0900 Subject: [PATCH 194/351] Reject empty UU input before decoding (#8389) Constraint: Match CPython's a2b_uu empty-input behavior. Confidence: high Scope-risk: narrow Directive: Preserve decoding behavior for non-empty UU buffers. Tested: prek run --all-files; test_binascii; cargo fmt --check; cargo clippy -p rustpython-stdlib -- -D warnings; cargo test -p rustpython-stdlib; manual full-suite verification Assisted-by: Codex:gpt-5.6-sol --- Lib/test/test_binascii.py | 2 -- crates/stdlib/src/binascii.rs | 10 +++++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_binascii.py b/Lib/test/test_binascii.py index cd8acb4c2cb..48631cecec7 100644 --- a/Lib/test/test_binascii.py +++ b/Lib/test/test_binascii.py @@ -222,7 +222,6 @@ def assertInvalidLength(data): assertInvalidLength(b'a' * (4 * 87 + 1)) assertInvalidLength(b'A\tB\nC ??DE') # only 5 valid characters - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Error not raised by a2b_uu def test_uu(self): MAX_UU = 45 for backtick in (True, False): @@ -445,7 +444,6 @@ def test_b2a_qp_a2b_qp_round_trip(self, binary, quotetabs, istext, header): self.assertConversion(binary, converted, restored, quotetabs=quotetabs, istext=istext, header=header) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Error not raised by a2b_uu def test_empty_string(self): # A test for SF bug #1022953. Make sure SystemError is not raised. empty = self.type2test(b'') diff --git a/crates/stdlib/src/binascii.rs b/crates/stdlib/src/binascii.rs index 0cf0056285a..d0cdc2148e7 100644 --- a/crates/stdlib/src/binascii.rs +++ b/crates/stdlib/src/binascii.rs @@ -746,12 +746,12 @@ mod decl { #[pyfunction] fn a2b_uu(s: ArgAsciiBuffer, vm: &VirtualMachine) -> PyResult> { s.with_ref(|b| { + if b.is_empty() { + return Err(super::new_binascii_error("Missing length byte", vm)); + } + // First byte: binary data length (in bytes) - let length = if b.is_empty() { - ((-0x20i32) & 0x3fi32) as usize - } else { - ((b[0] - b' ') & 0x3f) as usize - }; + let length = ((b[0] - b' ') & 0x3f) as usize; // Allocate the buffer let mut res = Vec::::with_capacity(length); From 2eeeb8763dd05aa04378cc4736ed0bc968a1af03 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:33:37 +0900 Subject: [PATCH 195/351] Update malachite dependencies to 0.10 (#8391) Assisted-by: Claude --- Cargo.lock | 16 ++++++++-------- Cargo.toml | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4f113adf091..700a75b205d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2113,9 +2113,9 @@ dependencies = [ [[package]] name = "malachite-base" -version = "0.9.2" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f44099731f17094b07825c88ccb5fbd1bfa1f82fafff7daa33e8b8652db16e" +checksum = "c6b9d4679f346f85a8f466d0171478304dab8b0e944dd38086411ab5f6100a17" dependencies = [ "hashbrown 0.16.1", "itertools 0.14.0", @@ -2125,9 +2125,9 @@ dependencies = [ [[package]] name = "malachite-bigint" -version = "0.9.2" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc58206ba15e9c406e20c95c5f86efa07b12f94080945908e910b3a0faa23fef" +checksum = "5064cf3abe01ff3b80b0349936ebad6c52f7c793182d9c7992bf79ece18c0d22" dependencies = [ "malachite-base", "malachite-nz", @@ -2138,9 +2138,9 @@ dependencies = [ [[package]] name = "malachite-nz" -version = "0.9.2" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a137660cdba20f136c8a223125f08088adb4e0b72fbb8466f08c43e31cc0427d" +checksum = "8a6821ab988221c35d421ba16c4f8dca101efe5ae1cbfa9831f1fdb1596c755e" dependencies = [ "itertools 0.14.0", "libm", @@ -2150,9 +2150,9 @@ dependencies = [ [[package]] name = "malachite-q" -version = "0.9.2" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ffcbeed95e34c0fcc3864ccd146e129cbbf7de1513d3afbcfb47c7674c82d94" +checksum = "3cf7894cd9617e43ef5d9824633f7dfc1bffd0298880dd668f20bdffb7a6e8ea" dependencies = [ "itertools 0.14.0", "libm", diff --git a/Cargo.toml b/Cargo.toml index 114b5983d92..ac1e35c5314 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -246,9 +246,9 @@ log = "0.4.30" lz4_flex = "0.13" nix = { version = "0.31", features = ["fs", "user", "process", "term", "time", "signal", "ioctl", "socket", "sched", "zerocopy", "dir", "hostname", "net", "poll"] } mac_address = "1.1.3" -malachite-bigint = "0.9.1" -malachite-q = "0.9.1" -malachite-base = "0.9.1" +malachite-bigint = "0.10.0" +malachite-q = "0.10.0" +malachite-base = "0.10.0" md-5 = "0.11" memchr = { version = "2.8.1", default-features = false, features = ["alloc"] } memmap2 = "0.9.10" From 6996beb36ac1665a9b049a6bd9e1f5b572a13ce8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:33:59 +0900 Subject: [PATCH 196/351] build(deps): bump actions/setup-python from 6.3.0 to 7.0.0 (#8392) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.3.0 to 7.0.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/ece7cb06caefa5fff74198d8649806c4678c61a1...5fda3b95a4ea91299a34e894583c3862153e4b97) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 6 +++--- .github/workflows/cron-ci.yaml | 6 +++--- .github/workflows/lib-deps-check.yaml | 2 +- .github/workflows/update-doc-db.yml | 2 +- .github/workflows/upgrade-pylib.lock.yml | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d1aa26fed3f..e88cad1ea9a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -343,7 +343,7 @@ jobs: # Windows runners randomly crashes, https://github.com/actions/cache/issues/1754 continue-on-error: true - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - name: Install macOS dependencies uses: ./.github/actions/install-macos-deps @@ -542,7 +542,7 @@ jobs: with: persist-credentials: false - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - uses: dtolnay/rust-toolchain@stable with: @@ -685,7 +685,7 @@ jobs: mkdir geckodriver tar -xzf geckodriver-v0.36.0-linux64.tar.gz -C geckodriver - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - run: python -m pip install -r requirements.txt working-directory: ./wasm/tests diff --git a/.github/workflows/cron-ci.yaml b/.github/workflows/cron-ci.yaml index 37af28a4ee3..43875f251ef 100644 --- a/.github/workflows/cron-ci.yaml +++ b/.github/workflows/cron-ci.yaml @@ -37,7 +37,7 @@ jobs: with: tool: cargo-llvm-cov - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - run: sudo apt-get update && sudo apt-get -y install lcov @@ -111,7 +111,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - name: build rustpython run: cargo build --release --verbose @@ -174,7 +174,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - run: cargo install cargo-criterion diff --git a/.github/workflows/lib-deps-check.yaml b/.github/workflows/lib-deps-check.yaml index 7461fa6a2eb..2808bf09f35 100644 --- a/.github/workflows/lib-deps-check.yaml +++ b/.github/workflows/lib-deps-check.yaml @@ -98,7 +98,7 @@ jobs: - name: Setup Python if: steps.changed-files.outputs.modules != '' - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - name: Run deps check if: steps.changed-files.outputs.modules != '' diff --git a/.github/workflows/update-doc-db.yml b/.github/workflows/update-doc-db.yml index 0d9c37a3256..6a31f43bc48 100644 --- a/.github/workflows/update-doc-db.yml +++ b/.github/workflows/update-doc-db.yml @@ -36,7 +36,7 @@ jobs: sparse-checkout: | crates/doc - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ inputs.python-version }} diff --git a/.github/workflows/upgrade-pylib.lock.yml b/.github/workflows/upgrade-pylib.lock.yml index 9790f37c53b..2794e24002f 100644 --- a/.github/workflows/upgrade-pylib.lock.yml +++ b/.github/workflows/upgrade-pylib.lock.yml @@ -107,7 +107,7 @@ jobs: with: persist-credentials: false - name: Setup Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.14' - name: Create gh-aw temp directory From b3be47440d61ec67a9b12f2466d65d85be9034b1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:34:09 +0900 Subject: [PATCH 197/351] build(deps): bump taiki-e/install-action from 2.83.2 to 2.84.0 (#8393) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.83.2 to 2.84.0. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/43aecc8d72668fbcfe75c31400bc4f890f1c5853...a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.84.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cron-ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cron-ci.yaml b/.github/workflows/cron-ci.yaml index 43875f251ef..447dd9bf22d 100644 --- a/.github/workflows/cron-ci.yaml +++ b/.github/workflows/cron-ci.yaml @@ -33,7 +33,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + - uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 # v2.84.0 with: tool: cargo-llvm-cov From 523c1fcf9b5fcbf48093e62126f0c51c50a21f0c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:34:24 +0900 Subject: [PATCH 198/351] build(deps): bump cargo-bins/cargo-binstall from 1.20.1 to 1.21.0 (#8394) Bumps [cargo-bins/cargo-binstall](https://github.com/cargo-bins/cargo-binstall) from 1.20.1 to 1.21.0. - [Release notes](https://github.com/cargo-bins/cargo-binstall/releases) - [Changelog](https://github.com/cargo-bins/cargo-binstall/blob/main/release-plz.toml) - [Commits](https://github.com/cargo-bins/cargo-binstall/compare/732870f031d2fb36309d0deaf36abcc704a7be65...ead08b90bd7b2e6d81963fb9cf0b7239f66d5db4) --- updated-dependencies: - dependency-name: cargo-bins/cargo-binstall dependency-version: 1.21.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e88cad1ea9a..1aaa715505c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -521,7 +521,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - uses: cargo-bins/cargo-binstall@732870f031d2fb36309d0deaf36abcc704a7be65 # v1.20.1 + - uses: cargo-bins/cargo-binstall@ead08b90bd7b2e6d81963fb9cf0b7239f66d5db4 # v1.21.0 - name: cargo shear run: | From c39c13bc3bafda608beab2c811d8ae91756c05a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:34:58 +0900 Subject: [PATCH 199/351] build(deps): bump actions/checkout from 7.0.0 to 7.0.1 (#8395) Bumps [actions/checkout](https://github.com/actions/checkout) from 7.0.0 to 7.0.1. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0...3d3c42e5aac5ba805825da76410c181273ba90b1) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 24 +++++++++++------------ .github/workflows/cron-ci.yaml | 8 ++++---- .github/workflows/lib-deps-check.yaml | 4 ++-- .github/workflows/release.yml | 6 +++--- .github/workflows/update-caches.yml | 2 +- .github/workflows/update-doc-db.yml | 4 ++-- .github/workflows/update-libs-status.yaml | 4 ++-- .github/workflows/upgrade-pylib.lock.yml | 4 ++-- 8 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1aaa715505c..a2498abf159 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -44,7 +44,7 @@ jobs: # Flag that is raised when any rust code is changed. rust_code: ${{ steps.check_rust_code.outputs.changed }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false @@ -88,7 +88,7 @@ jobs: os: [macos-latest, ubuntu-latest, windows-2025] fail-fast: false steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -204,7 +204,7 @@ jobs: target: x86_64-apple-darwin fail-fast: false steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -321,7 +321,7 @@ jobs: timeout: 50 fail-fast: false steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -477,7 +477,7 @@ jobs: - ubuntu-latest - windows-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -515,7 +515,7 @@ jobs: needs.determine_changes.outputs.rust_code == 'true' || github.ref == 'refs/heads/main' steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -538,7 +538,7 @@ jobs: pull-requests: write security-events: write # for zizmor steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -578,7 +578,7 @@ jobs: echo "version=${version}" >> "$GITHUB_OUTPUT" - name: Clone CPython - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: python/cpython path: cpython @@ -616,7 +616,7 @@ jobs: env: NIGHTLY_CHANNEL: nightly steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -651,7 +651,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -766,7 +766,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -818,7 +818,7 @@ jobs: name: cargo doc runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/cron-ci.yaml b/.github/workflows/cron-ci.yaml index 447dd9bf22d..059604625e8 100644 --- a/.github/workflows/cron-ci.yaml +++ b/.github/workflows/cron-ci.yaml @@ -27,7 +27,7 @@ jobs: env: INSTA_WORKSPACE_ROOT: ${{ github.workspace }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -67,7 +67,7 @@ jobs: # Disable this scheduled job when running on a fork. if: ${{ github.repository == 'RustPython/RustPython' || github.event_name != 'schedule' }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: true @@ -105,7 +105,7 @@ jobs: # Disable this scheduled job when running on a fork. if: ${{ github.repository == 'RustPython/RustPython' || github.event_name != 'schedule' }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: true @@ -168,7 +168,7 @@ jobs: # Disable this scheduled job when running on a fork. if: ${{ github.repository == 'RustPython/RustPython' || github.event_name != 'schedule' }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: true diff --git a/.github/workflows/lib-deps-check.yaml b/.github/workflows/lib-deps-check.yaml index 2808bf09f35..a938c6ff00a 100644 --- a/.github/workflows/lib-deps-check.yaml +++ b/.github/workflows/lib-deps-check.yaml @@ -18,7 +18,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout base branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Use base branch for scripts (security: don't run PR code with elevated permissions) ref: ${{ github.event.pull_request.base.ref }} @@ -41,7 +41,7 @@ jobs: echo "version=${version}" >> "$GITHUB_OUTPUT" - name: Checkout CPython - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: python/cpython path: cpython diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 990322cc26f..5957666de38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,7 @@ jobs: # target: aarch64-pc-windows-msvc fail-fast: false steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -91,7 +91,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -156,7 +156,7 @@ jobs: permissions: contents: write # for creating a release steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/update-caches.yml b/.github/workflows/update-caches.yml index de94f3f50e0..32c8e1a62de 100644 --- a/.github/workflows/update-caches.yml +++ b/.github/workflows/update-caches.yml @@ -40,7 +40,7 @@ jobs: target: "" steps: - name: Checkout RustPython main branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: RustPython/RustPython ref: main diff --git a/.github/workflows/update-doc-db.yml b/.github/workflows/update-doc-db.yml index 6a31f43bc48..e9fa285ae0a 100644 --- a/.github/workflows/update-doc-db.yml +++ b/.github/workflows/update-doc-db.yml @@ -30,7 +30,7 @@ jobs: - windows-latest - macos-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false sparse-checkout: | @@ -58,7 +58,7 @@ jobs: contents: write pull-requests: write steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: true ref: ${{ inputs.base-ref }} diff --git a/.github/workflows/update-libs-status.yaml b/.github/workflows/update-libs-status.yaml index 846818e12be..c1c656d2068 100644 --- a/.github/workflows/update-libs-status.yaml +++ b/.github/workflows/update-libs-status.yaml @@ -21,7 +21,7 @@ jobs: if: ${{ github.repository == 'RustPython/RustPython' }} steps: - name: Clone RustPython - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: rustpython persist-credentials: false @@ -37,7 +37,7 @@ jobs: echo "version=${version}" >> "$GITHUB_OUTPUT" - name: Clone CPython - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: python/cpython path: cpython diff --git a/.github/workflows/upgrade-pylib.lock.yml b/.github/workflows/upgrade-pylib.lock.yml index 2794e24002f..e53d21f1296 100644 --- a/.github/workflows/upgrade-pylib.lock.yml +++ b/.github/workflows/upgrade-pylib.lock.yml @@ -103,7 +103,7 @@ jobs: with: destination: /opt/gh-aw/actions - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Python @@ -1061,7 +1061,7 @@ jobs: path: /tmp/gh-aw/ - name: Checkout repository if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ github.token }} persist-credentials: false From 061351c1462fd4e668f0c5f67d9772e16e6f0589 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:35:23 +0900 Subject: [PATCH 200/351] build(deps): bump thiserror in the thiserror group across 1 directory (#8396) Bumps the thiserror group with 1 update in the / directory: [thiserror](https://github.com/dtolnay/thiserror). Updates `thiserror` from 2.0.18 to 2.0.19 - [Release notes](https://github.com/dtolnay/thiserror/releases) - [Commits](https://github.com/dtolnay/thiserror/compare/2.0.18...2.0.19) --- updated-dependencies: - dependency-name: thiserror dependency-version: 2.0.19 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: thiserror ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 105 +++++++++++++++++++++++++++++------------------------ 1 file changed, 58 insertions(+), 47 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 700a75b205d..1ccd819cc1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,7 +198,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -210,7 +210,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -233,7 +233,7 @@ dependencies = [ "manyhow", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -249,7 +249,7 @@ dependencies = [ "proc-macro2", "quote", "quote-use", - "syn", + "syn 2.0.119", ] [[package]] @@ -326,7 +326,7 @@ dependencies = [ "regex", "rustc-hash", "shlex 1.3.0", - "syn", + "syn 2.0.119", ] [[package]] @@ -346,7 +346,7 @@ dependencies = [ "regex", "rustc-hash", "shlex 1.3.0", - "syn", + "syn 2.0.119", ] [[package]] @@ -1045,7 +1045,7 @@ dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1092,7 +1092,7 @@ checksum = "59600e2c2d636fde9b65e99cc6445ac770c63d3628195ff39932b8d6d7409903" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1109,7 +1109,7 @@ checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1153,7 +1153,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1288,7 +1288,7 @@ checksum = "7693d9dd1ec1c54f52195dfe255b627f7cec7da33b679cd56de949e662b3db10" dependencies = [ "flame", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1378,7 +1378,7 @@ checksum = "f2b6d1e2f75c16bfbcd0f95d84f99858a6e2f885c2287d1f5c3a96e8444a34b4" dependencies = [ "attribute-derive", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1770,7 +1770,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1834,7 +1834,7 @@ checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1864,7 +1864,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.119", ] [[package]] @@ -1883,7 +1883,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2169,7 +2169,7 @@ dependencies = [ "manyhow-macros", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2367,7 +2367,7 @@ checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2428,7 +2428,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2597,7 +2597,7 @@ dependencies = [ "phf_shared 0.14.0", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2695,7 +2695,7 @@ checksum = "52a40bc70c2c58040d2d8b167ba9a5ff59fc9dab7ad44771cfde3dcfde7a09c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2757,7 +2757,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.119", ] [[package]] @@ -2846,7 +2846,7 @@ dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2858,7 +2858,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2889,7 +2889,7 @@ dependencies = [ "proc-macro-utils", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3042,7 +3042,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3118,7 +3118,7 @@ dependencies = [ "pmutil", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3393,7 +3393,7 @@ version = "0.5.0" dependencies = [ "rustpython-compiler", "rustpython-derive-impl", - "syn", + "syn 2.0.119", ] [[package]] @@ -3405,7 +3405,7 @@ dependencies = [ "quote", "rustpython-compiler-core", "rustpython-doc", - "syn", + "syn 2.0.119", "syn-ext", "textwrap", ] @@ -3904,7 +3904,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4101,7 +4101,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4121,6 +4121,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn-ext" version = "0.5.0" @@ -4129,7 +4140,7 @@ checksum = "b126de4ef6c2a628a68609dd00733766c3b015894698a438ebdf374933fc31d1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4140,7 +4151,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4215,22 +4226,22 @@ checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -4343,7 +4354,7 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4590,7 +4601,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -4730,7 +4741,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4741,7 +4752,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5044,7 +5055,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -5065,7 +5076,7 @@ checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5085,7 +5096,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -5106,7 +5117,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5141,7 +5152,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] From 14dcba23874e25eb4b9f93737cd14022c87b3ef0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:35:42 +0900 Subject: [PATCH 201/351] build(deps): bump github/gh-aw/actions/setup from 0.81.6 to 0.82.14 (#8398) Bumps [github/gh-aw/actions/setup](https://github.com/github/gh-aw) from 0.81.6 to 0.82.14. - [Release notes](https://github.com/github/gh-aw/releases) - [Changelog](https://github.com/github/gh-aw/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/gh-aw/compare/eed4304d8740f0593f2797276cb8299d228ffd9b...8b820ae1073f301991aaf2f307f7e271f618bb9f) --- updated-dependencies: - dependency-name: github/gh-aw/actions/setup dependency-version: 0.82.14 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/upgrade-pylib.lock.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/upgrade-pylib.lock.yml b/.github/workflows/upgrade-pylib.lock.yml index e53d21f1296..31a7ac26f1d 100644 --- a/.github/workflows/upgrade-pylib.lock.yml +++ b/.github/workflows/upgrade-pylib.lock.yml @@ -58,7 +58,7 @@ jobs: comment_repo: "" steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@eed4304d8740f0593f2797276cb8299d228ffd9b # v0.81.6 + uses: github/gh-aw/actions/setup@8b820ae1073f301991aaf2f307f7e271f618bb9f # v0.82.14 with: destination: /opt/gh-aw/actions - name: Check workflow file timestamps @@ -99,7 +99,7 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@eed4304d8740f0593f2797276cb8299d228ffd9b # v0.81.6 + uses: github/gh-aw/actions/setup@8b820ae1073f301991aaf2f307f7e271f618bb9f # v0.82.14 with: destination: /opt/gh-aw/actions - name: Checkout repository @@ -806,7 +806,7 @@ jobs: total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@eed4304d8740f0593f2797276cb8299d228ffd9b # v0.81.6 + uses: github/gh-aw/actions/setup@8b820ae1073f301991aaf2f307f7e271f618bb9f # v0.82.14 with: destination: /opt/gh-aw/actions - name: Download agent output artifact @@ -927,7 +927,7 @@ jobs: success: ${{ steps.parse_results.outputs.success }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@eed4304d8740f0593f2797276cb8299d228ffd9b # v0.81.6 + uses: github/gh-aw/actions/setup@8b820ae1073f301991aaf2f307f7e271f618bb9f # v0.82.14 with: destination: /opt/gh-aw/actions - name: Download agent artifacts @@ -1039,7 +1039,7 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@eed4304d8740f0593f2797276cb8299d228ffd9b # v0.81.6 + uses: github/gh-aw/actions/setup@8b820ae1073f301991aaf2f307f7e271f618bb9f # v0.82.14 with: destination: /opt/gh-aw/actions - name: Download agent output artifact From 3aaec0654bc39bd26f8a686cff6081866a69fb6f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:36:13 +0900 Subject: [PATCH 202/351] build(deps): bump https://github.com/astral-sh/ruff-pre-commit (#8400) Bumps [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit) from v0.15.21 to 0.15.22. - [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases) - [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.21...v0.15.22) --- updated-dependencies: - dependency-name: https://github.com/astral-sh/ruff-pre-commit dependency-version: 0.15.22 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 45fb5f47b97..80177744b79 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: priority: 0 - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.21 + rev: v0.15.22 hooks: - id: ruff-format priority: 0 From 1613741f861b4f289da59ffc78a20ead4b4444fc Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:20:04 +0900 Subject: [PATCH 203/351] Fix SymbolUsge::Iter, os.rename, sre_engine (#8390) * Fix SymbolUsge::Iter * fix os.rename * fix sre * Re-export rename from crate::posix on Windows posix_windows.rs is mounted as the `posix` module on Windows, so `crate::posix_windows` does not resolve. Assisted-by: Claude * Apply rustfmt to sre_engine tests Assisted-by: Claude * Update test expectations for the sre and tokenize fixes - test_re: drop expectedFailure from test_word_boundaries, test_possessive_quantifiers and test_bug_gh101955 - test_inspect, test_pydoc: drop expectedFailure from the signature tests that now pass - test_pdb: drop expectedFailure from the two file-modification tests and +EXPECTED_FAILURE from the test_post_mortem_chained and test_pdb_asynctask doctests - test_unittest: mark test_autospec_on_bound_builtin_function as expectedFailure; inspect.signature() now succeeds on time.ctime because of its auto-generated __text_signature__ Assisted-by: Claude --- Lib/test/test_inspect/test_inspect.py | 2 - Lib/test/test_pdb.py | 6 +-- Lib/test/test_pydoc/test_pydoc.py | 1 - Lib/test/test_re.py | 3 -- .../test_unittest/testmock/testhelpers.py | 1 + crates/codegen/src/symboltable.rs | 9 +++- crates/host_env/src/os.rs | 21 ++++++++ crates/sre_engine/src/engine.rs | 28 ++++++++-- crates/sre_engine/tests/tests.rs | 54 ++++++++++++++++++- crates/stdlib/src/_tokenize.rs | 4 +- 10 files changed, 113 insertions(+), 16 deletions(-) diff --git a/Lib/test/test_inspect/test_inspect.py b/Lib/test/test_inspect/test_inspect.py index 74126751835..d928c878a15 100644 --- a/Lib/test/test_inspect/test_inspect.py +++ b/Lib/test/test_inspect/test_inspect.py @@ -5987,7 +5987,6 @@ def _strip_non_python_syntax(self, input, self.assertEqual(computed_clean_signature, clean_signature) self.assertEqual(computed_self_parameter, self_parameter) - @unittest.expectedFailure # TODO: RUSTPYTHON; + (module, /, path, mode, *, dir_fd=None, effective_ids=False, follow_symlinks=True) def test_signature_strip_non_python_syntax(self): self._strip_non_python_syntax( "($module, /, path, mode, *, dir_fd=None, " + @@ -6318,7 +6317,6 @@ def test_weakref_module_has_signatures(self): no_signature = {'ReferenceType', 'ref'} self._test_module_has_signatures(weakref, no_signature) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: .func at 0xa4c07a580> builtin has invalid signature def test_python_function_override_signature(self): def func(*args, **kwargs): pass diff --git a/Lib/test/test_pdb.py b/Lib/test/test_pdb.py index cd1e88f5475..5eea014ccde 100644 --- a/Lib/test/test_pdb.py +++ b/Lib/test/test_pdb.py @@ -1290,7 +1290,7 @@ def test_post_mortem_chained(): ... except Exception as e: ... pdb._post_mortem(e, instance) - >>> with PdbTestInput([ # TODO: RUSTPYTHON # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE +EXPECTED_FAILURE + >>> with PdbTestInput([ # TODO: RUSTPYTHON # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE ... 'exceptions', ... 'exceptions 0', ... '$_exception', @@ -2133,7 +2133,7 @@ def test_pdb_asynctask(): >>> def test_function(): ... asyncio.run(test(), loop_factory=asyncio.EventLoop) - >>> with PdbTestInput([ # TODO: RUSTPYTHON # doctest: +ELLIPSIS +EXPECTED_FAILURE + >>> with PdbTestInput([ # doctest: +ELLIPSIS ... '$_asynctask', ... 'continue', ... ]): @@ -4185,7 +4185,6 @@ def test_blocks_at_first_code_line(self): self.assertTrue(any("__main__.py(4)()" in l for l in stdout.splitlines()), stdout) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_file_modified_after_execution(self): script = """ print("hello") @@ -4259,7 +4258,6 @@ def test_file_modified_after_execution_with_multiple_instances(self): self.assertIn("WARNING:", stdout) self.assertIn("was edited", stdout) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_file_modified_after_execution_with_restart(self): script = """ import random diff --git a/Lib/test/test_pydoc/test_pydoc.py b/Lib/test/test_pydoc/test_pydoc.py index 5c9059614de..d206e5a910d 100644 --- a/Lib/test/test_pydoc/test_pydoc.py +++ b/Lib/test/test_pydoc/test_pydoc.py @@ -1708,7 +1708,6 @@ def test_bound_builtin_classmethod_unrepresentable_default(self): "classmeth(a, b=) class method of " "_testcapi.DocStringUnrepresentableSignatureTest") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_overridden_text_signature(self): class C: def meth(*args, **kwargs): diff --git a/Lib/test/test_re.py b/Lib/test/test_re.py index 1c52eb0dcb2..1cfddb8e19c 100644 --- a/Lib/test/test_re.py +++ b/Lib/test/test_re.py @@ -890,7 +890,6 @@ def test_named_unicode_escapes(self): self.checkPatternError(br'\N{LESS-THAN SIGN}', r'bad escape \N', 0) self.checkPatternError(br'[\N{LESS-THAN SIGN}]', r'bad escape \N', 1) - @unittest.expectedFailure # TODO: RUSTPYTHON; re.search(r"\B", "") now returns a match in CPython 3.14 def test_word_boundaries(self): # See http://bugs.python.org/issue10713 self.assertEqual(re.search(r"\b(abc)\b", "abc").group(1), "abc") @@ -2493,7 +2492,6 @@ def test_search_anchor_at_beginning(self): # With optimization -- 0.0003 seconds. self.assertLess(stopwatch.seconds, 0.1) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_possessive_quantifiers(self): """Test Possessive Quantifiers Test quantifiers of the form @+ for some repetition operator @, @@ -2647,7 +2645,6 @@ def test_bug_gh100061(self): self.assertEqual(re.match("(?>(?:ab?c){1,3})", "aca").span(), (0, 2)) self.assertEqual(re.match("(?:ab?c){1,3}+", "aca").span(), (0, 2)) - @unittest.expectedFailure # TODO: RUSTPYTHON; self.assertEqual(re.match('((x)|y|z){3}+', 'xyz').groups(), ('z', 'x'))\n AssertionError: Tuples differ: ('x', 'x') != ('z', 'x') def test_bug_gh101955(self): # Possessive quantifier with nested alternative with capture groups self.assertEqual(re.match('((x)|y|z)*+', 'xyz').groups(), ('z', 'x')) diff --git a/Lib/test/test_unittest/testmock/testhelpers.py b/Lib/test/test_unittest/testmock/testhelpers.py index bcae14c5b3c..6877e92cb3c 100644 --- a/Lib/test/test_unittest/testmock/testhelpers.py +++ b/Lib/test/test_unittest/testmock/testhelpers.py @@ -929,6 +929,7 @@ def check_data_descriptor(mock_attr): check_data_descriptor(foo.desc) + @unittest.expectedFailure # TODO: RUSTPYTHON; time.ctime has an auto-generated __text_signature__, so inspect.signature() succeeds instead of raising ValueError def test_autospec_on_bound_builtin_function(self): meth = types.MethodType(time.ctime, time.time()) self.assertIsInstance(meth(), str) diff --git a/crates/codegen/src/symboltable.rs b/crates/codegen/src/symboltable.rs index bafd065ef93..a66410f9018 100644 --- a/crates/codegen/src/symboltable.rs +++ b/crates/codegen/src/symboltable.rs @@ -3356,7 +3356,14 @@ impl SymbolTableBuilder { flags.insert(SymbolFlags::USE); } SymbolUsage::Iter => { - flags.insert(SymbolFlags::ITER | SymbolFlags::DEF_COMP_ITER); + // CPython symtable_add_def_helper() records an inlined + // comprehension target as a local definition as well as a + // comprehension iterator. Keep ITER as the internal + // re-assignment check marker; DEF_LOCAL is part of the public + // ste_symbols flags exposed by _symtable. + flags.insert( + SymbolFlags::DEF_LOCAL | SymbolFlags::ITER | SymbolFlags::DEF_COMP_ITER, + ); } SymbolUsage::TypeParam => { flags.insert(SymbolFlags::DEF_LOCAL | SymbolFlags::DEF_TYPE_PARAM); diff --git a/crates/host_env/src/os.rs b/crates/host_env/src/os.rs index e5868871e8d..01711687c6c 100644 --- a/crates/host_env/src/os.rs +++ b/crates/host_env/src/os.rs @@ -4,6 +4,10 @@ use crate::crt_fd; #[cfg(windows)] use crate::fs; +#[cfg(windows)] +pub use crate::posix::rename; +#[cfg(any(unix, target_os = "wasi"))] +pub use crate::posix_unix_like::rename; #[cfg(any(unix, windows))] use core::ffi::CStr; use core::str::Utf8Error; @@ -28,6 +32,23 @@ use { }, }; +#[cfg(not(any(unix, windows, target_os = "wasi")))] +pub fn rename( + from: impl AsRef, + from_fd: Option>, + to: impl AsRef, + to_fd: Option>, +) -> io::Result<()> { + if from_fd.is_none() && to_fd.is_none() { + std::fs::rename(from, to) + } else { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "renameat is not available on this platform", + )) + } +} + /// Convert exit code to std::process::ExitCode /// /// On Windows, this supports the full u32 range including STATUS_CONTROL_C_EXIT (0xC000013A). diff --git a/crates/sre_engine/src/engine.rs b/crates/sre_engine/src/engine.rs index a91ea3455fb..a110a75d65f 100644 --- a/crates/sre_engine/src/engine.rs +++ b/crates/sre_engine/src/engine.rs @@ -110,6 +110,21 @@ impl Marks { self.marks_stack.pop(); } + fn stack_depth(&self) -> usize { + self.marks_stack.len() + } + + fn discard_to(&mut self, depth: usize) { + self.marks_stack.truncate(depth); + } + + fn restore_to(&mut self, depth: usize) { + let (marks, last_index) = self.marks_stack[depth].clone(); + self.marks = marks; + self.last_index = last_index; + self.marks_stack.truncate(depth); + } + fn clear(&mut self) { self.last_index = -1; self.marks.clear(); @@ -144,6 +159,7 @@ impl State { jump: Jump::OpCode, repeat_ctx_id: usize::MAX, count: -1, + marks_stack_base: usize::MAX, }; _match(req, self, ctx) } @@ -165,6 +181,7 @@ impl State { jump: Jump::OpCode, repeat_ctx_id: usize::MAX, count: -1, + marks_stack_base: usize::MAX, }; if ctx.peek_code(&req, 0) == SreOpcode::INFO as u32 { @@ -483,6 +500,7 @@ fn _match(req: &Request<'_, S>, state: &mut State, mut ctx: MatchCo } Jump::PossessiveRepeat2 => { if popped_result { + ctx.cursor = state.cursor; ctx.count += 1; ctx.jump = Jump::PossessiveRepeat1; continue 'context; @@ -495,6 +513,7 @@ fn _match(req: &Request<'_, S>, state: &mut State, mut ctx: MatchCo if ((ctx.count as usize) < max_count || max_count == MAXREPEAT) && ctx.cursor.position != state.cursor.position { + ctx.marks_stack_base = state.marks.stack_depth(); state.marks.push(); ctx.cursor = state.cursor; let mut next = ctx.next_offset(4, Jump::PossessiveRepeat4); @@ -507,12 +526,12 @@ fn _match(req: &Request<'_, S>, state: &mut State, mut ctx: MatchCo } Jump::PossessiveRepeat4 => { if popped_result { - state.marks.pop_discard(); + state.marks.discard_to(ctx.marks_stack_base); ctx.count += 1; ctx.jump = Jump::PossessiveRepeat3; continue 'context; } - state.marks.pop(); + state.marks.restore_to(ctx.marks_stack_base); state.cursor = ctx.cursor; ctx.skip_code_from(req, 1); ctx.skip_code(1); @@ -1057,6 +1076,7 @@ struct MatchContext { jump: Jump, repeat_ctx_id: usize, count: isize, + marks_stack_base: usize, } impl MatchContext { @@ -1147,7 +1167,9 @@ impl MatchContext { mut word_checker: F, ) -> bool { if self.at_beginning() && self.at_end(req) { - return false; + // Python 3.14 changed `\B` to match an empty input. Keep the + // boundary predicate false there, but its negation true. + return true; } let that = !self.at_beginning() && word_checker(self.back_peek_char::()); let this = !self.at_end(req) && word_checker(self.peek_char::()); diff --git a/crates/sre_engine/tests/tests.rs b/crates/sre_engine/tests/tests.rs index f1edb64cdbf..53f5225d4ad 100644 --- a/crates/sre_engine/tests/tests.rs +++ b/crates/sre_engine/tests/tests.rs @@ -2,6 +2,7 @@ #[cfg(test)] mod tests { use rustpython_sre_engine::{Request, State, StrDrive}; + use rustpython_wtf8::Wtf8Buf; struct Pattern { #[expect(unused, reason = "Needed for automated script")] @@ -44,7 +45,7 @@ mod tests { #[rustfmt::skip] let big_b = Pattern { pattern: "\\B", code: &[14, 4, 0, 0, 0, 6, 11, 1] }; // END GENERATED let (req, mut state) = big_b.state(""); - assert!(!state.search(req)); + assert!(state.search(req)); } #[test] @@ -169,6 +170,40 @@ mod tests { assert!(!state.py_match(&req)); } + #[test] + fn possessive_repeat_keeps_last_capture() { + use optional::Optioned; + + let single_code = &[17, 0, 24, 6, 0, 1, 16, 101, 1, 17, 1, 1]; + let req = Request::new("eeea", 3, usize::MAX, single_code, false); + let mut single_state = State::default(); + assert!(single_state.py_match(&req)); + assert_eq!( + single_state.marks.get(0), + (Optioned::some(3), Optioned::some(3)) + ); + + // (e?){2,4}+a: the fourth successful iteration is empty, so group 1 + // must retain its final empty span rather than the previous "e". + #[rustfmt::skip] let optional = Pattern { + pattern: "(e?){2,4}+a", + code: &[14, 4, 0, 1, 5, 28, 14, 2, 4, 17, 0, 24, 6, 0, 1, 16, 101, 1, 17, 1, 1, 16, 97, 1], + }; + let (req, mut state) = optional.state("eeea"); + assert!(state.py_match(&req)); + assert_eq!(state.marks.get(0), (Optioned::some(3), Optioned::some(3))); + + // ((x)|y|z){3}+: group 1 is the final "z"; group 2 retains "x". + #[rustfmt::skip] let alternation = Pattern { + pattern: "((x)|y|z){3}+", + code: &[14, 4, 0, 3, 3, 28, 28, 3, 3, 17, 0, 7, 9, 17, 2, 16, 120, 17, 3, 15, 12, 5, 16, 121, 15, 7, 5, 16, 122, 15, 2, 0, 17, 1, 1, 1], + }; + let (req, mut state) = alternation.state("xyz"); + assert!(state.py_match(&req)); + assert_eq!(state.marks.get(0), (Optioned::some(2), Optioned::some(3))); + assert_eq!(state.marks.get(1), (Optioned::some(0), Optioned::some(1))); + } + #[test] fn bug_20998() { // pattern p = re.compile('[a-c]+', re.I) @@ -181,6 +216,23 @@ mod tests { assert_eq!(state.cursor.position, 3); } + #[test] + fn ascii_ignore_keeps_nonascii_range_literal() { + // pattern p = re.compile(r'[\u0430-\u045f]', re.I | re.A) + // + // ASCII-only case folding must not discard an exact non-ASCII range: + // U+0450 lies in the compiled U+0430..U+045F interval. + #[rustfmt::skip] let p = Pattern { + pattern: "[\\u0430-\\u045f]", + code: &[14, 8, 4, 1, 1, 22, 1072, 1119, 0, 13, 5, 22, 1072, 1119, 0, 1], + }; + let (req, mut state) = p.state("\u{0450}"); + assert!(state.py_match(&req)); + let subject = Wtf8Buf::from("\u{0450}"); + let (req, mut state) = p.state(subject.as_ref()); + assert!(state.py_match(&req)); + } + #[test] fn bigcharset() { // pattern p = re.compile('[a-z]*', re.I) diff --git a/crates/stdlib/src/_tokenize.rs b/crates/stdlib/src/_tokenize.rs index 7106c527096..c071a2b62f2 100644 --- a/crates/stdlib/src/_tokenize.rs +++ b/crates/stdlib/src/_tokenize.rs @@ -237,7 +237,9 @@ mod _tokenize { } let raw_type = token_kind_value(kind); - let token_type = if extra_tokens && raw_type > TOKEN_DEDENT && raw_type < TOKEN_OP { + let token_type = if extra_tokens + && (kind == TokenKind::Unknown || (raw_type > TOKEN_DEDENT && raw_type < TOKEN_OP)) + { TOKEN_OP } else { raw_type From bf4a2b110c9eb25084d016ea2621a32d1396dfd1 Mon Sep 17 00:00:00 2001 From: Seonghun An <53287605+shAn-kor@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:27:59 +0900 Subject: [PATCH 204/351] Fix zero padding for string format specs (#8407) * Fix zero padding for string format specs Assisted-by: Codex:gpt-5 * Add string alignment format regression test Assisted-by: Codex:gpt-5 --- Lib/test/test_str.py | 2 +- crates/common/src/format.rs | 49 +++++++++++++++++++++++++- crates/vm/src/format.rs | 3 ++ extra_tests/snippets/builtin_format.py | 8 +++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_str.py b/Lib/test/test_str.py index 5135564284e..2a3c36f2e57 100644 --- a/Lib/test/test_str.py +++ b/Lib/test/test_str.py @@ -1070,7 +1070,7 @@ def test_issue18183(self): '\U00100000'.ljust(3, '\U00010000') '\U00100000'.rjust(3, '\U00010000') - @unittest.expectedFailure # TODO: RUSTPYTHON; '{0:08s}'.format('result') misalign — '0' fill treated as numeric zero-pad for str type + @unittest.expectedFailure # TODO: RUSTPYTHON; '{0.}'.format() raises ValueError instead of IndexError def test_format(self): self.assertEqual(''.format(), '') self.assertEqual('a'.format(), 'a') diff --git a/crates/common/src/format.rs b/crates/common/src/format.rs index 4218dca7b74..fa71b27c5d9 100644 --- a/crates/common/src/format.rs +++ b/crates/common/src/format.rs @@ -227,6 +227,7 @@ pub struct FormatSpec { conversion: Option, fill: Option, align: Option, + align_specified: bool, sign: Option, alternate_form: bool, width: Option, @@ -346,6 +347,7 @@ impl FormatSpec { // get_integer in CPython let (conversion, text) = FormatConversion::parse(text); let (mut fill, mut align, text) = parse_fill_and_align(text); + let align_specified = align.is_some(); let (sign, text) = FormatSign::parse(text); let (alternate_form, text) = parse_alternate_form(text); let (zero, text) = parse_zero(text); @@ -374,6 +376,7 @@ impl FormatSpec { conversion, fill, align, + align_specified, sign, alternate_form, width, @@ -1020,13 +1023,24 @@ impl FormatSpec { self.validate_format(FormatType::String)?; match self.format_type { Some(FormatType::String) | None => { + if self.align == Some(FormatAlign::AfterSign) && self.align_specified { + return Err(FormatSpecError::StringAlignmentFlag); + } // CPython parity: precision truncates BEFORE width pads. // `'{:3.2s}'.format('abc')` -> 'ab ' (truncate to 'ab', pad to 3). let truncated: String = match self.precision { Some(p) => s.deref().chars().take(p).collect(), None => s.deref().to_owned(), }; - Ok(self.format_sign_and_align(&truncated, "", FormatAlign::Left)) + let spec = Self { + align: if self.align == Some(FormatAlign::AfterSign) { + Some(FormatAlign::Left) + } else { + self.align + }, + ..*self + }; + Ok(spec.format_sign_and_align(&truncated, "", FormatAlign::Left)) } _ => { let ch = char::from(self.format_type.as_ref().unwrap()); @@ -1260,6 +1274,7 @@ pub enum FormatSpecError { CodeNotInRange, ZeroPadding, AlignmentFlag, + StringAlignmentFlag, NotImplemented(char, &'static str), } @@ -1609,6 +1624,7 @@ mod tests { conversion: None, fill: None, align: None, + align_specified: false, sign: None, alternate_form: false, width: Some(33), @@ -1626,6 +1642,7 @@ mod tests { conversion: None, fill: Some('<'.into()), align: Some(FormatAlign::Right), + align_specified: true, sign: None, alternate_form: false, width: Some(33), @@ -1643,6 +1660,7 @@ mod tests { conversion: None, fill: Some('<'.into()), align: Some(FormatAlign::Right), + align_specified: true, sign: Some(FormatSign::Minus), alternate_form: true, width: Some(23), @@ -1690,6 +1708,35 @@ mod tests { assert_eq!(format_bool("%", false), Ok("0.000000%".to_owned())); } + #[test] + fn format_string_zero_padding_uses_left_alignment() { + let spec = FormatSpec::parse("08s").unwrap(); + let value = "result".to_owned(); + + assert_eq!(spec.format_string(&value), Ok("result00".to_owned())); + } + + #[test] + fn format_string_explicit_after_sign_alignment_is_invalid() { + let spec = FormatSpec::parse("=8s").unwrap(); + let value = "result".to_owned(); + + assert_eq!( + spec.format_string(&value), + Err(FormatSpecError::StringAlignmentFlag) + ); + } + + #[test] + fn format_int_zero_padding_stays_after_sign() { + let spec = FormatSpec::parse("08").unwrap(); + + assert_eq!( + spec.format_int(&BigInt::from(-42)), + Ok("-0000042".to_owned()) + ); + } + #[test] fn format_int() { assert_eq!( diff --git a/crates/vm/src/format.rs b/crates/vm/src/format.rs index 2f4652dccdd..80b906505bf 100644 --- a/crates/vm/src/format.rs +++ b/crates/vm/src/format.rs @@ -77,6 +77,9 @@ impl IntoPyException for FormatSpecError { Self::AlignmentFlag => { vm.new_value_error("'=' alignment flag is not allowed in complex format specifier") } + Self::StringAlignmentFlag => { + vm.new_value_error("'=' alignment not allowed in string format specifier") + } Self::NotImplemented(c, s) => { let msg = format!("Format code '{c}' for object of type '{s}' not implemented yet"); vm.new_value_error(msg) diff --git a/extra_tests/snippets/builtin_format.py b/extra_tests/snippets/builtin_format.py index 250d8ad6cac..c2e2a897470 100644 --- a/extra_tests/snippets/builtin_format.py +++ b/extra_tests/snippets/builtin_format.py @@ -24,6 +24,14 @@ def test_zero_padding(): test_zero_padding() +try: + format("result", "=8s") +except ValueError as error: + if str(error) != "'=' alignment not allowed in string format specifier": + raise AssertionError(f"unexpected error message: {error}") from error +else: + raise AssertionError("expected ValueError for '=8s' string format specifier") + assert "{:,}".format(100) == "100" assert "{:,}".format(1024) == "1,024" assert "{:_}".format(65536) == "65_536" From 942ddae1c555de1debfdac162b60045fcd103cf8 Mon Sep 17 00:00:00 2001 From: Kwak Byoung Min <67214970+doma17@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:28:32 +0900 Subject: [PATCH 205/351] Add seekable() to mmap.mmap (#8405) * Allow mmap-backed readers to identify seek support mmap already supports seek() and tell(); expose seekable() to match CPython. Confidence: high Scope-risk: narrow Tested: test_mmap, stdlib_mmap snippet, clippy, prek, extra_tests Not-tested: full workspace test blocked by local rustpython-capi SIGSEGV Assisted-by: Codex:gpt-5.6-sol * Keep mmap regression test lint-clean CI ruff check removes the redundant blank line and treats the auto-fix as a failure. Constraint: CI runs prek with ruff check Confidence: high Scope-risk: narrow Tested: prek run --all-files; cargo run -- extra_tests/snippets/stdlib_mmap.py Assisted-by: Codex:gpt-5.6-sol * Keep mmap seekability method conventional The method is invoked at runtime through the Python wrapper, so const qualification provides no benefit. Constraint: Python methods execute at runtime Confidence: high Scope-risk: narrow Tested: prek run --all-files; cargo run -- extra_tests/snippets/stdlib_mmap.py Assisted-by: Codex:gpt-5.6-sol --- crates/stdlib/src/mmap.rs | 5 +++++ extra_tests/snippets/stdlib_mmap.py | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 extra_tests/snippets/stdlib_mmap.py diff --git a/crates/stdlib/src/mmap.rs b/crates/stdlib/src/mmap.rs index 2a07cf7d86a..312d35a4ed4 100644 --- a/crates/stdlib/src/mmap.rs +++ b/crates/stdlib/src/mmap.rs @@ -1141,6 +1141,11 @@ mod mmap { self.pos() } + #[pymethod] + fn seekable(&self) -> bool { + true + } + #[pymethod] fn write(&self, bytes: ArgBytesLike, vm: &VirtualMachine) -> PyResult { let pos = self.pos(); diff --git a/extra_tests/snippets/stdlib_mmap.py b/extra_tests/snippets/stdlib_mmap.py new file mode 100644 index 00000000000..3a2b139a333 --- /dev/null +++ b/extra_tests/snippets/stdlib_mmap.py @@ -0,0 +1,6 @@ +import mmap + +mapped = mmap.mmap(-1, 1) +assert mapped.seekable() +mapped.close() +assert mapped.seekable() From 9bf458cb461a21e438eb2c61a6850d165e0662af Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Wed, 29 Jul 2026 07:32:09 -0400 Subject: [PATCH 206/351] host_env: Fix wcslen, leverage NonNull more (#8386) `strlen` was both unused and unneeded. Rust's `CStr` handles the length for us without needing to call into `libc` manually or implementing our own version of `strlen`. Our `strlen` implemented a fallback that incremented and dereferenced a pointer to check for NUL. This is slower than what `libc` usually does. For example, `musl` operates on words instead of individual bytes. This implementation also caused UB if the caller passed in a null pointer. `wcslen` is similar. The currently implementation can cause UB for null pointers. It's also slow from the same reason mentioned above. Luckily, the widestring crate can handle this for us. The crate panics on null pointers, so using it was a good excuse to use NonNull in more places. --- crates/host_env/Cargo.toml | 2 +- crates/host_env/src/ctypes.rs | 76 +++++++------------- crates/host_env/src/wmi.rs | 96 +++++++++++++------------ crates/vm/src/stdlib/_ctypes/pointer.rs | 11 ++- 4 files changed, 80 insertions(+), 105 deletions(-) diff --git a/crates/host_env/Cargo.toml b/crates/host_env/Cargo.toml index 128afc59285..e26fdeafe0b 100644 --- a/crates/host_env/Cargo.toml +++ b/crates/host_env/Cargo.toml @@ -17,6 +17,7 @@ libc = { workspace = true } num-traits = { workspace = true } parking_lot = { workspace = true } paste = { workspace = true } +widestring = { workspace = true } [target.'cfg(unix)'.dependencies] nix = { workspace = true } @@ -54,7 +55,6 @@ system-configuration = { workspace = true } memchr.workspace = true junction = { workspace = true } schannel = { workspace = true } -widestring = { workspace = true } windows-sys = { workspace = true, features = [ "Win32_Foundation", "Win32_Globalization", diff --git a/crates/host_env/src/ctypes.rs b/crates/host_env/src/ctypes.rs index bbb3e4afe60..a038e7a6d49 100644 --- a/crates/host_env/src/ctypes.rs +++ b/crates/host_env/src/ctypes.rs @@ -3,6 +3,7 @@ use core::ffi::{ CStr, c_char, c_double, c_float, c_int, c_long, c_longlong, c_schar, c_short, c_uchar, c_uint, c_ulong, c_ulonglong, c_ushort, c_void, }; +use core::ptr::NonNull; #[cfg(all( any( target_os = "linux", @@ -36,6 +37,7 @@ use rustpython_wtf8::Wtf8; use rustpython_wtf8::Wtf8Buf; #[cfg(any(unix, windows))] use std::{collections::HashMap, ffi::OsStr, sync::OnceLock}; +use widestring::WideCStr; #[cfg(all( any( @@ -392,33 +394,11 @@ pub fn dyld_shared_cache_contains_path(path: &str) -> Result usize { - #[cfg(any(unix, windows, target_os = "wasi"))] - { - unsafe { libc::strlen(ptr) } - } - #[cfg(not(any(unix, windows, target_os = "wasi")))] - { - let mut len = 0; - while unsafe { *ptr.add(len) } != 0 { - len += 1; - } - len - } -} - /// # Safety /// /// `ptr` must be valid to read until the first NUL wide character. -pub unsafe fn wcslen(ptr: *const WChar) -> usize { - let mut len = 0; - while unsafe { *ptr.add(len) } != 0 as WChar { - len += 1; - } - len +pub unsafe fn wcslen(ptr: NonNull) -> usize { + unsafe { WideCStr::from_ptr_str(ptr.as_ptr().cast()).len() } } /// # Safety @@ -1309,10 +1289,10 @@ pub unsafe fn callback_arg_value(type_code: Option<&str>, ptr: *const c_void) -> } Some("Z") => { let wstr_ptr = unsafe { *(ptr as *const *const WChar) }; - if wstr_ptr.is_null() { - DecodedValue::None - } else { + if let Some(wstr_ptr) = NonNull::new(wstr_ptr.cast_mut()) { DecodedValue::String(unsafe { read_wide_string(wstr_ptr) }.to_string()) + } else { + DecodedValue::None } } Some("P") => DecodedValue::Pointer(unsafe { *(ptr as *const usize) }), @@ -2272,8 +2252,7 @@ pub unsafe fn borrowed_slice_as_mut(slice: &[u8]) -> &mut [u8] { pub fn wide_chars_to_wtf8(wchars: &[WChar]) -> Wtf8Buf { #[cfg(windows)] { - let wide: Vec = wchars.to_vec(); - Wtf8Buf::from_wide(&wide) + Wtf8Buf::from_wide(wchars) } #[cfg(not(windows))] { @@ -2292,10 +2271,10 @@ pub fn wide_chars_to_wtf8(wchars: &[WChar]) -> Wtf8Buf { /// # Safety /// /// `ptr` must be a valid NUL-terminated wide C string. -pub unsafe fn read_wide_string(ptr: *const WChar) -> Wtf8Buf { - let len = unsafe { wcslen(ptr) }; - let wchars = unsafe { core::slice::from_raw_parts(ptr, len) }; - wide_chars_to_wtf8(wchars) +pub unsafe fn read_wide_string(ptr: NonNull) -> Wtf8Buf { + // SAFETY: WideCStr does not assume an encoding. + let wchars = unsafe { WideCStr::from_ptr_str(ptr.as_ptr().cast()) }; + Wtf8Buf::from_string(wchars.to_string_lossy()) } /// # Safety @@ -2313,18 +2292,15 @@ pub unsafe fn read_c_string_from_address(addr: usize) -> Option> { /// /// `addr` must either be zero or a valid NUL-terminated wide C string pointer. pub unsafe fn read_wide_string_from_address(addr: usize) -> Option { - if addr == 0 { - None - } else { - Some(unsafe { read_wide_string(addr as *const WChar) }) - } + let ptr = NonNull::new(addr as *mut WChar)?; + Some(unsafe { read_wide_string(ptr) }) } /// # Safety /// /// `ptr` must point to `len` readable wide characters. -pub unsafe fn read_wide_string_with_len(ptr: *const WChar, len: usize) -> Wtf8Buf { - let wchars = unsafe { core::slice::from_raw_parts(ptr, len) }; +pub unsafe fn read_wide_string_with_len(ptr: NonNull, len: usize) -> Wtf8Buf { + let wchars = unsafe { core::slice::from_raw_parts(ptr.as_ptr(), len) }; wide_chars_to_wtf8(wchars) } @@ -2348,13 +2324,12 @@ pub fn string_at(ptr: usize, size: isize) -> Result, StringAtError> { } pub fn wstring_at(ptr: usize, size: isize) -> Result { - if ptr == 0 { + let Some(ptr) = NonNull::new(ptr as *mut WChar) else { return Err(StringAtError::NullPointer); - } - let w_ptr = ptr as *const WChar; + }; if size < 0 { // SAFETY: caller passed a non-null NUL-terminated wide string pointer. - return Ok(unsafe { read_wide_string(w_ptr) }); + return Ok(unsafe { read_wide_string(ptr) }); } let len = { let size_usize = size as usize; @@ -2364,7 +2339,7 @@ pub fn wstring_at(ptr: usize, size: isize) -> Result { size_usize }; // SAFETY: caller requested exactly `len` readable wide characters from non-null pointer. - Ok(unsafe { read_wide_string_with_len(w_ptr, len) }) + Ok(unsafe { read_wide_string_with_len(ptr, len) }) } /// # Safety @@ -2414,14 +2389,14 @@ pub unsafe fn read_pointer_char_slice( /// # Safety /// /// `start` must be valid to read `len` wide characters following `step`. -pub unsafe fn read_wide_string_strided(start: *const WChar, len: usize, step: isize) -> Wtf8Buf { +pub unsafe fn read_wide_string_strided(start: NonNull, len: usize, step: isize) -> Wtf8Buf { if step == 1 { return unsafe { read_wide_string_with_len(start, len) }; } let mut wchars = Vec::with_capacity(len); let mut cur = start; for _ in 0..len { - wchars.push(unsafe { *cur }); + wchars.push(unsafe { cur.read() }); cur = unsafe { cur.offset(step) }; } wide_chars_to_wtf8(&wchars) @@ -2436,10 +2411,9 @@ pub unsafe fn read_pointer_wchar_slice( start: isize, len: usize, step: isize, -) -> Wtf8Buf { - let wchar_size = core::mem::size_of::(); - let start_addr = (ptr_value as isize + start * wchar_size as isize) as *const WChar; - unsafe { read_wide_string_strided(start_addr, len, step) } +) -> Option { + let start_addr = unsafe { NonNull::new(ptr_value as *mut WChar)?.offset(start) }; + Some(unsafe { read_wide_string_strided(start_addr, len, step) }) } /// # Safety diff --git a/crates/host_env/src/wmi.rs b/crates/host_env/src/wmi.rs index a6d77f77e9f..592ffd1dc23 100644 --- a/crates/host_env/src/wmi.rs +++ b/crates/host_env/src/wmi.rs @@ -6,7 +6,7 @@ #![allow(unsafe_op_in_unsafe_fn)] use core::ffi::c_void; -use core::ptr::{null, null_mut}; +use core::ptr::{NonNull, null, null_mut}; use windows_sys::Win32::Foundation::{ CloseHandle, ERROR_BROKEN_PIPE, ERROR_MORE_DATA, ERROR_NOT_ENOUGH_MEMORY, GetLastError, HANDLE, WAIT_OBJECT_0, WAIT_TIMEOUT, @@ -17,6 +17,8 @@ use windows_sys::Win32::System::Threading::{ CreateEventW, CreateThread, GetExitCodeThread, SetEvent, WaitForSingleObject, }; +use crate::ctypes::wcslen; + pub const BUFFER_SIZE: usize = 8192; pub enum ExecQueryError { @@ -238,7 +240,7 @@ unsafe fn object_end_enumeration(this: *mut c_void) -> HRESULT { method(this) } -fn hresult_from_win32(err: u32) -> HRESULT { +const fn hresult_from_win32(err: u32) -> HRESULT { if err == 0 { 0 } else { @@ -246,11 +248,11 @@ fn hresult_from_win32(err: u32) -> HRESULT { } } -fn succeeded(hr: HRESULT) -> bool { +const fn succeeded(hr: HRESULT) -> bool { hr >= 0 } -fn failed(hr: HRESULT) -> bool { +const fn failed(hr: HRESULT) -> bool { hr < 0 } @@ -258,14 +260,6 @@ fn wide_str(s: &str) -> Vec { s.encode_utf16().chain(core::iter::once(0)).collect() } -unsafe fn wcslen(s: *const u16) -> usize { - let mut len = 0; - while unsafe { *s.add(len) } != 0 { - len += 1; - } - len -} - unsafe fn wait_event(event: HANDLE, timeout: u32) -> u32 { match unsafe { WaitForSingleObject(event, timeout) } { WAIT_OBJECT_0 => 0, @@ -471,16 +465,25 @@ unsafe fn query_thread_impl(param: *mut c_void) -> u32 { } if succeeded(hr) && (flavor & WBEM_FLAVOR_MASK_ORIGIN) != WBEM_FLAVOR_ORIGIN_SYSTEM { + let Some(cb_str1) = NonNull::new(prop_name) + .map(|prop_name| (unsafe { wcslen(prop_name) } * 2) as u32) + else { + unsafe { + SysFreeString(prop_name); + } + break; + }; + let mut prop_str = [0u16; BUFFER_SIZE]; hr = unsafe { VariantToString(&prop_value, prop_str.as_mut_ptr(), BUFFER_SIZE as u32) }; + let cb_str2 = NonNull::new(prop_str.as_ptr().cast_mut()) + .map(|prop_str| (unsafe { wcslen(prop_str) } * 2) as u32) + .expect("prop_str is never null"); - if succeeded(hr) { - let cb_str1 = (unsafe { wcslen(prop_name) } * 2) as u32; - let cb_str2 = (unsafe { wcslen(prop_str.as_ptr()) } * 2) as u32; - - if unsafe { + if succeeded(hr) + && unsafe { WriteFile( write_pipe, prop_name as *const _, @@ -489,36 +492,35 @@ unsafe fn query_thread_impl(param: *mut c_void) -> u32 { null_mut(), ) } == 0 - || unsafe { - WriteFile( - write_pipe, - &eq_sign as *const u16 as *const _, - 2, - &mut written, - null_mut(), - ) - } == 0 - || unsafe { - WriteFile( - write_pipe, - prop_str.as_ptr() as *const _, - cb_str2, - &mut written, - null_mut(), - ) - } == 0 - || unsafe { - WriteFile( - write_pipe, - &null_sep as *const u16 as *const _, - 2, - &mut written, - null_mut(), - ) - } == 0 - { - hr = hresult_from_win32(unsafe { GetLastError() }); - } + || unsafe { + WriteFile( + write_pipe, + &eq_sign as *const u16 as *const _, + 2, + &mut written, + null_mut(), + ) + } == 0 + || unsafe { + WriteFile( + write_pipe, + prop_str.as_ptr() as *const _, + cb_str2, + &mut written, + null_mut(), + ) + } == 0 + || unsafe { + WriteFile( + write_pipe, + &null_sep as *const u16 as *const _, + 2, + &mut written, + null_mut(), + ) + } == 0 + { + hr = hresult_from_win32(unsafe { GetLastError() }); } unsafe { diff --git a/crates/vm/src/stdlib/_ctypes/pointer.rs b/crates/vm/src/stdlib/_ctypes/pointer.rs index 36d86282efd..f522e6dfb7e 100644 --- a/crates/vm/src/stdlib/_ctypes/pointer.rs +++ b/crates/vm/src/stdlib/_ctypes/pointer.rs @@ -524,13 +524,12 @@ impl PyCPointer { // c_wchar → str if type_code.as_deref() == Some("u") { - if len == 0 { - return Ok(vm.ctx.new_str("").into()); + if len > 0 + && let Some(s) = unsafe { read_pointer_wchar_slice(ptr_value, start, len, step) } + { + return Ok(vm.ctx.new_str(s).into()); } - return Ok(vm - .ctx - .new_str(unsafe { read_pointer_wchar_slice(ptr_value, start, len, step) }) - .into()); + return Ok(vm.ctx.new_str("").into()); } // other types → list with Pointer_item for each From bf8bca8039afaa7011881a5183797cbeef1c7f98 Mon Sep 17 00:00:00 2001 From: Jiwoo Ahn Date: Wed, 29 Jul 2026 20:33:32 +0900 Subject: [PATCH 207/351] wasi: support monotonic / perf time (#8385) Assisted-by: Codex:GPT-5.6 Sol Signed-off-by: Jiwoo Ahn --- .github/workflows/ci.yaml | 4 +- crates/host_env/src/time.rs | 23 ++++++- crates/vm/src/stdlib/time.rs | 23 +++++-- extra_tests/snippets/stdlib_time.py | 99 +++++++++++++++++++---------- 4 files changed, 108 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a2498abf159..10541d1b680 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -801,7 +801,9 @@ jobs: - name: build rustpython run: cargo build --profile wasm-release --target wasm32-wasip1 --no-default-features --features freeze-stdlib,stdlib,stdio,importlib,host_env --verbose - name: run snippets - run: wasmer run --dir "$(pwd)" target/wasm32-wasip1/wasm-release/rustpython.wasm -- "$(pwd)/extra_tests/snippets/stdlib_random.py" + run: | + wasmer run --dir "$(pwd)" target/wasm32-wasip1/wasm-release/rustpython.wasm -- "$(pwd)/extra_tests/snippets/stdlib_random.py" + wasmer run --dir "$(pwd)" target/wasm32-wasip1/wasm-release/rustpython.wasm -- "$(pwd)/extra_tests/snippets/stdlib_time.py" - name: run cpython unittest run: wasmer run --dir "$(pwd)" target/wasm32-wasip1/wasm-release/rustpython.wasm -- "$(pwd)/Lib/test/test_int.py" diff --git a/crates/host_env/src/time.rs b/crates/host_env/src/time.rs index 451e4884098..cc0b7d30cb6 100644 --- a/crates/host_env/src/time.rs +++ b/crates/host_env/src/time.rs @@ -227,11 +227,13 @@ pub fn process_times() -> std::io::Result { }) } -#[cfg(unix)] -#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[cfg(any(unix, target_os = "wasi"))] +#[derive(Copy, Clone, Debug)] +// WASI libc represents clockid_t as an opaque pointer type without Eq or PartialEq. +#[cfg_attr(unix, derive(Eq, PartialEq))] pub struct ClockId(libc::clockid_t); -#[cfg(unix)] +#[cfg(any(unix, target_os = "wasi"))] impl ClockId { pub const fn from_raw(raw: libc::clockid_t) -> Self { Self(raw) @@ -259,6 +261,7 @@ impl ClockId { target_os = "solaris", target_os = "openbsd", target_os = "redox", + target_os = "wasi", )))] pub const CLOCK_THREAD_CPUTIME_ID: Self = Self(libc::CLOCK_THREAD_CPUTIME_ID); } @@ -275,6 +278,20 @@ pub fn clock_gettime(id: ClockId) -> std::io::Result { .map_err(std::io::Error::from) } +#[cfg(target_os = "wasi")] +pub fn clock_gettime(id: ClockId) -> std::io::Result { + let mut ts = core::mem::MaybeUninit::::uninit(); + + let ret = unsafe { libc::clock_gettime(id.as_raw(), ts.as_mut_ptr()) }; + if ret != 0 { + return Err(std::io::Error::last_os_error()); + } + + let ts = unsafe { ts.assume_init() }; + + Ok(Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32)) +} + #[cfg(all(unix, not(target_os = "redox")))] pub fn clock_getres(id: ClockId) -> std::io::Result { nix::time::clock_getres(nix_clock_id(id)) diff --git a/crates/vm/src/stdlib/time.rs b/crates/vm/src/stdlib/time.rs index aaac706bfac..4c9fd70c4d9 100644 --- a/crates/vm/src/stdlib/time.rs +++ b/crates/vm/src/stdlib/time.rs @@ -31,6 +31,8 @@ mod decl { naive::{NaiveDate, NaiveDateTime, NaiveTime}, }; use core::time::Duration; + #[cfg(target_os = "wasi")] + use rustpython_host_env::time::ClockId; #[cfg(any(unix, windows))] use rustpython_host_env::time::asctime_from_tm; use rustpython_host_env::time::{self as host_time}; @@ -60,14 +62,27 @@ mod decl { #[pyattr] pub const _STRUCT_TM_ITEMS: usize = 11; - // TODO: implement proper monotonic time for wasm/wasi. - #[cfg(not(any(unix, windows)))] + #[cfg(target_os = "wasi")] + fn get_clock_time(id: ClockId, vm: &VirtualMachine) -> PyResult { + host_time::clock_gettime(id).map_err(|err| vm.new_os_error(err.to_string())) + } + + #[cfg(target_os = "wasi")] + fn get_monotonic_time(vm: &VirtualMachine) -> PyResult { + get_clock_time(ClockId::CLOCK_MONOTONIC, vm) + } + + #[cfg(target_os = "wasi")] + fn get_perf_time(vm: &VirtualMachine) -> PyResult { + get_clock_time(ClockId::CLOCK_MONOTONIC, vm) + } + + #[cfg(not(any(unix, windows, target_os = "wasi")))] fn get_monotonic_time(vm: &VirtualMachine) -> PyResult { duration_since_system_now(vm) } - // TODO: implement proper perf time for wasm/wasi. - #[cfg(not(any(unix, windows)))] + #[cfg(not(any(unix, windows, target_os = "wasi")))] fn get_perf_time(vm: &VirtualMachine) -> PyResult { duration_since_system_now(vm) } diff --git a/extra_tests/snippets/stdlib_time.py b/extra_tests/snippets/stdlib_time.py index 1629443a9e7..68ceab89521 100644 --- a/extra_tests/snippets/stdlib_time.py +++ b/extra_tests/snippets/stdlib_time.py @@ -1,3 +1,4 @@ +import sys import time x = time.gmtime(1000) @@ -11,41 +12,73 @@ # print(s) assert s == "1970-01-01-00-16-40" -x2 = time.strptime(s, "%Y-%m-%d-%H-%M-%S") -assert x2.tm_min == 16 +if sys.platform != "wasi": + # _strptime depends on time.tzname, which is not available on WASI yet. + x2 = time.strptime(s, "%Y-%m-%d-%H-%M-%S") + assert x2.tm_min == 16 + + # TODO: WASI currently does not raise OverflowError for some out-of-range + # struct_time values in asctime() and strftime(). + # Re-enable this regression on WASI once the non-Unix time conversion path is fixed. + + # Regression test for RustPython issue #4938: + # struct_time field overflow should raise OverflowError (matching CPython), + # not TypeError. Covers mktime, asctime, and strftime. + I32_MAX_PLUS_1 = 2147483648 + overflow_cases = [ + (I32_MAX_PLUS_1, 1, 1, 0, 0, 0, 0, 0, 0), # i32 overflow in year + (2024, I32_MAX_PLUS_1, 1, 0, 0, 0, 0, 0, 0), # i32 overflow in month + (2024, 1, I32_MAX_PLUS_1, 0, 0, 0, 0, 0, 0), # i32 overflow in mday + (2024, 1, 1, 0, 0, I32_MAX_PLUS_1, 0, 0, 0), # i32 overflow in sec + (88888888888,) * 9, # multi-field i32 overflow + ] + + for case in overflow_cases: + for func_name, call in [ + ("mktime", lambda c=case: time.mktime(c)), + ("asctime", lambda c=case: time.asctime(c)), + ("strftime", lambda c=case: time.strftime("%Y", c)), + ]: + try: + call() + except OverflowError: + pass # expected, matches CPython + except TypeError as e: + raise AssertionError( + f"{func_name}({case}) raised TypeError (should be OverflowError): {e}" + ) from e + else: + raise AssertionError( + f"{func_name}({case}) did not raise — expected OverflowError" + ) s = time.asctime(x) -# print(s) assert s == "Thu Jan 1 00:16:40 1970" +# Monotonic and performance clocks should advance with elapsed time. +monotonic_before = time.monotonic() +monotonic_ns = time.monotonic_ns() +monotonic_after = time.monotonic() + +assert isinstance(monotonic_before, float) +assert isinstance(monotonic_ns, int) +assert monotonic_before <= monotonic_ns / 1_000_000_000 <= monotonic_after + +perf_before = time.perf_counter() +perf_ns = time.perf_counter_ns() +perf_after = time.perf_counter() + +assert isinstance(perf_before, float) +assert isinstance(perf_ns, int) +assert perf_before <= perf_ns / 1_000_000_000 <= perf_after + +monotonic_start = time.monotonic() +perf_start = time.perf_counter() + +time.sleep(0.02) + +monotonic_elapsed = time.monotonic() - monotonic_start +perf_elapsed = time.perf_counter() - perf_start -# Regression test for RustPython issue #4938: -# struct_time field overflow should raise OverflowError (matching CPython), -# not TypeError. Covers mktime, asctime, and strftime. -I32_MAX_PLUS_1 = 2147483648 -overflow_cases = [ - (I32_MAX_PLUS_1, 1, 1, 0, 0, 0, 0, 0, 0), # i32 overflow in year - (2024, I32_MAX_PLUS_1, 1, 0, 0, 0, 0, 0, 0), # i32 overflow in month - (2024, 1, I32_MAX_PLUS_1, 0, 0, 0, 0, 0, 0), # i32 overflow in mday - (2024, 1, 1, 0, 0, I32_MAX_PLUS_1, 0, 0, 0), # i32 overflow in sec - (88888888888,) * 9, # multi-field i32 overflow -] - -for case in overflow_cases: - for func_name, call in [ - ("mktime", lambda c=case: time.mktime(c)), - ("asctime", lambda c=case: time.asctime(c)), - ("strftime", lambda c=case: time.strftime("%Y", c)), - ]: - try: - call() - except OverflowError: - pass # expected, matches CPython - except TypeError as e: - raise AssertionError( - f"{func_name}({case}) raised TypeError (should be OverflowError): {e}" - ) from e - else: - raise AssertionError( - f"{func_name}({case}) did not raise — expected OverflowError" - ) +assert monotonic_elapsed >= 0.01 +assert perf_elapsed >= 0.01 From 363e8a9c70d01f6544aa59522a15f387607d89bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:11:49 +0900 Subject: [PATCH 208/351] build(deps): bump libc from 0.2.186 to 0.2.189 (#8399) Bumps [libc](https://github.com/rust-lang/libc) from 0.2.186 to 0.2.189. - [Release notes](https://github.com/rust-lang/libc/releases) - [Changelog](https://github.com/rust-lang/libc/blob/0.2.189/CHANGELOG.md) - [Commits](https://github.com/rust-lang/libc/compare/0.2.186...0.2.189) --- updated-dependencies: - dependency-name: libc dependency-version: 0.2.189 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1ccd819cc1c..dda6b1d0934 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1978,9 +1978,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libffi" From 76f6f6ce83b3a619f91c2e6dc497a040deb52a8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:12:09 +0900 Subject: [PATCH 209/351] build(deps): bump webpki-roots (#8397) Bumps the webpki-root group with 1 update in the / directory: [webpki-roots](https://github.com/rustls/webpki-roots). Updates `webpki-roots` from 1.0.8 to 1.0.9 - [Release notes](https://github.com/rustls/webpki-roots/releases) - [Commits](https://github.com/rustls/webpki-roots/compare/v/1.0.8...v/1.0.9) --- updated-dependencies: - dependency-name: webpki-roots dependency-version: 1.0.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: webpki-root ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dda6b1d0934..0acd840c079 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4657,9 +4657,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] From 6906aa1747f424e188f75a46628e8447584ce9b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=A7=84=EB=AA=85?= Date: Thu, 30 Jul 2026 00:15:33 +0900 Subject: [PATCH 210/351] csv: support multi-character lineterminator in writer (#8328) * csv: support multi-character lineterminator in writer Store the dialect line terminator as an owned String instead of the single-byte csv_core::Terminator, dropping PyDialect's Copy derive. The manual writer paths emit the full terminator; the csv-core-backed QUOTE_ALL/QUOTE_NONNUMERIC paths emit a sentinel byte (preserving csv-core's quote/empty-record bookkeeping) and append the real terminator. field_needs_quotes/escape now quote a field containing any terminator byte. The reader ignores lineterminator and always uses CRLF, matching CPython and avoiding mid-UTF-8 record splits. * csv: unmark now-passing test_write_lineterminator * csv: address review feedback - Remove the redundant per-branch sentinel terminator setup in to_writer; the unconditional terminator call after the match is the single source. - Reword the empty-lineterminator errors to "must not be empty" (the constraint is non-empty, not single-character) on both entry points. - Promote the sentinel invariant check in writerow from debug_assert_eq! to assert_eq! so it also guards release builds. - Add a snippet case for a field containing a line-break byte to ensure the csv-core path drops only the trailing terminator. * csv: reject non-ASCII lineterminator The writer decides what to quote and escape by comparing raw bytes, so a non-ASCII terminator quoted a field that merely shared a UTF-8 lead byte, and QUOTE_NONE escaped individual bytes of the terminator and then failed to decode the record back to a string. Reject non-ASCII terminators, including lone surrogates, as csv.Error when the dialect is parsed, and leave code-point-wise handling to a follow-up (#8310). The non-string error message now matches CPython as well. --- Lib/test/test_csv.py | 1 - crates/stdlib/src/csv.rs | 223 ++++++++++++++++------------- extra_tests/snippets/stdlib_csv.py | 114 +++++++++++++++ 3 files changed, 238 insertions(+), 100 deletions(-) diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py index 379f4c9b799..2b1de5d70d9 100644 --- a/Lib/test/test_csv.py +++ b/Lib/test/test_csv.py @@ -262,7 +262,6 @@ def test_write_escape(self): self._write_test(['C\\', '6', '7', 'X"'], 'C\\\\,6,7,"X"""', escapechar='\\', quoting=csv.QUOTE_MINIMAL) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_write_lineterminator(self): for lineterminator in '\r\n', '\n', '\r', '!@#', '\0': with self.subTest(lineterminator=lineterminator): diff --git a/crates/stdlib/src/csv.rs b/crates/stdlib/src/csv.rs index 91717801bc4..cd065f634f2 100644 --- a/crates/stdlib/src/csv.rs +++ b/crates/stdlib/src/csv.rs @@ -77,18 +77,27 @@ mod _csv { #[pyattr] #[pyclass(module = "csv", name = "Dialect")] - #[derive(Debug, PyPayload, Clone, Copy)] + #[derive(Debug, PyPayload, Clone)] struct PyDialect { delimiter: u8, quotechar: Option, escapechar: Option, doublequote: bool, skipinitialspace: bool, - lineterminator: csv_core::Terminator, + lineterminator: String, quoting: QuoteStyle, strict: bool, } + /// Placeholder single-byte terminator for the csv-core writer paths + /// (`QUOTE_ALL` / `QUOTE_NONNUMERIC`). csv-core can only emit a single byte + /// for the record terminator, but its `terminator()` call also performs + /// essential bookkeeping — closing the final quote and emitting `""` for an + /// empty record — that must not be bypassed. So the writer emits this + /// sentinel byte, and `writerow` strips it and appends the real (possibly + /// multi-character) line terminator afterwards. + const CSV_CORE_TERMINATOR_SENTINEL: u8 = b'\n'; + impl Constructor for PyDialect { type Args = PyObjectRef; @@ -121,11 +130,7 @@ mod _csv { #[pygetset] fn lineterminator(&self, vm: &VirtualMachine) -> PyRef { - match self.lineterminator { - Terminator::CRLF => vm.ctx.new_str("\r\n".to_string()), - Terminator::Any(t) => vm.ctx.new_str(format!("{}", t as char)), - _ => unreachable!(), - } + vm.ctx.new_str(self.lineterminator.clone()) } #[pygetset] @@ -230,19 +235,42 @@ mod _csv { }) } - fn prase_lineterminator_from_obj(vm: &VirtualMachine, obj: &PyObject) -> PyResult { + /// Validate that a line terminator is ASCII and return it as a `str`. + /// + /// The writer's quoting and escaping predicates compare raw bytes, so a + /// non-ASCII terminator would either quote a field that merely shares a + /// UTF-8 lead byte or splice an escape character into the middle of a + /// multi-byte sequence. Reject those here. + /// + /// The ASCII check must come before any UTF-8 conversion so that lone + /// surrogates are reported as this `csv.Error` too. + /// + /// TODO: RUSTPYTHON; handle non-ASCII terminators code-point-wise as part + /// of full Unicode dialect support. + fn ascii_lineterminator<'a>(vm: &VirtualMachine, s: &'a PyStr) -> PyResult<&'a str> { + if !s.as_wtf8().is_ascii() { + return Err(new_csv_error( + vm, + r#""lineterminator" must be an ASCII string"#, + )); + } + // An ASCII string is always valid UTF-8. + s.to_str() + .ok_or_else(|| new_csv_error(vm, r#""lineterminator" must be a string"#)) + } + + fn prase_lineterminator_from_obj(vm: &VirtualMachine, obj: &PyObject) -> PyResult { match_class!(match obj.get_attr("lineterminator", vm)? { s @ PyStr => { - Ok(if s.as_bytes().eq(b"\r\n") { - csv_core::Terminator::CRLF - } else if let Some(t) = s.as_bytes().first() { - // Due to limitations in the current implementation within csv_core - // the support for multiple characters in lineterminator is not complete. - // only capture the first character - csv_core::Terminator::Any(*t) - } else { - return Err(new_csv_error(vm, r#""lineterminator" must be a string"#)); - }) + // Store the full line terminator string. CPython accepts an + // arbitrary-length terminator; the manual writer paths emit it + // verbatim and the csv-core writer path appends it after a + // sentinel terminator (see `writerow`). + let value = ascii_lineterminator(vm, &s)?; + if value.is_empty() { + return Err(new_csv_error(vm, r#""lineterminator" must not be empty"#)); + } + Ok(value.to_owned()) } attr => { Err(vm.new_type_error(format!( @@ -344,7 +372,7 @@ mod _csv { let g = GLOBAL_HASHMAP.lock(); if let Some(dialect) = g.get(name.as_str()) { - return Ok(*dialect); + return Ok(dialect.clone()); } Err(new_csv_error(vm, "unknown dialect")) @@ -540,7 +568,7 @@ mod _csv { escapechar: Option, doublequote: Option, skipinitialspace: Option, - lineterminator: Option, + lineterminator: Option, quoting: Option, strict: Option, } @@ -629,15 +657,22 @@ mod _csv { }; if let Some(lineterminator) = args.kwargs.swap_remove("lineterminator") { - res.lineterminator = Some(csv_core::Terminator::Any( - lineterminator - .try_to_value::<&str>(vm)? - .bytes() - .exactly_one() - .map_err(|_| { - vm.new_type_error(r#""lineterminator" must be a 1-character string"#) - })?, - )) + let s = lineterminator.downcast_ref::().ok_or_else(|| { + vm.new_type_error(format!( + r#""lineterminator" must be a string, not {}"#, + lineterminator.class().name() + )) + })?; + let value = ascii_lineterminator(vm, s)?; + // Preserve the previous behavior of rejecting an empty terminator + // (full validation parity is deferred to a follow-up). Any + // non-empty string, including multi-character ones, is stored. + if value.is_empty() { + return Err(vm + .new_type_error(r#""lineterminator" must not be empty"#) + .into()); + } + res.lineterminator = Some(value.to_owned()); }; if let Some(doublequote) = args.kwargs.swap_remove("doublequote") { @@ -717,7 +752,7 @@ mod _csv { } impl FormatOptions { - const fn update_py_dialect(&self, mut res: PyDialect) -> PyDialect { + fn update_py_dialect(&self, mut res: PyDialect) -> PyDialect { macro_rules! check_and_fill { ($res:ident, $e:ident) => {{ if let Some(t) = self.$e { @@ -741,7 +776,9 @@ mod _csv { }; check_and_fill!(res, quoting); - check_and_fill!(res, lineterminator); + if let Some(t) = &self.lineterminator { + res.lineterminator.clone_from(t); + }; check_and_fill!(res, strict); res } @@ -751,16 +788,16 @@ mod _csv { DialectItem::Str(name) => { let g = GLOBAL_HASHMAP.lock(); if let Some(dialect) = g.get(name) { - Ok(self.update_py_dialect(*dialect)) + Ok(self.update_py_dialect(dialect.clone())) } else { Err(new_csv_error(vm, format!("{name} is not registered."))) } // TODO: Maybe need to update the obj from HashMap } - DialectItem::Obj(o) => Ok(self.update_py_dialect(*o)), + DialectItem::Obj(o) => Ok(self.update_py_dialect(o.clone())), DialectItem::None => { let g = GLOBAL_HASHMAP.lock(); - let res = *g.get("excel").unwrap(); + let res = g.get("excel").unwrap().clone(); Ok(self.update_py_dialect(res)) } } @@ -788,27 +825,6 @@ mod _csv { skipinitialspace } - fn get_lineterminator(&self) -> csv_core::Terminator { - let mut lineterminator = match &self.dialect { - DialectItem::Str(name) => { - let g = GLOBAL_HASHMAP.lock(); - if let Some(dialect) = g.get(name) { - dialect.lineterminator - } else { - Terminator::CRLF - } - } - DialectItem::Obj(obj) => obj.lineterminator, - _ => Terminator::CRLF, - }; - - if let Some(attr) = self.lineterminator { - lineterminator = attr - } - - lineterminator - } - fn get_quoting(&self) -> QuoteStyle { let mut quoting = match &self.dialect { DialectItem::Str(name) => { @@ -832,11 +848,11 @@ mod _csv { fn to_reader(&self) -> csv_core::Reader { let dialect = match &self.dialect { - DialectItem::Str(name) => GLOBAL_HASHMAP.lock().get(name).copied(), - DialectItem::Obj(obj) => Some(*obj), + DialectItem::Str(name) => GLOBAL_HASHMAP.lock().get(name).cloned(), + DialectItem::Obj(obj) => Some(obj.clone()), DialectItem::None => { let g = GLOBAL_HASHMAP.lock(); - Some(*g.get("excel").unwrap()) + Some(g.get("excel").unwrap().clone()) } }; @@ -868,10 +884,6 @@ mod _csv { reader = reader.quoting(self.quoting != Some(QuoteStyle::None)); } - if let Some(t) = self.lineterminator { - reader = reader.terminator(t); - } - if let Some(t) = self.doublequote { reader = reader.double_quote(t); } @@ -880,7 +892,12 @@ mod _csv { reader = reader.escape(self.escapechar); } - reader = reader.terminator(self.lineterminator.unwrap_or(Terminator::CRLF)); + // CPython's reader ignores the dialect's `lineterminator` entirely and + // only recognizes `\r`, `\n`, and `\r\n` as record separators. Match + // that: always use CRLF mode. Feeding a multi-byte terminator's first + // byte here would otherwise split records mid-UTF-8 and raise a + // UnicodeDecodeError. + reader = reader.terminator(Terminator::CRLF); reader.build() } @@ -893,8 +910,7 @@ mod _csv { if let Some(dialect) = g.get(name) { let mut builder = builder .delimiter(dialect.delimiter) - .double_quote(dialect.doublequote) - .terminator(dialect.lineterminator); + .double_quote(dialect.doublequote); if let Some(t) = dialect.quotechar { builder = builder.quote(t); @@ -910,8 +926,7 @@ mod _csv { DialectItem::Obj(obj) => { let mut builder = builder .delimiter(obj.delimiter) - .double_quote(obj.doublequote) - .terminator(obj.lineterminator); + .double_quote(obj.doublequote); if let Some(t) = obj.quotechar { builder = builder.quote(t); @@ -934,7 +949,7 @@ mod _csv { writer = writer.double_quote(t); } - writer = writer.terminator(self.get_lineterminator()); + writer = writer.terminator(Terminator::Any(CSV_CORE_TERMINATOR_SENTINEL)); if let Some(e) = self.escapechar { writer = writer.escape(e); @@ -979,8 +994,8 @@ mod _csv { } #[pygetset] - const fn dialect(&self, _vm: &VirtualMachine) -> PyDialect { - self.dialect + fn dialect(&self, _vm: &VirtualMachine) -> PyDialect { + self.dialect.clone() } } @@ -1014,7 +1029,7 @@ mod _csv { &mut self, input: &[u8], index: usize, - dialect: PyDialect, + dialect: &PyDialect, unquoted_escape: bool, ) -> (QuoteScanEvent, usize) { let byte = input[index]; @@ -1067,7 +1082,7 @@ mod _csv { fn read_quote_record( input: &[u8], - dialect: PyDialect, + dialect: &PyDialect, field_limit: isize, vm: &VirtualMachine, ) -> PyResult> { @@ -1179,13 +1194,13 @@ mod _csv { ) || (zelf.dialect.quoting == QuoteStyle::None && zelf.dialect.escapechar.is_some()); if use_quote_record { - let out = read_quote_record(input, zelf.dialect, field_limit, vm)?; + let out = read_quote_record(input, &zelf.dialect, field_limit, vm)?; *line_num += 1; return Ok(PyIterReturn::Return(vm.ctx.new_list(out).into())); } #[inline] - fn trim_initial_spaces(input: &[u8], dialect: PyDialect) -> Vec { + fn trim_initial_spaces(input: &[u8], dialect: &PyDialect) -> Vec { let mut trimmed = Vec::with_capacity(input.len()); let mut scan_state = QuoteScanState::new(); let mut index = 0; @@ -1215,7 +1230,7 @@ mod _csv { } let input = if *skipinitialspace { - String::from_utf8(trim_initial_spaces(input, zelf.dialect)).unwrap() + String::from_utf8(trim_initial_spaces(input, &zelf.dialect)).unwrap() } else { String::from_utf8(input.to_vec()).unwrap() }; @@ -1314,7 +1329,7 @@ mod _csv { fn write_quoted_field( output: &mut Vec, data: &[u8], - dialect: PyDialect, + dialect: &PyDialect, vm: &VirtualMachine, ) -> PyResult<()> { let quotechar = dialect @@ -1346,7 +1361,7 @@ mod _csv { fn write_unquoted_field( output: &mut Vec, data: &[u8], - dialect: PyDialect, + dialect: &PyDialect, vm: &VirtualMachine, ) -> PyResult<()> { for &byte in data { @@ -1361,36 +1376,38 @@ mod _csv { Ok(()) } - fn field_needs_quotes(data: &[u8], dialect: PyDialect) -> bool { + fn field_needs_quotes(data: &[u8], dialect: &PyDialect) -> bool { data.iter().any(|&byte| { byte == dialect.delimiter || dialect.quotechar == Some(byte) || matches!(byte, b'\r' | b'\n') - || matches!(dialect.lineterminator, Terminator::Any(t) if byte == t) + // CPython quotes a field containing any character of the line + // terminator. The terminator is ASCII-validated at parse time, so + // comparing raw bytes cannot match part of a multi-byte character. + // TODO: RUSTPYTHON; supporting non-ASCII terminators needs + // code-point-wise quoting and escaping as part of full + // Unicode dialect support. + || dialect.lineterminator.as_bytes().contains(&byte) }) } - fn field_needs_escape(byte: u8, dialect: PyDialect) -> bool { + fn field_needs_escape(byte: u8, dialect: &PyDialect) -> bool { byte == dialect.delimiter || dialect.quotechar == Some(byte) || dialect.escapechar == Some(byte) || matches!(byte, b'\r' | b'\n') - || matches!(dialect.lineterminator, Terminator::Any(t) if byte == t) + || dialect.lineterminator.as_bytes().contains(&byte) } - fn write_lineterminator(output: &mut Vec, terminator: Terminator) { - match terminator { - Terminator::CRLF => output.extend_from_slice(b"\r\n"), - Terminator::Any(byte) => output.push(byte), - _ => unreachable!(), - } + fn write_lineterminator(output: &mut Vec, terminator: &str) { + output.extend_from_slice(terminator.as_bytes()); } #[pyclass(flags(DISALLOW_INSTANTIATION))] impl Writer { #[pygetset(name = "dialect")] - const fn get_dialect(&self, _vm: &VirtualMachine) -> PyDialect { - self.dialect + fn get_dialect(&self, _vm: &VirtualMachine) -> PyDialect { + self.dialect.clone() } fn writerow_quoted_strings(&self, row: PyObjectRef, vm: &VirtualMachine) -> PyResult { @@ -1421,12 +1438,12 @@ mod _csv { }); let should_quote = match self.dialect.quoting { - QuoteStyle::Strings => is_str || field_needs_quotes(data, self.dialect), + QuoteStyle::Strings => is_str || field_needs_quotes(data, &self.dialect), QuoteStyle::Notnull => !is_none, _ => unreachable!(), }; if should_quote { - write_quoted_field(&mut output, data, self.dialect, vm)?; + write_quoted_field(&mut output, data, &self.dialect, vm)?; } else if single_field && data.is_empty() { return Err(new_csv_error( vm, @@ -1437,7 +1454,7 @@ mod _csv { } } - write_lineterminator(&mut output, self.dialect.lineterminator); + write_lineterminator(&mut output, &self.dialect.lineterminator); let s = core::str::from_utf8(&output).map_err(|e| new_not_utf8_error(vm, &output, e))?; self.write.call((s,), vm) @@ -1479,10 +1496,10 @@ mod _csv { )); } - write_unquoted_field(&mut output, data, self.dialect, vm)?; + write_unquoted_field(&mut output, data, &self.dialect, vm)?; } - write_lineterminator(&mut output, self.dialect.lineterminator); + write_lineterminator(&mut output, &self.dialect.lineterminator); let s = core::str::from_utf8(&output).map_err(|e| new_not_utf8_error(vm, &output, e))?; @@ -1524,14 +1541,14 @@ mod _csv { // terminator, regardless of which line terminator is // configured. A row with a single empty field is also quoted // so that it is not read back as an empty line. - if field_needs_quotes(data, self.dialect) || (single_field && data.is_empty()) { - write_quoted_field(&mut output, data, self.dialect, vm)?; + if field_needs_quotes(data, &self.dialect) || (single_field && data.is_empty()) { + write_quoted_field(&mut output, data, &self.dialect, vm)?; } else { output.extend_from_slice(data); } } - write_lineterminator(&mut output, self.dialect.lineterminator); + write_lineterminator(&mut output, &self.dialect.lineterminator); let s = core::str::from_utf8(&output).map_err(|e| new_not_utf8_error(vm, &output, e))?; @@ -1607,8 +1624,16 @@ mod _csv { handle_res!(writer.terminator(&mut buffer[buffer_offset..])); } - let s = core::str::from_utf8(&buffer[..buffer_offset]) - .map_err(|e| new_not_utf8_error(vm, &buffer[..buffer_offset], e))?; + // csv-core just emitted the single-byte sentinel terminator (after + // closing the final quote / emitting an empty record as needed). + // Drop that sentinel byte and append the real, possibly + // multi-character, line terminator. + assert_eq!(buffer[buffer_offset - 1], CSV_CORE_TERMINATOR_SENTINEL); + let mut output = buffer[..buffer_offset - 1].to_vec(); + output.extend_from_slice(self.dialect.lineterminator.as_bytes()); + + let s = + core::str::from_utf8(&output).map_err(|e| new_not_utf8_error(vm, &output, e))?; self.write.call((s,), vm) } diff --git a/extra_tests/snippets/stdlib_csv.py b/extra_tests/snippets/stdlib_csv.py index 61b82459a28..418d383d84a 100644 --- a/extra_tests/snippets/stdlib_csv.py +++ b/extra_tests/snippets/stdlib_csv.py @@ -1,5 +1,6 @@ import csv import io +import sys from testutils import assert_raises @@ -181,6 +182,119 @@ def test_quote_minimal_writer_lineterminator(): test_quote_minimal_writer_lineterminator() +def test_multichar_lineterminator(): + # https://github.com/RustPython/RustPython/issues/8322 + # The writer must store and emit a full multi-character line terminator. + for lineterminator in "\r\n", "\n", "\r", "!@#", "\0": + buf = io.StringIO() + writer = csv.writer(buf, lineterminator=lineterminator) + writer.writerow(["a", "b"]) + writer.writerow([1, 2]) + writer.writerow(["\r", "\n"]) + assert buf.getvalue() == ( + f'a,b{lineterminator}1,2{lineterminator}"\r","\n"{lineterminator}' + ), (lineterminator, buf.getvalue()) + + # A field is quoted when it contains any byte of the terminator (QUOTE_MINIMAL). + for field, expected in [ + ("a@b", '"a@b",x!@#'), + ("a!b", '"a!b",x!@#'), + ("a#b", '"a#b",x!@#'), + ("abc", "abc,x!@#"), + ]: + buf = io.StringIO() + csv.writer(buf, lineterminator="!@#").writerow([field, "x"]) + assert buf.getvalue() == expected, (field, buf.getvalue()) + + # The csv-core-backed QUOTE_ALL / QUOTE_NONNUMERIC paths emit the full + # terminator too, and keep the state machine correct across rows. + allq = io.StringIO() + writer = csv.writer(allq, lineterminator="!@#", quoting=csv.QUOTE_ALL) + writer.writerow(["a", "b"]) + writer.writerow(["c", "d"]) + assert allq.getvalue() == '"a","b"!@#"c","d"!@#', allq.getvalue() + + nonnum = io.StringIO() + csv.writer(nonnum, lineterminator="!@#", quoting=csv.QUOTE_NONNUMERIC).writerow( + ["a", 1] + ) + assert nonnum.getvalue() == '"a",1!@#', nonnum.getvalue() + + # A field that itself contains a line-break byte must be kept intact: the + # csv-core path drops only the trailing record terminator, not a byte from + # the field data. + embedded = io.StringIO() + csv.writer(embedded, lineterminator="!@#", quoting=csv.QUOTE_ALL).writerow( + ["x\ny", "z"] + ) + assert embedded.getvalue() == '"x\ny","z"!@#', embedded.getvalue() + + # QUOTE_NONE escapes any byte of the terminator. + none = io.StringIO() + csv.writer( + none, lineterminator="!@#", quoting=csv.QUOTE_NONE, escapechar="\\" + ).writerow(["a!b", "x"]) + assert none.getvalue() == "a\\!b,x!@#", none.getvalue() + + # register_dialect round-trips a multi-character terminator. + csv.register_dialect("multichar_lt", delimiter=",", lineterminator="!@#") + try: + reg = io.StringIO() + csv.writer(reg, dialect="multichar_lt").writerow(["a", "b"]) + assert reg.getvalue() == "a,b!@#", reg.getvalue() + finally: + csv.unregister_dialect("multichar_lt") + + # The dialect attribute reflects the full terminator. + assert ( + csv.writer(io.StringIO(), lineterminator="!@#").dialect.lineterminator == "!@#" + ) + + # The reader ignores lineterminator (like CPython) and only splits on \r\n. + assert list(csv.reader(io.StringIO("a,b!@#c,d!@#"), lineterminator="!@#")) == [ + ["a", "b!@#c", "d!@#"] + ] + + +test_multichar_lineterminator() + + +def test_reject_non_ascii_lineterminator(): + # CPython accepts non-ASCII line terminators; RustPython rejects them + # because the writer quotes and escapes byte by byte. Supporting them + # requires code-point-wise handling as part of full Unicode dialect support. + with assert_raises(csv.Error): + csv.writer(io.StringIO(), lineterminator="é") + + with assert_raises(csv.Error): + csv.writer(io.StringIO(), lineterminator="\x85") + + with assert_raises(csv.Error): + csv.writer(io.StringIO(), lineterminator="\ud800") + + with assert_raises(csv.Error): + csv.writer( + io.StringIO(), lineterminator="é", quoting=csv.QUOTE_NONE, escapechar="\\" + ) + + with assert_raises(csv.Error): + csv.register_dialect("non_ascii_lt", lineterminator="é") + + class NonAsciiDialect(csv.excel): + lineterminator = "é" + + with assert_raises(csv.Error): + NonAsciiDialect() + + buf = io.StringIO() + csv.writer(buf, lineterminator="!@#").writerow(["a", "b"]) + assert buf.getvalue() == "a,b!@#" + + +if sys.implementation.name == "rustpython": + test_reject_non_ascii_lineterminator() + + def test_quote_minimal_writer_empty_fields(): buf = io.StringIO() writer = csv.writer(buf) From ede18e44cbd5a522d5bd55d89da9391294a5af6b Mon Sep 17 00:00:00 2001 From: Dino <164735145+2jiyong@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:53:35 +0900 Subject: [PATCH 211/351] dict: match CPython errors for invalid update sequence elements (#8338) * fix dict update type error * Use tuple for dictionary update sequence pairs * Propagate errors while adding dict update sequence notes * Match BaseException.add_note error message with CPython --- Lib/test/test_dict.py | 1 - crates/vm/src/builtins/dict.rs | 90 ++++++++++++++++++++++++++++------ crates/vm/src/exceptions.rs | 2 +- 3 files changed, 76 insertions(+), 17 deletions(-) diff --git a/Lib/test/test_dict.py b/Lib/test/test_dict.py index 79c975946f7..e2a73773cc2 100644 --- a/Lib/test/test_dict.py +++ b/Lib/test/test_dict.py @@ -275,7 +275,6 @@ def __next__(self): self.assertRaises(ValueError, {}.update, [(1, 2, 3)]) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_update_type_error(self): with self.assertRaises(TypeError) as cm: {}.update([object() for _ in range(3)]) diff --git a/crates/vm/src/builtins/dict.rs b/crates/vm/src/builtins/dict.rs index af74a259157..5db071e1d8f 100644 --- a/crates/vm/src/builtins/dict.rs +++ b/crates/vm/src/builtins/dict.rs @@ -7,7 +7,7 @@ use crate::object::{Traverse, TraverseFn}; use crate::{ AsObject, Context, Py, PyExact, PyObject, PyObjectRef, PyPayload, PyRef, PyRefExact, PyResult, TryFromObject, atomic_func, - builtins::{PyTuple, iter::builtins_iter, type_::PyAttributes}, + builtins::{PyList, PyTuple, iter::builtins_iter, type_::PyAttributes}, class::{PyClassDef, PyClassImpl}, common::ascii, dict_inner::{self, DictKey}, @@ -183,6 +183,76 @@ impl PyDict { self.merge_object_with_override(other, false, vm) } + fn add_update_sequence_note( + exc: PyBaseExceptionRef, + index: usize, + vm: &VirtualMachine, + ) -> PyBaseExceptionRef { + if !exc.fast_isinstance(vm.ctx.exceptions.type_error) { + return exc; + } + + let note = + format!("Cannot convert dictionary update sequence element #{index} to a sequence"); + match vm.call_method(exc.as_object(), "add_note", (vm.ctx.new_str(note),)) { + Ok(_) => exc, + Err(note_err) => { + note_err.set___context__(Some(exc)); + note_err + } + } + } + + fn update_sequence_pair_from_slice( + elements: &[PyObjectRef], + index: usize, + vm: &VirtualMachine, + ) -> PyResult<(PyObjectRef, PyObjectRef)> { + let [key, value] = elements else { + return Err(vm.new_value_error(format!( + "dictionary update sequence element #{index} has length {}; 2 is required", + elements.len() + ))); + }; + Ok((key.clone(), value.clone())) + } + + fn update_sequence_pair( + element: PyObjectRef, + index: usize, + vm: &VirtualMachine, + ) -> PyResult<(PyObjectRef, PyObjectRef)> { + let element = match element.downcast_exact::(vm) { + Ok(list) => { + let elements = list.borrow_vec(); + return Self::update_sequence_pair_from_slice(&elements, index, vm); + } + Err(element) => element, + }; + let element = match element.downcast_exact::(vm) { + Ok(tuple) => { + return Self::update_sequence_pair_from_slice(tuple.as_slice(), index, vm); + } + Err(element) => element, + }; + + let elements = (|| { + let elem_iter = element.get_iter(vm).map_err(|exc| { + if exc.fast_isinstance(vm.ctx.exceptions.type_error) { + vm.new_type_error("object is not iterable") + } else { + exc + } + })?; + elem_iter + .into_iter::(vm)? + .collect::>>() + })() + .map_err(|exc| Self::add_update_sequence_note(exc, index, vm))?; + + Self::update_sequence_pair_from_slice(&elements, index, vm) + } + pub fn merge_from_seq2( &self, seq2: PyObjectRef, @@ -191,20 +261,10 @@ impl PyDict { ) -> PyResult<()> { let iter = seq2.get_iter(vm)?; let dict = &self.entries; - loop { - fn err(vm: &VirtualMachine) -> PyBaseExceptionRef { - vm.new_value_error("Iterator must have exactly two elements") - } - let element = match iter.next(vm)? { - PyIterReturn::Return(obj) => obj, - PyIterReturn::StopIteration(_) => break, - }; - let elem_iter = element.get_iter(vm)?; - let key = elem_iter.next(vm)?.into_result().map_err(|_| err(vm))?; - let value = elem_iter.next(vm)?.into_result().map_err(|_| err(vm))?; - if matches!(elem_iter.next(vm)?, PyIterReturn::Return(_)) { - return Err(err(vm)); - } + + for (index, element) in iter.iter_without_hint::(vm)?.enumerate() { + let (key, value) = Self::update_sequence_pair(element?, index, vm)?; + if !override_existing && dict.contains(vm, &*key)? { continue; } diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index fe07a7e3c9e..7eaf8bafcdd 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -704,7 +704,7 @@ impl PyRef { let notes = notes .downcast::() - .map_err(|_| vm.new_type_error("__notes__ must be a list"))?; + .map_err(|_| vm.new_type_error("Cannot add note: __notes__ is not a list"))?; notes.borrow_vec_mut().push(note.into()); Ok(()) From 87de0dd000dd0e7861112c152e705276e610ab79 Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:11:31 -0400 Subject: [PATCH 212/351] Fix some clippy lints for 1.99 (#8412) --- crates/common/src/format.rs | 3 +-- crates/compiler-core/src/bytecode.rs | 17 +++++++++++++---- crates/host_env/src/nt.rs | 4 +++- crates/stdlib/src/_testconsole.rs | 8 ++++---- crates/vm/src/builtins/interpolation.rs | 3 +-- crates/vm/src/dict_inner.rs | 2 +- crates/vm/src/stdlib/_sre.rs | 6 ++---- crates/vm/src/stdlib/sys/monitoring.rs | 10 +++++----- crates/vm/src/vm/mod.rs | 5 +---- crates/vm/src/vm/vm_object.rs | 3 +-- 10 files changed, 32 insertions(+), 29 deletions(-) diff --git a/crates/common/src/format.rs b/crates/common/src/format.rs index fa71b27c5d9..b4b25b2739a 100644 --- a/crates/common/src/format.rs +++ b/crates/common/src/format.rs @@ -1380,8 +1380,7 @@ impl FieldName { FieldType::Index(index) } else if first .as_str() - .ok() - .is_some_and(|s| s.bytes().all(|b| b.is_ascii_digit())) + .is_ok_and(|s| s.bytes().all(|b| b.is_ascii_digit())) { // All-digit segment whose value overflows usize itself. return Err(FormatParseError::TooManyDecimalDigits); diff --git a/crates/compiler-core/src/bytecode.rs b/crates/compiler-core/src/bytecode.rs index 3a7eec439e3..ba1639170a7 100644 --- a/crates/compiler-core/src/bytecode.rs +++ b/crates/compiler-core/src/bytecode.rs @@ -557,6 +557,14 @@ impl TryFrom<&[u8]> for CodeUnit { } } +impl TryFrom<[u8; 2]> for CodeUnit { + type Error = MarshalError; + + fn try_from(value: [u8; 2]) -> Result { + Ok(Self::new(value[0].try_into()?, value[1].into())) + } +} + pub struct CodeUnits { units: UnsafeCell>, adaptive_counters: Box<[AtomicU16]>, @@ -610,12 +618,13 @@ impl TryFrom<&[u8]> for CodeUnits { type Error = MarshalError; fn try_from(value: &[u8]) -> Result { - if !value.len().is_multiple_of(2) { + let (chunks, []) = value.as_chunks::<2>() else { return Err(Self::Error::InvalidBytecode); - } + }; - let units = value - .chunks_exact(2) + let units = chunks + .iter() + .copied() .map(CodeUnit::try_from) .collect::, _>>()?; Ok(units.into()) diff --git a/crates/host_env/src/nt.rs b/crates/host_env/src/nt.rs index 4c77b30e616..780a75910ea 100644 --- a/crates/host_env/src/nt.rs +++ b/crates/host_env/src/nt.rs @@ -1333,7 +1333,9 @@ pub fn readlink(path: &Path) -> Result { let path_slice = &buffer[path_start..path_end]; let mut wide_chars: Vec = path_slice - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) .collect(); diff --git a/crates/stdlib/src/_testconsole.rs b/crates/stdlib/src/_testconsole.rs index 78cba3b397d..3b7aad17178 100644 --- a/crates/stdlib/src/_testconsole.rs +++ b/crates/stdlib/src/_testconsole.rs @@ -17,11 +17,11 @@ mod _testconsole { let data = &*data; // Interpret as UTF-16-LE pairs - if !data.len().is_multiple_of(2) { + let (chunks, []) = data.as_chunks::<2>() else { return Err(vm.new_value_error("buffer must contain UTF-16-LE data (even length)")); - } - let wchars: Vec = data - .chunks_exact(2) + }; + let wchars: Vec = chunks + .iter() .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) .collect(); host_testconsole::write_console_input(fd, &wchars).map_err(|e| e.into_pyexception(vm)) diff --git a/crates/vm/src/builtins/interpolation.rs b/crates/vm/src/builtins/interpolation.rs index a865ff390de..0ae1b33120b 100644 --- a/crates/vm/src/builtins/interpolation.rs +++ b/crates/vm/src/builtins/interpolation.rs @@ -68,8 +68,7 @@ impl Constructor for PyInterpolation { .as_bytes() .iter() .exactly_one() - .ok() - .is_some_and(|s| matches!(*s, b's' | b'r' | b'a')); + .is_ok_and(|s| matches!(*s, b's' | b'r' | b'a')); if !has_flag { return Err(vm.new_value_error( "Interpolation() argument 'conversion' must be one of 's', 'a' or 'r'", diff --git a/crates/vm/src/dict_inner.rs b/crates/vm/src/dict_inner.rs index cc26383d9b8..3e75e6f27a6 100644 --- a/crates/vm/src/dict_inner.rs +++ b/crates/vm/src/dict_inner.rs @@ -61,7 +61,7 @@ static KEYS_VERSION: AtomicU32 = AtomicU32::new(0); /// unrealistic in practice. fn next_keys_version() -> u32 { KEYS_VERSION - .fetch_update(Relaxed, Relaxed, |v| v.checked_add(1)) + .try_update(Relaxed, Relaxed, |v| v.checked_add(1)) .map_or(0, |v| v + 1) } diff --git a/crates/vm/src/stdlib/_sre.rs b/crates/vm/src/stdlib/_sre.rs index c90ff4d4f6f..03382549b47 100644 --- a/crates/vm/src/stdlib/_sre.rs +++ b/crates/vm/src/stdlib/_sre.rs @@ -146,11 +146,9 @@ mod _sre { let mut items = Vec::with_capacity(1); let v = template.borrow_vec(); let literal = v.first().ok_or_else(err)?.clone(); - let trunks = v[1..].chunks_exact(2); - - if !trunks.remainder().is_empty() { + let (trunks, []) = v[1..].as_chunks::<2>() else { return Err(err()); - } + }; for trunk in trunks { let index: usize = trunk[0] diff --git a/crates/vm/src/stdlib/sys/monitoring.rs b/crates/vm/src/stdlib/sys/monitoring.rs index 56a68cea619..f468a86b0a5 100644 --- a/crates/vm/src/stdlib/sys/monitoring.rs +++ b/crates/vm/src/stdlib/sys/monitoring.rs @@ -344,7 +344,7 @@ pub(crate) fn instrument_code(code: &PyCode, events: u32) { continue; } // Excluded: RESUME, END_FOR, CACHE (and their instrumented variants) - let base = op.to_base().map_or(op, |b| b); + let base = op.to_base().unwrap_or(op); if matches!( base, Instruction::Resume { .. } | Instruction::EndFor | Instruction::Cache @@ -387,7 +387,7 @@ pub(crate) fn instrument_code(code: &PyCode, events: u32) { .skip(first_traceable) { let op = unit.op; - let base = op.to_base().map_or(op, |b| b); + let base = op.to_base().unwrap_or(op); if matches!(base, Instruction::ExtendedArg) { continue; } @@ -425,7 +425,7 @@ pub(crate) fn instrument_code(code: &PyCode, events: u32) { let mut instr_idx = first_traceable; for unit in code.code.instructions[first_traceable..len].iter().copied() { let (op, arg) = arg_state.get(unit); - let base = op.to_base().map_or(op, |b| b); + let base = op.to_base().unwrap_or(op); if matches!(base, Instruction::ExtendedArg) || matches!(base, Instruction::Cache) { instr_idx += 1; @@ -460,7 +460,7 @@ pub(crate) fn instrument_code(code: &PyCode, events: u32) { && !no_loc_mask.get(target_idx).copied().unwrap_or(false) { let target_op = code.code.instructions[target_idx].op; - let target_base = target_op.to_base().map_or(target_op, |b| b); + let target_base = target_op.to_base().unwrap_or(target_op); // Skip synthetic cleanup targets. if matches!(target_base, Instruction::PopIter) { instr_idx += 1; @@ -483,7 +483,7 @@ pub(crate) fn instrument_code(code: &PyCode, events: u32) { && !no_loc_mask.get(target_idx).copied().unwrap_or(false) { let target_op = code.code.instructions[target_idx].op; - let target_base = target_op.to_base().map_or(target_op, |b| b); + let target_base = target_op.to_base().unwrap_or(target_op); if !matches!(target_base, Instruction::PopIter) && let Some((loc, _)) = line_locations.get(target_idx) && loc.line.get() > 0 diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 3062ea07f98..801b1297b74 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -2092,10 +2092,7 @@ impl VirtualMachine { if exc.class().is(self.ctx.exceptions.attribute_error) { let exc = exc.as_object(); // Check if this exception was already augmented - let already_set = exc - .get_attr("name", self) - .ok() - .is_some_and(|v| !self.is_none(&v)); + let already_set = exc.get_attr("name", self).is_ok_and(|v| !self.is_none(&v)); if already_set { return; } diff --git a/crates/vm/src/vm/vm_object.rs b/crates/vm/src/vm/vm_object.rs index 8a7be140dc8..aa68f7f4dee 100644 --- a/crates/vm/src/vm/vm_object.rs +++ b/crates/vm/src/vm/vm_object.rs @@ -47,8 +47,7 @@ impl VirtualMachine { /// Returns true if the file object's `closed` attribute is truthy. fn file_is_closed(&self, file: &PyObject) -> bool { file.get_attr("closed", self) - .ok() - .is_some_and(|v| v.try_to_bool(self).unwrap_or(false)) + .is_ok_and(|v| v.try_to_bool(self).unwrap_or_default()) } pub(crate) fn flush_std(&self) -> i32 { From 55b3d681ef9558d359912ea0d42e76e08ad65145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B4=91=ED=9A=A8?= <87641474+widehyo1@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:12:02 +0900 Subject: [PATCH 213/351] ast: match CPython argument validation order (#8414) Validate parameter annotations while converting ast.arguments, before merging positional and keyword-only defaults. This gives invalid annotation contexts precedence over default-list shape errors, as in CPython. Merge positional defaults before keyword-only defaults so an excess positional-default error also takes precedence over a keyword-only default length mismatch. Remove the expected-failure markers from the FunctionDef and Lambda AST validator tests that now pass. Assisted-by: Codex:gpt-5.6-sol --- Lib/test/test_ast/test_ast.py | 2 -- crates/vm/src/stdlib/_ast/parameter.rs | 34 +++++++++++++++++++++++++- crates/vm/src/stdlib/_ast/validate.rs | 5 +++- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_ast/test_ast.py b/Lib/test/test_ast/test_ast.py index 1450f440dee..a3ce5703424 100644 --- a/Lib/test/test_ast/test_ast.py +++ b/Lib/test/test_ast/test_ast.py @@ -2055,7 +2055,6 @@ def arguments(args=None, posonlyargs=None, vararg=None, kw_defaults=[None, ast.Name("x", ast.Store())]), "must have Load context") - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError not raised def test_funcdef(self): a = ast.arguments([], [], None, [], [], None, []) f = ast.FunctionDef("x", a, [], [], None, None, []) @@ -2267,7 +2266,6 @@ def test_unaryop(self): u = ast.UnaryOp(ast.Not(), ast.Name("x", ast.Store())) self.expr(u, "must have Load context") - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError not raised def test_lambda(self): a = ast.arguments([], [], None, [], [], None, []) self.expr(ast.Lambda(a, ast.Name("x", ast.Store())), diff --git a/crates/vm/src/stdlib/_ast/parameter.rs b/crates/vm/src/stdlib/_ast/parameter.rs index 477ceeb6242..1ce4973b494 100644 --- a/crates/vm/src/stdlib/_ast/parameter.rs +++ b/crates/vm/src/stdlib/_ast/parameter.rs @@ -88,14 +88,23 @@ impl Node for ast::Parameters { "arguments", )?; + validate_parameter_annotations( + vm, + &posonlyargs, + &args, + vararg.as_deref(), + &kwonlyargs, + kwarg.as_deref(), + )?; + let ParameterDefaults { runtime_defaults, defaults, _range: _, } = defaults; - let kwonlyargs = merge_keyword_parameter_defaults(vm, kwonlyargs, kw_defaults)?; let (posonlyargs, args) = merge_positional_parameter_defaults(vm, posonlyargs, args, defaults)?; + let kwonlyargs = merge_keyword_parameter_defaults(vm, kwonlyargs, kw_defaults)?; Ok(Self { node_index: Default::default(), @@ -114,6 +123,29 @@ impl Node for ast::Parameters { } } +fn validate_parameter_annotations( + vm: &VirtualMachine, + posonlyargs: &PositionalParameters, + args: &PositionalParameters, + vararg: Option<&ast::Parameter>, + kwonlyargs: &KeywordParameters, + kwarg: Option<&ast::Parameter>, +) -> PyResult<()> { + for parameter in posonlyargs.args.iter().chain(&args.args) { + super::validate::validate_parameter_annotation(vm, parameter)?; + } + if let Some(parameter) = vararg { + super::validate::validate_parameter_annotation(vm, parameter)?; + } + for parameter in &kwonlyargs.keywords { + super::validate::validate_parameter_annotation(vm, parameter)?; + } + if let Some(parameter) = kwarg { + super::validate::validate_parameter_annotation(vm, parameter)?; + } + Ok(()) +} + // product impl Node for ast::Parameter { fn ast_to_object(self, vm: &VirtualMachine, source_file: &SourceFile) -> PyObjectRef { diff --git a/crates/vm/src/stdlib/_ast/validate.rs b/crates/vm/src/stdlib/_ast/validate.rs index 081197d6b1e..e936c9d3fb7 100644 --- a/crates/vm/src/stdlib/_ast/validate.rs +++ b/crates/vm/src/stdlib/_ast/validate.rs @@ -56,7 +56,10 @@ fn validate_keywords(vm: &VirtualMachine, keywords: &[ast::Keyword]) -> PyResult Ok(()) } -fn validate_parameter_annotation(vm: &VirtualMachine, parameter: &ast::Parameter) -> PyResult<()> { +pub(super) fn validate_parameter_annotation( + vm: &VirtualMachine, + parameter: &ast::Parameter, +) -> PyResult<()> { if let Some(annotation) = ¶meter.annotation { validate_expr(vm, annotation, ast::ExprContext::Load)?; } From 1e34770094ab4e74733416a3239e6530b3fa2a16 Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:12:38 -0400 Subject: [PATCH 214/351] Fix x86_64-pc-windows-gnu build (#8411) A few functions were improperly gated to MSVC. Those functions call `windows-sys` so they're not MSVC. --- .github/actions/install-linux-deps/action.yml | 8 ++++++++ .github/workflows/ci.yaml | 5 +++++ crates/host_env/src/time.rs | 6 +++--- crates/vm/src/stdlib/time.rs | 2 +- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/actions/install-linux-deps/action.yml b/.github/actions/install-linux-deps/action.yml index 46ce74d50e4..c2f1b20f2d9 100644 --- a/.github/actions/install-linux-deps/action.yml +++ b/.github/actions/install-linux-deps/action.yml @@ -29,6 +29,10 @@ inputs: description: Install gcc-aarch64-linux-gnu (gcc-aarch64-linux-gnu) required: false default: "false" + gcc-mingw-w64-x86-64: + description: Install gcc-mingw-w64-x86-64 (gcc-mingw-w64-x86-64) + required: false + default: "false" clang: description: Install clang (clang) required: false @@ -44,6 +48,7 @@ runs: MUSL_TOOLS: ${{ inputs.musl-tools }} CLANG: ${{ inputs.clang }} GCC_AARCH64_LINUX_GNU: ${{ inputs.gcc-aarch64-linux-gnu }} + GCC_MINGW_W64_X86_64: ${{ inputs.gcc-mingw-w64-x86-64 }} run: | if ! sudo apt-get update; then echo "::warning::apt-get update failed; disabling nonessential Microsoft apt sources and retrying" @@ -68,6 +73,9 @@ runs: if [[ "$GCC_AARCH64_LINUX_GNU" == "true" ]]; then packages+=(gcc-aarch64-linux-gnu linux-libc-dev-arm64-cross libc6-dev-arm64-cross) fi + if [[ "$GCC_MINGW_W64_X86_64" == "true" ]]; then + packages+=(gcc-mingw-w64-x86-64) + fi if ((${#packages[@]})); then sudo apt-get install --no-install-recommends "${packages[@]}" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 10541d1b680..7d0ab2e7c40 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -198,6 +198,10 @@ jobs: target: aarch64-unknown-linux-gnu dependencies: gcc-aarch64-linux-gnu: true + - os: ubuntu-latest + target: x86_64-pc-windows-gnu + dependencies: + gcc-mingw-w64-x86-64: true - os: macos-latest target: aarch64-apple-ios - os: macos-latest @@ -216,6 +220,7 @@ jobs: gcc-multilib: ${{ matrix.dependencies.gcc-multilib || false }} musl-tools: ${{ matrix.dependencies.musl-tools || false }} gcc-aarch64-linux-gnu: ${{ matrix.dependencies.gcc-aarch64-linux-gnu || false }} + gcc-mingw-w64-x86-64: ${{ matrix.dependencies.gcc-mingw-w64-x86-64 || false }} - name: Restore cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/crates/host_env/src/time.rs b/crates/host_env/src/time.rs index cc0b7d30cb6..19f320dad33 100644 --- a/crates/host_env/src/time.rs +++ b/crates/host_env/src/time.rs @@ -334,7 +334,7 @@ pub fn gethrvtime_duration() -> Duration { Duration::from_nanos(unsafe { libc::gethrvtime() }) } -#[cfg(target_env = "msvc")] +#[cfg(windows)] #[cfg(not(target_arch = "wasm32"))] #[derive(Clone, Debug)] pub struct WindowsTimeZoneInfo { @@ -345,7 +345,7 @@ pub struct WindowsTimeZoneInfo { pub daylight_name: String, } -#[cfg(target_env = "msvc")] +#[cfg(windows)] #[cfg(not(target_arch = "wasm32"))] fn decode_tz_name(name: &[u16]) -> String { widestring::decode_utf16_lossy(name.iter().copied()) @@ -353,7 +353,7 @@ fn decode_tz_name(name: &[u16]) -> String { .collect() } -#[cfg(target_env = "msvc")] +#[cfg(windows)] #[cfg(not(target_arch = "wasm32"))] #[must_use] pub fn get_tz_info() -> WindowsTimeZoneInfo { diff --git a/crates/vm/src/stdlib/time.rs b/crates/vm/src/stdlib/time.rs index 4c9fd70c4d9..226432654bb 100644 --- a/crates/vm/src/stdlib/time.rs +++ b/crates/vm/src/stdlib/time.rs @@ -210,7 +210,7 @@ mod decl { Ok(get_perf_time(vm)?.as_nanos()) } - #[cfg(target_env = "msvc")] + #[cfg(windows)] #[cfg(not(target_arch = "wasm32"))] pub(super) fn get_tz_info() -> host_time::WindowsTimeZoneInfo { host_time::get_tz_info() From 04c3ecfdcdf442820725d77be48df416e4e11757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=9A=A8=EC=A2=85?= Date: Thu, 30 Jul 2026 22:13:22 +0900 Subject: [PATCH 215/351] Allow lone-surrogate keyword keys in f(**d) (#8409) * Allow lone-surrogate keyword keys in f(**d) `f(**{'\udc81': 2})` raised `TypeError: keywords must be strings` even though the key is a valid `str`, because `KwArgs` stored keys as Rust `String` (strict UTF-8) and `collect_ex_args` narrowed each key to `PyUtf8Str` (valid UTF-8 required). CPython only checks that the key is a `str` (`Py_TPFLAGS_UNICODE_SUBCLASS`), never encoding validity. Change `KwArgs`'s key type from `String` to `Wtf8Buf` (the WTF-8 storage `PyStr` already uses) and relax the keyword-key downcast from `PyUtf8Str` to `PyStr` in `collect_ex_args`, `from_vectorcall`/ `from_vectorcall_owned` (which previously panicked on surrogate keys), the `functools.partial` keyword merge, and the C-API `dict_to_kwargs`. `Wtf8Buf` borrows only as `Wtf8`, so inherent `get`/`contains_key`/ `swap_remove`/`shift_remove(&str)` on `KwArgs` restore the `&str` lookup interface `String: Borrow` used to provide (via the zero-cost `Wtf8::new` cast), and a generic `FromIterator<(K: Into, T)>` keeps construction sites unchanged. WTF-8 awareness stays localized to `function/argument.rs`. Fixes #8228 Assisted-by: Claude Code:claude-opus-4-8 * Avoid cloning kwargs keys in _ast, borrow via as_ref instead `new_str` only needs a `&Wtf8`, so pass `key.as_ref()` rather than cloning the key into an owned `Wtf8Buf`. The key is reused afterwards (error message, `intern_str`), so a borrow is the right fit. Assisted-by: Claude Code:claude-opus-4-8 --- crates/capi/src/abstract_.rs | 4 +- crates/stdlib/src/_asyncio.rs | 8 +-- crates/vm/src/builtins/function.rs | 11 ++++- crates/vm/src/builtins/function/jit.rs | 3 ++ crates/vm/src/frame.rs | 6 ++- crates/vm/src/function/argument.rs | 60 +++++++++++++++++------ crates/vm/src/stdlib/_ast/python.rs | 13 ++--- crates/vm/src/stdlib/_ctypes/structure.rs | 9 ++-- crates/vm/src/stdlib/_ctypes/union.rs | 9 ++-- crates/vm/src/stdlib/_functools.rs | 5 +- crates/vm/src/stdlib/_operator.rs | 2 +- crates/vm/src/stdlib/_typing.rs | 4 +- crates/vm/src/types/structseq.rs | 3 +- crates/wasm/src/convert.rs | 13 +++-- 14 files changed, 102 insertions(+), 48 deletions(-) diff --git a/crates/capi/src/abstract_.rs b/crates/capi/src/abstract_.rs index 08b4e540029..fd6e966bdae 100644 --- a/crates/capi/src/abstract_.rs +++ b/crates/capi/src/abstract_.rs @@ -25,9 +25,11 @@ fn dict_to_kwargs(vm: &VirtualMachine, dict: &Py) -> PyResult { dict.items_vec() .into_iter() .map(|(key, value)| { + // `to_string()` would replace lone surrogates with U+FFFD; keep the + // raw WTF-8 so surrogate keys round-trip (issue #8228). let key = key .downcast_ref::() - .map(|s| s.to_string()) + .map(|s| s.as_wtf8().to_owned()) .ok_or_else(|| vm.new_type_error("keywords must be strings"))?; Ok((key, value)) }) diff --git a/crates/stdlib/src/_asyncio.rs b/crates/stdlib/src/_asyncio.rs index 0c74310eaef..3146e39b77d 100644 --- a/crates/stdlib/src/_asyncio.rs +++ b/crates/stdlib/src/_asyncio.rs @@ -566,7 +566,7 @@ pub(crate) mod _asyncio { let args = if let Some(ctx) = context { FuncArgs::new( vec![callback, future_arg], - KwArgs::new(core::iter::once(("context".to_owned(), ctx)).collect()), + KwArgs::new(core::iter::once((Wtf8Buf::from("context"), ctx)).collect()), ) } else { FuncArgs::new(vec![callback, future_arg], KwArgs::default()) @@ -1498,7 +1498,7 @@ pub(crate) mod _asyncio { let args = if let Some(ctx) = context { FuncArgs::new( vec![callback, task_arg], - KwArgs::new(core::iter::once(("context".to_owned(), ctx)).collect()), + KwArgs::new(core::iter::once((Wtf8Buf::from("context"), ctx)).collect()), ) } else { FuncArgs::new(vec![callback, task_arg], KwArgs::default()) @@ -1527,7 +1527,7 @@ pub(crate) mod _asyncio { let cancel_args = if let Some(ref m) = msg_value { FuncArgs::new( vec![], - KwArgs::new(core::iter::once(("msg".to_owned(), m.clone())).collect()), + KwArgs::new(core::iter::once((Wtf8Buf::from("msg"), m.clone())).collect()), ) } else { FuncArgs::new(vec![], KwArgs::default()) @@ -2213,7 +2213,7 @@ pub(crate) mod _asyncio { let cancel_args = if let Some(ref m) = cancel_msg { FuncArgs::new( vec![], - KwArgs::new(core::iter::once(("msg".to_owned(), m.clone())).collect()), + KwArgs::new(core::iter::once((Wtf8Buf::from("msg"), m.clone())).collect()), ) } else { FuncArgs::new(vec![], KwArgs::default()) diff --git a/crates/vm/src/builtins/function.rs b/crates/vm/src/builtins/function.rs index af359f11190..43cc33a90af 100644 --- a/crates/vm/src/builtins/function.rs +++ b/crates/vm/src/builtins/function.rs @@ -337,8 +337,13 @@ impl PyFunction { let mut posonly_passed_as_kwarg = Vec::new(); // Handle keyword arguments for (name, value) in func_args.kwargs { + // Parameter names are plain identifiers, so a non-UTF-8 (surrogate) key + // can never match one and just falls through to **kwargs / the error path. + let name_str = name.as_str().ok(); // Check if we have a parameter with this name: - if let Some(pos) = arg_pos(code.posonlyarg_count as usize..total_args, &name) { + if let Some(pos) = + name_str.and_then(|s| arg_pos(code.posonlyarg_count as usize..total_args, s)) + { let slot = &mut fastlocals[pos]; if slot.is_some() { return Err(vm.new_type_error(format!( @@ -350,7 +355,9 @@ impl PyFunction { *slot = Some(value); } else if let Some(kwargs) = kwargs.as_ref() { kwargs.set_item(&name, value, vm)?; - } else if arg_pos(0..code.posonlyarg_count as usize, &name).is_some() { + } else if name_str + .is_some_and(|s| arg_pos(0..code.posonlyarg_count as usize, s).is_some()) + { posonly_passed_as_kwarg.push(name); } else { return Err(vm.new_type_error(format!( diff --git a/crates/vm/src/builtins/function/jit.rs b/crates/vm/src/builtins/function/jit.rs index 8432bb5369a..96c1465d4f1 100644 --- a/crates/vm/src/builtins/function/jit.rs +++ b/crates/vm/src/builtins/function/jit.rs @@ -184,6 +184,9 @@ pub(crate) fn get_jit_args<'a>( for (name, value) in &func_args.kwargs { let arg_pos = |args: &[&PyStrInterned], name: &str| args.iter().position(|arg| arg.as_str() == name); + // Parameter names are plain identifiers, so a non-UTF-8 (surrogate) key + // can never match one. + let name = name.as_str().map_err(|_| ArgsError::NotAKeywordArg)?; if let Some(arg_idx) = arg_pos(arg_names.args, name) { if jit_args.is_set(arg_idx) { return Err(ArgsError::ArgPassedMultipleTimes); diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index 2743bf3d542..5df8b85e56b 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -7112,11 +7112,13 @@ impl ExecutingFrame<'_> { let func_str = Self::object_function_str(callable, vm); Self::iterate_mapping_keys(vm, &kw_obj, &func_str, |key| { + // `PyStr`, not `PyUtf8Str`: CPython only checks that the key is a + // `str`, not that it is valid UTF-8, so surrogate keys are accepted. let key_str = key - .downcast_ref::() + .downcast_ref::() .ok_or_else(|| vm.new_type_error("keywords must be strings"))?; let value = kw_obj.get_item(&*key, vm)?; - kwargs.insert(key_str.as_str().to_owned(), value); + kwargs.insert(key_str.as_wtf8().to_owned(), value); Ok(()) })? }; diff --git a/crates/vm/src/function/argument.rs b/crates/vm/src/function/argument.rs index 88ef5581bb2..aabe484c282 100644 --- a/crates/vm/src/function/argument.rs +++ b/crates/vm/src/function/argument.rs @@ -1,6 +1,7 @@ use crate::{ AsObject, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, VirtualMachine, builtins::{PyBaseExceptionRef, PyTupleRef, PyTypeRef}, + common::wtf8::{Wtf8, Wtf8Buf}, convert::ToPyObject, object::{Traverse, TraverseFn}, }; @@ -155,10 +156,12 @@ impl FuncArgs { .iter() .zip(&args[nargs..nargs + names.len()]) .map(|(name, val)| { + // `PyStr`, not `PyUtf8Str`: a surrogate key is a valid str and + // must survive as WTF-8 rather than panic. let key = name - .downcast_ref::() + .downcast_ref::() .expect("kwnames must be strings") - .as_str() + .as_wtf8() .to_owned(); (key, val.clone()) }) @@ -187,9 +190,9 @@ impl FuncArgs { .zip(args.drain(nargs..nargs + kw_count)) .map(|(name, val)| { let key = name - .downcast_ref::() + .downcast_ref::() .expect("kwnames must be strings") - .as_str() + .as_wtf8() .to_owned(); (key, val) }) @@ -268,7 +271,7 @@ impl FuncArgs { self.kwargs.swap_remove(name) } - pub fn remaining_keywords(&mut self) -> impl Iterator + '_ { + pub fn remaining_keywords(&mut self) -> impl Iterator + '_ { self.kwargs.drain(..) } @@ -406,8 +409,12 @@ impl FromArgOptional for T { /// KwArgs is only for functions that accept arbitrary keyword arguments. For /// functions that accept only *specific* named arguments, a rust struct with /// an appropriate FromArgs implementation must be created. +// Keys are stored as `Wtf8Buf`, not `String`, so that a lone-surrogate keyword +// name coming through `f(**d)` is preserved instead of being rejected (see +// issue #8228). `PyStr` is WTF-8 backed, and CPython only requires that a +// keyword key be a `str`, not that it be valid UTF-8. #[derive(Clone, Debug)] -pub struct KwArgs(IndexMap); +pub struct KwArgs(IndexMap); impl Default for KwArgs { fn default() -> Self { @@ -416,7 +423,7 @@ impl Default for KwArgs { } impl Deref for KwArgs { - type Target = IndexMap; + type Target = IndexMap; fn deref(&self) -> &Self::Target { &self.0 @@ -440,24 +447,47 @@ where impl KwArgs { #[must_use] - pub const fn new(map: IndexMap) -> Self { + pub const fn new(map: IndexMap) -> Self { Self(map) } + // `String` keys accepted `&str` lookups for free via `Borrow`; `Wtf8Buf` + // borrows only as `Wtf8`, so these inherent methods restore the `&str` interface + // via the zero-cost `Wtf8::new` cast, keeping every call site unchanged. + #[must_use] + pub fn get(&self, name: &str) -> Option<&T> { + self.0.get(Wtf8::new(name)) + } + + #[must_use] + pub fn contains_key(&self, name: &str) -> bool { + self.0.contains_key(Wtf8::new(name)) + } + + pub fn swap_remove(&mut self, name: &str) -> Option { + self.0.swap_remove(Wtf8::new(name)) + } + + pub fn shift_remove(&mut self, name: &str) -> Option { + self.0.shift_remove(Wtf8::new(name)) + } + pub fn pop_kwarg(&mut self, name: &str) -> Option { self.swap_remove(name) } } -impl FromIterator<(String, T)> for KwArgs { - fn from_iter>(iter: I) -> Self { - Self(iter.into_iter().collect()) +// Accept any key that converts into `Wtf8Buf` (notably `String`), so existing +// call sites that build kwargs from string literals keep compiling unchanged. +impl, T> FromIterator<(K, T)> for KwArgs { + fn from_iter>(iter: I) -> Self { + Self(iter.into_iter().map(|(k, v)| (k.into(), v)).collect()) } } impl<'a, T> IntoIterator for &'a KwArgs { - type Item = (&'a String, &'a T); - type IntoIter = indexmap::map::Iter<'a, String, T>; + type Item = (&'a Wtf8Buf, &'a T); + type IntoIter = indexmap::map::Iter<'a, Wtf8Buf, T>; fn into_iter(self) -> Self::IntoIter { self.0.iter() @@ -465,8 +495,8 @@ impl<'a, T> IntoIterator for &'a KwArgs { } impl IntoIterator for KwArgs { - type Item = (String, T); - type IntoIter = indexmap::map::IntoIter; + type Item = (Wtf8Buf, T); + type IntoIter = indexmap::map::IntoIter; fn into_iter(self) -> Self::IntoIter { self.0.into_iter() diff --git a/crates/vm/src/stdlib/_ast/python.rs b/crates/vm/src/stdlib/_ast/python.rs index e7697e4cf4b..db92f20db17 100644 --- a/crates/vm/src/stdlib/_ast/python.rs +++ b/crates/vm/src/stdlib/_ast/python.rs @@ -8,8 +8,9 @@ use super::{ pub(crate) mod _ast { use crate::{ AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, - builtins::{PyDictRef, PySet, PyStr, PyTupleRef, PyType, PyTypeRef, PyUtf8Str}, + builtins::{PyDictRef, PySet, PyStr, PyTupleRef, PyType, PyTypeRef}, class::{PyClassImpl, StaticType}, + common::wtf8::Wtf8Buf, function::{ArgIterable, FuncArgs, KwArgs, PyMethodDef, PyMethodFlags}, stdlib::_ast::repr, types::{Constructor, Initializer}, @@ -229,7 +230,7 @@ pub(crate) mod _ast { ast_replace_set_update(&expecting, attributes.as_ref(), vm)?; for (key, _value) in &args.kwargs { - let key_obj: PyObjectRef = vm.ctx.new_str(key.as_str()).into(); + let key_obj: PyObjectRef = vm.ctx.new_str(key.as_ref()).into(); if !ast_replace_set_discard(&expecting, &key_obj, vm)? { return Err(vm.new_type_error(format!( "{}.__replace__ got an unexpected keyword argument '{}'.", @@ -290,11 +291,11 @@ pub(crate) mod _ast { .into_iter() .map(|(key, value)| { let key = key - .downcast::() + .downcast::() .map_err(|_| vm.new_type_error("keywords must be strings"))?; - Ok((key.as_str().to_owned(), value)) + Ok((key.as_wtf8().to_owned(), value)) }) - .collect::>>()?; + .collect::>>()?; let result = type_obj.call(FuncArgs::new(vec![], KwArgs::new(kwargs)), vm)?; Ok(result) } @@ -418,7 +419,7 @@ pub(crate) mod _ast { ast_replace_set_discard(&remaining_fields, &name, vm)?; } for (key, value) in args.kwargs { - let key_obj: PyObjectRef = vm.ctx.new_str(key.as_str()).into(); + let key_obj: PyObjectRef = vm.ctx.new_str(key.as_ref()).into(); let contains = fields_seq.contains(&key_obj, vm)?; if contains { if !ast_replace_set_discard(&remaining_fields, &key_obj, vm)? { diff --git a/crates/vm/src/stdlib/_ctypes/structure.rs b/crates/vm/src/stdlib/_ctypes/structure.rs index 39bb8a57413..34f53f52d60 100644 --- a/crates/vm/src/stdlib/_ctypes/structure.rs +++ b/crates/vm/src/stdlib/_ctypes/structure.rs @@ -1,5 +1,6 @@ use super::base::{CDATA_BUFFER_METHODS, PyCData, PyCField, StgInfo, StgInfoFlags}; use crate::builtins::{PyList, PyStr, PyTuple, PyType, PyTypeRef, PyUtf8Str}; +use crate::common::wtf8::Wtf8Buf; use crate::convert::ToPyObject; use crate::function::{FuncArgs, OptionalArg, PySetterValue}; use crate::protocol::{BufferDescriptor, PyBuffer, PyNumberMethods}; @@ -712,7 +713,7 @@ impl PyCStructure { self_obj: &Py, type_obj: &Py, args: &[PyObjectRef], - kwargs: &indexmap::IndexMap, + kwargs: &indexmap::IndexMap, index: usize, vm: &VirtualMachine, ) -> PyResult { @@ -746,7 +747,7 @@ impl PyCStructure { && let Some(name) = tuple.first() && let Some(name_str) = name.downcast_ref::() { - let field_name = name_str.as_str().to_owned(); + let field_name = name_str.as_wtf8().to_owned(); // Check for duplicate in kwargs if kwargs.contains_key(&field_name) { return Err( @@ -784,9 +785,9 @@ impl Initializer for PyCStructure { } // 2. Process keyword arguments - for (key, value) in &args.kwargs { + for (key, value) in args.kwargs { zelf.as_object() - .set_attr(vm.ctx.intern_str(key.as_str()), value.clone(), vm)?; + .set_attr(vm.ctx.intern_str(key), value, vm)?; } Ok(()) diff --git a/crates/vm/src/stdlib/_ctypes/union.rs b/crates/vm/src/stdlib/_ctypes/union.rs index 8d37178d587..727ad0118ad 100644 --- a/crates/vm/src/stdlib/_ctypes/union.rs +++ b/crates/vm/src/stdlib/_ctypes/union.rs @@ -1,6 +1,7 @@ use super::base::{CDATA_BUFFER_METHODS, StgInfoFlags}; use super::{PyCData, PyCField, StgInfo}; use crate::builtins::{PyList, PyStr, PyTuple, PyType, PyTypeRef, PyUtf8Str}; +use crate::common::wtf8::Wtf8Buf; use crate::convert::ToPyObject; use crate::function::{ArgBytesLike, FuncArgs, OptionalArg, PySetterValue}; use crate::protocol::{BufferDescriptor, PyBuffer}; @@ -581,7 +582,7 @@ impl PyCUnion { self_obj: &Py, type_obj: &Py, args: &[PyObjectRef], - kwargs: &indexmap::IndexMap, + kwargs: &indexmap::IndexMap, index: usize, vm: &VirtualMachine, ) -> PyResult { @@ -617,7 +618,7 @@ impl PyCUnion { && let Some(name) = tuple.first() && let Some(name_str) = name.downcast_ref::() { - let field_name = name_str.as_str().to_owned(); + let field_name = name_str.as_wtf8().to_owned(); // Check for duplicate in kwargs if kwargs.contains_key(&field_name) { return Err( @@ -655,9 +656,9 @@ impl Initializer for PyCUnion { } // 2. Process keyword arguments - for (key, value) in &args.kwargs { + for (key, value) in args.kwargs { zelf.as_object() - .set_attr(vm.ctx.intern_str(key.as_str()), value.clone(), vm)?; + .set_attr(vm.ctx.intern_str(key), value, vm)?; } Ok(()) diff --git a/crates/vm/src/stdlib/_functools.rs b/crates/vm/src/stdlib/_functools.rs index 7c6914c2fb4..94b9565e79d 100644 --- a/crates/vm/src/stdlib/_functools.rs +++ b/crates/vm/src/stdlib/_functools.rs @@ -373,7 +373,7 @@ mod _functools { // Add new keywords for (key, value) in args.kwargs { - final_keywords.set_item(vm.ctx.intern_str(key.as_str()), value, vm)?; + final_keywords.set_item(vm.ctx.intern_str(key), value, vm)?; } Ok(Self { @@ -436,10 +436,11 @@ mod _functools { // Add keywords from self.keywords for (key, value) in &*keywords { + // `expect_str()` would panic on surrogate keys; keep them as WTF-8. let key_str = key .downcast_ref::() .ok_or_else(|| vm.new_type_error("keywords must be strings"))?; - final_kwargs.insert(key_str.expect_str().to_owned(), value); + final_kwargs.insert(key_str.as_wtf8().to_owned(), value); } // Add keywords from args.kwargs (these override self.keywords) diff --git a/crates/vm/src/stdlib/_operator.rs b/crates/vm/src/stdlib/_operator.rs index e4db046053b..5e72ef03eb4 100644 --- a/crates/vm/src/stdlib/_operator.rs +++ b/crates/vm/src/stdlib/_operator.rs @@ -610,7 +610,7 @@ mod _operator { } for (key, value) in kwargs { result.push_str(", "); - result.push_str(key); + result.push_wtf8(key); result.push_char('='); result.push_wtf8(value.repr(vm)?.as_wtf8()); } diff --git a/crates/vm/src/stdlib/_typing.rs b/crates/vm/src/stdlib/_typing.rs index 45740f7ebad..04b9208b319 100644 --- a/crates/vm/src/stdlib/_typing.rs +++ b/crates/vm/src/stdlib/_typing.rs @@ -353,9 +353,9 @@ pub(crate) mod decl { // typealias(name, value, *, type_params=()) // name and value are positional-or-keyword; type_params is keyword-only. - // Reject unexpected keyword arguments + // Reject unexpected keyword arguments. for key in args.kwargs.keys() { - if key != "name" && key != "value" && key != "type_params" { + if !matches!(key.as_str(), Ok("name" | "value" | "type_params")) { return Err(vm.new_type_error(format!( "typealias() got an unexpected keyword argument '{key}'" ))); diff --git a/crates/vm/src/types/structseq.rs b/crates/vm/src/types/structseq.rs index b7468d4a702..703cc79c193 100644 --- a/crates/vm/src/types/structseq.rs +++ b/crates/vm/src/types/structseq.rs @@ -1,4 +1,5 @@ use crate::common::lock::LazyLock; +use crate::common::wtf8::Wtf8; use crate::{ AsObject, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, atomic_func, builtins::{PyBaseExceptionRef, PyStr, PyStrRef, PyTuple, PyTupleRef, PyType, PyTypeRef}, @@ -276,7 +277,7 @@ pub trait PyStructSequence: StaticType + PyClassImpl + Sized + 'static { // Check for unexpected keyword arguments if !kwargs.is_empty() { - let names: Vec<&str> = kwargs.keys().map(|k| k.as_str()).collect(); + let names: Vec<&Wtf8> = kwargs.keys().map(|k| k.as_ref()).collect(); return Err(vm.new_type_error(format!("Got unexpected field name(s): {names:?}"))); } diff --git a/crates/wasm/src/convert.rs b/crates/wasm/src/convert.rs index 17ad5b62946..3e07b27d4a0 100644 --- a/crates/wasm/src/convert.rs +++ b/crates/wasm/src/convert.rs @@ -119,9 +119,12 @@ pub fn py_to_js(vm: &VirtualMachine, py_obj: PyObjectRef) -> JsValue { if let Some(ref kwargs) = kwargs { for pair in object_entries(kwargs) { let (key, val) = pair?; - py_func_args - .kwargs - .insert(js_sys::JsString::from(key).into(), js_to_py(vm, val)); + py_func_args.kwargs.insert( + // JS strings coming in are UTF-16; go through Rust `String` + // (kwargs keys are now WTF-8, so convert String -> Wtf8Buf). + String::from(js_sys::JsString::from(key)).into(), + js_to_py(vm, val), + ); } } let result = py_obj.call(py_func_args, vm); @@ -229,7 +232,9 @@ pub fn js_to_py(vm: &VirtualMachine, js_val: JsValue) -> PyObjectRef { move |args: FuncArgs, vm: &VirtualMachine| -> PyResult { let this = Object::new(); for (k, v) in args.kwargs { - Reflect::set(&this, &k.into(), &py_to_js(vm, v)) + // WTF-8 -> JS string: lone surrogates in the key become U+FFFD + // (wasm-bindgen only accepts Rust `String`); acceptable at this boundary. + Reflect::set(&this, &k.to_string().into(), &py_to_js(vm, v)) .expect("property to be settable"); } let js_args = args From f08933be876ef7987d026bc1c550f3ecaa4005ac Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:59:41 +0900 Subject: [PATCH 216/351] Split InterpreterFrame from FrameObject for stack-allocated execution (#8354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add LightFrame for unobserved Python-to-Python calls Allocate a lightweight LightFrame header on the DataStack instead of a full Frame PyObject for specialized exact-args call paths. The frame is materialized lazily only when observed (sys._getframe, traceback, tracing). Key changes: - LightFrame struct with borrowed pointers and no refcount overhead - FrameSource enum (Heavy/Light) replacing the object field on ExecutingFrame - CURRENT_LIGHT_FRAME TLS for the light frame chain - Interleaved heavy/light frame walking in frame_at_offset_vm and current_thread_frame_vm - invoke_light_slots on PyFunction for the fast call path Call overhead reduced from ~92ns to ~58ns (~37% reduction). fib(28) improved from ~152ms to ~111ms (~27% reduction). Assisted-by: Claude * Streamline with_frame: inline recursion check, skip tracing dispatch - Inline check_recursive_call instead of wrapping in with_recursion closure - Amortize C stack overflow check to every 64th call depth - Skip dispatch_traced_frame when use_tracing is false (hot path) - Add recursion_depth_increment/decrement helpers Assisted-by: Claude * Address review findings for LightFrame correctness - Fix materialize_light_frame previous pointer: use payload pointer (&**frame) instead of Py pointer, matching the chain convention - Fix predecessor ref management: use retained_back instead of mem::forget to keep materialized predecessors alive - Fix materialized ref reclaim: use FrameRef::from_raw to drop the leaked keeper ref without double-incrementing - Use PyAtomic for LightFrame.lasti instead of AtomicU32, removing the unsound cast on non-threading targets - Add amortized C stack overflow check to light frame path - Add scopeguard to light frame execution for panic safety - Remove unnecessary Send/Sync impls from LightFrame - Make check_c_stack_overflow pub(crate) for light frame path access Assisted-by: Claude * Fix CI failures: cspell, stale materialized state, f_back chain gaps - Fix cspell: 'amortised' → 'amortized' - Sync lasti, prev_line, and fastlocals when re-observing a materialized light frame, fixing stale f_locals/f_lineno in test_inspect and others - In with_frame, materialize any active light frame as the heavy frame's predecessor and store it in retained_back, so f_back and inspect.stack see the correct interleaved order (H_new → L_mat → H_old) - Guard invoke_light_slots on NEWLOCALS | OPTIMIZED code flags Assisted-by: Claude * Unify frame chain: replace dual-chain with single FrameChainPtr Replace the dual-chain frame management (CURRENT_FRAME AtomicPtr + CURRENT_LIGHT_FRAME Cell<*const LightFrame>) with a single unified chain using FrameChainPtr, a tagged pointer where bit 0 distinguishes heavy (*const Frame) from light (*const LightFrame) entries. Changes: - Add FrameChainPtr type with tagged pointer encoding - Change LightFrame: replace previous_light + saved_current_frame with single previous: FrameChainPtr field - Change InterpreterFrame.previous from AtomicPtr to PyAtomic - Change CURRENT_FRAME TLS from AtomicPtr to AtomicUsize - Remove CURRENT_LIGHT_FRAME TLS entirely - Simplify all chain walkers (frame_at_offset, frame_at_offset_vm, find_owned_chain_frame, for_each_current_frame, current_thread_frame) - Remove light-frame materialization block from with_frame - Update f_back to dispatch on FrameChainPtr tags - Update faulthandler and gc_state to walk unified chain - Adaptive C stack check: every call in debug, every 16th in release Assisted-by: Claude * Fix light frame materialization: current_frame, f_back, and GC tracking - current_frame() now uses current_thread_frame_vm() to materialize light frames, fixing super()/compile()/exec() when called from light frame context - materialize_light_frame stores retained_back for heavy predecessors too, so f_back resolves after the caller returns - Escaped materialized frames (traceback, sys._getframe) are GC-tracked at cleanup so reference cycles are collectible - Restore pre-existing @expectedFailure markers for test_frame proxy tests Assisted-by: Claude * Limit light frame materialization to super() only current_frame() returns heavy-only again to avoid unnecessary materialization that extends object lifetimes. super() init uses current_thread_frame_vm() directly since it must inspect the light frame's code and freevars to find __class__. Assisted-by: Claude * Fix remaining CI failures: C stack check, GC tracking threshold, expectedFailure - Check C stack overflow every call in light frame path (not every 16th) to prevent stack overflow on CI workers with smaller stacks - Fix GC tracking threshold: escaped materialized frame detection uses strong_count > 1 (was > 2, missed frames referenced only by traceback in the result) - current_frame() materializes light frames so warnings, compile(), and other callers see the correct frame - Mark test_futures2 PyFutureTests.test_task_exc_handler_correct_context as expectedFailure (pre-existing on main: PyTask __del__ timing) Assisted-by: Claude * Avoid materialization in hot paths: add light-frame-aware accessors current_frame() stays heavy-only to avoid costly materialization on every call. Instead, add current_globals(), current_code(), and current_builtins() that read directly from light frame raw pointers without creating frame PyObjects. Callers migrated: - PyFunction::new: current_builtins() for fallback builtins lookup - compile()/eval(): current_code() for future feature flags - type.__new__: current_globals() for __module__ detection - typevar/typing: current_globals() for caller module name - _io: current_code() for source path check - super(): current_thread_frame_vm() for __class__ cell access - warnings.warn: current_thread_frame_vm() for stack level walk - import: current_globals() for module resolution Assisted-by: Claude * Fix current_locals() to include light frames current_locals() must materialize light frames so locals() builtin returns the correct scope. This fixes test_zipimport doctest and other tests that rely on locals() in light frame context. Assisted-by: Claude * Fix clippy, profile tracing, resume_gen_frame, and faulthandler issues - Fix clippy: collapsible_if in materialize_light_frame, map_unwrap_or in import - Fix profile breakage: use current_thread_frame_vm in trace_event_inner to materialize light frames, preventing frame identity mismatch when profiling is enabled inside a light frame (e.g. profile.Profile.runctx) - Disable light frames inside trace/profile callbacks (tracing_is_suppressed) - Fix resume_gen_frame: skip light frames in previous chain to avoid dangling pointers after generator suspend - Fix faulthandler: emit '' when chain has only light frames Assisted-by: Claude * Fix materialized frame refcount: transfer localsplus ownership on exit Remove @expectedFailure from test_futures2: the underlying issue was that materialized light frames cloned localsplus values (refcount +1 each), keeping objects alive until the next GC cycle collection. Fix: after run_light_frame returns, transfer ownership of localsplus values from the light frame to the materialized frame, replacing the clones. This eliminates the extra refcount, allowing objects to be GC'd immediately when their last reference is dropped. Also stabilize materialized frame's localsplus on the heap in sync_materialized_on_exit before GC tracking — the data-stack backing will be popped imminently. Assisted-by: Claude * Fix rustfmt, increase stack margin, address review comments - Fix rustfmt formatting issues (lint CI failure) - Increase STACK_MARGIN_BYTES from 2048 to 3072 words to prevent C stack overflow in deep recursion tests on macOS CI (test_functools SIGSEGV) - Skip dispatch_traced_frame when use_tracing is false (review comment) - Fix post-fork top_frame initialization: walk past light frames to find nearest heavy frame (review comment) Assisted-by: Claude * Remove redundant test override in test_futures2 The override only called super() without any marker, making it a no-op that prek flags as a redundant test patch. Assisted-by: Claude * Fix test_pdb and test_functools CI failures - Revert with_frame dispatch_traced_frame skip: tracing can be enabled mid-execution (pdb.set_trace), so Return events must always be checked. This fixes 3 test_pdb doctest failures across all platforms. - Check C stack overflow on every with_frame call instead of every 64th: the previous sampling approach missed overflows between checks when native stack frames are large (invoke_light_slots + lru_cache recursion). - Add #[inline(never)] to invoke_light_slots to prevent the optimizer from merging its stack frame into callers. - Add early C stack check before DataStack allocation in invoke_light_slots. Assisted-by: Claude * Rename Frame to FrameObject, FrameRef to FrameObjectRef Prepare for _PyInterpreterFrame/PyFrameObject separation by renaming the PyObject-backed frame type to FrameObject. Python-visible name ("frame") is unchanged. Assisted-by: Claude * Remove Deref from FrameObject Replace implicit field access through Deref with explicit iframe()/iframe_ref()/iframe_mut() calls. This decouples FrameObject from InterpreterFrame field layout, enabling independent modification of InterpreterFrame in future commits. Assisted-by: Claude * Move InterpreterFrame identity fields to raw pointers InterpreterFrame's code/globals/builtins/func_obj fields are now borrowed raw pointers instead of owned PyRef/PyObjectRef. Ownership of these references is anchored in FrameObject's new owned_code, owned_globals, owned_builtins, owned_func_obj fields. This aligns InterpreterFrame's layout with LightFrame's existing raw-pointer pattern, enabling future DataStack-based allocation without PyObject heap allocation. - Add code()/globals()/builtins()/func_obj() accessor methods - Add FrameObject::new_ref() for combined create+allocate+patch - Update ExecutingFrame to use &Py / &Py / &PyObject - Update monitoring.rs functions to take &Py instead of &PyRef - Add Send+Sync impls for InterpreterFrame (raw pointers are !Send by default) Assisted-by: Claude * Remove LightFrame, FrameChainPtr, and dual-frame-type infrastructure Delete LightFrame struct, FrameChainPtr tagged pointer, FrameSource enum, and all supporting functions: materialize_light_frame, sync_light_to_materialized, transfer_localsplus_to_materialized, sync_materialized_on_exit, run_light_frame, invoke_light_slots body, LocalsPlus::from_datastack_raw. All call paths now use a single frame type (FrameObject wrapping InterpreterFrame). The frame chain is a simple *const FrameObject linked list through InterpreterFrame.previous. invoke_light_slots delegates to invoke_exact_args_slots. ExecutingFrame.frame_source replaced with ExecutingFrame.frame. Frame chain walking simplified throughout (no more light/heavy dispatch). Net: -724 lines of dual-frame-type complexity. Assisted-by: Claude * Clean up invoke_light_slots and _vm function variants - Replace invoke_light_slots calls with invoke_exact_args_slots - Delete the invoke_light_slots delegate method - Replace current_thread_frame_vm/frame_at_offset_vm with their non-_vm equivalents (no more light frame materialization needed) - Remove stale light-frame comment in callable.rs Assisted-by: Claude * Fix CI lint issues - Move github context expressions from run blocks to env blocks (zizmor/template-injection) - Add workflow-level permissions: {} to lib-deps-check.yaml - Suppress zizmor excessive-permissions for lib-deps-check.yaml (pull_request_target is required for PR comments) - Apply rustfmt formatting Assisted-by: Claude * Stack-allocate InterpreterFrame for non-generator function calls - Extract InterpreterFrame::new() from FrameObject::new() - Change frame chain from *const FrameObject to *const InterpreterFrame - Add InterpreterFrame.materialized field for lazy FrameObject creation - Add vm.with_iframe()/run_frame_fast() for stack-allocated frame execution - Move ExecutingFrame.frame to iframe: *const InterpreterFrame - Implement lazy materialize() for on-demand FrameObject creation - Update invoke_with_locals/invoke_prepared_exact_args to skip heap allocation - Update frame chain walking in faulthandler, gc_state, builtins/frame - super() reads InterpreterFrame directly without materialization Assisted-by: Claude * Remove unnecessary refcount and atomic ops from fast call path - Use raw pointer for func_obj instead of cloning PyObjectRef (saves 2 atomic RMW) - Skip owner AcqRel swap for stack-allocated frames (always Thread-owned) Assisted-by: Claude * Change trace field to Option, remove vm from InterpreterFrame::new - trace: PyMutex -> PyMutex> - Eliminates vm.ctx.none() refcount inc/dec per frame init - Remove vm parameter from InterpreterFrame::new() (no longer needed) - f_trace getter returns None when trace is unset - f_trace setter stores None instead of the None singleton Assisted-by: Claude * Store current frame pointer on VirtualMachine for fast lookup - Add current_frame_ptr Cell field to VirtualMachine - Hot path (with_iframe) reads/writes vm.current_frame_ptr instead of TLS - TLS CURRENT_FRAME still maintained for signal-safe traceback walking - Use set_current_frame_nosave() to skip cross-thread top_frame update - super(), frame_at_offset, current_thread_frame_materialize use vm Cell Assisted-by: Claude * Apply rustfmt Assisted-by: Claude * Fix clippy use_self warnings, add inline hints for hot path Assisted-by: Claude * Remove code refcount clone from fast call path Defer code.to_owned() to the generator/coroutine path only. On the fast path, use &Py directly — the code object is alive via the PyFunction on the caller's stack. Assisted-by: Claude * Remove VM current_frame_ptr Cell (fix SIGSEGV in threading builds) The Cell on VirtualMachine could desynchronize from the TLS CURRENT_FRAME in nested VM scenarios (enter_vm called with different VMs on the same thread). Revert to TLS-only frame chain management. Keep set_current_frame_nosave() for the stack-frame fast path: it skips cross-thread top_frame publication but still does the TLS swap. Fix clippy not_unsafe_ptr_arg_deref in set_current_frame. Fix unnecessary Result wrapper in make_generator_or_coro. Assisted-by: Claude * Fix CI failures: GC frame assertion, faulthandler Radium import, dead code warning - Fix SIGSEGV in threading builds: GC debug assertion walked frame chain via top_frame, which is not updated by set_current_frame_nosave used by stack-allocated iframes. Walk CURRENT_FRAME TLS chain instead. - Add InterpreterFrame::get_lasti() accessor, remove direct Radium import from faulthandler.rs (fixes unused import warning). - Add cfg_attr allow(dead_code) for from_payload_ptr without threading (fixes WASM build warning). Assisted-by: Claude * Fix materialized frame: copy localsplus, clear stale previous pointers - materialize_slow: copy fastlocals snapshot to heap instead of empty localsplus (fixes index-out-of-bounds panic in locals()/f_locals) - materialize_slow: set previous to null (stack iframes become dangling) - with_frame/resume_gen_frame: clear iframe.previous on pop (prevents dangling pointers to freed stack iframes) - f_back: walk TLS CURRENT_FRAME chain instead of top_frame for cross-thread lookup (top_frame is stale for stack-allocated iframes) - check_locals_access: match materialized frames by comparing the Py address stored in the stack iframe's materialized field Assisted-by: Claude * Fix Windows clippy: gate unix-only imports and variables FrameObject, Py imports and top_iframe variable are only used in unix+threading cfg blocks. Gate them properly for non-unix builds. Assisted-by: Claude * Fall back to FrameObject when tracing; fix cross-thread frame access - invoke_with_locals: fall back to heap-allocated FrameObject path when use_tracing is active, so trace/profile callbacks fire correctly. This fixes test_trace, test_bdb, test_monitoring regressions. - set_current_frame: publish top_iframe alongside top_frame in ThreadSlot so cross-thread readers (sys._current_frames) can materialize stack-allocated frames from other threads under stop-the-world. - get_all_current_frames: use TLS CURRENT_FRAME for current thread, fall back to top_iframe materialization for other threads. - Use set_current_frame (not nosave) in with_iframe for proper cross-thread visibility. Assisted-by: Claude * Apply rustfmt Assisted-by: Claude * Fix Windows: gate unix-only top_iframe_ptr variable Assisted-by: Claude * Fix frame chain: f_back, retained_back, owner, faulthandler - Set materialized frame owner to FrameObject (not Thread) so frame.clear() works on traceback frames - Fix f_back for materialized frames: walk TLS chain to find source iframe and materialize its previous frame - Fix faulthandler dump_traceback to use dump_live_frames (reads live lasti from stack iframes instead of stale materialized copies) - Add retained_back propagation in with_frame cleanup: when a frame escapes, materialize caller and mark it escaped for chain propagation - Fix release_datastack_frame: don't overwrite retained_back if already set by with_frame cleanup Assisted-by: Claude * Apply rustfmt Assisted-by: Claude * Refine with_frame retained_back: use frame_obj instead of materialize Avoid materializing caller frames in with_frame cleanup to prevent creating localsplus snapshots that keep extra refcounts. Only use existing FrameObjects (frame_obj) for retained_back. Also keep previous pointer alive in release_datastack_frame since the caller is still executing at that point. Assisted-by: Claude * Fix f_lineno: use Cell for prev_line, update on every instruction prev_line was only updated when tracing was active, causing f_lineno to return stale values when observed mid-CALL (e.g. from warnings.warn or sys._getframe). Changed prev_line from u32 to Cell for interior mutability so it can be read safely while ExecutingFrame holds a shared reference, and update it on every instruction so f_lineno always returns the correct line. Assisted-by: Claude * Fix f_lineno for materialized frames: read live prev_line from TLS chain Materialized FrameObjects have a snapshot of prev_line from materialize time. When f_lineno is called on a materialized frame that is still executing (its source iframe is on the TLS chain), walk the TLS chain to find the source iframe and read its live prev_line. This fixes lineno tracking in warnings, gettext, and other modules that read frame line numbers during function calls. Also fixed the pointer comparison: use from_payload_ptr to convert FrameObject payload address to Py address for matching against the materialized field. Assisted-by: Claude * Sync live frame state for materialized frames - Add find_live_source_iframe() to walk TLS chain and find the live source InterpreterFrame for a materialized FrameObject - f_lasti: read live lasti from source iframe when available - f_lineno: use read_volatile for live prev_line from source iframe - sync_visible_locals_to_mapping: read live localsplus from source iframe - framelocalsproxy_getval: read live localsplus from source iframe - with_iframe cleanup: sync localsplus, prev_line, lasti to materialized FrameObject when the frame returns - sync_fastlocals_from: new LocalsPlus method to update fastlocals Fixes test_inspect (stale f_locals, stale lineno in positions), test_listcomps (stale iteration variable in locals()). Assisted-by: Claude * Fix retained_back: use materialize_chain and strong_count check - Add materialize_chain() that creates a lightweight FrameObject with empty localsplus for f_back chain building only. This avoids cloning local variables which would create extra refcounts (fixes test_memoryview). - with_frame cleanup: use strong_count > 1 instead of escaped flag to determine if retained_back is needed, ensuring chain propagation. - with_iframe cleanup: use materialize_chain for retained_back to avoid extra refcounts from localsplus snapshots. Fixes test_memoryview (refcount from retained_back chain) and test_traceback.test_extract_stack (f_back chain depth). Assisted-by: Claude * Fix cross-thread f_back chain for sys._current_frames/exceptions - get_all_current_frames: materialize entire frame chain and link retained_back during stop-the-world for other threads - f_back: use stop-the-world to safely materialize cross-thread frame chains on unix when prev iframe is on another thread - f_back: check frame_obj() before STW for fast path Fixes test_sys.test_current_frames and test_sys.test_current_exceptions. Assisted-by: Claude * Fix monitoring LINE events and faulthandler thread dump - Skip all instrumented opcodes in bytecode loop prev_line update to avoid defeating InstrumentedLine de-duplication - Add prev_line update in execute_instrumented for non-RESUME/non-LINE instrumented opcodes - update_events_mask: walk TLS iframe chain to re-instrument all frames including stack-allocated ones (fixes missing LINE events for inline frames already past RESUME) - faulthandler: use top_iframe fallback for stack-allocated frames in dump_all_threads and watchdog_thread - faulthandler: use dump_live_frames for current thread dump Fixes test_monitoring LINE/CALL tests, test_faulthandler thread dumps. Assisted-by: Claude * Fix clippy: remove duplicate Radium import, use previous() helper Assisted-by: Claude * Fix materialized frame issues: GC tracking, live locals, f_trace propagation - Track materialized FrameObjects in GC so cycle collection works (fixes __del__ not called at exit for exception traceback cycles) - materialize_chain returns owned PyRef without temporary_refs to avoid defeating GC cycle detection from non-GC-tracked storage - framelocalsproxy_setval writes to live source iframe when available (fixes f_locals proxy writes not affecting executing frame) - f_lineno uses lasti-based line for non-executing frames instead of prev_line (fixes PEP 626 wrong line after exception unwind) - f_trace/f_trace_lines/f_trace_opcodes setters propagate to live source iframe (fixes pdb set_trace not working through materialized frames) - frame.clear() checks find_live_source_iframe to reject clearing a frame that is backed by a live stack-allocated iframe - Fix clippy warnings and unused imports in faulthandler - Remove unsafe top_iframe access from faulthandler watchdog thread Assisted-by: Claude * Fix faulthandler crashes, GC tracking timing, and test_generators - Use top_iframe instead of top_frame for frame chain walking in faulthandler (dump_all_threads and watchdog), avoiding stale or GC-cleared FrameObject pointer dereference - Defer GC tracking of materialized FrameObjects to with_iframe cleanup (after set_current_frame restores old chain), preventing premature collection while temporary_refs still holds the only ref - Add FrameObject::try_iframe() for safe access to potentially GC-cleared frames - Remove expectedFailure for test_exhausted_generator_frame_cycle (now passes with GC tracking fix) - Add 'noalias' to cspell dictionary Assisted-by: Claude * Fix Windows frame dump and remove dead code - Publish top_iframe on all platforms (not just unix) so Windows faulthandler and sys._current_frames can see stack-allocated frames - Use top_iframe instead of frames Mutex in Windows faulthandler dump and watchdog paths - Use top_iframe in non-unix get_all_current_frames for sys._current_frames - Remove unused dump_traceback_thread_chain (replaced by top_iframe walk) - Mark test_pdb_await_support as expected failure Assisted-by: Claude * Fix ThreadSlot init on all platforms, skip test_pdb_await_support - Add top_iframe to ThreadSlot initializer in init_thread_slot_if_needed (fixes wasm32, Windows compile errors) - Skip test_pdb_await_support on RustPython (async pdb exception callback receives None exc argument) Assisted-by: Claude * Remove dead faulthandler code (dump_frame_from_ref, dump_traceback_thread_frames, try_iframe) These functions are no longer called after switching all frame chain walking to use top_iframe directly. Assisted-by: Claude * Fix Windows sys._current_frames: materialize full frame chain The non-unix get_all_current_frames path was only materializing the top iframe without linking retained_back, so f_back chain walking could not find deeper frames like f123() in test_sys tests. Replicate the full chain materialization from the unix path. Assisted-by: Claude * Use STW-based cross-thread f_back on all platforms Replace the non-unix frames-mutex fallback with the same stop-the-world iframe chain materialization used on unix. Fixes test_current_exceptions on Windows where stack-allocated iframes were not found in the FrameObject-only frames list. Assisted-by: Claude * Fix set_f_lineno to write to live iframe, use STW for cross-thread access - set_f_lineno: write lasti/pending_stack_pops to the live source iframe instead of the materialized copy so debugger jumps take effect on stack-allocated frames - f_back cross-thread: enter STW before dereferencing the prev pointer to prevent use-after-free if the owning thread returns - get_all_current_frames (non-unix): add STW protection when walking cross-thread iframe chains, matching the unix path Assisted-by: Claude * Guard current_location() against lasti==0, always capture retained_back - current_location(): return first_line_number instead of panicking when lasti is 0 (before first instruction executes) - with_frame retained_back: materialize the caller iframe when needed so f_back always resolves for escaped FrameObjects Assisted-by: Claude * Fix format, revert eager retained_back materialization Remove double blank line in frame.rs (lint failure). Revert with_frame retained_back to only use already-materialized caller FrameObjects — eagerly materializing the caller added refcounts on local variables, preventing timely deallocation and causing test_io, test_memoryview, and test_futures failures. Assisted-by: Claude * Remove rustpython-unicode-isolation-issue.md from gitignore Assisted-by: Claude * Remove test_pdb_await_support skip: async pdb now works correctly Assisted-by: Claude * Fix STOP_ITERATION monitoring: wrap value in StopIteration instance fire_stop_iteration was passing the raw iterator return value to callbacks, but the STOP_ITERATION event callback signature expects a StopIteration exception instance. Wrap non-StopIteration values in a new StopIteration(value), matching PyMonitoring_FireStopIterationEvent. This fixes test_pdb_await_support where bdb's exception_callback received None instead of a StopIteration instance. Assisted-by: Claude --- .cspell.dict/rust-more.txt | 1 + .github/workflows/cron-ci.yaml | 12 +- .github/workflows/lib-deps-check.yaml | 17 +- .github/zizmor.yml | 5 + .gitignore | 2 +- Lib/test/test_generators.py | 1 - crates/capi/src/ceval.rs | 18 +- crates/capi/src/pyframe.rs | 4 +- crates/stdlib/src/faulthandler.rs | 237 ++-- crates/vm/src/builtins/asyncgenerator.rs | 11 +- crates/vm/src/builtins/coroutine.rs | 8 +- crates/vm/src/builtins/frame.rs | 342 +++-- crates/vm/src/builtins/frame_locals_proxy.rs | 8 +- crates/vm/src/builtins/function.rs | 236 +++- crates/vm/src/builtins/generator.rs | 8 +- crates/vm/src/builtins/super.rs | 33 +- crates/vm/src/builtins/traceback.rs | 17 +- crates/vm/src/builtins/type.rs | 11 +- crates/vm/src/coroutine.rs | 20 +- crates/vm/src/exceptions.rs | 4 +- crates/vm/src/frame.rs | 1286 +++++++++++++----- crates/vm/src/gc_state.rs | 39 +- crates/vm/src/import.rs | 4 +- crates/vm/src/object/core.rs | 3 +- crates/vm/src/object/ext.rs | 8 +- crates/vm/src/object/payload.rs | 2 +- crates/vm/src/protocol/callable.rs | 6 +- crates/vm/src/stdlib/_io.rs | 4 +- crates/vm/src/stdlib/_thread.rs | 107 +- crates/vm/src/stdlib/_typing.rs | 5 +- crates/vm/src/stdlib/builtins.rs | 10 +- crates/vm/src/stdlib/sys.rs | 10 +- crates/vm/src/stdlib/sys/monitoring.rs | 90 +- crates/vm/src/stdlib/typevar.rs | 11 +- crates/vm/src/suggestion.rs | 12 +- crates/vm/src/types/zoo.rs | 2 +- crates/vm/src/vm/mod.rs | 358 +++-- crates/vm/src/vm/thread.rs | 125 +- crates/vm/src/warn.rs | 26 +- 39 files changed, 2157 insertions(+), 946 deletions(-) diff --git a/.cspell.dict/rust-more.txt b/.cspell.dict/rust-more.txt index c4457723c6c..b53639c3b41 100644 --- a/.cspell.dict/rust-more.txt +++ b/.cspell.dict/rust-more.txt @@ -50,6 +50,7 @@ modpow msvc muldiv nanos +noalias nonoverlapping objclass peekable diff --git a/.github/workflows/cron-ci.yaml b/.github/workflows/cron-ci.yaml index 059604625e8..c6eebbdf88c 100644 --- a/.github/workflows/cron-ci.yaml +++ b/.github/workflows/cron-ci.yaml @@ -85,7 +85,6 @@ jobs: if: ${{ github.event_name != 'pull_request' }} env: SSHKEY: ${{ secrets.ACTIONS_TESTS_DATA_DEPLOY_KEY }} - GITHUB_ACTOR: ${{ github.actor }} run: | echo "$SSHKEY" >~/github_key chmod 600 ~/github_key @@ -95,7 +94,7 @@ jobs: cd website cp ../extra_tests/cpython_tests_results.json ./_data/regrtests_results.json git add ./_data/regrtests_results.json - if git -c user.name="Github Actions" -c user.email="actions@github.com" commit -m "Update regression test results" --author="$GITHUB_ACTOR"; then + if git -c user.name="Github Actions" -c user.email="actions@github.com" commit -m "Update regression test results"; then git push fi @@ -127,7 +126,6 @@ jobs: if: ${{ github.event_name != 'pull_request' }} env: SSHKEY: ${{ secrets.ACTIONS_TESTS_DATA_DEPLOY_KEY }} - GITHUB_ACTOR: ${{ github.actor }} run: | echo "$SSHKEY" >~/github_key chmod 600 ~/github_key @@ -158,7 +156,7 @@ jobs: } EOF git add -A - if git -c user.name="Github Actions" -c user.email="actions@github.com" commit -m "Update what is left results" --author="$GITHUB_ACTOR"; then + if git -c user.name="Github Actions" -c user.email="actions@github.com" commit -m "Update what is left results"; then git push fi @@ -204,6 +202,8 @@ jobs: if: ${{ github.event_name != 'pull_request' }} env: SSHKEY: ${{ secrets.ACTIONS_TESTS_DATA_DEPLOY_KEY }} + COMMIT_SHA: ${{ github.sha }} + REF_NAME: ${{ github.ref_name }} run: | echo "$SSHKEY" >~/github_key chmod 600 ~/github_key @@ -215,8 +215,8 @@ jobs: cp -r ../target/criterion ./assets/criterion printf '{\n "generated_at": "%s",\n "rustpython_commit": "%s",\n "rustpython_ref": "%s"\n}\n' \ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - "${{ github.sha }}" \ - "${{ github.ref_name }}" > ./_data/criterion-metadata.json + "$COMMIT_SHA" \ + "$REF_NAME" > ./_data/criterion-metadata.json git add ./assets/criterion ./_data/criterion-metadata.json if git -c user.name="Github Actions" -c user.email="actions@github.com" commit -m "Update benchmark results"; then git push diff --git a/.github/workflows/lib-deps-check.yaml b/.github/workflows/lib-deps-check.yaml index a938c6ff00a..6f147542ee6 100644 --- a/.github/workflows/lib-deps-check.yaml +++ b/.github/workflows/lib-deps-check.yaml @@ -6,6 +6,8 @@ on: paths: - "Lib/**" +permissions: {} + concurrency: group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.event.pull_request.number }} cancel-in-progress: true @@ -26,13 +28,17 @@ jobs: persist-credentials: false - name: Fetch PR head + env: + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - git fetch origin ${{ github.event.pull_request.head.sha }} + git fetch origin "$PR_HEAD_SHA" - name: Checkout PR Lib files + env: + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | # Checkout only Lib/ directory from PR head for accurate comparison - git checkout ${{ github.event.pull_request.head.sha }} -- Lib/ + git checkout "$PR_HEAD_SHA" -- Lib/ - name: Get target CPython version id: cpython-version @@ -51,14 +57,17 @@ jobs: - name: Get changed Lib files id: all-changed-files + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | # Get the list of changed files under Lib/ { echo 'changed<> "$GITHUB_OUTPUT" - name: Parse changed files diff --git a/.github/zizmor.yml b/.github/zizmor.yml index f22f76b70d8..33ac61c6489 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,4 +1,9 @@ rules: + excessive-permissions: + ignore: + # pull_request_target is needed to post PR comments with pull-requests: write. + # Workflow-level permissions: {} restricts defaults; only the job has write access. + - lib-deps-check.yaml:3 unpinned-uses: config: policies: diff --git a/.gitignore b/.gitignore index 92fc399bf75..cb8548877c2 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,5 @@ Lib/site-packages/* Lib/test/data/* !Lib/test/data/README cpython/ -.claude/scheduled_tasks.lock +.claude/ docs/superpowers/ \ No newline at end of file diff --git a/Lib/test/test_generators.py b/Lib/test/test_generators.py index b3826f4229d..07c1decb42c 100644 --- a/Lib/test/test_generators.py +++ b/Lib/test/test_generators.py @@ -134,7 +134,6 @@ def gen(): self.assertEqual(len(resurrected), 1) self.assertIsInstance(resurrected[0].gi_code, types.CodeType) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: is not None def test_exhausted_generator_frame_cycle(self): def g(): yield diff --git a/crates/capi/src/ceval.rs b/crates/capi/src/ceval.rs index 366cb0071bb..7de5b3cf6fe 100644 --- a/crates/capi/src/ceval.rs +++ b/crates/capi/src/ceval.rs @@ -58,7 +58,7 @@ pub extern "C" fn PyEval_GetBuiltins() -> *mut PyObject { with_vm(|vm| { vm.current_frame().map_or_else( || vm.builtins.as_object().as_raw(), - |frame| frame.builtins.as_object().as_raw(), + |frame| frame.iframe().builtins().as_raw(), ) }) } @@ -78,7 +78,7 @@ pub extern "C" fn PyEval_GetFrameBuiltins() -> *mut PyObject { with_vm(|vm| { vm.current_frame().map_or_else( || vm.builtins.as_object().to_owned(), - |frame| frame.builtins.as_object().to_owned(), + |frame| frame.iframe().builtins().to_owned(), ) }) } @@ -87,7 +87,15 @@ pub extern "C" fn PyEval_GetFrameBuiltins() -> *mut PyObject { pub extern "C" fn PyEval_GetFrameGlobals() -> *mut PyObject { with_vm(|vm| { vm.current_frame() - .map(|frame| frame.globals.as_object().to_owned().into_raw().as_ptr()) + .map(|frame| { + frame + .iframe() + .globals() + .as_object() + .to_owned() + .into_raw() + .as_ptr() + }) .unwrap_or_default() }) } @@ -107,7 +115,7 @@ pub extern "C" fn PyEval_GetFrameLocals() -> *mut PyObject { pub extern "C" fn PyEval_GetGlobals() -> *mut PyObject { with_vm(|vm| { vm.current_frame() - .map(|frame| frame.globals.as_object().as_raw()) + .map(|frame| frame.iframe().globals().as_object().as_raw()) .unwrap_or_default() }) } @@ -119,7 +127,7 @@ pub extern "C" fn PyEval_GetLocals() -> *mut PyObject { return Ok(core::ptr::null_mut()); }; let _ = frame.locals(vm)?; - Ok(frame.locals.as_object(vm).as_raw().cast_mut()) + Ok(frame.iframe().locals.as_object(vm).as_raw().cast_mut()) }) } diff --git a/crates/capi/src/pyframe.rs b/crates/capi/src/pyframe.rs index 5c9ad371708..611cf79b0b6 100644 --- a/crates/capi/src/pyframe.rs +++ b/crates/capi/src/pyframe.rs @@ -2,9 +2,9 @@ use crate::pystate::with_vm; use core::ffi::c_int; use rustpython_vm::Py; use rustpython_vm::builtins::PyCode; -use rustpython_vm::frame::Frame; +use rustpython_vm::frame::FrameObject; -pub type PyFrameObject = Py; +pub type PyFrameObject = Py; pub type PyCodeObject = Py; #[unsafe(no_mangle)] diff --git a/crates/stdlib/src/faulthandler.rs b/crates/stdlib/src/faulthandler.rs index 900d66b76e6..6edd023f1eb 100644 --- a/crates/stdlib/src/faulthandler.rs +++ b/crates/stdlib/src/faulthandler.rs @@ -3,8 +3,6 @@ pub(crate) use decl::module_def; #[allow(static_mut_refs)] // TODO: group code only with static mut refs #[pymodule(name = "faulthandler")] mod decl { - #[cfg(any(unix, windows))] - use crate::vm::frame::Frame; use crate::vm::{ PyObjectRef, PyResult, VirtualMachine, function::{ArgIntoFloat, OptionalArg}, @@ -119,43 +117,45 @@ mod decl { } /// Dump the current thread's live frame chain to fd (signal-safe). - /// Walks the `Frame.previous` pointer chain starting from the - /// thread-local current frame pointer. + /// Walks the InterpreterFrame chain directly. #[cfg(any(unix, windows))] fn dump_live_frames(fd: i32) { const MAX_FRAME_DEPTH: usize = 100; - let mut frame_ptr = crate::vm::vm::thread::get_current_frame(); - if frame_ptr.is_null() { + let mut cur = crate::vm::vm::thread::get_current_frame(); + if cur.is_null() { puts(fd, " \n"); return; } let mut depth = 0; - while !frame_ptr.is_null() && depth < MAX_FRAME_DEPTH { - let frame = unsafe { &*frame_ptr }; - dump_frame_from_raw(fd, frame); - frame_ptr = frame.previous_frame(); + while !cur.is_null() && depth < MAX_FRAME_DEPTH { + let iframe = unsafe { &*cur }; + dump_iframe(fd, iframe); depth += 1; + cur = iframe.previous(); } - if depth >= MAX_FRAME_DEPTH && !frame_ptr.is_null() { + if depth == 0 { + puts(fd, " \n"); + } else if depth >= MAX_FRAME_DEPTH && !cur.is_null() { puts(fd, " ...\n"); } } - /// Dump a single frame's info to fd (signal-safe), reading live data. + /// Dump a single InterpreterFrame's info to fd (signal-safe). #[cfg(any(unix, windows))] - fn dump_frame_from_raw(fd: i32, frame: &Frame) { - let filename = frame.code.source_path().as_str(); - let funcname = frame.code.obj_name.as_str(); - let lasti = frame.lasti(); + fn dump_iframe(fd: i32, iframe: &rustpython_vm::frame::InterpreterFrame) { + let code = iframe.code(); + let filename = code.source_path().as_str(); + let funcname = code.obj_name.as_str(); + let lasti = iframe.get_lasti(); let lineno = if lasti == 0 { - frame.code.first_line_number.map_or(1, |n| n.get()) as u32 + code.first_line_number.map_or(1, |n| n.get()) as u32 } else { let idx = (lasti as usize).saturating_sub(1); - if idx < frame.code.locations.len() { - frame.code.locations[idx].0.line.get() as u32 + if idx < code.locations.len() { + code.locations[idx].0.line.get() as u32 } else { - frame.code.first_line_number.map_or(0, |n| n.get()) as u32 + code.first_line_number.map_or(0, |n| n.get()) as u32 } }; @@ -218,77 +218,6 @@ mod decl { } } - /// Write a frame's info to an fd using signal-safe I/O. - #[cfg(any(unix, windows))] - fn dump_frame_from_ref(fd: i32, frame: &crate::vm::Py) { - let funcname = frame.code.obj_name.as_str(); - let filename = frame.code.source_path().as_str(); - let lineno = if frame.lasti() == 0 { - frame.code.first_line_number.map_or(1, |n| n.get()) as u32 - } else { - frame.current_location().line.get() as u32 - }; - - puts(fd, " File \""); - dump_ascii(fd, filename); - puts(fd, "\", line "); - dump_decimal(fd, lineno as usize); - puts(fd, " in "); - dump_ascii(fd, funcname); - puts(fd, "\n"); - } - - /// Dump traceback for a thread given its frame stack (for cross-thread dumping). - /// # Safety - /// Each `FramePtr` must point to a live frame (caller holds the Mutex). - #[cfg(all(windows, feature = "threading"))] - fn dump_traceback_thread_frames( - fd: i32, - thread_id: u64, - is_current: bool, - frames: &[rustpython_vm::vm::FramePtr], - ) { - write_thread_id(fd, thread_id, is_current); - - if frames.is_empty() { - puts(fd, " \n"); - } else { - for fp in frames.iter().rev() { - // SAFETY: caller holds the Mutex, so the owning thread can't pop. - dump_frame_from_ref(fd, unsafe { fp.as_ref() }); - } - } - } - - /// Dump a thread's traceback by walking its published top frame down the - /// `previous` chain (most recent first). Signal-safe: only atomic pointer - /// loads, no locks. Callers guarantee frame liveness — under stop-the-world - /// for `faulthandler.dump_traceback`, or best-effort for the watchdog (like - /// `_Py_DumpTracebackThreads`, which walks lock-free while other threads - /// may still run). - #[cfg(all(unix, feature = "threading"))] - fn dump_traceback_thread_chain(fd: i32, thread_id: u64, is_current: bool, top: *const Frame) { - const MAX_FRAME_DEPTH: usize = 100; - write_thread_id(fd, thread_id, is_current); - - if top.is_null() { - puts(fd, " \n"); - return; - } - let mut frame_ptr = top; - let mut depth = 0; - while !frame_ptr.is_null() && depth < MAX_FRAME_DEPTH { - // SAFETY: the frame is alive per the caller's liveness guarantee. - let frame = unsafe { &*frame_ptr }; - dump_frame_from_raw(fd, frame); - frame_ptr = frame.previous_frame(); - depth += 1; - } - if depth >= MAX_FRAME_DEPTH && !frame_ptr.is_null() { - puts(fd, " ...\n"); - } - } - #[derive(FromArgs)] struct DumpTracebackArgs { #[pyarg(any, default)] @@ -307,9 +236,7 @@ mod decl { dump_all_threads(fd, vm); } else { puts(fd, "Stack (most recent call first):\n"); - crate::vm::frame::for_each_current_frame(|frame| { - dump_frame_from_ref(fd, frame); - }); + dump_live_frames(fd); } } @@ -344,21 +271,35 @@ mod decl { if tid == current_tid { continue; } - let top = slot.top_frame.load(Ordering::Relaxed) as *const Frame; - dump_traceback_thread_chain(fd, tid, false, top); + // Under STW, all other threads are suspended so their + // stack-allocated iframes are stable. Walk via top_iframe + // which covers both FrameObject and stack-allocated paths. + let iframe_ptr = slot.top_iframe.load(Ordering::Relaxed) + as *const rustpython_vm::frame::InterpreterFrame; + write_thread_id(fd, tid, false); + if iframe_ptr.is_null() { + puts(fd, " \n"); + } else { + let mut cur = iframe_ptr; + let mut depth = 0; + while !cur.is_null() && depth < 100 { + let iframe = unsafe { &*cur }; + dump_iframe(fd, iframe); + depth += 1; + cur = iframe.previous(); + } + if depth >= 100 && !cur.is_null() { + puts(fd, " ...\n"); + } + } puts(fd, "\n"); } } - // Now dump current thread from its live frame chain. + // Now dump current thread from its live frame chain + // (includes stack-allocated iframes without FrameObject). write_thread_id(fd, current_tid, true); - if crate::vm::vm::thread::get_current_frame().is_null() { - puts(fd, " \n"); - } else { - crate::vm::frame::for_each_current_frame(|frame| { - dump_frame_from_ref(fd, frame); - }); - } + dump_live_frames(fd); } #[cfg(all(not(unix), feature = "threading"))] @@ -366,7 +307,8 @@ mod decl { let current_tid = rustpython_vm::stdlib::_thread::get_ident(); let registry = vm.state.thread_frames.lock(); - // First dump non-current threads, then current thread last + // Dump non-current threads using top_iframe, which includes + // both FrameObject and stack-allocated frames. #[expect( clippy::iter_over_hash_type, reason = "Iteration order doesn't matter here" @@ -376,29 +318,38 @@ mod decl { continue; } - let frames_guard = slot.frames.lock(); - dump_traceback_thread_frames(fd, tid, false, &frames_guard); + let iframe_ptr = slot.top_iframe.load(core::sync::atomic::Ordering::Relaxed) + as *const rustpython_vm::frame::InterpreterFrame; + write_thread_id(fd, tid, false); + if iframe_ptr.is_null() { + puts(fd, " \n"); + } else { + let mut cur = iframe_ptr; + let mut depth = 0; + while !cur.is_null() && depth < 100 { + let iframe = unsafe { &*cur }; + dump_iframe(fd, iframe); + depth += 1; + cur = iframe.previous(); + } + if depth >= 100 && !cur.is_null() { + puts(fd, " ...\n"); + } + } puts(fd, "\n"); } - // Now dump current thread from its live frame chain. + // Now dump current thread from its live frame chain + // (includes stack-allocated iframes without FrameObject). write_thread_id(fd, current_tid, true); - if crate::vm::vm::thread::get_current_frame().is_null() { - puts(fd, " \n"); - } else { - crate::vm::frame::for_each_current_frame(|frame| { - dump_frame_from_ref(fd, frame); - }); - } + dump_live_frames(fd); } #[cfg(not(feature = "threading"))] { let _ = vm; write_thread_id(fd, current_thread_id(), true); - crate::vm::frame::for_each_current_frame(|frame| { - dump_frame_from_ref(fd, frame); - }); + dump_live_frames(fd); } } @@ -724,18 +675,56 @@ mod decl { // the VM, so it cannot stop-the-world. Walk each // published top frame lock-free and best-effort, like // the faulthandler watchdog thread. + // Walk via top_iframe for all threads. The watchdog + // cannot stop-the-world, so this is best-effort + // (like CPython's _Py_DumpTracebackThreads). Stack + // frames are still alive because the target thread + // is executing (inside a blocking call). for (tid, slot) in &thread_frame_slots { - let top = slot - .top_frame + let iframe_ptr = slot + .top_iframe .load(core::sync::atomic::Ordering::Relaxed) - as *const Frame; - dump_traceback_thread_chain(fd, *tid, false, top); + as *const rustpython_vm::frame::InterpreterFrame; + write_thread_id(fd, *tid, false); + if iframe_ptr.is_null() { + puts(fd, " \n"); + } else { + let mut cur = iframe_ptr; + let mut depth = 0; + while !cur.is_null() && depth < 100 { + let iframe = unsafe { &*cur }; + dump_iframe(fd, iframe); + depth += 1; + cur = iframe.previous(); + } + if depth >= 100 && !cur.is_null() { + puts(fd, " ...\n"); + } + } } } all(not(unix), feature = "threading") => { for (tid, slot) in &thread_frame_slots { - let frames = slot.frames.lock(); - dump_traceback_thread_frames(fd, *tid, false, &frames); + let iframe_ptr = slot + .top_iframe + .load(core::sync::atomic::Ordering::Relaxed) + as *const rustpython_vm::frame::InterpreterFrame; + write_thread_id(fd, *tid, false); + if iframe_ptr.is_null() { + puts(fd, " \n"); + } else { + let mut cur = iframe_ptr; + let mut depth = 0; + while !cur.is_null() && depth < 100 { + let iframe = unsafe { &*cur }; + dump_iframe(fd, iframe); + depth += 1; + cur = iframe.previous(); + } + if depth >= 100 && !cur.is_null() { + puts(fd, " ...\n"); + } + } } } _ => { diff --git a/crates/vm/src/builtins/asyncgenerator.rs b/crates/vm/src/builtins/asyncgenerator.rs index dea40062a9a..b53e59d58c1 100644 --- a/crates/vm/src/builtins/asyncgenerator.rs +++ b/crates/vm/src/builtins/asyncgenerator.rs @@ -5,7 +5,7 @@ use crate::{ class::PyClassImpl, common::lock::PyMutex, coroutine::{Coro, warn_deprecated_throw_signature}, - frame::FrameRef, + frame::FrameObjectRef, function::OptionalArg, object::{Traverse, TraverseFn}, protocol::PyIterReturn, @@ -50,7 +50,7 @@ impl PyAsyncGen { } #[must_use] - pub fn new(frame: FrameRef, name: PyStrRef, qualname: PyStrRef) -> Self { + pub fn new(frame: FrameObjectRef, name: PyStrRef, qualname: PyStrRef) -> Self { Self { inner: Coro::new(frame, name, qualname), running_async: AtomicCell::new(false), @@ -127,7 +127,7 @@ impl PyAsyncGen { self.inner.frame().yield_from_target() } #[pygetset] - fn ag_frame(&self, _vm: &VirtualMachine) -> Option { + fn ag_frame(&self, _vm: &VirtualMachine) -> Option { if self.inner.closed() { None } else { @@ -140,7 +140,7 @@ impl PyAsyncGen { } #[pygetset] fn ag_code(&self, _vm: &VirtualMachine) -> PyRef { - self.inner.frame().code.clone() + self.inner.frame().iframe().code().to_owned() } #[pyclassmethod] @@ -688,7 +688,8 @@ impl PyAnextAwaitable { && generator .as_coro() .frame() - .code + .iframe() + .code() .flags .contains(crate::bytecode::CodeFlags::ITERABLE_COROUTINE) { diff --git a/crates/vm/src/builtins/coroutine.rs b/crates/vm/src/builtins/coroutine.rs index 1780370b43c..d472f1a0bfa 100644 --- a/crates/vm/src/builtins/coroutine.rs +++ b/crates/vm/src/builtins/coroutine.rs @@ -3,7 +3,7 @@ use crate::{ AsObject, Context, Py, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, class::PyClassImpl, coroutine::{Coro, warn_deprecated_throw_signature}, - frame::FrameRef, + frame::FrameObjectRef, function::OptionalArg, object::{Traverse, TraverseFn}, protocol::PyIterReturn, @@ -41,7 +41,7 @@ impl PyCoroutine { } #[must_use] - pub fn new(frame: FrameRef, name: PyStrRef, qualname: PyStrRef) -> Self { + pub fn new(frame: FrameObjectRef, name: PyStrRef, qualname: PyStrRef) -> Self { Self { inner: Coro::new(frame, name, qualname), } @@ -80,7 +80,7 @@ impl PyCoroutine { self.inner.frame().yield_from_target() } #[pygetset] - fn cr_frame(&self, _vm: &VirtualMachine) -> Option { + fn cr_frame(&self, _vm: &VirtualMachine) -> Option { if self.inner.closed() { None } else { @@ -93,7 +93,7 @@ impl PyCoroutine { } #[pygetset] fn cr_code(&self, _vm: &VirtualMachine) -> PyRef { - self.inner.frame().code.clone() + self.inner.frame().iframe().code().to_owned() } // TODO: coroutine origin tracking: // https://docs.python.org/3/library/sys.html#sys.set_coroutine_origin_tracking_depth diff --git a/crates/vm/src/builtins/frame.rs b/crates/vm/src/builtins/frame.rs index 710d23b7c2f..70ff9997946 100644 --- a/crates/vm/src/builtins/frame.rs +++ b/crates/vm/src/builtins/frame.rs @@ -6,11 +6,14 @@ use super::{PyCode, PyDictRef, PyIntRef, PyStrRef}; use crate::{ Context, Py, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, class::PyClassImpl, - frame::{Frame, FrameOwner, FrameRef}, + frame::{FrameObject, FrameObjectRef, FrameOwner}, function::PySetterValue, types::Representable, }; +use core::sync::atomic::Ordering::Relaxed; use num_traits::Zero; +#[allow(unused_imports)] +use rustpython_common::atomic::Radium; use rustpython_compiler_core::bytecode::{self, Constant, Instruction, StackEffect}; use stack_analysis::*; @@ -426,10 +429,10 @@ pub(crate) mod stack_analysis { } pub(crate) fn init(context: &'static Context) { - Frame::extend_class(context, context.types.frame_type); + FrameObject::extend_class(context, context.types.frame_type); } -impl Representable for Frame { +impl Representable for FrameObject { #[inline] fn repr(_zelf: &Py, vm: &VirtualMachine) -> PyResult { const REPR: &str = ""; @@ -442,38 +445,86 @@ impl Representable for Frame { } } +impl FrameObject { + /// Find the live source InterpreterFrame on the TLS chain for a + /// materialized FrameObject. Returns the raw pointer if found, or null + /// if this FrameObject has no live source (already returned or not + /// currently executing on this thread). + pub(crate) fn find_live_source_iframe(&self) -> *const crate::frame::InterpreterFrame { + let self_py_ptr = unsafe { Py::::from_payload_ptr(self) } as usize; + let mut cur = crate::vm::thread::get_current_frame(); + while !cur.is_null() { + let materialized = unsafe { (*cur).materialized.load(Relaxed) }; + if materialized == self_py_ptr { + return cur; + } + cur = unsafe { &*cur }.previous(); + } + core::ptr::null() + } +} + #[pyclass(flags(DISALLOW_INSTANTIATION), with(Py))] -impl Frame { +impl FrameObject { #[pygetset] fn f_globals(&self) -> PyDictRef { - self.globals.clone() + self.iframe().globals().to_owned() } #[pygetset] fn f_builtins(&self) -> PyObjectRef { - self.builtins.clone() + self.iframe().builtins().to_owned() } #[pygetset] pub fn f_code(&self) -> PyRef { - self.code.clone() + self.iframe().code().to_owned() } #[pygetset] fn f_lasti(&self) -> u32 { - // Return byte offset (each instruction is 2 bytes) for compatibility - self.lasti() * 2 + // Return byte offset (each instruction is 2 bytes) for compatibility. + // For materialized frames, read live lasti from the source iframe on + // the TLS chain so f_lasti reflects the current execution position. + let live = self.find_live_source_iframe(); + let val = if !live.is_null() { + unsafe { (*live).lasti.load(Relaxed) } + } else { + self.lasti() + }; + val * 2 } #[pygetset] pub fn f_lineno(&self) -> usize { // If lasti is 0, execution hasn't started yet - use first line number - // Similar to PyCode_Addr2Line which returns co_firstlineno for addr_q < 0 if self.lasti() == 0 { - self.code.first_line_number.map_or(1, |n| n.get()) - } else { - self.current_location().line.get() + return self + .iframe() + .code() + .first_line_number + .map_or(1, |n| n.get()); + } + // For executing frames (on the TLS chain), use prev_line which is + // updated at each bytecode instruction *before* the instruction + // runs. This gives the correct line even when observed mid-CALL + // (where lasti has already advanced past the CALL instruction). + let live = self.find_live_source_iframe(); + if !live.is_null() { + // Read live prev_line. Use read_volatile to bypass LLVM noalias + // on the &mut InterpreterFrame borrow in with_iframe. + let prev = unsafe { + let field_ptr = core::ptr::addr_of!((*live).prev_line); + core::ptr::read_volatile(field_ptr as *const u32) + }; + if prev > 0 { + return prev as usize; + } } + // For returned frames, use lasti-based location lookup. This is + // correct for exception tracebacks where prev_line may have been + // updated by cleanup instructions after the exception. + self.current_location().line.get() } #[pygetset(setter)] @@ -492,7 +543,11 @@ impl Frame { } }; - let first_line = self.code.first_line_number.map_or(1, |n| n.get() as i32); + let first_line = self + .iframe() + .code() + .first_line_number + .map_or(1, |n| n.get() as i32); if l_new_lineno < first_line { return Err(vm.new_value_error(format!( @@ -500,7 +555,7 @@ impl Frame { ))); } - let py_code: &PyCode = &self.code; + let py_code: &PyCode = self.iframe().code(); let code = &py_code.code; let lines = mark_lines(code); @@ -513,13 +568,19 @@ impl Frame { } let stacks = mark_stacks(code); - let len = self.code.instructions.len(); + let len = self.iframe().code().instructions.len(); // lasti points past the current instruction (already incremented). // stacks[lasti - 1] gives the stack state before executing the // instruction that triggered this trace event, which is the current - // evaluation stack. - let current_lasti = self.lasti() as usize; + // evaluation stack. Read from the live iframe when available so the + // value reflects the actual execution position. + let live = self.find_live_source_iframe(); + let current_lasti = if !live.is_null() { + (unsafe { (*live).lasti.load(Relaxed) }) as usize + } else { + self.lasti() as usize + }; let start_idx = current_lasti.saturating_sub(1); let start_stack = if start_idx < stacks.len() { stacks[start_idx] @@ -567,36 +628,61 @@ impl Frame { } } - // Store the pending unwind for the execution loop to perform. - // We cannot pop stack entries here because the execution loop - // holds the state mutex, and trying to lock it again would deadlock. - self.set_pending_stack_pops(pop_count as u32); - self.set_pending_unwind_from_stack(start_stack); - - // Set lasti to best_addr. The executor will read lasti and execute - // the instruction at that index next. - self.set_lasti(best_addr as u32); + // Store the pending unwind and new lasti. When this frame is backed + // by a live stack-allocated iframe, write to the live iframe so the + // execution loop picks up the jump target. Reuse `live` from above. + let target = if !live.is_null() { + unsafe { &*live } + } else { + self.iframe() + }; + target.pending_stack_pops.store(pop_count as u32, Relaxed); + target.pending_unwind_from_stack.store(start_stack, Relaxed); + target.lasti.store(best_addr as u32, Relaxed); Ok(()) } #[pygetset] - fn f_trace(&self) -> PyObjectRef { - let boxed = self.trace.lock(); - boxed.clone() + fn f_trace(&self, vm: &VirtualMachine) -> PyObjectRef { + // Read from live source iframe if available. + let live = self.find_live_source_iframe(); + let trace = if !live.is_null() { + unsafe { &*live }.trace.lock().clone() + } else { + self.iframe().trace.lock().clone() + }; + trace.unwrap_or_else(|| vm.ctx.none()) } #[pygetset(setter)] fn set_f_trace(&self, value: PySetterValue, vm: &VirtualMachine) { - let mut storage = self.trace.lock(); - *storage = value.unwrap_or_none(vm); + let trace = match value { + PySetterValue::Assign(v) => { + if vm.is_none(&v) { + None + } else { + Some(v) + } + } + PySetterValue::Delete => None, + }; + // Set on the materialized FrameObject. + (*self.iframe().trace.lock()).clone_from(&trace); + // Also propagate to the live source iframe if this is a + // materialized copy of a stack-allocated frame, so pdb's + // f_trace assignment takes effect on the executing frame. + let live = self.find_live_source_iframe(); + if !live.is_null() { + *unsafe { &*live }.trace.lock() = trace; + } } #[expect(clippy::unnecessary_wraps, reason = "Needs to comply with a signature")] #[pymember(type = "bool")] fn f_trace_lines(vm: &VirtualMachine, zelf: PyObjectRef) -> PyResult { - let zelf: FrameRef = zelf.downcast().unwrap_or_else(|_| unreachable!()); + let zelf: FrameObjectRef = zelf.downcast().unwrap_or_else(|_| unreachable!()); - let boxed = zelf.trace_lines.lock(); + let boxed = zelf.iframe().trace_lines.lock(); Ok(vm.ctx.new_bool(*boxed).into()) } @@ -608,14 +694,19 @@ impl Frame { ) -> PyResult<()> { match value { PySetterValue::Assign(value) => { - let zelf: FrameRef = zelf.downcast().unwrap_or_else(|_| unreachable!()); + let zelf: FrameObjectRef = zelf.downcast().unwrap_or_else(|_| unreachable!()); let value: PyIntRef = value .downcast() .map_err(|_| vm.new_type_error("attribute value type must be bool"))?; - let mut trace_lines = zelf.trace_lines.lock(); - *trace_lines = !value.as_bigint().is_zero(); + let val = !value.as_bigint().is_zero(); + *zelf.iframe().trace_lines.lock() = val; + // Propagate to live source iframe. + let live = zelf.find_live_source_iframe(); + if !live.is_null() { + *unsafe { &*live }.trace_lines.lock() = val; + } Ok(()) } @@ -626,8 +717,8 @@ impl Frame { #[expect(clippy::unnecessary_wraps, reason = "Needs to comply with a signature")] #[pymember(type = "bool")] fn f_trace_opcodes(vm: &VirtualMachine, zelf: PyObjectRef) -> PyResult { - let zelf: FrameRef = zelf.downcast().unwrap_or_else(|_| unreachable!()); - let trace_opcodes = zelf.trace_opcodes.lock(); + let zelf: FrameObjectRef = zelf.downcast().unwrap_or_else(|_| unreachable!()); + let trace_opcodes = zelf.iframe().trace_opcodes.lock(); Ok(vm.ctx.new_bool(*trace_opcodes).into()) } @@ -639,14 +730,19 @@ impl Frame { ) -> PyResult<()> { match value { PySetterValue::Assign(value) => { - let zelf: FrameRef = zelf.downcast().unwrap_or_else(|_| unreachable!()); + let zelf: FrameObjectRef = zelf.downcast().unwrap_or_else(|_| unreachable!()); let value: PyIntRef = value .downcast() .map_err(|_| vm.new_type_error("attribute value type must be bool"))?; - let mut trace_opcodes = zelf.trace_opcodes.lock(); - *trace_opcodes = !value.as_bigint().is_zero(); + let val = !value.as_bigint().is_zero(); + *zelf.iframe().trace_opcodes.lock() = val; + // Propagate to live source iframe. + let live = zelf.find_live_source_iframe(); + if !live.is_null() { + *unsafe { &*live }.trace_opcodes.lock() = val; + } // TODO: Implement the equivalent of _PyEval_SetOpcodeTrace() @@ -658,11 +754,15 @@ impl Frame { } #[pyclass] -impl Py { +impl Py { #[pymethod] // = frame_clear_impl fn clear(&self, vm: &VirtualMachine) -> PyResult<()> { - let owner = FrameOwner::from_i8(self.owner.load(core::sync::atomic::Ordering::Acquire)); + let owner = FrameOwner::from_i8( + self.iframe() + .owner + .load(core::sync::atomic::Ordering::Acquire), + ); match owner { FrameOwner::Generator => { // Generator frame: check if suspended (lasti > 0 means @@ -677,12 +777,16 @@ impl Py { return Err(vm.new_runtime_error("cannot clear an executing frame")); } FrameOwner::FrameObject => { - // Detached frame: safe to clear. + // Check if this materialized frame is backed by a live + // stack-allocated iframe — if so, the frame is executing. + if !self.find_live_source_iframe().is_null() { + return Err(vm.new_runtime_error("cannot clear an executing frame")); + } } } // Clear fastlocals - // SAFETY: Frame is not executing (detached or stopped). + // SAFETY: FrameObject is not executing (detached or stopped). { let fastlocals = unsafe { self.fastlocals_mut() }; for slot in fastlocals.iter_mut() { @@ -694,10 +798,10 @@ impl Py { self.clear_stack_and_cells(); // Clear temporary refs - self.temporary_refs.lock().clear(); - self.f_locals_hidden_overlay.lock().take(); - self.f_extra_locals.lock().take(); - self.retained_back.lock().take(); + self.iframe().temporary_refs.lock().clear(); + self.iframe().f_locals_hidden_overlay.lock().take(); + self.iframe().f_extra_locals.lock().take(); + self.iframe().retained_back.lock().take(); Ok(()) } @@ -707,7 +811,12 @@ impl Py { // Optimized (function) frames expose a live write-through // FrameLocalsProxy; class/module/exec frames expose their namespace // mapping directly. - if self.code.flags.contains(bytecode::CodeFlags::OPTIMIZED) { + if self + .iframe() + .code() + .flags + .contains(bytecode::CodeFlags::OPTIMIZED) + { self.check_locals_access(vm)?; self.mark_escaped(); let proxy = crate::builtins::FrameLocalsProxy::new(self.to_owned()); @@ -719,85 +828,98 @@ impl Py { #[pygetset] fn f_generator(&self) -> Option { - self.generator.to_owned() + self.iframe().generator.to_owned() } #[pygetset] - pub fn f_back(&self, vm: &VirtualMachine) -> Option> { - #[cfg(not(feature = "threading"))] - let _ = vm; - let previous = self.previous_frame(); - if previous.is_null() { - return None; + pub fn f_back(&self, #[allow(unused)] vm: &VirtualMachine) -> Option> { + let mut prev = self.previous_iframe(); + + // For materialized frames (previous == 0), find the source iframe on + // the TLS chain and use its `previous` instead. + if prev.is_null() { + // materialized stores `*const Py` as usize. + // `self` is `&Py` — compare addresses directly. + let self_py_ptr = self as *const Self as usize; + let mut cur = crate::vm::thread::get_current_frame(); + while !cur.is_null() { + let materialized = unsafe { (*cur).materialized.load(Relaxed) }; + if materialized == self_py_ptr { + // Found the source iframe — use its previous + prev = unsafe { (*cur).previous() }; + break; + } + cur = unsafe { (*cur).previous() }; + } + if prev.is_null() { + // Check retained_back for frames whose callers have returned + let retained = self.iframe().retained_back.lock().clone(); + if let Some(frame) = retained { + frame.mark_escaped(); + return Some(frame); + } + return None; + } } - // Look for the caller on the current thread's signal-safe frame chain. - // Finding it there proves it is still live on this thread. - if let Some(frame) = crate::frame::find_owned_chain_frame(previous) { - frame.mark_escaped(); - return Some(frame); + // Walk the TLS chain to find the prev iframe and materialize it. + // This handles both heap-allocated FrameObjects and stack-allocated + // iframes that haven't been observed yet. + { + let mut cur = crate::vm::thread::get_current_frame(); + while !cur.is_null() { + if core::ptr::eq(cur, prev) { + let iframe_ref = unsafe { &*cur }; + let fo = iframe_ref.materialize(vm); + fo.mark_escaped(); + return Some(fo.to_owned()); + } + cur = unsafe { (*cur).previous() }; + } } - // The caller already returned and left the live chain, but this frame - // escaped and retained a strong reference to it at release time. - let retained = self.retained_back.lock().clone(); + // The caller already returned — check retained_back + let retained = self.iframe().retained_back.lock().clone(); if let Some(frame) = retained { frame.mark_escaped(); return Some(frame); } - // The caller lives on another thread. unix: park every thread under - // stop-the-world so their frame chains are quiescent and alive, then - // walk each published top frame down its `previous` chain looking for - // the caller. Request stop-the-world before the registry lock. - #[cfg(all(unix, feature = "threading"))] + // The caller lives on another thread. Use stop-the-world to + // safely materialize the cross-thread frame chain. + #[cfg(feature = "threading")] { - use core::sync::atomic::Ordering; + // Enter STW before dereferencing `prev` — the owning thread may + // return and free the stack-allocated iframe at any time. vm.state.stop_the_world.stop_the_world(vm); scopeguard::defer! { vm.state.stop_the_world.start_the_world(vm); } - let registry = vm.state.thread_frames.lock(); - #[expect( - clippy::iter_over_hash_type, - reason = "Iteration order doesn't matter here" - )] - for slot in registry.values() { - let mut cur = slot.top_frame.load(Ordering::Relaxed) as *const Frame; - while !cur.is_null() { - if core::ptr::eq(cur, previous) { - // SAFETY: world stopped -> this frame is alive on its - // owning thread's parked call stack. - let f = unsafe { &*Self::from_payload_ptr(cur) }; - f.mark_escaped(); - return Some(f.to_owned()); - } - // SAFETY: chain frames on a parked thread are alive. - cur = unsafe { (*cur).previous_frame() }; - } + let prev_ref = unsafe { &*prev }; + // Fast path: already materialized. + if let Some(fo) = prev_ref.frame_obj() { + fo.mark_escaped(); + return Some(fo.to_owned()); } - } - - #[cfg(all(not(unix), feature = "threading"))] - { - let registry = vm.state.thread_frames.lock(); - #[expect( - clippy::iter_over_hash_type, - reason = "Iteration order doesn't matter here" - )] - for slot in registry.values() { - let frames = slot.frames.lock(); - // SAFETY: the owning thread can't pop while we hold the Mutex, - // so FramePtr is valid for the duration of the lock. - if let Some(frame) = frames.iter().find_map(|fp| { - let f = unsafe { fp.as_ref() }; - let ptr: *const Frame = &**f; - core::ptr::eq(ptr, previous).then(|| f.to_owned()) - }) { - frame.mark_escaped(); - return Some(frame); + // Slow path: materialize the entire chain and link retained_back. + let mut cur = prev; + let mut child_fo: Option> = None; + while !cur.is_null() { + let iframe = unsafe { &*cur }; + let fo = iframe.materialize(vm).to_owned(); + if let Some(child) = child_fo.take() { + let mut guard = child.iframe().retained_back.lock(); + if guard.is_none() { + *guard = Some(fo.clone()); + } } + child_fo = Some(fo); + cur = iframe.previous(); } + let fo = prev_ref.materialize(vm); + fo.mark_escaped(); + return Some(fo.to_owned()); } + #[allow(unreachable_code)] None } } diff --git a/crates/vm/src/builtins/frame_locals_proxy.rs b/crates/vm/src/builtins/frame_locals_proxy.rs index fbbc7f5d9cd..eaba0bca609 100644 --- a/crates/vm/src/builtins/frame_locals_proxy.rs +++ b/crates/vm/src/builtins/frame_locals_proxy.rs @@ -7,7 +7,7 @@ use crate::{ AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, atomic_func, class::PyClassImpl, - frame::FrameRef, + frame::FrameObjectRef, function::{FuncArgs, OptionalArg, PyArithmeticValue, PyComparisonValue}, object::{Traverse, TraverseFn}, protocol::{PyMappingMethods, PyNumberMethods, PySequenceMethods}, @@ -23,7 +23,7 @@ use rustpython_common::wtf8::Wtf8Buf; #[pyclass(module = false, name = "FrameLocalsProxy", traverse = "manual")] #[derive(Debug)] pub struct FrameLocalsProxy { - frame: FrameRef, + frame: FrameObjectRef, } unsafe impl Traverse for FrameLocalsProxy { @@ -40,7 +40,7 @@ impl PyPayload for FrameLocalsProxy { } impl FrameLocalsProxy { - pub(crate) fn new(frame: FrameRef) -> Self { + pub(crate) fn new(frame: FrameObjectRef) -> Self { Self { frame } } @@ -67,7 +67,7 @@ impl Constructor for FrameLocalsProxy { args.len() ))); } - let frame: FrameRef = args + let frame: FrameObjectRef = args .pop() .unwrap() .downcast() diff --git a/crates/vm/src/builtins/function.rs b/crates/vm/src/builtins/function.rs index 43cc33a90af..39195202af7 100644 --- a/crates/vm/src/builtins/function.rs +++ b/crates/vm/src/builtins/function.rs @@ -14,7 +14,7 @@ use crate::{ bytecode, class::PyClassImpl, common::wtf8::{Wtf8Buf, wtf8_concat}, - frame::{Frame, FrameRef}, + frame::{FrameObject, FrameObjectRef}, function::{FuncArgs, OptionalArg, PyComparisonValue, PySetterValue}, scope::Scope, types::{ @@ -182,11 +182,7 @@ impl PyFunction { let module = vm.unwrap_or_none(globals.get_item_opt(identifier!(vm, __name__), vm)?); let builtins = globals.get_item("__builtins__", vm).unwrap_or_else(|_| { // If not in globals, inherit from current execution context - if let Some(frame) = vm.current_frame() { - frame.builtins.clone() - } else { - vm.builtins.dict().into() - } + crate::frame::current_builtins().unwrap_or_else(|| vm.builtins.dict().into()) }); // If builtins is a module, use its __dict__ instead let builtins = if let Some(module) = builtins.downcast_ref::() { @@ -228,7 +224,28 @@ impl PyFunction { fn fill_locals_from_args( &self, - frame: &Frame, + frame: &FrameObject, + func_args: FuncArgs, + vm: &VirtualMachine, + ) -> PyResult<()> { + // SAFETY: FrameObject was just created and not yet executing. + let fastlocals = unsafe { frame.fastlocals_mut() }; + self.fill_locals_from_args_inner(fastlocals, func_args, vm) + } + + fn fill_locals_from_args_iframe( + &self, + iframe: &mut crate::frame::InterpreterFrame, + func_args: FuncArgs, + vm: &VirtualMachine, + ) -> PyResult<()> { + let fastlocals = iframe.localsplus.fastlocals_mut(); + self.fill_locals_from_args_inner(fastlocals, func_args, vm) + } + + fn fill_locals_from_args_inner( + &self, + fastlocals: &mut [Option], func_args: FuncArgs, vm: &VirtualMachine, ) -> PyResult<()> { @@ -236,16 +253,6 @@ impl PyFunction { let nargs = func_args.args.len(); let n_expected_args = code.arg_count as usize; let total_args = code.arg_count as usize + code.kwonlyarg_count as usize; - // let arg_names = self.code.arg_names(); - - // This parses the arguments from args and kwargs into - // the proper variables keeping into account default values - // and star-args and kwargs. - // See also: PyEval_EvalCodeWithName in cpython: - // https://github.com/python/cpython/blob/main/Python/ceval.c#L3681 - - // SAFETY: Frame was just created and not yet executing. - let fastlocals = unsafe { frame.fastlocals_mut() }; let mut args_iter = func_args.args.into_iter(); @@ -564,69 +571,114 @@ impl Py { } } - let code: PyRef = (*self.code).to_owned(); - - let locals = if code.flags.contains(bytecode::CodeFlags::NEWLOCALS) { - None - } else if let Some(locals) = locals { - Some(locals) - } else { - Some(ArgMapping::from_dict_exact(self.globals.clone())) - }; + let code = &*self.code; let is_gen = code.flags.contains(bytecode::CodeFlags::GENERATOR); let is_coro = code.flags.contains(bytecode::CodeFlags::COROUTINE); let is_async_gen = code.flags.contains(bytecode::CodeFlags::ASYNC_GENERATOR); - let use_datastack = !(is_gen || is_coro || is_async_gen); - // Construct frame: - let frame = Frame::new( - code, - Scope::new(locals, self.globals.clone()), - self.builtins.clone(), - self.closure.as_ref().map_or(&[], |c| c.as_slice()), - Some(self.to_owned().into()), - use_datastack, - vm, - ) - .into_ref(&vm.ctx); + let needs_heap_frame = is_gen || is_coro || is_async_gen || vm.use_tracing.get(); - self.fill_locals_from_args(&frame, func_args, vm)?; - if use_datastack { - let result = vm.run_frame(frame.clone()); - // Release data stack memory after frame execution completes. - crate::frame::release_datastack_frame(&frame, vm); - result - } else { - let obj = if is_async_gen { - PyAsyncGen::new(frame.clone(), self.__name__(), self.__qualname__()) - .into_pyobject(vm) - } else if is_gen { - PyGenerator::new(frame.clone(), self.__name__(), self.__qualname__()) - .into_pyobject(vm) + if needs_heap_frame { + // Heap-allocate FrameObject for generators/coroutines (lifetime + // exceeds call stack) or when tracing is active (trace callbacks + // need a FrameObject). + let code_owned: PyRef = code.to_owned(); + let locals = if code.flags.contains(bytecode::CodeFlags::NEWLOCALS) { + None + } else if let Some(locals) = locals { + Some(locals) } else { - PyCoroutine::new(frame.clone(), self.__name__(), self.__qualname__()) - .into_pyobject(vm) + Some(ArgMapping::from_dict_exact(self.globals.clone())) }; - // Generator/coroutine frames outlive this call and can join a - // reference cycle through their owning generator, so they must - // participate in the GC. They were created untracked - // (NEW_REF_UNTRACKED); track them now, before the back-reference - // is installed. Their localsplus is heap-backed by construction - // (use_datastack == false), so a collector never reads data-stack - // storage when it traverses them. - debug_assert!( - !frame.localsplus_is_datastack_backed(), - "generator frame is data-stack-backed" + let use_datastack = !is_gen && !is_coro && !is_async_gen; + let frame = FrameObject::new_ref( + code_owned, + Scope::new(locals, self.globals.clone()), + self.builtins.clone(), + self.closure.as_ref().map_or(&[], |c| c.as_slice()), + Some(self.to_owned().into()), + use_datastack, + vm, ); - // SAFETY: the frame is alive (held by `frame`) and untracked. + self.fill_locals_from_args(&frame, func_args, vm)?; + if is_gen || is_coro || is_async_gen { + return Ok(self.make_generator_or_coro(frame, vm)); + } + // Tracing active: use heap frame with full trace support. + let result = vm.run_frame(frame.clone()); unsafe { - crate::gc_state::gc_state() - .track_object(core::ptr::NonNull::from(frame.as_object())); + if let Some(base) = frame.iframe_mut().localsplus.release_datastack() { + vm.datastack_pop(base); + } } - frame.set_generator(&obj); - Ok(obj) + return result; } + + // Fast path: stack-allocated InterpreterFrame, no FrameObject. + // No refcount inc for code — it's alive via self.code for the call duration. + let nlocalsplus = code.localspluskinds.len(); + let max_stackdepth = code.max_stackdepth as usize; + let localsplus = + crate::frame::LocalsPlus::new_on_datastack(nlocalsplus, max_stackdepth, vm); + + let locals = if code.flags.contains(bytecode::CodeFlags::NEWLOCALS) { + crate::frame::FrameLocals::lazy() + } else if let Some(locals) = locals { + crate::frame::FrameLocals::with_locals(locals) + } else { + crate::frame::FrameLocals::with_locals(crate::function::ArgMapping::from_dict_exact( + self.globals.clone(), + )) + }; + + // Use self.as_object() as raw pointer — no refcount inc/dec. + // The function is alive on the caller's stack for the call duration. + let mut iframe = crate::frame::InterpreterFrame::new( + &self.code, + &self.globals, + &self.builtins, + Some(self.as_object()), + localsplus, + locals, + self.closure.as_ref().map_or(&[], |c| c.as_slice()), + crate::frame::FrameOwner::Thread, + ); + let result = self + .fill_locals_from_args_iframe(&mut iframe, func_args, vm) + .and_then(|()| vm.run_frame_fast(&mut iframe)); + // Release data stack memory — must happen on both success and error. + unsafe { + if let Some(base) = iframe.localsplus.release_datastack() { + vm.datastack_pop(base); + } + } + result + } + + /// Create generator, coroutine, or async generator from a FrameObject. + fn make_generator_or_coro(&self, frame: FrameObjectRef, vm: &VirtualMachine) -> PyObjectRef { + let code = frame.iframe().code(); + let is_async_gen = code.flags.contains(bytecode::CodeFlags::ASYNC_GENERATOR); + let is_gen = code.flags.contains(bytecode::CodeFlags::GENERATOR); + + let obj = if is_async_gen { + PyAsyncGen::new(frame.clone(), self.__name__(), self.__qualname__()).into_pyobject(vm) + } else if is_gen { + PyGenerator::new(frame.clone(), self.__name__(), self.__qualname__()).into_pyobject(vm) + } else { + PyCoroutine::new(frame.clone(), self.__name__(), self.__qualname__()).into_pyobject(vm) + }; + debug_assert!( + !frame.localsplus_is_datastack_backed(), + "generator frame is data-stack-backed" + ); + // SAFETY: the frame is alive (held by `frame`) and untracked. + unsafe { + crate::gc_state::gc_state().track_object(core::ptr::NonNull::from(frame.as_object())); + } + frame.set_generator(&obj); + obj } #[inline(always)] @@ -696,7 +748,7 @@ impl Py { &self, args: impl ExactSizeIterator, vm: &VirtualMachine, - ) -> FrameRef { + ) -> FrameObjectRef { let code: PyRef = (*self.code).to_owned(); debug_assert_eq!(args.len(), code.arg_count as usize); @@ -719,7 +771,7 @@ impl Py { Some(ArgMapping::from_dict_exact(self.globals.clone())) }; - let frame = Frame::new( + let frame = FrameObject::new_ref( code, Scope::new(locals, self.globals.clone()), self.builtins.clone(), @@ -727,8 +779,7 @@ impl Py { Some(self.to_owned().into()), true, // Exact-args fast path is only used for non-gen/coro functions. vm, - ) - .into_ref(&vm.ctx); + ); { let fastlocals = unsafe { frame.fastlocals_mut() }; @@ -745,10 +796,45 @@ impl Py { args: impl ExactSizeIterator, vm: &VirtualMachine, ) -> PyResult { - let frame = self.prepare_exact_args_frame(args, vm); + let code = &*self.code; + let nlocalsplus = code.localspluskinds.len(); + let max_stackdepth = code.max_stackdepth as usize; + let localsplus = + crate::frame::LocalsPlus::new_on_datastack(nlocalsplus, max_stackdepth, vm); - let result = vm.run_frame(frame.clone()); - crate::frame::release_datastack_frame(&frame, vm); + let locals = if code.flags.contains(bytecode::CodeFlags::NEWLOCALS) { + crate::frame::FrameLocals::lazy() + } else { + crate::frame::FrameLocals::with_locals(ArgMapping::from_dict_exact( + self.globals.clone(), + )) + }; + + let mut iframe = crate::frame::InterpreterFrame::new( + code, + &self.globals, + &self.builtins, + Some(self.as_object()), + localsplus, + locals, + self.closure.as_ref().map_or(&[], |c| c.as_slice()), + crate::frame::FrameOwner::Thread, + ); + + // Fill arguments directly into fastlocals + { + let fastlocals = iframe.localsplus.fastlocals_mut(); + for (slot, arg) in fastlocals.iter_mut().zip(args) { + *slot = Some(arg); + } + } + + let result = vm.run_frame_fast(&mut iframe); + unsafe { + if let Some(base) = iframe.localsplus.release_datastack() { + vm.datastack_pop(base); + } + } result } diff --git a/crates/vm/src/builtins/generator.rs b/crates/vm/src/builtins/generator.rs index 6b16917e390..52db3c9522a 100644 --- a/crates/vm/src/builtins/generator.rs +++ b/crates/vm/src/builtins/generator.rs @@ -7,7 +7,7 @@ use crate::{ AsObject, Context, Py, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, class::PyClassImpl, coroutine::{Coro, warn_deprecated_throw_signature}, - frame::FrameRef, + frame::FrameObjectRef, function::OptionalArg, object::{Traverse, TraverseFn}, protocol::PyIterReturn, @@ -43,7 +43,7 @@ impl PyGenerator { } #[must_use] - pub fn new(frame: FrameRef, name: PyStrRef, qualname: PyStrRef) -> Self { + pub fn new(frame: FrameObjectRef, name: PyStrRef, qualname: PyStrRef) -> Self { Self { inner: Coro::new(frame, name, qualname), } @@ -70,7 +70,7 @@ impl PyGenerator { } #[pygetset] - fn gi_frame(&self, _vm: &VirtualMachine) -> Option { + fn gi_frame(&self, _vm: &VirtualMachine) -> Option { if self.inner.closed() { None } else { @@ -85,7 +85,7 @@ impl PyGenerator { #[pygetset] fn gi_code(&self, _vm: &VirtualMachine) -> PyRef { - self.inner.frame().code.clone() + self.inner.frame().iframe().code().to_owned() } #[pygetset] diff --git a/crates/vm/src/builtins/super.rs b/crates/vm/src/builtins/super.rs index 62036396603..f71bf51a6e1 100644 --- a/crates/vm/src/builtins/super.rs +++ b/crates/vm/src/builtins/super.rs @@ -79,22 +79,27 @@ impl Initializer for PySuper { let (typ, obj) = if let OptionalArg::Present(ty) = py_type { (ty, py_obj.unwrap_or_none(vm)) } else { - let frame = vm - .current_frame() - .ok_or_else(|| vm.new_runtime_error("super(): no current frame"))?; + // Access the InterpreterFrame directly — no need to materialize + // a FrameObject just to read code/locals. + let iframe_ptr = crate::vm::thread::get_current_frame(); + if iframe_ptr.is_null() { + return Err(vm.new_runtime_error("super(): no current frame")); + } + let iframe = unsafe { &*iframe_ptr }; + let code = iframe.code(); - if frame.code.arg_count == 0 { + if code.arg_count == 0 { return Err(vm.new_runtime_error("super(): no arguments")); } - // SAFETY: Frame is current and not concurrently mutated. + // SAFETY: InterpreterFrame is current and not concurrently mutated. use rustpython_compiler_core::bytecode::CO_FAST_CELL; - let obj = unsafe { frame.fastlocals() }[0] + let fastlocals = iframe.localsplus.fastlocals(); + let obj = fastlocals[0] .clone() .and_then(|val| { // If slot 0 is a merged cell (LOCAL|CELL), extract value from cell - if frame - .code + if code .localspluskinds .first() .is_some_and(|&k| k & CO_FAST_CELL != 0) @@ -108,13 +113,15 @@ impl Initializer for PySuper { let mut typ = None; // Search for __class__ in freevars using localspluskinds - let nlocalsplus = frame.code.localspluskinds.len(); - let nfrees = frame.code.freevars.len(); + let nlocalsplus = code.localspluskinds.len(); + let nfrees = code.freevars.len(); let free_start = nlocalsplus - nfrees; - for (i, var) in frame.code.freevars.iter().enumerate() { + for (i, var) in code.freevars.iter().enumerate() { if var.as_bytes() == b"__class__" { - let class = frame - .get_cell_contents(free_start + i) + let class = fastlocals[free_start + i] + .as_ref() + .and_then(|v| v.downcast_ref::()) + .and_then(|c| c.get()) .ok_or_else(|| vm.new_runtime_error("super(): empty __class__ cell"))?; typ = Some(class.downcast().map_err(|o| { vm.new_type_error(format!( diff --git a/crates/vm/src/builtins/traceback.rs b/crates/vm/src/builtins/traceback.rs index b31de874b73..0e93768dca9 100644 --- a/crates/vm/src/builtins/traceback.rs +++ b/crates/vm/src/builtins/traceback.rs @@ -1,7 +1,7 @@ use super::{PyList, PyType}; use crate::{ AsObject, Context, Py, PyPayload, PyRef, PyResult, VirtualMachine, class::PyClassImpl, - frame::FrameRef, function::PySetterValue, types::Constructor, + frame::FrameObjectRef, function::PySetterValue, types::Constructor, }; use rustpython_common::lock::PyMutex; use rustpython_compiler_core::OneIndexed; @@ -10,7 +10,7 @@ use rustpython_compiler_core::OneIndexed; #[derive(Debug)] pub struct PyTraceback { pub next: PyMutex>, - pub frame: FrameRef, + pub frame: FrameObjectRef, #[pytraverse(skip)] pub lasti: u32, #[pytraverse(skip)] @@ -31,7 +31,7 @@ impl PyTraceback { #[must_use] pub const fn new( next: Option>, - frame: FrameRef, + frame: FrameObjectRef, lasti: u32, lineno: OneIndexed, ) -> Self { @@ -44,7 +44,7 @@ impl PyTraceback { } #[pygetset] - fn tb_frame(&self) -> FrameRef { + fn tb_frame(&self) -> FrameObjectRef { self.frame.clone() } @@ -104,7 +104,7 @@ impl PyTraceback { } impl Constructor for PyTraceback { - type Args = (Option>, FrameRef, u32, usize); + type Args = (Option>, FrameObjectRef, u32, usize); fn py_new(_cls: &Py, args: Self::Args, vm: &VirtualMachine) -> PyResult { let (next, frame, lasti, lineno) = args; @@ -130,9 +130,12 @@ impl serde::Serialize for PyTraceback { use serde::ser::SerializeStruct; let mut struc = s.serialize_struct("PyTraceback", 3)?; - struc.serialize_field("name", self.frame.code.obj_name.as_str())?; + struc.serialize_field("name", self.frame.iframe().code().obj_name.as_str())?; struc.serialize_field("lineno", &self.lineno.get())?; - struc.serialize_field("filename", self.frame.code.source_path().as_str())?; + struc.serialize_field( + "filename", + self.frame.iframe().code().source_path().as_str(), + )?; struc.end() } } diff --git a/crates/vm/src/builtins/type.rs b/crates/vm/src/builtins/type.rs index c27c6475f42..0ccfe155dc2 100644 --- a/crates/vm/src/builtins/type.rs +++ b/crates/vm/src/builtins/type.rs @@ -1513,7 +1513,7 @@ impl PyType { // temporary refs so they never see a dangling pointer. let keep_alive = |type_ref: PyTypeRef, retired: &mut Vec| { if let Some(frame) = vm.current_frame() { - frame.temporary_refs.lock().push(type_ref.into()); + frame.iframe().temporary_refs.lock().push(type_ref.into()); } else { retired.push(type_ref.into()); } @@ -2183,14 +2183,11 @@ impl Constructor for PyType { *f = PyStaticMethod::from(f.clone()).into_pyobject(vm); } - if let Some(current_frame) = vm.current_frame() { + if let Some(globals) = crate::frame::current_globals() { let entry = attributes.entry(identifier!(vm, __module__)); if matches!(entry, Entry::Vacant(_)) { - let module_name = vm.unwrap_or_none( - current_frame - .globals - .get_item_opt(identifier!(vm, __name__), vm)?, - ); + let module_name = + vm.unwrap_or_none(globals.get_item_opt(identifier!(vm, __name__), vm)?); entry.or_insert(module_name); } } diff --git a/crates/vm/src/coroutine.rs b/crates/vm/src/coroutine.rs index 844887f8520..c7252c66d12 100644 --- a/crates/vm/src/coroutine.rs +++ b/crates/vm/src/coroutine.rs @@ -3,7 +3,7 @@ use crate::{ builtins::PyStrRef, common::lock::PyMutex, exceptions::types::PyBaseException, - frame::{ExecutionResult, Frame, FrameOwner, FrameRef}, + frame::{ExecutionResult, FrameObject, FrameObjectRef, FrameOwner}, function::OptionalArg, object::{PyAtomicRef, Traverse, TraverseFn}, protocol::PyIterReturn, @@ -29,7 +29,7 @@ impl ExecutionResult { #[derive(Debug)] pub struct Coro { - frame: FrameRef, + frame: FrameObjectRef, pub closed: AtomicCell, // TODO: https://github.com/RustPython/RustPython/pull/3183#discussion_r720560652 running: AtomicCell, // code @@ -62,7 +62,7 @@ fn gen_name(jen: &PyObject, vm: &VirtualMachine) -> &'static str { } impl Coro { - pub fn new(frame: FrameRef, name: PyStrRef, qualname: PyStrRef) -> Self { + pub fn new(frame: FrameObjectRef, name: PyStrRef, qualname: PyStrRef) -> Self { Self { frame, closed: AtomicCell::new(false), @@ -94,8 +94,8 @@ impl Coro { match res { Ok(ExecutionResult::Return(_)) | Err(_) => { self.closed.store(true); - // Frame is no longer suspended; allow frame.clear() to succeed. - self.frame.owner.store( + // FrameObject is no longer suspended; allow frame.clear() to succeed. + self.frame.iframe().owner.store( FrameOwner::FrameObject as i8, core::sync::atomic::Ordering::Release, ); @@ -114,7 +114,7 @@ impl Coro { func: F, ) -> (PyResult, bool) where - F: FnOnce(&Py) -> PyResult, + F: FnOnce(&Py) -> PyResult, { if self.running.compare_exchange(false, true).is_err() { return ( @@ -290,7 +290,7 @@ impl Coro { self.closed.load() } - pub fn frame(&self) -> FrameRef { + pub fn frame(&self) -> FrameObjectRef { self.frame.clone() } @@ -337,7 +337,8 @@ pub(crate) fn get_awaitable_iter(obj: PyObjectRef, vm: &VirtualMachine) -> PyRes || obj.downcast_ref::().is_some_and(|g| { g.as_coro() .frame() - .code + .iframe() + .code() .flags .contains(crate::bytecode::CodeFlags::ITERABLE_COROUTINE) }) @@ -352,7 +353,8 @@ pub(crate) fn get_awaitable_iter(obj: PyObjectRef, vm: &VirtualMachine) -> PyRes || result.downcast_ref::().is_some_and(|g| { g.as_coro() .frame() - .code + .iframe() + .code() .flags .contains(crate::bytecode::CodeFlags::ITERABLE_COROUTINE) }) diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index 7eaf8bafcdd..4facffee15d 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -401,13 +401,13 @@ fn write_traceback_entry( output: &mut W, tb_entry: &Py, ) -> Result<(), W::Error> { - let filename = tb_entry.frame.code.source_path().as_str(); + let filename = tb_entry.frame.iframe().code().source_path().as_str(); writeln!( output, r##" File "{}", line {}, in {}"##, filename.trim_start_matches(r"\\?\"), tb_entry.lineno, - tb_entry.frame.code.obj_name + tb_entry.frame.iframe().code().obj_name )?; #[cfg(feature = "host_env")] diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index 5df8b85e56b..5c21ad29c8b 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -38,7 +38,6 @@ use bstr::ByteSlice; use core::cell::UnsafeCell; use core::ptr::NonNull; use core::sync::atomic; -use core::sync::atomic::AtomicPtr; use core::sync::atomic::Ordering::{Acquire, Relaxed}; use itertools::Itertools; use malachite_bigint::BigInt; @@ -48,72 +47,152 @@ use rustpython_common::{ lock::{OnceCell, PyMutex}, wtf8::{Wtf8, Wtf8Buf, wtf8_concat}, }; -use rustpython_compiler_core::SourceLocation; +use rustpython_compiler_core::{OneIndexed, SourceLocation}; -pub type FrameRef = PyRef; +pub type FrameObjectRef = PyRef; -/// Recover an owned reference to a live chain frame, or `None` for null. +// -- Frame chain utilities -- +// The frame chain is a singly-linked list of `*const InterpreterFrame` stored +// as `usize` values in `InterpreterFrame.previous` and the TLS +// `CURRENT_FRAME`. Null (0) marks the end. +// Each InterpreterFrame has a `materialized` pointer that is non-null when +// a FrameObject wraps it (always the case today; stack-allocated frames will +// leave it null until observed). + +/// Recover an owned reference to the FrameObject that wraps the given +/// InterpreterFrame, or `None` if null or not materialized. /// /// # Safety -/// A non-null `frame` must reference a frame that is live on the current -/// thread's execution chain, so the object outlives this call. -unsafe fn owned_chain_frame(frame: *const Frame) -> Option { - if frame.is_null() { +/// A non-null `iframe` must reference a live InterpreterFrame on the current +/// thread's execution chain. +unsafe fn owned_chain_frame(iframe: *const InterpreterFrame) -> Option { + if iframe.is_null() { return None; } - // SAFETY: caller guarantees the frame is live; from_payload_ptr recovers - // the enclosing object from the payload address. - let py = unsafe { &*Py::::from_payload_ptr(frame) }; - Some(py.to_owned()) + let iframe_ref = unsafe { &*iframe }; + let fo = iframe_ref.frame_obj()?; + Some(fo.to_owned()) } /// The current thread's topmost frame object, if any. #[must_use] -pub fn current_thread_frame() -> Option { - // SAFETY: the chain top executes on this thread, hence is alive. - unsafe { owned_chain_frame(crate::vm::thread::get_current_frame()) } +pub fn current_thread_frame() -> Option { + let ptr = crate::vm::thread::get_current_frame(); + unsafe { owned_chain_frame(ptr) } +} + +/// Get the current thread's topmost InterpreterFrame pointer. +#[must_use] +pub fn current_thread_iframe() -> *const InterpreterFrame { + crate::vm::thread::get_current_frame() +} + +/// The current thread's topmost frame object, materializing if necessary. +/// Unlike `current_thread_frame()`, this always returns `Some` if there is +/// an active frame, even if it's stack-allocated and hasn't been observed yet. +/// Uses `vm.current_frame` Cell for fast lookup (no TLS). +#[must_use] +pub fn current_thread_frame_materialize(vm: &VirtualMachine) -> Option { + let ptr = crate::vm::thread::get_current_frame(); + if ptr.is_null() { + return None; + } + let iframe = unsafe { &*ptr }; + Some(iframe.materialize(vm).to_owned()) +} + +/// Read the globals dict from the topmost frame on this thread's chain. +/// Returns `None` if the chain is empty. +#[must_use] +pub fn current_globals() -> Option { + let ptr = crate::vm::thread::get_current_frame(); + if ptr.is_null() { + return None; + } + Some(unsafe { (*ptr).globals().to_owned() }) +} + +/// Read the code object from the topmost frame on this thread's chain. +/// Returns `None` if the chain is empty. +#[must_use] +pub fn current_code() -> Option> { + let ptr = crate::vm::thread::get_current_frame(); + if ptr.is_null() { + return None; + } + Some(unsafe { (*ptr).code().to_owned() }) +} + +/// Read the builtins object from the topmost frame on this thread's chain. +#[must_use] +pub fn current_builtins() -> Option { + let ptr = crate::vm::thread::get_current_frame(); + if ptr.is_null() { + return None; + } + Some(unsafe { (*ptr).builtins().to_owned() }) } /// The frame `offset` positions below the current thread's top frame (offset 0 /// is the top), or `None` if the stack is not that deep. +/// Materializes the FrameObject on demand for stack-allocated frames. #[must_use] -pub fn frame_at_offset(offset: usize) -> Option { +pub fn frame_at_offset(offset: usize, vm: &VirtualMachine) -> Option { let mut cur = crate::vm::thread::get_current_frame(); - for _ in 0..offset { - if cur.is_null() { - return None; + let mut remaining = offset; + while !cur.is_null() { + if remaining == 0 { + let iframe = unsafe { &*cur }; + return Some(iframe.materialize(vm).to_owned()); } - // SAFETY: chain frames are alive on the current thread's stack. - cur = unsafe { (*cur).previous_frame() }; + remaining -= 1; + cur = unsafe { (*cur).previous.load(Relaxed) as *const InterpreterFrame }; } - // SAFETY: same as above. - unsafe { owned_chain_frame(cur) } + None } -/// If `target` is a frame on the current thread's chain, return an owned -/// reference to it; otherwise `None`. Presence on the chain proves liveness. +/// If a FrameObject wrapping `target` InterpreterFrame is on the current +/// thread's chain, return an owned reference to it; otherwise `None`. #[must_use] -pub fn find_owned_chain_frame(target: *const Frame) -> Option { +pub fn find_owned_chain_frame_by_iframe(target: *const InterpreterFrame) -> Option { let mut cur = crate::vm::thread::get_current_frame(); while !cur.is_null() { if core::ptr::eq(cur, target) { - // SAFETY: a frame on the current thread's chain is alive. return unsafe { owned_chain_frame(cur) }; } - // SAFETY: chain frames are alive on the current thread's stack. - cur = unsafe { (*cur).previous_frame() }; + cur = unsafe { (*cur).previous.load(Relaxed) as *const InterpreterFrame }; + } + None +} + +/// If `target` FrameObject is on the current thread's chain, return an +/// owned reference to it; otherwise `None`. Presence on the chain proves liveness. +#[must_use] +pub fn find_owned_chain_frame(target: *const FrameObject) -> Option { + let mut cur = crate::vm::thread::get_current_frame(); + while !cur.is_null() { + let iframe_ref = unsafe { &*cur }; + if let Some(fo) = iframe_ref.frame_obj() { + let fo_payload: *const FrameObject = &**fo; + if core::ptr::eq(fo_payload, target) { + return Some(fo.to_owned()); + } + } + cur = iframe_ref.previous.load(Relaxed) as *const InterpreterFrame; } None } -/// Invoke `f` for each frame on the current thread's chain, from the topmost -/// frame down to the bottom. -pub fn for_each_current_frame(mut f: impl FnMut(&Py)) { +/// Invoke `f` for each frame on the current thread's chain, from the +/// topmost frame down to the bottom. +pub fn for_each_current_frame(mut f: impl FnMut(&Py)) { let mut cur = crate::vm::thread::get_current_frame(); while !cur.is_null() { - // SAFETY: chain frames are alive on the current thread's stack. - f(unsafe { &*Py::::from_payload_ptr(cur) }); - cur = unsafe { (*cur).previous_frame() }; + let iframe_ref = unsafe { &*cur }; + if let Some(fo) = iframe_ref.frame_obj() { + f(fo); + } + cur = iframe_ref.previous.load(Relaxed) as *const InterpreterFrame; } } @@ -157,7 +236,7 @@ impl FrameOwner { /// Lock-free mutable storage for frame-internal data. /// /// # Safety -/// Frame execution is single-threaded: only one thread at a time executes +/// FrameObject execution is single-threaded: only one thread at a time executes /// a given frame (enforced by the owner field and generator running flag). /// External readers (e.g. `f_locals`) are on the same thread as execution /// (trace callback) or the frame is not executing. @@ -182,7 +261,7 @@ impl FrameUnsafeCell { } } -// SAFETY: Frame execution is single-threaded. See FrameUnsafeCell doc. +// SAFETY: FrameObject execution is single-threaded. See FrameUnsafeCell doc. #[cfg(feature = "threading")] unsafe impl Send for FrameUnsafeCell {} #[cfg(feature = "threading")] @@ -216,7 +295,7 @@ enum LocalsPlusData { } // SAFETY: DataStack variant points to thread-local DataStack memory. -// Frame execution is single-threaded (enforced by owner field). +// FrameObject execution is single-threaded (enforced by owner field). #[cfg(feature = "threading")] unsafe impl Send for LocalsPlusData {} #[cfg(feature = "threading")] @@ -247,7 +326,11 @@ impl LocalsPlus { /// When the frame finishes, the caller must migrate data to the heap with /// `materialize_localsplus()` (or drop it in place with /// `release_localsplus()`), then `datastack_pop()` to free the memory. - fn new_on_datastack(nlocalsplus: usize, stacksize: usize, vm: &VirtualMachine) -> Self { + pub(crate) fn new_on_datastack( + nlocalsplus: usize, + stacksize: usize, + vm: &VirtualMachine, + ) -> Self { let capacity = nlocalsplus .checked_add(stacksize) .expect("LocalsPlus capacity overflow"); @@ -288,7 +371,7 @@ impl LocalsPlus { /// /// Only valid when the values can never be observed again (the enclosing /// frame is uniquely referenced): the locals are gone afterwards. - fn release_datastack(&mut self) -> Option<*mut u8> { + pub(crate) fn release_datastack(&mut self) -> Option<*mut u8> { let LocalsPlusData::DataStack { ptr, .. } = &self.data else { return None; }; @@ -304,6 +387,50 @@ impl LocalsPlus { Some(base) } + /// Create a new heap-backed LocalsPlus that is a clone of the + /// fastlocals portion of this one. Stack slots are NOT copied + /// (stack_top = 0, stacksize = 0). + /// + /// # Safety + /// The caller must ensure that `self.fastlocals()` is a valid slice + /// (backing storage is alive, not concurrently mutated). + pub(crate) unsafe fn snapshot_to_heap(&self) -> Self { + let n = self.nlocalsplus as usize; + let src = self.fastlocals(); + let mut data = vec![0usize; n]; + // Clone each Option into the heap buffer. + for (i, slot) in src.iter().enumerate() { + if let Some(obj) = slot { + let cloned: Option = Some(obj.clone()); + // SAFETY: Option has the same layout as usize. + data[i] = unsafe { core::mem::transmute_copy(&cloned) }; + core::mem::forget(cloned); + } + } + Self { + data: LocalsPlusData::Heap(data.into_boxed_slice()), + nlocalsplus: self.nlocalsplus, + stack_top: 0, + } + } + + /// Update fastlocals in `self` from `src`. For each slot, drops the old + /// value and clones the new one. `self` must be heap-backed. + /// + /// # Safety + /// Both `self` and `src` must have valid backing storage, and the caller + /// must ensure no concurrent mutable access. + pub(crate) unsafe fn sync_fastlocals_from(&mut self, src: &Self) { + let n = core::cmp::min(self.nlocalsplus as usize, src.nlocalsplus as usize); + let dst = self.fastlocals_mut(); + let source = src.fastlocals(); + for i in 0..n { + let old = dst[i].take(); + dst[i].clone_from(&source[i]); + drop(old); + } + } + /// Drop all contained values without freeing the backing storage. fn drop_values(&mut self) { self.stack_clear(); @@ -360,7 +487,7 @@ impl LocalsPlus { /// Immutable access to fastlocals as `Option` slice. #[inline(always)] - fn fastlocals(&self) -> &[Option] { + pub(crate) fn fastlocals(&self) -> &[Option] { let data = self.data_as_slice(); let ptr = data.as_ptr() as *const Option; unsafe { core::slice::from_raw_parts(ptr, self.nlocalsplus as usize) } @@ -368,7 +495,7 @@ impl LocalsPlus { /// Mutable access to fastlocals as `Option` slice. #[inline(always)] - fn fastlocals_mut(&mut self) -> &mut [Option] { + pub(crate) fn fastlocals_mut(&mut self) -> &mut [Option] { let nlocalsplus = self.nlocalsplus as usize; let data = self.data_as_mut_slice(); let ptr = data.as_mut_ptr() as *mut Option; @@ -593,7 +720,7 @@ pub struct FrameLocals { impl FrameLocals { /// Create with an already-initialized locals mapping (non-NEWLOCALS frames). - fn with_locals(locals: ArgMapping) -> Self { + pub(crate) fn with_locals(locals: ArgMapping) -> Self { let cell = OnceCell::new(); let _ = cell.set(locals); Self { inner: cell } @@ -601,7 +728,7 @@ impl FrameLocals { /// Create an empty lazy locals (for NEWLOCALS frames). /// The dict will be created on first access. - fn lazy() -> Self { + pub(crate) fn lazy() -> Self { Self { inner: OnceCell::new(), } @@ -668,38 +795,43 @@ unsafe impl Traverse for FrameLocals { /// Lightweight execution frame. Not a PyObject. /// Analogous to CPython's `_PyInterpreterFrame`. /// -/// Currently always embedded inside a `Frame` PyObject via `FrameUnsafeCell`. -/// In future PRs this will be usable independently for normal function calls -/// (allocated on the Rust stack + DataStack), eliminating PyObject overhead. +/// The four "identity" fields (`code`, `globals`, `builtins`, `func_obj`) +/// are borrowed raw pointers — refcounts are maintained by the owner +/// (FrameObject's owned fields, or the PyFunction on the caller's stack +/// in the DataStack path). +#[repr(C)] pub struct InterpreterFrame { - pub code: PyRef, - pub func_obj: Option, + // Borrowed pointers — owned by FrameObject or by PyFunction on caller's stack. + pub(crate) code: *const Py, + pub(crate) func_obj: *const PyObject, // nullable + pub(crate) globals: *const Py, + pub(crate) builtins: *const PyObject, /// Unified storage for local variables and evaluation stack. pub(crate) localsplus: LocalsPlus, pub locals: FrameLocals, - pub globals: PyDictRef, - pub builtins: PyObjectRef, /// index of last instruction ran pub lasti: PyAtomic, - /// tracer function for this frame (usually is None) - pub trace: PyMutex, + /// Per-frame tracer function. `None` means no per-frame trace is set + /// (equivalent to `f_trace = None` in Python). This avoids a refcounted + /// None clone on every frame init. + pub trace: PyMutex>, /// Previous line number for LINE event suppression. - pub(crate) prev_line: u32, + pub(crate) prev_line: core::cell::Cell, // member pub trace_lines: PyMutex, pub trace_opcodes: PyMutex, pub temporary_refs: PyMutex>, /// Back-reference to owning generator/coroutine/async generator. - /// Borrowed reference (not ref-counted) to avoid Generator↔Frame cycle. + /// Borrowed reference (not ref-counted) to avoid Generator↔FrameObject cycle. /// Cleared by the generator's Drop impl. pub generator: PyAtomicBorrow, - /// Previous frame in the call chain for signal-safe traceback walking. - /// Mirrors `_PyInterpreterFrame.previous`. - pub(crate) previous: AtomicPtr, + /// Linked-list pointer to the previous frame in the call chain. + /// Stores a `*const FrameObject` as `usize`. + pub(crate) previous: PyAtomic, /// Who owns this frame. Mirrors `_PyInterpreterFrame.owner`. /// Used by `frame.clear()` to reject clearing an executing frame, /// even when called from a different thread. @@ -717,7 +849,7 @@ pub struct InterpreterFrame { /// Strong reference to the caller frame, captured when this frame escapes /// its execution so `f_back` still resolves after the caller returns and /// leaves the live frame chain. - pub(crate) retained_back: PyMutex>, + pub(crate) retained_back: PyMutex>, /// Number of stack entries to pop after set_f_lineno returns to the /// execution loop. set_f_lineno cannot pop directly because the /// execution loop holds the state mutex. @@ -726,19 +858,344 @@ pub struct InterpreterFrame { /// Used together with `pending_stack_pops` to identify Except entries /// that need special exception-state handling. pub(crate) pending_unwind_from_stack: PyAtomic, + /// Pointer to the owning `Py`, or null for stack-allocated + /// frames that have not been materialized yet. + /// Stored as `usize` for `PyAtomic` compatibility. + pub(crate) materialized: PyAtomic, +} + +// Raw pointers make InterpreterFrame !Send+!Sync by default. +// SAFETY: The pointers reference heap-resident PyObjects whose lifetimes +// are managed by the owning FrameObject (or PyFunction). Frame execution +// is single-threaded (enforced by the owner field). +#[cfg(feature = "threading")] +unsafe impl Send for InterpreterFrame {} +#[cfg(feature = "threading")] +unsafe impl Sync for InterpreterFrame {} + +impl InterpreterFrame { + /// Construct a new InterpreterFrame with raw pointers set from the given references. + /// + /// The caller must ensure that the pointed-to objects outlive this frame. + /// For FrameObject-owned frames, `init_iframe_ptrs` patches the pointers + /// after heap allocation; the pointers passed here are then overwritten. + /// For stack-allocated frames (future), the pointers remain valid for the + /// frame's lifetime on the native stack. + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + code: &Py, + globals: &Py, + builtins: &PyObject, + func_obj: Option<&PyObject>, + localsplus: LocalsPlus, + locals: FrameLocals, + closure: &[PyCellRef], + owner: FrameOwner, + ) -> Self { + let mut localsplus = localsplus; + let nlocalsplus = code.localspluskinds.len(); + + // Pre-copy closure cells into free var slots so that locals() works + // even before COPY_FREE_VARS runs (e.g. coroutine before first send). + // COPY_FREE_VARS will overwrite these on first execution. + { + let nfrees = code.freevars.len(); + if nfrees > 0 { + let freevar_start = nlocalsplus - nfrees; + let fastlocals = localsplus.fastlocals_mut(); + for (i, cell) in closure.iter().enumerate() { + fastlocals[freevar_start + i] = Some(cell.clone().into()); + } + } + } + + // For generators/coroutines, initialize prev_line to the def line + // so that preamble instructions (RETURN_GENERATOR, POP_TOP) don't + // fire spurious LINE events. + let prev_line = if code.flags.intersects( + bytecode::CodeFlags::GENERATOR + | bytecode::CodeFlags::COROUTINE + | bytecode::CodeFlags::ASYNC_GENERATOR, + ) { + code.first_line_number.map_or(0, |line| line.get() as u32) + } else { + 0 + }; + + Self { + code: code as *const Py, + func_obj: match func_obj { + Some(obj) => obj as *const PyObject, + None => core::ptr::null(), + }, + globals: globals as *const Py, + builtins: builtins as *const PyObject, + localsplus, + locals, + lasti: Radium::new(0), + prev_line: core::cell::Cell::new(prev_line), + trace: PyMutex::new(None), + trace_lines: PyMutex::new(true), + trace_opcodes: PyMutex::new(false), + temporary_refs: PyMutex::new(vec![]), + generator: PyAtomicBorrow::new(), + previous: Radium::new(0), + owner: atomic::AtomicI8::new(owner as i8), + f_locals_hidden_overlay: PyMutex::new(None), + f_extra_locals: PyMutex::new(None), + escaped: atomic::AtomicBool::new(false), + retained_back: PyMutex::new(None), + pending_stack_pops: Default::default(), + pending_unwind_from_stack: Default::default(), + materialized: Radium::new(0), + } + } + + /// Get the last instruction index. + #[inline(always)] + pub fn get_lasti(&self) -> u32 { + self.lasti.load(Relaxed) + } + + /// Get the previous InterpreterFrame in the chain, or null. + #[inline(always)] + pub fn previous(&self) -> *const Self { + self.previous.load(Relaxed) as *const Self + } + + /// Get the owning FrameObject, if this frame has been materialized. + #[inline(always)] + pub(crate) fn frame_obj(&self) -> Option<&Py> { + let ptr = self.materialized.load(Relaxed); + if ptr == 0 { + None + } else { + Some(unsafe { &*(ptr as *const Py) }) + } + } + + /// Materialize a FrameObject for this InterpreterFrame on demand. + /// If already materialized, returns the existing one. + /// The created FrameObject shares the raw pointers with this frame. + #[cold] + #[inline(never)] + pub(crate) fn materialize(&self, vm: &VirtualMachine) -> &Py { + if let Some(fo) = self.frame_obj() { + return fo; + } + self.materialize_slow(vm) + } + + /// Create a lightweight FrameObject with empty localsplus, suitable for + /// f_back chain building (retained_back). Unlike `materialize`, this does + /// NOT store into `temporary_refs` or set the `materialized` pointer, so + /// the returned FrameObject is only kept alive by the caller's `PyRef`. + /// This prevents non-GC-tracked `temporary_refs` on a stack-allocated + /// iframe from defeating cycle collection. + #[cold] + #[inline(never)] + pub(crate) fn materialize_chain(&self, vm: &VirtualMachine) -> FrameObjectRef { + if let Some(fo) = self.frame_obj() { + return fo.to_owned(); + } + self.materialize_slow_chain(vm) + } + + #[cold] + fn materialize_slow(&self, vm: &VirtualMachine) -> &Py { + // Create a full FrameObject with its own InterpreterFrame copy. + // The FrameObject owns references to the same objects (code, globals, etc). + let code: PyRef = self.code().to_owned(); + let globals: PyDictRef = self.globals().to_owned(); + let builtins: PyObjectRef = self.builtins().to_owned(); + let func_obj: Option = self.func_obj().map(|o| o.to_owned()); + + // Copy localsplus from the stack frame so materialized frames have + // usable fastlocals (for locals(), f_locals, tracebacks, etc). + let localsplus = unsafe { self.localsplus.snapshot_to_heap() }; + + // Copy the locals mapping if it exists. + let locals = match self.locals.get() { + Some(mapping) => FrameLocals::with_locals(mapping.clone()), + None => FrameLocals::lazy(), + }; + + // Build a fresh InterpreterFrame inside the FrameObject. + // Its raw pointers will be patched by init_iframe_ptrs. + let inner_iframe = Self { + code: core::ptr::null(), + func_obj: core::ptr::null(), + globals: core::ptr::null(), + builtins: core::ptr::null(), + localsplus, + locals, + lasti: Radium::new(self.lasti.load(Relaxed)), + prev_line: core::cell::Cell::new(self.prev_line.get()), + trace: PyMutex::new(None), + trace_lines: PyMutex::new(true), + trace_opcodes: PyMutex::new(false), + temporary_refs: PyMutex::new(vec![]), + generator: PyAtomicBorrow::new(), + // Do NOT copy previous — it may point to stack-allocated frames + // that become dangling after their call returns. The f_back chain + // is resolved through the TLS CURRENT_FRAME chain instead. + previous: Radium::new(0), + // Materialized frame is a detached snapshot — always FrameObject-owned. + // If we copied Thread from the source iframe, frame.clear() would + // reject the frame with "cannot clear an executing frame". + owner: atomic::AtomicI8::new(FrameOwner::FrameObject as i8), + f_locals_hidden_overlay: PyMutex::new(None), + f_extra_locals: PyMutex::new(None), + escaped: atomic::AtomicBool::new(true), + retained_back: PyMutex::new(None), + pending_stack_pops: Default::default(), + pending_unwind_from_stack: Default::default(), + materialized: Radium::new(0), + }; + + let frame_obj = FrameObject { + owned_code: Some(code), + owned_globals: Some(globals), + owned_builtins: Some(builtins), + owned_func_obj: func_obj, + iframe: FrameUnsafeCell::new(Some(inner_iframe)), + }; + let frame_ref = frame_obj.into_ref(&vm.ctx); + FrameObject::init_iframe_ptrs(&frame_ref); + // Set the inner iframe's materialized pointer to self + unsafe { + frame_ref + .iframe_mut() + .materialized + .store(&*frame_ref as *const Py as usize, Relaxed); + } + + // Store the materialized pointer on this stack frame. + let fo_ptr = &*frame_ref as *const Py as usize; + self.materialized.store(fo_ptr, Relaxed); + + // Keep the FrameObject alive by storing it in temporary_refs. + // GC tracking is deferred to with_iframe cleanup, where the frame + // is no longer executing and temporary_refs is cleared — at that + // point the FrameObject is self-sustaining and GC can safely + // traverse and collect it. + self.temporary_refs.lock().push(frame_ref.clone().into()); + + // SAFETY: the pointer we stored above remains valid because + // temporary_refs holds a strong reference. + unsafe { &*(fo_ptr as *const Py) } + } + + /// Like `materialize_slow` but with empty localsplus to avoid extra + /// refcounts on local variables. Only suitable for f_back chain building. + /// Returns an owned `PyRef` without storing into `temporary_refs` or + /// setting the `materialized` pointer, so GC can still detect cycles. + #[cold] + fn materialize_slow_chain(&self, vm: &VirtualMachine) -> FrameObjectRef { + let code: PyRef = self.code().to_owned(); + let globals: PyDictRef = self.globals().to_owned(); + let builtins: PyObjectRef = self.builtins().to_owned(); + let func_obj: Option = self.func_obj().map(|o| o.to_owned()); + + // Empty localsplus — reads go through find_live_source_iframe. + let nlocalsplus = code.localspluskinds.len() as u32; + let localsplus = LocalsPlus { + data: LocalsPlusData::Heap(vec![0usize; nlocalsplus as usize].into_boxed_slice()), + nlocalsplus, + stack_top: 0, + }; + + let locals = match self.locals.get() { + Some(mapping) => FrameLocals::with_locals(mapping.clone()), + None => FrameLocals::lazy(), + }; + + let inner_iframe = Self { + code: core::ptr::null(), + func_obj: core::ptr::null(), + globals: core::ptr::null(), + builtins: core::ptr::null(), + localsplus, + locals, + lasti: Radium::new(self.lasti.load(Relaxed)), + prev_line: core::cell::Cell::new(self.prev_line.get()), + trace: PyMutex::new(None), + trace_lines: PyMutex::new(true), + trace_opcodes: PyMutex::new(false), + temporary_refs: PyMutex::new(vec![]), + generator: PyAtomicBorrow::new(), + previous: Radium::new(0), + owner: atomic::AtomicI8::new(FrameOwner::FrameObject as i8), + f_locals_hidden_overlay: PyMutex::new(None), + f_extra_locals: PyMutex::new(None), + escaped: atomic::AtomicBool::new(true), + retained_back: PyMutex::new(None), + pending_stack_pops: Default::default(), + pending_unwind_from_stack: Default::default(), + materialized: Radium::new(0), + }; + + let frame_obj = FrameObject { + owned_code: Some(code), + owned_globals: Some(globals), + owned_builtins: Some(builtins), + owned_func_obj: func_obj, + iframe: FrameUnsafeCell::new(Some(inner_iframe)), + }; + let frame_ref = frame_obj.into_ref(&vm.ctx); + FrameObject::init_iframe_ptrs(&frame_ref); + + frame_ref + } + + /// Borrowed code object. + #[inline(always)] + pub fn code(&self) -> &Py { + unsafe { &*self.code } + } + + /// Borrowed globals dict. + #[inline(always)] + pub fn globals(&self) -> &Py { + unsafe { &*self.globals } + } + + /// Borrowed builtins object. + #[inline(always)] + pub fn builtins(&self) -> &PyObject { + unsafe { &*self.builtins } + } + + /// Borrowed function object, or None if not set. + #[inline(always)] + pub fn func_obj(&self) -> Option<&PyObject> { + if self.func_obj.is_null() { + None + } else { + Some(unsafe { &*self.func_obj }) + } + } } /// Python-visible frame object. Currently always wraps an `InterpreterFrame`. /// Analogous to CPython's `PyFrameObject`. #[pyclass(module = false, name = "frame", traverse = "manual")] -pub struct Frame { +pub struct FrameObject { + // Owned references — keep the pointed-to objects alive for InterpreterFrame's + // raw pointers. Wrapped in Option so Traverse::clear can release them, + // allowing GC cycle collection to reclaim referenced objects. + pub(crate) owned_code: Option>, + pub(crate) owned_globals: Option, + pub(crate) owned_builtins: Option, + pub(crate) owned_func_obj: Option, + /// Always `Some` while the frame is reachable from Python. Emptied only /// by `Traverse::clear` during deallocation, leaving a trivially-droppable /// husk that the freelist can cache. pub(crate) iframe: FrameUnsafeCell>, } -impl Frame { +impl FrameObject { /// Shared access to the embedded interpreter frame. /// /// # Safety @@ -746,7 +1203,7 @@ impl Frame { /// and that the frame has not been cleared (i.e. it is still reachable /// from Python; `Traverse::clear` only runs during deallocation). #[inline(always)] - unsafe fn iframe_ref(&self) -> &InterpreterFrame { + pub(crate) unsafe fn iframe_ref(&self) -> &InterpreterFrame { let opt = unsafe { &*self.iframe.get() }; #[cfg(debug_assertions)] if opt.is_none() { @@ -763,7 +1220,7 @@ impl Frame { /// the frame has not been cleared. #[inline(always)] #[allow(clippy::mut_from_ref)] - unsafe fn iframe_mut(&self) -> &mut InterpreterFrame { + pub(crate) unsafe fn iframe_mut(&self) -> &mut InterpreterFrame { let opt = unsafe { &mut *self.iframe.get() }; #[cfg(debug_assertions)] if opt.is_none() { @@ -772,6 +1229,18 @@ impl Frame { // SAFETY: iframe is always Some while the frame is reachable (see above). unsafe { opt.as_mut().unwrap_unchecked() } } + + /// Shared access to the embedded interpreter frame. Safe to call on any + /// reachable FrameObject: immutable fields and atomic/mutex fields are + /// always safe to access. + #[inline(always)] + pub fn iframe(&self) -> &InterpreterFrame { + // SAFETY: FrameObject is always reachable from Python when this is + // called. Immutable fields and atomic/mutex fields provide their own + // synchronization. Mutable fields (localsplus, prev_line) are only + // mutated during single-threaded execution via with_exec. + unsafe { self.iframe_ref() } + } } /// Out-of-line panic for the debug-only cleared-frame check, keeping the @@ -783,31 +1252,19 @@ fn cleared_frame_access() -> ! { panic!("frame accessed after clear"); } -impl core::ops::Deref for Frame { - type Target = InterpreterFrame; - /// Transparent access to InterpreterFrame fields. - /// - /// # Safety argument - /// Immutable fields (code, globals, builtins, func_obj, locals) are safe - /// to access at any time. Atomic/mutex fields (lasti, trace, owner, etc.) - /// provide their own synchronization. Mutable fields (localsplus, prev_line) - /// are only mutated during single-threaded execution via `with_exec`. - #[inline(always)] - fn deref(&self) -> &InterpreterFrame { - unsafe { self.iframe_ref() } - } -} +// NOTE: Deref removed to decouple FrameObject +// from InterpreterFrame field layout. Access through iframe_ref()/iframe_mut(). thread_local! { /// Free list of dead frame objects for reuse. Entries are cleared husks /// (`iframe == None`) whose child references were already released. - /// PyInner is fixed-size (localsplus storage is out-of-line), + /// PyInner is fixed-size (localsplus storage is out-of-line), /// so a single bucket suffices. - static FRAME_FREELIST: core::cell::Cell> = + static FRAME_FREELIST: core::cell::Cell> = const { core::cell::Cell::new(crate::object::FreeList::new()) }; } -impl PyPayload for Frame { +impl PyPayload for FrameObject { const MAX_FREELIST: usize = 200; const HAS_FREELIST: bool = true; // Ordinary call frames are created untracked and only enter the GC when @@ -851,37 +1308,21 @@ impl PyPayload for Frame { } } -unsafe impl Traverse for Frame { +unsafe impl Traverse for FrameObject { fn traverse(&self, tracer_fn: &mut TraverseFn<'_>) { - // SAFETY: this traversal reads the frame's live interpreter state - // (`localsplus`), which the owning thread mutates without - // synchronization while executing bytecode. It is only sound when no - // other thread is executing this frame: in threading builds the - // collector stops the world around the traversal phases, and in - // single-threaded builds there is no other thread. A cleared frame - // (iframe == None) has no children to visit. - // - // Invariant (load-bearing for the untracked-frame optimization): every - // reference *to* a frame is recorded as a graph edge by - // `PyRef::traverse` — no other type's `traverse` recurses into a - // frame's payload. So the collector reads a frame's `localsplus` only - // when the frame is itself a tracked candidate. Tracked datastack - // frames are tracked only at `release_datastack_frame`, after they stop - // executing and their localsplus is materialized onto the heap; tracked - // generator frames are heap-backed by construction and their execution - // is stopped-the-world. Hence a running, data-stack-resident frame is - // never traversed by a concurrent collector. If a future change makes - // some type's `traverse` recurse into a frame payload, this invariant - // (and the debug asserts at the track sites) breaks. + // Visit the owned reference anchors on FrameObject. + // After clear(), these are None. + self.owned_code.traverse(tracer_fn); + self.owned_func_obj.traverse(tracer_fn); + self.owned_globals.traverse(tracer_fn); + self.owned_builtins.traverse(tracer_fn); + + // Visit interior references in the InterpreterFrame. let Some(iframe) = (unsafe { &*self.iframe.get() }) else { return; }; - iframe.code.traverse(tracer_fn); - iframe.func_obj.traverse(tracer_fn); iframe.localsplus.traverse(tracer_fn); iframe.locals.traverse(tracer_fn); - iframe.globals.traverse(tracer_fn); - iframe.builtins.traverse(tracer_fn); iframe.trace.traverse(tracer_fn); iframe.temporary_refs.traverse(tracer_fn); iframe.f_locals_hidden_overlay.traverse(tracer_fn); @@ -890,14 +1331,14 @@ unsafe impl Traverse for Frame { } fn clear(&mut self, _out: &mut Vec) { - // Drop the interpreter frame in place instead of extracting children - // into `_out`: pushing ~10 refs per frame would grow the buffer, a - // heap allocation on the hot dealloc path. Direct drops release the - // same references under the same recursion protection (trashcan in - // dealloc, deferred-drop context in cycle collection) as the payload - // drop did before the freelist existed. The payload is left as a - // trivially-droppable husk for the freelist. + // Drop the interpreter frame and owned reference anchors so GC + // cycle collection can reclaim the referenced objects. The payload + // is left as a trivially-droppable husk for the freelist. drop(self.iframe.get_mut().take()); + self.owned_code.take(); + self.owned_globals.take(); + self.owned_builtins.take(); + self.owned_func_obj.take(); } } @@ -910,7 +1351,7 @@ pub enum ExecutionResult { /// A valid execution result, or an exception type FrameResult = PyResult>; -impl Frame { +impl FrameObject { pub(crate) fn new( code: PyRef, scope: Scope, @@ -922,73 +1363,76 @@ impl Frame { ) -> Self { let nlocalsplus = code.localspluskinds.len(); let max_stackdepth = code.max_stackdepth as usize; - let mut localsplus = if use_datastack { + let localsplus = if use_datastack { LocalsPlus::new_on_datastack(nlocalsplus, max_stackdepth, vm) } else { LocalsPlus::new(nlocalsplus, max_stackdepth) }; - // Pre-copy closure cells into free var slots so that locals() works - // even before COPY_FREE_VARS runs (e.g. coroutine before first send). - // COPY_FREE_VARS will overwrite these on first execution. - { - let nfrees = code.freevars.len(); - if nfrees > 0 { - let freevar_start = nlocalsplus - nfrees; - let fastlocals = localsplus.fastlocals_mut(); - for (i, cell) in closure.iter().enumerate() { - fastlocals[freevar_start + i] = Some(cell.clone().into()); - } - } - } - - // For generators/coroutines, initialize prev_line to the def line - // so that preamble instructions (RETURN_GENERATOR, POP_TOP) don't - // fire spurious LINE events. - let prev_line = if code.flags.intersects( - bytecode::CodeFlags::GENERATOR - | bytecode::CodeFlags::COROUTINE - | bytecode::CodeFlags::ASYNC_GENERATOR, - ) { - code.first_line_number.map_or(0, |line| line.get() as u32) - } else { - 0 + let locals = match scope.locals { + Some(locals) => FrameLocals::with_locals(locals), + None if code.flags.contains(bytecode::CodeFlags::NEWLOCALS) => FrameLocals::lazy(), + None => FrameLocals::with_locals(ArgMapping::from_dict_exact(scope.globals.clone())), }; - let iframe = InterpreterFrame { + // Build the InterpreterFrame using the constructor. + // Pointers are initially set from owned fields' references but will be + // dangling after the FrameObject moves into heap allocation — they get + // patched by `init_iframe_ptrs` after `into_ref`. + let iframe = InterpreterFrame::new( + &code, + &scope.globals, + &builtins, + func_obj.as_deref(), localsplus, - locals: match scope.locals { - Some(locals) => FrameLocals::with_locals(locals), - None if code.flags.contains(bytecode::CodeFlags::NEWLOCALS) => FrameLocals::lazy(), - None => { - FrameLocals::with_locals(ArgMapping::from_dict_exact(scope.globals.clone())) - } - }, - globals: scope.globals, - builtins, - code, - func_obj, - lasti: Radium::new(0), - prev_line, - trace: PyMutex::new(vm.ctx.none()), - trace_lines: PyMutex::new(true), - trace_opcodes: PyMutex::new(false), - temporary_refs: PyMutex::new(vec![]), - generator: PyAtomicBorrow::new(), - previous: AtomicPtr::new(core::ptr::null_mut()), - owner: atomic::AtomicI8::new(FrameOwner::FrameObject as i8), - f_locals_hidden_overlay: PyMutex::new(None), - f_extra_locals: PyMutex::new(None), - escaped: atomic::AtomicBool::new(false), - retained_back: PyMutex::new(None), - pending_stack_pops: Default::default(), - pending_unwind_from_stack: Default::default(), - }; + locals, + closure, + FrameOwner::FrameObject, + ); Self { + owned_code: Some(code), + owned_globals: Some(scope.globals), + owned_builtins: Some(builtins), + owned_func_obj: func_obj, iframe: FrameUnsafeCell::new(Some(iframe)), } } + /// Patch the InterpreterFrame's raw pointers to point at this + /// FrameObject's owned fields. Must be called once after the + /// FrameObject is allocated on the heap (i.e. after `into_ref`). + fn init_iframe_ptrs(self_: &Py) { + let iframe = unsafe { self_.iframe_mut() }; + iframe.code = &**self_.owned_code.as_ref().unwrap() as *const Py; + iframe.globals = &**self_.owned_globals.as_ref().unwrap() as *const Py; + iframe.builtins = &**self_.owned_builtins.as_ref().unwrap() as *const PyObject; + iframe.func_obj = match &self_.owned_func_obj { + Some(obj) => &**obj as *const PyObject, + None => core::ptr::null(), + }; + // Link the InterpreterFrame back to its owning FrameObject. + iframe + .materialized + .store(self_ as *const Py as usize, Relaxed); + } + + /// Create a new FrameObject, allocate it on the heap, and patch + /// the InterpreterFrame's raw pointers. Returns an owned reference. + pub(crate) fn new_ref( + code: PyRef, + scope: Scope, + builtins: PyObjectRef, + closure: &[PyCellRef], + func_obj: Option, + use_datastack: bool, + vm: &VirtualMachine, + ) -> FrameObjectRef { + let frame = Self::new(code, scope, builtins, closure, func_obj, use_datastack, vm) + .into_ref(&vm.ctx); + Self::init_iframe_ptrs(&frame); + frame + } + /// Access fastlocals immutably. /// /// # Safety @@ -1056,31 +1500,21 @@ impl Frame { /// Releases references held by the frame, matching _PyFrame_ClearLocals. pub(crate) fn clear_locals_and_stack(&self) { self.clear_stack_and_cells(); - // SAFETY: Frame is not executing (generator closed). + // SAFETY: FrameObject is not executing (generator closed). let fastlocals = unsafe { self.iframe_mut().localsplus.fastlocals_mut() }; for slot in fastlocals.iter_mut() { *slot = None; } - self.f_locals_hidden_overlay.lock().take(); - self.f_extra_locals.lock().take(); - } - - /// Get cell contents by localsplus index. - pub(crate) fn get_cell_contents(&self, localsplus_idx: usize) -> Option { - // SAFETY: Frame not executing; no concurrent mutation. - let fastlocals = unsafe { self.iframe_ref().localsplus.fastlocals() }; - fastlocals - .get(localsplus_idx) - .and_then(|slot| slot.as_ref()) - .and_then(|obj| obj.downcast_ref::()) - .and_then(|cell| cell.get()) + self.iframe().f_locals_hidden_overlay.lock().take(); + self.iframe().f_extra_locals.lock().take(); } /// Store a borrowed back-reference to the owning generator/coroutine. /// The caller must ensure the generator outlives the frame. pub fn set_generator(&self, generator: &PyObject) { - self.generator.store(generator); - self.owner + self.iframe().generator.store(generator); + self.iframe() + .owner .store(FrameOwner::Generator as i8, atomic::Ordering::Release); } @@ -1101,51 +1535,68 @@ impl Frame { } pub fn current_location(&self) -> SourceLocation { - self.code.locations[self.lasti() as usize - 1].0 + let lasti = self.lasti() as usize; + if lasti == 0 { + return SourceLocation { + line: self + .iframe() + .code() + .first_line_number + .unwrap_or(OneIndexed::MIN), + character_offset: OneIndexed::from_zero_indexed(0), + }; + } + self.iframe().code().locations[lasti - 1].0 } - /// Get the previous frame pointer for signal-safe traceback walking. + /// Get the previous InterpreterFrame in the chain. + /// Returns null if the frame has been cleared (GC deallocation). + pub fn previous_iframe(&self) -> *const InterpreterFrame { + // Use raw access instead of iframe() to avoid panicking on cleared frames. + let iframe_opt = unsafe { &*self.iframe.get() }; + match iframe_opt.as_ref() { + Some(iframe) => { + iframe.previous.load(atomic::Ordering::Relaxed) as *const InterpreterFrame + } + None => core::ptr::null(), + } + } + + /// Get the previous FrameObject in the chain, if any. + /// Walks through the chain to find the next materialized frame. pub fn previous_frame(&self) -> *const Self { - self.previous.load(atomic::Ordering::Relaxed) + let mut cur = self.previous_iframe(); + while !cur.is_null() { + let iframe = unsafe { &*cur }; + if let Some(fo) = iframe.frame_obj() { + return &**fo as *const Self; + } + cur = iframe.previous.load(atomic::Ordering::Relaxed) as *const InterpreterFrame; + } + core::ptr::null() } /// Record that a durable Python-level reference to this frame escaped. pub(crate) fn mark_escaped(&self) { - self.escaped.store(true, atomic::Ordering::Release); + self.iframe().escaped.store(true, atomic::Ordering::Release); } /// Whether a durable reference to this frame has escaped. pub(crate) fn has_escaped(&self) -> bool { - self.escaped.load(atomic::Ordering::Acquire) + self.iframe().escaped.load(atomic::Ordering::Acquire) } pub fn lasti(&self) -> u32 { - self.lasti.load(Relaxed) + self.iframe().lasti.load(Relaxed) } pub fn set_lasti(&self, val: u32) { - self.lasti.store(val, Relaxed); - } - - pub(crate) fn pending_stack_pops(&self) -> u32 { - self.pending_stack_pops.load(Relaxed) - } - - pub(crate) fn set_pending_stack_pops(&self, val: u32) { - self.pending_stack_pops.store(val, Relaxed); - } - - pub(crate) fn pending_unwind_from_stack(&self) -> i64 { - self.pending_unwind_from_stack.load(Relaxed) - } - - pub(crate) fn set_pending_unwind_from_stack(&self, val: i64) { - self.pending_unwind_from_stack.store(val, Relaxed); + self.iframe().lasti.store(val, Relaxed); } fn has_active_hidden_locals(&self) -> bool { use rustpython_compiler_core::bytecode::{CO_FAST_CELL, CO_FAST_FREE, CO_FAST_HIDDEN}; - let code = &**self.code; + let code = self.iframe().code(); let fastlocals = unsafe { self.iframe_ref().localsplus.fastlocals() }; let is_optimized = code.flags.contains(bytecode::CodeFlags::OPTIMIZED); !is_optimized @@ -1177,8 +1628,16 @@ impl Frame { }; // SAFETY: Either the frame is not executing (caller checked owner), // or we're in a trace callback on the same thread that's executing. - let code = &**self.code; - let fastlocals = unsafe { self.iframe_ref().localsplus.fastlocals() }; + let code = self.iframe().code(); + // If this FrameObject has a live source iframe on the TLS chain, read + // its localsplus for up-to-date values (the materialized copy is a + // stale snapshot from materialize time). + let live = self.find_live_source_iframe(); + let fastlocals = if !live.is_null() { + unsafe { (*live).localsplus.fastlocals() } + } else { + unsafe { self.iframe_ref().localsplus.fastlocals() } + }; // Iterate through all localsplus slots using localspluskinds let nlocalsplus = code.localspluskinds.len(); @@ -1281,16 +1740,26 @@ impl Frame { /// builtin, trace callbacks) is fine: the frame sits on the current /// thread's frame chain and is at a bytecode boundary. pub(crate) fn check_locals_access(&self, vm: &VirtualMachine) -> PyResult<()> { - let owner = FrameOwner::from_i8(self.owner.load(atomic::Ordering::Acquire)); + let owner = FrameOwner::from_i8(self.iframe().owner.load(atomic::Ordering::Acquire)); if owner != FrameOwner::Thread { return Ok(()); } + let self_iframe = self.iframe() as *const InterpreterFrame; + // Get the Py address from &FrameObject (payload). + // materialized stores a *const Py. + let self_py_ptr = unsafe { Py::::from_payload_ptr(self) } as usize; let mut cur = crate::vm::thread::get_current_frame(); while !cur.is_null() { - if core::ptr::eq(cur, self) { + if core::ptr::eq(cur, self_iframe) { + return Ok(()); + } + // Also match if this FrameObject is the materialized version + // of a stack-allocated frame in the chain. + let materialized = unsafe { (*cur).materialized.load(Relaxed) }; + if materialized == self_py_ptr { return Ok(()); } - cur = unsafe { (*cur).previous_frame() }; + cur = unsafe { (*cur).previous.load(Relaxed) as *const InterpreterFrame }; } Err(vm.new_runtime_error( "cannot access frame locals while the frame is executing in another thread", @@ -1300,12 +1769,12 @@ impl Frame { pub fn f_locals_mapping(&self, vm: &VirtualMachine) -> PyResult { self.check_locals_access(vm)?; if !self.has_active_hidden_locals() { - self.f_locals_hidden_overlay.lock().take(); + self.iframe().f_locals_hidden_overlay.lock().take(); return self.locals(vm); } let overlay_dict = { - let mut overlay = self.f_locals_hidden_overlay.lock(); + let mut overlay = self.iframe().f_locals_hidden_overlay.lock(); match overlay.as_ref() { Some(dict) => dict.clone(), None => { @@ -1329,8 +1798,8 @@ impl Frame { self.sync_visible_locals_to_mapping(overlay.mapping(), vm)?; overlay } else { - self.sync_visible_locals_to_mapping(self.locals.mapping(vm), vm)?; - self.locals.clone_mapping(vm) + self.sync_visible_locals_to_mapping(self.iframe().locals.mapping(vm), vm)?; + self.iframe().locals.clone_mapping(vm) }; self.fold_extra_locals(&mapping, vm)?; Ok(mapping) @@ -1339,7 +1808,7 @@ impl Frame { /// Copy the frame's extra-locals side storage (proxy keys that are not /// fast locals) into `mapping`. No-op when nothing was ever stored. fn fold_extra_locals(&self, mapping: &ArgMapping, vm: &VirtualMachine) -> PyResult<()> { - let extra = self.f_extra_locals.lock().clone(); + let extra = self.iframe().f_extra_locals.lock().clone(); if let Some(extra) = extra { for (key, value) in &extra { mapping.mapping().ass_subscript(&key, Some(value), vm)?; @@ -1354,9 +1823,21 @@ impl Frame { use rustpython_compiler_core::bytecode::{CO_FAST_CELL, CO_FAST_FREE}; // SAFETY: callers first pass through `check_locals_access`, so the // frame is not executing on another thread. - let fastlocals = unsafe { self.iframe_ref().localsplus.fastlocals() }; + // Use live source iframe if available for up-to-date values. + let live = self.find_live_source_iframe(); + let fastlocals = if !live.is_null() { + unsafe { (*live).localsplus.fastlocals() } + } else { + unsafe { self.iframe_ref().localsplus.fastlocals() } + }; let obj = fastlocals.get(i)?.as_ref()?; - let kind = self.code.localspluskinds.get(i).copied().unwrap_or(0); + let kind = self + .iframe() + .code() + .localspluskinds + .get(i) + .copied() + .unwrap_or(0); if kind & (CO_FAST_CELL | CO_FAST_FREE) != 0 { if let Some(cell) = obj.downcast_ref::() { cell.get() @@ -1372,9 +1853,22 @@ impl Frame { /// the slot holds one so closures keep sharing the same cell. fn framelocalsproxy_setval(&self, i: usize, value: PyObjectRef) { use rustpython_compiler_core::bytecode::{CO_FAST_CELL, CO_FAST_FREE}; - let kind = self.code.localspluskinds.get(i).copied().unwrap_or(0); + let kind = self + .iframe() + .code() + .localspluskinds + .get(i) + .copied() + .unwrap_or(0); // SAFETY: callers first pass through `check_locals_access`. - let fastlocals = unsafe { self.iframe_mut().localsplus.fastlocals_mut() }; + // Use live source iframe if available so writes reach the + // executing frame's actual local variables. + let live = self.find_live_source_iframe(); + let fastlocals = if !live.is_null() { + unsafe { &mut *live.cast_mut() }.localsplus.fastlocals_mut() + } else { + unsafe { self.iframe_mut().localsplus.fastlocals_mut() } + }; if kind & (CO_FAST_CELL | CO_FAST_FREE) != 0 && let Some(obj) = fastlocals[i].as_ref() && let Some(cell) = obj.downcast_ref::() @@ -1398,8 +1892,8 @@ impl Frame { use rustpython_compiler_core::bytecode::CO_FAST_HIDDEN; // The proxy hashes the key first; an unhashable key raises TypeError. key.hash(vm)?; - for (i, &kind) in self.code.localspluskinds.iter().enumerate() { - let name = localsplus_name(&self.code, i); + for (i, &kind) in self.iframe().code().localspluskinds.iter().enumerate() { + let name = localsplus_name(self.iframe().code(), i); if !name .as_object() .rich_compare_bool(key, PyComparisonOp::Eq, vm)? @@ -1440,7 +1934,7 @@ impl Frame { { return Ok(value); } - let extra = self.f_extra_locals.lock().clone(); + let extra = self.iframe().f_extra_locals.lock().clone(); if let Some(extra) = extra && let Some(value) = extra.get_item_opt(&*key, vm)? { @@ -1459,7 +1953,7 @@ impl Frame { if self.framelocalsproxy_getkeyindex(&key, true, vm)?.is_some() { return Ok(true); } - let extra = self.f_extra_locals.lock().clone(); + let extra = self.iframe().f_extra_locals.lock().clone(); if let Some(extra) = extra { return Ok(extra.get_item_opt(&*key, vm)?.is_some()); } @@ -1497,7 +1991,7 @@ impl Frame { { return Err(vm.new_value_error("cannot remove local variables from FrameLocalsProxy")); } - let extra = self.f_extra_locals.lock().clone(); + let extra = self.iframe().f_extra_locals.lock().clone(); if let Some(extra) = extra && extra.get_item_opt(&*key, vm)?.is_some() { @@ -1520,7 +2014,7 @@ impl Frame { { return Err(vm.new_value_error("cannot remove local variables from FrameLocalsProxy")); } - let extra = self.f_extra_locals.lock().clone(); + let extra = self.iframe().f_extra_locals.lock().clone(); if let Some(extra) = extra && let Some(value) = extra.pop_item(&*key, vm)? { @@ -1547,42 +2041,55 @@ impl Frame { } fn extra_locals_get_or_create(&self, vm: &VirtualMachine) -> PyDictRef { - let mut extra = self.f_extra_locals.lock(); + let mut extra = self.iframe().f_extra_locals.lock(); extra.get_or_insert_with(|| vm.ctx.new_dict()).clone() } } -impl Py { +impl Py { #[inline(always)] fn with_exec(&self, vm: &VirtualMachine, f: impl FnOnce(ExecutingFrame<'_>) -> R) -> R { - // SAFETY: Frame execution is single-threaded. Only one thread at a time + // SAFETY: FrameObject execution is single-threaded. Only one thread at a time // executes a given frame (enforced by the owner field and generator // running flag). Same safety argument as FastLocals (UnsafeCell). let iframe = unsafe { self.iframe_mut() }; + // Dereference the raw pointers before taking mutable borrows + // to localsplus/prev_line. The raw pointers point to FrameObject's + // owned fields, not into InterpreterFrame, so there is no aliasing. + let code: &Py = unsafe { &*iframe.code }; + let globals: &Py = unsafe { &*iframe.globals }; + let builtins: &PyObject = unsafe { &*iframe.builtins }; + let func_obj: Option<&PyObject> = if iframe.func_obj.is_null() { + None + } else { + Some(unsafe { &*iframe.func_obj }) + }; + let builtins_dict = if globals.class().is(vm.ctx.types.dict_type) { + builtins + .downcast_ref_if_exact::(vm) + // SAFETY: downcast_ref_if_exact already verified exact type + .map(|d| unsafe { PyExact::ref_unchecked(d) }) + } else { + None + }; + let iframe_ptr = iframe as *const InterpreterFrame; let exec = ExecutingFrame { - code: &iframe.code, + code, localsplus: &mut iframe.localsplus, locals: &iframe.locals, - globals: &iframe.globals, - builtins: &iframe.builtins, - builtins_dict: if iframe.globals.class().is(vm.ctx.types.dict_type) { - iframe - .builtins - .downcast_ref_if_exact::(vm) - // SAFETY: downcast_ref_if_exact already verified exact type - .map(|d| unsafe { PyExact::ref_unchecked(d) }) - } else { - None - }, + globals, + builtins, + builtins_dict, lasti: &iframe.lasti, - object: self, - prev_line: &mut iframe.prev_line, + iframe: iframe_ptr, + func_obj, + prev_line: &iframe.prev_line, monitoring_mask: 0, }; f(exec) } - // #[cfg_attr(feature = "flame-it", flame("Frame"))] + // #[cfg_attr(feature = "flame-it", flame("FrameObject"))] pub fn run(&self, vm: &VirtualMachine) -> PyResult { self.with_exec(vm, |mut exec| exec.run(vm)) } @@ -1613,22 +2120,32 @@ impl Py { pub fn yield_from_target(&self) -> Option { // If the frame is currently executing (owned by thread), it has no // yield-from target to report. - let owner = FrameOwner::from_i8(self.owner.load(atomic::Ordering::Acquire)); + let owner = FrameOwner::from_i8(self.iframe().owner.load(atomic::Ordering::Acquire)); if owner == FrameOwner::Thread { return None; } - // SAFETY: Frame is not executing, so UnsafeCell access is safe. + // SAFETY: FrameObject is not executing, so UnsafeCell access is safe. let iframe = unsafe { self.iframe_mut() }; + let code: &Py = unsafe { &*iframe.code }; + let globals: &Py = unsafe { &*iframe.globals }; + let builtins: &PyObject = unsafe { &*iframe.builtins }; + let func_obj: Option<&PyObject> = if iframe.func_obj.is_null() { + None + } else { + Some(unsafe { &*iframe.func_obj }) + }; + let iframe_ptr = iframe as *const InterpreterFrame; let exec = ExecutingFrame { - code: &iframe.code, + code, localsplus: &mut iframe.localsplus, locals: &iframe.locals, - globals: &iframe.globals, - builtins: &iframe.builtins, + globals, + builtins, builtins_dict: None, lasti: &iframe.lasti, - object: self, - prev_line: &mut iframe.prev_line, + iframe: iframe_ptr, + func_obj, + prev_line: &iframe.prev_line, monitoring_mask: 0, }; exec.yield_from_target().map(PyObject::to_owned) @@ -1641,7 +2158,7 @@ impl Py { filename.find(b"importlib").is_some() && filename.find(b"_bootstrap").is_some() } - pub fn next_external_frame(&self, vm: &VirtualMachine) -> Option { + pub fn next_external_frame(&self, vm: &VirtualMachine) -> Option { let mut frame = self.f_back(vm); while let Some(ref f) = frame { if !f.is_internal_frame() { @@ -1653,22 +2170,71 @@ impl Py { } } +/// Execute an InterpreterFrame's bytecode directly, without a FrameObject. +/// +/// # Safety +/// The InterpreterFrame's raw pointers (code, globals, builtins, func_obj) +/// must be valid for the duration of this call. +#[inline(always)] +pub(crate) fn run_iframe( + iframe: &mut InterpreterFrame, + vm: &VirtualMachine, +) -> PyResult { + let code: &Py = unsafe { &*iframe.code }; + let globals: &Py = unsafe { &*iframe.globals }; + let builtins: &PyObject = unsafe { &*iframe.builtins }; + let func_obj: Option<&PyObject> = if iframe.func_obj.is_null() { + None + } else { + Some(unsafe { &*iframe.func_obj }) + }; + let builtins_dict = if globals.class().is(vm.ctx.types.dict_type) { + builtins + .downcast_ref_if_exact::(vm) + .map(|d| unsafe { PyExact::ref_unchecked(d) }) + } else { + None + }; + let iframe_ptr = iframe as *const InterpreterFrame; + let mut exec = ExecutingFrame { + code, + localsplus: &mut iframe.localsplus, + locals: &iframe.locals, + globals, + builtins, + builtins_dict, + lasti: &iframe.lasti, + iframe: iframe_ptr, + func_obj, + prev_line: &mut iframe.prev_line, + monitoring_mask: 0, + }; + exec.run(vm) +} + /// An executing frame; borrows mutable frame-internal data for the duration /// of bytecode execution. -struct ExecutingFrame<'a> { - code: &'a PyRef, +pub(crate) struct ExecutingFrame<'a> { + code: &'a Py, localsplus: &'a mut LocalsPlus, locals: &'a FrameLocals, - globals: &'a PyDictRef, - builtins: &'a PyObjectRef, + globals: &'a Py, + builtins: &'a PyObject, /// Cached downcast of builtins to PyDict for fast LOAD_GLOBAL. /// Only set when both globals and builtins are exact dict types (not /// subclasses), so that `__missing__` / `__getitem__` overrides are /// not bypassed. builtins_dict: Option<&'a PyExact>, - object: &'a Py, + /// Raw pointer to the underlying InterpreterFrame. Used to access the + /// materialized FrameObject (via `frame_obj()`) and frame-level fields + /// like trace, pending_stack_pops, etc. Stored as a raw pointer because + /// mutable borrows to `localsplus` and `prev_line` are also held. + /// All accesses through this pointer use atomic/mutex operations. + iframe: *const InterpreterFrame, + /// Borrowed function object that created this frame (if any). + func_obj: Option<&'a PyObject>, lasti: &'a PyAtomic, - prev_line: &'a mut u32, + prev_line: &'a core::cell::Cell, /// Cached monitoring events mask. Reloaded at Resume instruction only, monitoring_mask: u32, } @@ -1757,7 +2323,7 @@ fn localsplus_name(code: &PyCode, idx: usize) -> &'static PyStrInterned { /// heap copy. Otherwise (the frame escaped through a traceback, /// `sys._getframe`, a trace callback, ...) the values are copied to the heap /// first so they stay readable through the escaped reference. -pub(crate) fn release_datastack_frame(frame: &Py, vm: &VirtualMachine) { +pub(crate) fn release_datastack_frame(frame: &Py, vm: &VirtualMachine) { let frame_obj = frame.as_object(); // Uniqueness argument: at this point the frame is already out of // the thread-frames registry and the current-frame chain @@ -1805,8 +2371,17 @@ pub(crate) fn release_datastack_frame(frame: &Py, vm: &VirtualMachine) { // the caller returns and leaves the live frame chain. The caller is still // executing here (this frame is unwinding back into it), so its payload // pointer is live. - // SAFETY: `previous` points at the live caller on this thread's stack. - *frame.retained_back.lock() = unsafe { owned_chain_frame(frame.previous_frame()) }; + { + let mut guard = frame.iframe().retained_back.lock(); + if guard.is_none() { + let prev = frame.previous_iframe(); + *guard = unsafe { owned_chain_frame(prev) }; + } + } + // Note: previous is NOT cleared here. retained_back captures the + // caller reference, and previous may be read again by f_back or + // frame chain walkers (the pointer is live as long as the caller + // is still executing, which it is at this point). // Invariant: a tracked frame must always have heap-backed localsplus // (proven here for escaped datastack frames and by construction for // generator frames, which are born heap-backed). A stop-the-world @@ -2017,7 +2592,54 @@ impl fmt::Debug for ExecutingFrame<'_> { } } +/// Run bytecode in a light frame context. +#[allow(clippy::too_many_arguments)] impl ExecutingFrame<'_> { + /// Get the underlying InterpreterFrame. + #[inline(always)] + fn iframe(&self) -> &InterpreterFrame { + // SAFETY: the iframe pointer is valid for the lifetime of the ExecutingFrame. + unsafe { &*self.iframe } + } + + /// Get the frame object. Materializes a FrameObject on demand if this + /// is a stack-allocated frame that hasn't been observed yet. + #[cold] + #[inline(never)] + fn frame_object(&self, vm: &VirtualMachine) -> FrameObjectRef { + self.iframe().materialize(vm).to_owned() + } + + /// Whether this frame has a per-frame trace function set. + #[inline] + fn trace_is_set(&self, _vm: &VirtualMachine) -> bool { + self.iframe().trace.lock().is_some() + } + + /// Access the frame's trace_opcodes lock. + #[inline] + fn trace_opcodes_is_set(&self) -> bool { + *self.iframe().trace_opcodes.lock() + } + + /// Get pending_stack_pops from the frame. + #[inline] + fn pending_stack_pops(&self) -> u32 { + self.iframe().pending_stack_pops.load(Relaxed) + } + + /// Get pending_unwind_from_stack from the frame. + #[inline] + fn pending_unwind_from_stack(&self) -> i64 { + self.iframe().pending_unwind_from_stack.load(Relaxed) + } + + /// Set pending_stack_pops on the frame. + #[inline] + fn set_pending_stack_pops(&self, val: u32) { + self.iframe().pending_stack_pops.store(val, Relaxed); + } + /// Run `__init__` for the tp_new specialization. `args` holds the /// `__init__` args with slot 0 left empty; it is filled with `new_obj` /// here. Enforces the `__init__() should return None` contract and @@ -2108,7 +2730,7 @@ impl ExecutingFrame<'_> { /// Matches `_PyEval_MonitorRaise` → `PY_MONITORING_EVENT_RAISE` → /// `sys_trace_exception_func` in legacy_tracing.c. fn fire_exception_trace(&self, exc: &PyBaseExceptionRef, vm: &VirtualMachine) -> PyResult<()> { - if vm.use_tracing.get() && !vm.is_none(&self.object.trace.lock()) { + if vm.use_tracing.get() && self.trace_is_set(vm) { let exc_type: PyObjectRef = exc.class().to_owned().into(); let exc_value: PyObjectRef = exc.clone().into(); let exc_tb: PyObjectRef = exc @@ -2122,7 +2744,7 @@ impl ExecutingFrame<'_> { fn run(&mut self, vm: &VirtualMachine) -> PyResult { flame_guard!(format!( - "Frame::run({obj_name})", + "FrameObject::run({obj_name})", obj_name = self.code.obj_name )); // Execute until return or exception: @@ -2140,26 +2762,26 @@ impl ExecutingFrame<'_> { // (frames entered before sys.settrace() have trace=None). // Skip RESUME – it should not generate user-visible line events. if vm.use_tracing.get() - && !vm.is_none(&self.object.trace.lock()) + && self.trace_is_set(vm) && !matches!( self.code.instructions.read_op(idx), Instruction::Resume { .. } | Instruction::InstrumentedResume ) && let Some((loc, _)) = self.code.locations.get(idx) - && loc.line.get() as u32 != *self.prev_line + && loc.line.get() as u32 != self.prev_line.get() { - *self.prev_line = loc.line.get() as u32; + self.prev_line.set(loc.line.get() as u32); vm.trace_event(crate::protocol::TraceEvent::Line, None)?; // Trace callback may have changed lasti via set_f_lineno. // Re-read and restart the loop from the new position. if self.lasti() != (idx as u32 + 1) { // set_f_lineno defers stack unwinding because we hold // the state mutex. Perform it now. - let pops = self.object.pending_stack_pops(); + let pops = self.pending_stack_pops(); if pops > 0 { - let from_stack = self.object.pending_unwind_from_stack(); + let from_stack = self.pending_unwind_from_stack(); self.unwind_stack_for_lineno(pops as usize, from_stack, vm); - self.object.set_pending_stack_pops(0); + self.set_pending_stack_pops(0); } arg_state.reset(); continue; @@ -2170,22 +2792,30 @@ impl ExecutingFrame<'_> { let mut do_extend_arg = false; let caches = op.cache_entries(); - // Update prev_line only when tracing or monitoring is active. - // When neither is enabled, prev_line is stale but unused. - if vm.use_tracing.get() { - if !matches!( - op.into(), - Opcode::Resume | Opcode::ExtendedArg | Opcode::InstrumentedLine - ) && let Some((loc, _)) = self.code.locations.get(idx) - { - *self.prev_line = loc.line.get() as u32; - } + // Always update prev_line so f_lineno returns the correct line + // even when the frame is observed mid-call (e.g. sys._getframe, + // warnings.warn). The lookup is a simple array index, so the + // cost is negligible. + // Update prev_line for f_lineno. Skip RESUME, ExtendedArg, + // and InstrumentedLine (it manages prev_line in its own handler; + // updating here first would defeat LINE de-duplication). + // Other instrumented opcodes update prev_line via + // execute_instrumented. + if !matches!( + op.into(), + Opcode::Resume | Opcode::ExtendedArg | Opcode::InstrumentedLine + ) && !op.is_instrumented() + && let Some((loc, _)) = self.code.locations.get(idx) + { + self.prev_line.set(loc.line.get() as u32); + } + if vm.use_tracing.get() { // Fire 'opcode' trace event for sys.settrace when f_trace_opcodes // is set. Skip RESUME and ExtendedArg // (_Py_call_instrumentation_instruction). - if !vm.is_none(&self.object.trace.lock()) - && *self.object.trace_opcodes.lock() + if self.trace_is_set(vm) + && self.trace_opcodes_is_set() && !matches!( op.into(), Opcode::Resume | Opcode::InstrumentedResume | Opcode::ExtendedArg @@ -2208,7 +2838,7 @@ impl ExecutingFrame<'_> { let next = exception.__traceback__(); let new_traceback = PyTraceback::new( next, - frame.object.to_owned(), + frame.frame_object(vm), idx as u32 * 2, loc.line, ); @@ -2277,7 +2907,7 @@ impl ExecutingFrame<'_> { let new_traceback = PyTraceback::new( next, - frame.object.to_owned(), + frame.frame_object(vm), idx as u32 * 2, loc.line, ); @@ -2388,7 +3018,9 @@ impl ExecutingFrame<'_> { // The traceback was created with the correct lasti when exception // was first raised, but frame.lasti may have changed during cleanup if let Some(tb) = exception.__traceback__() - && core::ptr::eq::>(&*tb.frame, self.object) + && self.iframe().frame_obj().is_some_and(|fo| { + core::ptr::eq::>(&*tb.frame, fo) + }) { // This traceback entry is for this frame - restore its lasti // tb.lasti is in bytes (idx * 2), convert back to instruction index @@ -2477,12 +3109,8 @@ impl ExecutingFrame<'_> { if idx < self.code.locations.len() { let (loc, _end_loc) = self.code.locations[idx]; let next = err.__traceback__(); - let new_traceback = PyTraceback::new( - next, - self.object.to_owned(), - idx as u32 * 2, - loc.line, - ); + let new_traceback = + PyTraceback::new(next, self.frame_object(vm), idx as u32 * 2, loc.line); err.set_traceback_typed(Some(new_traceback.into_ref(&vm.ctx))); } @@ -2490,7 +3118,7 @@ impl ExecutingFrame<'_> { vm.contextualize_exception(&err); return match self.unwind_blocks(vm, UnwindReason::Raising { exception: err }) { Ok(None) => { - *self.prev_line = 0; + self.prev_line.set(0); self.run(vm) } Ok(Some(result)) => Ok(result), @@ -2524,7 +3152,7 @@ impl ExecutingFrame<'_> { let next = err.__traceback__(); let new_traceback = PyTraceback::new( next, - self.object.to_owned(), + self.frame_object(vm), idx as u32 * 2, loc.line, ); @@ -2535,7 +3163,7 @@ impl ExecutingFrame<'_> { vm.contextualize_exception(&err); match self.unwind_blocks(vm, UnwindReason::Raising { exception: err }) { Ok(None) => { - *self.prev_line = 0; + self.prev_line.set(0); self.run(vm) } Ok(Some(result)) => Ok(result), @@ -2566,7 +3194,7 @@ impl ExecutingFrame<'_> { let (loc, _end_loc) = self.code.locations[idx]; let next = exception.__traceback__(); let new_traceback = - PyTraceback::new(next, self.object.to_owned(), idx as u32 * 2, loc.line); + PyTraceback::new(next, self.frame_object(vm), idx as u32 * 2, loc.line); exception.set_traceback_typed(Some(new_traceback.into_ref(&vm.ctx))); } @@ -2609,7 +3237,7 @@ impl ExecutingFrame<'_> { // Reset prev_line so that the first instruction in the handler // fires a LINE event. In CPython, gen_send_ex re-enters the // eval loop which reinitializes its local prev_instr tracker. - *self.prev_line = 0; + self.prev_line.set(0); self.run(vm) } Ok(Some(result)) => Ok(result), @@ -2673,7 +3301,7 @@ impl ExecutingFrame<'_> { vm: &VirtualMachine, ) -> FrameResult { flame_guard!(format!( - "Frame::execute_instruction({instruction:?} {arg:?})" + "FrameObject::execute_instruction({instruction:?} {arg:?})" )); #[cfg(feature = "vm-tracing-logging")] @@ -2930,9 +3558,7 @@ impl ExecutingFrame<'_> { let n = n.get(arg) as usize; if n > 0 { let closure = self - .object .func_obj - .as_ref() .and_then(|f| f.downcast_ref::()) .and_then(|f| f.closure.as_ref()); let nlocalsplus = self.code.localspluskinds.len(); @@ -3899,7 +4525,7 @@ impl ExecutingFrame<'_> { // Python preserves exception tracebacks even after the exception is no longer // the "current exception". This is important for code that catches an exception, // stores it, and later inspects its traceback. - // Reference cycles (Exception → Traceback → Frame → locals) are handled by + // Reference cycles (Exception → Traceback → FrameObject → locals) are handled by // Python's garbage collector which can detect and break cycles. Ok(None) @@ -4240,7 +4866,7 @@ impl ExecutingFrame<'_> { Ok(None) } PyIterReturn::StopIteration(value) => { - if vm.use_tracing.get() && !vm.is_none(&self.object.trace.lock()) { + if vm.use_tracing.get() && self.trace_is_set(vm) { let stop_exc = vm.new_stop_iteration(value.clone()); self.fire_exception_trace(&stop_exc, vm)?; } @@ -4277,7 +4903,7 @@ impl ExecutingFrame<'_> { return Ok(None); } PyIterReturn::StopIteration(value) => { - if vm.use_tracing.get() && !vm.is_none(&self.object.trace.lock()) { + if vm.use_tracing.get() && self.trace_is_set(vm) { let stop_exc = vm.new_stop_iteration(value.clone()); self.fire_exception_trace(&stop_exc, vm)?; } @@ -4295,7 +4921,7 @@ impl ExecutingFrame<'_> { Ok(None) } PyIterReturn::StopIteration(value) => { - if vm.use_tracing.get() && !vm.is_none(&self.object.trace.lock()) { + if vm.use_tracing.get() && self.trace_is_set(vm) { let stop_exc = vm.new_stop_iteration(value.clone()); self.fire_exception_trace(&stop_exc, vm)?; } @@ -6299,7 +6925,7 @@ impl ExecutingFrame<'_> { self.push_value(value); } Ok(PyIterReturn::StopIteration(value)) => { - if vm.use_tracing.get() && !vm.is_none(&self.object.trace.lock()) { + if vm.use_tracing.get() && self.trace_is_set(vm) { let stop_exc = vm.new_stop_iteration(value); self.fire_exception_trace(&stop_exc, vm)?; } @@ -6387,6 +7013,20 @@ impl ExecutingFrame<'_> { instruction.is_instrumented(), "execute_instrumented called with non-instrumented opcode {instruction:?}" ); + // Update prev_line for f_lineno. The main bytecode loop skips + // instrumented opcodes to avoid interfering with LINE event + // de-duplication in InstrumentedLine. Update here instead, except + // for RESUME (prev_line must stay 0 for the first LINE event) and + // InstrumentedLine (manages prev_line in its own handler). + if !matches!( + instruction, + Instruction::InstrumentedResume | Instruction::InstrumentedLine + ) { + let idx = self.lasti() as usize - 1; + if let Some((loc, _)) = self.code.locations.get(idx) { + self.prev_line.set(loc.line.get() as u32); + } + } self.monitoring_mask = vm.state.monitoring_events.load(); match instruction { Instruction::InstrumentedResume => { @@ -6689,8 +7329,8 @@ impl ExecutingFrame<'_> { // Fire LINE event only if line changed if let Some((loc, _)) = self.code.locations.get(idx) { let line = loc.line.get() as u32; - if line != *self.prev_line && line > 0 { - *self.prev_line = line; + if line != self.prev_line.get() && line > 0 { + self.prev_line.set(line); monitoring::fire_line(vm, self.code, offset, line)?; } } @@ -6700,6 +7340,12 @@ impl ExecutingFrame<'_> { monitoring::fire_instruction(vm, self.code, offset)?; } + // Update prev_line for f_lineno since the bytecode loop's + // update skips all instrumented opcodes. + if let Some((loc, _)) = self.code.locations.get(idx) { + self.prev_line.set(loc.line.get() as u32); + } + // Re-dispatch to the real original opcode let original_op = Instruction::try_from(real_op_byte) .expect("invalid opcode in side-table chain"); @@ -6781,7 +7427,7 @@ impl ExecutingFrame<'_> { if let Some(builtins_dict) = self.builtins_dict { // Fast path: both globals and builtins are exact dicts // SAFETY: builtins_dict is only set when globals is also exact dict - let globals_exact = unsafe { PyExact::ref_unchecked(self.globals.as_ref()) }; + let globals_exact = unsafe { PyExact::ref_unchecked(self.globals) }; globals_exact .get_chain_exact(builtins_dict, name, vm)? .ok_or_else(|| { @@ -6802,7 +7448,7 @@ impl ExecutingFrame<'_> { } } - #[cfg_attr(feature = "flame-it", flame("Frame"))] + #[cfg_attr(feature = "flame-it", flame("FrameObject"))] fn import(&mut self, vm: &VirtualMachine, module_name: Option<&Py>) -> PyResult<()> { let module_name = module_name.unwrap_or(vm.ctx.empty_str); let top = self.pop_value(); @@ -6818,7 +7464,7 @@ impl ExecutingFrame<'_> { Ok(()) } - #[cfg_attr(feature = "flame-it", flame("Frame"))] + #[cfg_attr(feature = "flame-it", flame("FrameObject"))] fn import_from(&mut self, vm: &VirtualMachine, idx: bytecode::NameIdx) -> PyResult { let module = self.top_value(); let name = self.code.names[idx as usize]; @@ -6920,7 +7566,7 @@ impl ExecutingFrame<'_> { Err(err) } - #[cfg_attr(feature = "flame-it", flame("Frame"))] + #[cfg_attr(feature = "flame-it", flame("FrameObject"))] fn import_star(&mut self, vm: &VirtualMachine) -> PyResult<()> { let module = self.pop_value(); @@ -6973,7 +7619,7 @@ impl ExecutingFrame<'_> { /// The reason for unwinding gives a hint on what to do when /// unwinding a block. /// Optionally returns an exception. - #[cfg_attr(feature = "flame-it", flame("Frame"))] + #[cfg_attr(feature = "flame-it", flame("FrameObject"))] fn unwind_blocks(&mut self, vm: &VirtualMachine, reason: UnwindReason) -> FrameResult { // use exception table for exception handling match reason { @@ -7580,7 +8226,7 @@ impl ExecutingFrame<'_> { self.push_value(vm.ctx.new_int(value).into()); return Ok(true); } - if vm.use_tracing.get() && !vm.is_none(&self.object.trace.lock()) { + if vm.use_tracing.get() && self.trace_is_set(vm) { let stop_exc = vm.new_stop_iteration(None); self.fire_exception_trace(&stop_exc, vm)?; } @@ -7599,7 +8245,7 @@ impl ExecutingFrame<'_> { Ok(PyIterReturn::StopIteration(value)) => { // Fire 'exception' trace event for StopIteration, matching // FOR_ITER's inline call to _PyEval_MonitorRaise. - if vm.use_tracing.get() && !vm.is_none(&self.object.trace.lock()) { + if vm.use_tracing.get() && self.trace_is_set(vm) { let stop_exc = vm.new_stop_iteration(value); self.fire_exception_trace(&stop_exc, vm)?; } @@ -7634,7 +8280,7 @@ impl ExecutingFrame<'_> { .expect("Stack value should be code object"); // Create function with minimal attributes - let func_obj = PyFunction::new(code_obj, self.globals.clone(), vm)?.into_pyobject(vm); + let func_obj = PyFunction::new(code_obj, self.globals.to_owned(), vm)?.into_pyobject(vm); self.push_value(func_obj); Ok(None) @@ -7669,7 +8315,7 @@ impl ExecutingFrame<'_> { Ok(None) } - #[cfg_attr(feature = "flame-it", flame("Frame"))] + #[cfg_attr(feature = "flame-it", flame("FrameObject"))] fn execute_bin_op(&mut self, vm: &VirtualMachine, op: bytecode::BinaryOperator) -> FrameResult { let b_ref = &self.pop_value(); let a_ref = &self.pop_value(); @@ -8023,7 +8669,7 @@ impl ExecutingFrame<'_> { Ok(!self._in(vm, needle, haystack)?) } - #[cfg_attr(feature = "flame-it", flame("Frame"))] + #[cfg_attr(feature = "flame-it", flame("FrameObject"))] fn execute_compare(&mut self, vm: &VirtualMachine, arg: bytecode::OpArg) -> FrameResult { let op = bytecode::ComparisonOperator::try_from(u32::from(arg)) .unwrap_or(bytecode::ComparisonOperator::Equal); @@ -10404,12 +11050,12 @@ impl ExecutingFrame<'_> { } } -impl fmt::Debug for Frame { +impl fmt::Debug for FrameObject { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // SAFETY: Debug is best-effort; concurrent mutation is unlikely // and would only affect debug output. let Some(iframe) = (unsafe { &*self.iframe.get() }) else { - return f.write_str("Frame Object { cleared }"); + return f.write_str("FrameObject Object { cleared }"); }; let stack_str = iframe @@ -10433,9 +11079,9 @@ impl fmt::Debug for Frame { // TODO: fix this up write!( f, - "Frame Object {{ \n Stack:{}\n Locals initialized:{}\n}}", + "FrameObject Object {{ \n Stack:{}\n Locals initialized:{}\n}}", stack_str, - self.locals.get().is_some() + self.iframe().locals.get().is_some() ) } } diff --git a/crates/vm/src/gc_state.rs b/crates/vm/src/gc_state.rs index ebae58f96cb..5843dfe24c7 100644 --- a/crates/vm/src/gc_state.rs +++ b/crates/vm/src/gc_state.rs @@ -633,33 +633,26 @@ impl GcState { // classified reachable. A running frame appearing in `unreachable` // would mean the reachability analysis observed its interpreter state // as garbage — the exact hazard the barrier exists to prevent. + // Verify no running frame is classified unreachable. + // Walk the TLS frame chain (CURRENT_FRAME) instead of top_frame, + // because stack-allocated frames update only CURRENT_FRAME (via + // set_current_frame_nosave), not top_frame. #[cfg(all(unix, feature = "threading", debug_assertions))] if stw.stopped { let unreachable_set: HashSet = unreachable.iter().copied().collect(); - crate::vm::thread::try_with_current_vm(|vm| { - let registry = vm.state.thread_frames.lock(); - #[expect( - clippy::iter_over_hash_type, - reason = "assertion over every registered thread slot" - )] - for slot in registry.values() { - let mut cur = slot.top_frame.load(core::sync::atomic::Ordering::Relaxed) - as *const crate::frame::Frame; - while !cur.is_null() { - // SAFETY: frames on a thread's active call stack are - // alive, and the world is stopped so none can be popped. - let obj = - unsafe { &*crate::Py::::from_payload_ptr(cur) } - .as_object(); - let ptr = GcPtr(NonNull::from(obj)); - debug_assert!( - !unreachable_set.contains(&ptr), - "running frame {obj:p} classified unreachable during GC" - ); - cur = unsafe { (*cur).previous_frame() }; - } + let mut cur = crate::vm::thread::get_current_frame(); + while !cur.is_null() { + let iframe = unsafe { &*cur }; + if let Some(fo) = iframe.frame_obj() { + let obj = fo.as_object(); + let ptr = GcPtr(NonNull::from(obj)); + debug_assert!( + !unreachable_set.contains(&ptr), + "running frame {obj:p} classified unreachable during GC" + ); } - }); + cur = iframe.previous(); + } } if debug.contains(GcDebugFlags::STATS) { diff --git a/crates/vm/src/import.rs b/crates/vm/src/import.rs index f7cc03d991e..eb07c76a201 100644 --- a/crates/vm/src/import.rs +++ b/crates/vm/src/import.rs @@ -221,12 +221,12 @@ fn remove_importlib_frames_inner( return (None, false); }; - let file_name = traceback.frame.code.source_path().as_str(); + let file_name = traceback.frame.iframe().code().source_path().as_str(); let (inner_tb, mut now_in_importlib) = remove_importlib_frames_inner(vm, traceback.next.lock().clone(), always_trim); if file_name == "_frozen_importlib" || file_name == "_frozen_importlib_external" { - if traceback.frame.code.obj_name.as_str() == "_call_with_frames_removed" { + if traceback.frame.iframe().code().obj_name.as_str() == "_call_with_frames_removed" { now_in_importlib = true; } if always_trim || now_in_importlib { diff --git a/crates/vm/src/object/core.rs b/crates/vm/src/object/core.rs index 228fe1290ea..0ee7a062ee7 100644 --- a/crates/vm/src/object/core.rs +++ b/crates/vm/src/object/core.rs @@ -211,7 +211,7 @@ pub(super) unsafe fn default_dealloc(obj: *mut PyObject) { } // Extract child references to break circular refs (tp_clear), then drop - // them. Some payloads (e.g. Frame) drop children in place inside clear_fn + // them. Some payloads (e.g. FrameObject) drop children in place inside clear_fn // instead of extracting them, so user code (`__del__`) may run here. let mut edges = Vec::new(); if let Some(clear_fn) = vtable.clear { @@ -2191,6 +2191,7 @@ impl Py { /// obtained by dereferencing a `Py`), and the object must outlive the /// returned pointer's use. #[inline] + #[cfg_attr(not(feature = "threading"), allow(dead_code))] pub(crate) unsafe fn from_payload_ptr(payload: *const T) -> *const Self { let offset = core::mem::offset_of!(PyInner, payload); // `Py` is a newtype over `PyInner`, so their addresses coincide. diff --git a/crates/vm/src/object/ext.rs b/crates/vm/src/object/ext.rs index e576ac2c191..c4732d1cb3f 100644 --- a/crates/vm/src/object/ext.rs +++ b/crates/vm/src/object/ext.rs @@ -333,7 +333,7 @@ impl PyAtomicRef { pub fn swap_to_temporary_refs(&self, pyref: PyRef, vm: &VirtualMachine) { let old = unsafe { self.swap(pyref) }; if let Some(frame) = vm.current_frame() { - frame.temporary_refs.lock().push(old.into()); + frame.iframe().temporary_refs.lock().push(old.into()); } } } @@ -409,7 +409,7 @@ impl PyAtomicRef> { return; }; if let Some(frame) = vm.current_frame() { - frame.temporary_refs.lock().push(old.into()); + frame.iframe().temporary_refs.lock().push(old.into()); } } } @@ -452,7 +452,7 @@ impl PyAtomicRef { pub fn swap_to_temporary_refs(&self, obj: PyObjectRef, vm: &VirtualMachine) { let old = unsafe { self.swap(obj) }; if let Some(frame) = vm.current_frame() { - frame.temporary_refs.lock().push(old); + frame.iframe().temporary_refs.lock().push(old); } } } @@ -499,7 +499,7 @@ impl PyAtomicRef> { return; }; if let Some(frame) = vm.current_frame() { - frame.temporary_refs.lock().push(old); + frame.iframe().temporary_refs.lock().push(old); } } } diff --git a/crates/vm/src/object/payload.rs b/crates/vm/src/object/payload.rs index b6590239ee3..261b2782108 100644 --- a/crates/vm/src/object/payload.rs +++ b/crates/vm/src/object/payload.rs @@ -51,7 +51,7 @@ pub trait PyPayload: MaybeTraverse + PyThreadingConstraint + Sized + 'static { /// Whether `PyRef::new_ref` skips auto-tracking this type in the GC even /// when it would otherwise qualify (has traverse, dict, or heap type). /// Such objects are created untracked and must be tracked explicitly if - /// and when they can become part of a reference cycle. Used by `Frame`, + /// and when they can become part of a reference cycle. Used by `FrameObject`, /// which is created untracked and tracked lazily only on escape. const NEW_REF_UNTRACKED: bool = false; diff --git a/crates/vm/src/protocol/callable.rs b/crates/vm/src/protocol/callable.rs index 70e0a54dcec..9e80c5b2c25 100644 --- a/crates/vm/src/protocol/callable.rs +++ b/crates/vm/src/protocol/callable.rs @@ -228,12 +228,12 @@ impl VirtualMachine { let is_profile_event = event.is_profile_event(); let is_opcode_event = event.is_opcode_event(); - let Some(frame_ref) = self.current_frame() else { + let Some(frame_ref) = crate::frame::current_thread_frame_materialize(self) else { return Ok(None); }; // Opcode events are only dispatched when f_trace_opcodes is set. - if is_opcode_event && !*frame_ref.trace_opcodes.lock() { + if is_opcode_event && !*frame_ref.iframe().trace_opcodes.lock() { return Ok(None); } @@ -261,7 +261,7 @@ impl VirtualMachine { // trace_trampoline behavior: clear per-frame f_trace // and propagate the error. if let Some(frame_ref) = self.current_frame() { - *frame_ref.trace.lock() = self.ctx.none(); + *frame_ref.iframe().trace.lock() = None; } return Err(e); } diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index ce41a942891..43a869fbecf 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -5393,10 +5393,10 @@ mod _io { if vm.state.config.settings.warn_default_encoding { let mut stacklevel = stacklevel.unwrap_or(2); if stacklevel > 1 - && let Some(frame) = vm.current_frame() + && let Some(code) = crate::frame::current_code() && let Some(stdlib_dir) = vm.state.config.paths.stdlib_dir.as_deref() { - let path = frame.code.source_path().as_str(); + let path = code.source_path().as_str(); if !path.starts_with(stdlib_dir) { stacklevel = stacklevel.saturating_sub(1); } diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index a1942adc784..e9d75dc02e9 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -25,7 +25,7 @@ pub(crate) mod _thread { PyUtf8StrRef, }, common::{lock::PyMutex, wtf8::Wtf8Buf}, - frame::FrameRef, + frame::FrameObjectRef, function::{ArgCallable, FuncArgs, KwArgs, OptionalArg, PySetterValue, TimeoutSeconds}, object::{Traverse, TraverseFn}, types::{Constructor, GetAttr, Representable, SetAttr}, @@ -1161,44 +1161,117 @@ pub(crate) mod _thread { pub(crate) use crate::vm::thread::CurrentFrameSlot; /// Get all threads' current (top) frames. Used by sys._current_frames(). - pub(crate) fn get_all_current_frames(vm: &VirtualMachine) -> Vec<(u64, FrameRef)> { + pub(crate) fn get_all_current_frames(vm: &VirtualMachine) -> Vec<(u64, FrameObjectRef)> { // unix: read each thread's published top frame under stop-the-world so // the owning thread is parked at a safepoint and cannot pop or free the // frame while we take a strong reference. Request stop-the-world before // the registry lock to avoid deadlocking a thread parking mid-registry. + // + // For the current thread, use TLS CURRENT_FRAME directly because + // stack-allocated frames only update TLS (not top_frame). #[cfg(unix)] { use core::sync::atomic::Ordering; + let current_ident = get_ident(); vm.state.stop_the_world.stop_the_world(vm); scopeguard::defer! { vm.state.stop_the_world.start_the_world(vm); } let registry = vm.state.thread_frames.lock(); registry .iter() .filter_map(|(id, slot)| { - let top = slot.top_frame.load(Ordering::Relaxed); - core::ptr::NonNull::new(top).map(|p| { - // SAFETY: world stopped -> the owning thread is parked - // and cannot pop or free this frame; it is alive on - // that thread's call stack. - let py = - unsafe { &*Py::::from_payload_ptr(p.as_ptr()) }; - (*id, py.to_owned()) - }) + if *id == current_ident { + // Current thread: materialize from TLS chain + crate::frame::current_thread_frame_materialize(vm).map(|frame| (*id, frame)) + } else { + // Other threads: try top_frame first (FrameObject), + // fall back to top_iframe (may be a stack-allocated frame). + let top = slot.top_frame.load(Ordering::Relaxed); + if let Some(p) = core::ptr::NonNull::new(top) { + let py = unsafe { + &*Py::::from_payload_ptr(p.as_ptr()) + }; + Some((*id, py.to_owned())) + } else { + // Stack-allocated frame: materialize from top_iframe. + // SAFETY: world stopped -> owning thread is parked. + let iframe_ptr = slot.top_iframe.load(Ordering::Relaxed) + as *const crate::frame::InterpreterFrame; + if !iframe_ptr.is_null() { + // Materialize the entire frame chain and link + // retained_back so f_back works after STW ends. + let mut cur = iframe_ptr; + let mut child_fo: Option> = + None; + while !cur.is_null() { + let iframe = unsafe { &*cur }; + let fo = iframe.materialize(vm).to_owned(); + if let Some(child) = child_fo.take() { + let mut guard = child.iframe().retained_back.lock(); + if guard.is_none() { + *guard = Some(fo.clone()); + } + } + child_fo = Some(fo); + cur = iframe.previous(); + } + let iframe = unsafe { &*iframe_ptr }; + let fo = iframe.materialize(vm); + Some((*id, fo.to_owned())) + } else { + None + } + } + } }) .collect() } #[cfg(not(unix))] { + use core::sync::atomic::Ordering; + let current_ident = get_ident(); + vm.state.stop_the_world.stop_the_world(vm); + scopeguard::defer! { vm.state.stop_the_world.start_the_world(vm); } let registry = vm.state.thread_frames.lock(); registry .iter() .filter_map(|(id, slot)| { - let frames = slot.frames.lock(); - // SAFETY: the owning thread can't pop while we hold the Mutex, - // so the FramePtr is valid for the duration of the lock. - frames - .last() - .map(|fp| (*id, unsafe { fp.as_ref() }.to_owned())) + if *id == current_ident { + // Current thread: materialize from TLS chain + crate::frame::current_thread_frame_materialize(vm).map(|frame| (*id, frame)) + } else { + // Other threads: use top_iframe to include + // stack-allocated frames. Materialize the entire + // chain and link retained_back so f_back works. + // SAFETY: world stopped -> owning thread is parked. + let iframe_ptr = slot.top_iframe.load(Ordering::Relaxed) + as *const crate::frame::InterpreterFrame; + if !iframe_ptr.is_null() { + let mut cur = iframe_ptr; + let mut child_fo: Option> = + None; + while !cur.is_null() { + let iframe = unsafe { &*cur }; + let fo = iframe.materialize(vm).to_owned(); + if let Some(child) = child_fo.take() { + let mut guard = child.iframe().retained_back.lock(); + if guard.is_none() { + *guard = Some(fo.clone()); + } + } + child_fo = Some(fo); + cur = iframe.previous(); + } + let iframe = unsafe { &*iframe_ptr }; + let fo = iframe.materialize(vm); + Some((*id, fo.to_owned())) + } else { + // Fall back to frames stack for FrameObject-only path + let frames = slot.frames.lock(); + frames + .last() + .map(|fp| (*id, unsafe { fp.as_ref() }.to_owned())) + } + } }) .collect() } diff --git a/crates/vm/src/stdlib/_typing.rs b/crates/vm/src/stdlib/_typing.rs index 04b9208b319..0214c3cc544 100644 --- a/crates/vm/src/stdlib/_typing.rs +++ b/crates/vm/src/stdlib/_typing.rs @@ -417,9 +417,8 @@ pub(crate) mod decl { }; // Get caller's module name from frame globals, like typevar.rs caller() - let module = vm - .current_frame() - .and_then(|f| f.globals.get_item("__name__", vm).ok()); + let module = + crate::frame::current_globals().and_then(|g| g.get_item("__name__", vm).ok()); Ok(Self::new_eager(name, type_params, value, module)) } diff --git a/crates/vm/src/stdlib/builtins.rs b/crates/vm/src/stdlib/builtins.rs index e329763fa45..7817d6ecb97 100644 --- a/crates/vm/src/stdlib/builtins.rs +++ b/crates/vm/src/stdlib/builtins.rs @@ -126,12 +126,12 @@ mod builtins { fn merge_compile_future_features( flags: i32, dont_inherit: bool, - vm: &VirtualMachine, + _vm: &VirtualMachine, ) -> bytecode::CodeFlags { let mut future_features = compile_future_features_from_flags(flags); - if !dont_inherit && let Some(frame) = vm.current_frame() { + if !dont_inherit && let Some(code) = crate::frame::current_code() { future_features |= bytecode::CodeFlags::from_bits_truncate( - frame.code.flags.bits() & compile_future_feature_mask().bits(), + code.flags.bits() & compile_future_feature_mask().bits(), ); } future_features @@ -649,9 +649,9 @@ mod builtins { Either::A(string) => { let source = string.as_str(); let mut opts = vm.compile_opts(); - if let Some(frame) = vm.current_frame() { + if let Some(code) = crate::frame::current_code() { opts.future_features = bytecode::CodeFlags::from_bits_truncate( - frame.code.flags.bits() & compile_future_feature_mask().bits(), + code.flags.bits() & compile_future_feature_mask().bits(), ); } vm.compile_with_opts(source, mode, "", opts) diff --git a/crates/vm/src/stdlib/sys.rs b/crates/vm/src/stdlib/sys.rs index 65917865d07..8f264d7a739 100644 --- a/crates/vm/src/stdlib/sys.rs +++ b/crates/vm/src/stdlib/sys.rs @@ -44,7 +44,7 @@ pub mod sys { hash::{PyHash, PyUHash}, }, convert::ToPyObject, - frame::FrameRef, + frame::FrameObjectRef, function::{FuncArgs, KwArgs, OptionalArg, PosArgs}, stdlib::{_warnings::warn, builtins}, types::PyStructSequence, @@ -968,9 +968,9 @@ pub mod sys { } #[pyfunction] - fn _getframe(offset: OptionalArg, vm: &VirtualMachine) -> PyResult { + fn _getframe(offset: OptionalArg, vm: &VirtualMachine) -> PyResult { let offset = offset.into_option().unwrap_or(0); - let frame_ref = crate::frame::frame_at_offset(offset) + let frame_ref = crate::frame::frame_at_offset(offset, vm) .ok_or_else(|| vm.new_value_error("call stack is not deep enough"))?; frame_ref.mark_escaped(); @@ -992,8 +992,8 @@ pub mod sys { } // Get the frame at the specified depth - let func_obj = match crate::frame::frame_at_offset(depth) { - Some(frame) => frame.func_obj.clone(), + let func_obj = match crate::frame::frame_at_offset(depth, vm) { + Some(frame) => frame.iframe().func_obj().map(|o| o.to_owned()), None => return Ok(vm.ctx.none()), }; diff --git a/crates/vm/src/stdlib/sys/monitoring.rs b/crates/vm/src/stdlib/sys/monitoring.rs index f468a86b0a5..7e47185bbe5 100644 --- a/crates/vm/src/stdlib/sys/monitoring.rs +++ b/crates/vm/src/stdlib/sys/monitoring.rs @@ -1,5 +1,5 @@ use crate::{ - AsObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, + AsObject, Py, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, builtins::{PyCode, PyDictRef, PyNamespace, PyUtf8StrRef, code::CoMonitoringData}, function::FuncArgs, }; @@ -528,16 +528,23 @@ fn update_events_mask(vm: &VirtualMachine, state: &MonitoringState) { // Each code object gets only the events that apply to it (global + its // own local events), preventing e.g. INSTRUCTION from being applied to // unrelated code objects. - crate::frame::for_each_current_frame(|frame| { - let code = &frame.code; - let code_ver = code.instrumentation_version.load(Ordering::Acquire); - if code_ver != new_ver { - let code_events = state.events_for_code(code.get_id()); - instrument_code(code, code_events); - code.instrumentation_version - .store(new_ver, Ordering::Release); + // Re-instrument all frames on the current thread's stack, including + // stack-allocated iframes (with_iframe path) that have no FrameObject. + { + let mut cur = crate::vm::thread::get_current_frame(); + while !cur.is_null() { + let iframe_ref = unsafe { &*cur }; + let code = iframe_ref.code(); + let code_ver = code.instrumentation_version.load(Ordering::Acquire); + if code_ver != new_ver { + let code_events = state.events_for_code(code.get_id()); + instrument_code(code, code_events); + code.instrumentation_version + .store(new_ver, Ordering::Release); + } + cur = iframe_ref.previous(); } - }); + } } fn use_tool_id(tool_id: i32, name: &str, vm: &VirtualMachine) -> PyResult<()> { @@ -740,7 +747,7 @@ thread_local! { fn fire( vm: &VirtualMachine, event: u32, - code: &PyRef, + code: &Py, offset: u32, cb_extra: &[PyObjectRef], ) -> PyResult<()> { @@ -788,7 +795,7 @@ fn fire( } let mut args_vec = Vec::with_capacity(1 + cb_extra.len()); - args_vec.push(code.clone().into()); + args_vec.push(code.to_owned().into()); args_vec.extend_from_slice(cb_extra); let args = FuncArgs::from(args_vec); @@ -823,11 +830,7 @@ fn fire( // Public dispatch functions (called from frame.rs) -pub(crate) fn fire_py_start( - vm: &VirtualMachine, - code: &PyRef, - offset: u32, -) -> PyResult<()> { +pub(crate) fn fire_py_start(vm: &VirtualMachine, code: &Py, offset: u32) -> PyResult<()> { fire( vm, EVENT_PY_START, @@ -837,11 +840,7 @@ pub(crate) fn fire_py_start( ) } -pub(crate) fn fire_py_resume( - vm: &VirtualMachine, - code: &PyRef, - offset: u32, -) -> PyResult<()> { +pub(crate) fn fire_py_resume(vm: &VirtualMachine, code: &Py, offset: u32) -> PyResult<()> { fire( vm, EVENT_PY_RESUME, @@ -853,7 +852,7 @@ pub(crate) fn fire_py_resume( pub(crate) fn fire_py_return( vm: &VirtualMachine, - code: &PyRef, + code: &Py, offset: u32, retval: &PyObjectRef, ) -> PyResult<()> { @@ -868,7 +867,7 @@ pub(crate) fn fire_py_return( pub(crate) fn fire_py_yield( vm: &VirtualMachine, - code: &PyRef, + code: &Py, offset: u32, retval: &PyObjectRef, ) -> PyResult<()> { @@ -883,7 +882,7 @@ pub(crate) fn fire_py_yield( pub(crate) fn fire_call( vm: &VirtualMachine, - code: &PyRef, + code: &Py, offset: u32, callable: &PyObjectRef, arg0: PyObjectRef, @@ -899,7 +898,7 @@ pub(crate) fn fire_call( pub(crate) fn fire_c_return( vm: &VirtualMachine, - code: &PyRef, + code: &Py, offset: u32, callable: &PyObjectRef, arg0: PyObjectRef, @@ -915,7 +914,7 @@ pub(crate) fn fire_c_return( pub(crate) fn fire_c_raise( vm: &VirtualMachine, - code: &PyRef, + code: &Py, offset: u32, callable: &PyObjectRef, arg0: PyObjectRef, @@ -931,7 +930,7 @@ pub(crate) fn fire_c_raise( pub(crate) fn fire_line( vm: &VirtualMachine, - code: &PyRef, + code: &Py, offset: u32, line: u32, ) -> PyResult<()> { @@ -940,7 +939,7 @@ pub(crate) fn fire_line( pub(crate) fn fire_instruction( vm: &VirtualMachine, - code: &PyRef, + code: &Py, offset: u32, ) -> PyResult<()> { fire( @@ -954,7 +953,7 @@ pub(crate) fn fire_instruction( pub(crate) fn fire_raise( vm: &VirtualMachine, - code: &PyRef, + code: &Py, offset: u32, exception: &PyObjectRef, ) -> PyResult<()> { @@ -971,7 +970,7 @@ pub(crate) fn fire_raise( /// preventing duplicate events from chained cleanup handlers. pub(crate) fn fire_reraise( vm: &VirtualMachine, - code: &PyRef, + code: &Py, offset: u32, exception: &PyObjectRef, ) -> PyResult<()> { @@ -994,7 +993,7 @@ pub(crate) fn fire_reraise( pub(crate) fn fire_exception_handled( vm: &VirtualMachine, - code: &PyRef, + code: &Py, offset: u32, exception: &PyObjectRef, ) -> PyResult<()> { @@ -1010,7 +1009,7 @@ pub(crate) fn fire_exception_handled( pub(crate) fn fire_py_unwind( vm: &VirtualMachine, - code: &PyRef, + code: &Py, offset: u32, exception: &PyObjectRef, ) -> PyResult<()> { @@ -1026,7 +1025,7 @@ pub(crate) fn fire_py_unwind( pub(crate) fn fire_py_throw( vm: &VirtualMachine, - code: &PyRef, + code: &Py, offset: u32, exception: &PyObjectRef, ) -> PyResult<()> { @@ -1039,24 +1038,35 @@ pub(crate) fn fire_py_throw( ) } +/// If `value` is already a `StopIteration`, pass it directly; otherwise wrap +/// it in a new `StopIteration(value)` — matching `PyMonitoring_FireStopIterationEvent`. pub(crate) fn fire_stop_iteration( vm: &VirtualMachine, - code: &PyRef, + code: &Py, offset: u32, - exception: &PyObjectRef, + value: &PyObjectRef, ) -> PyResult<()> { + let exc: PyObjectRef = if value.fast_isinstance(vm.ctx.exceptions.stop_iteration) { + value.clone() + } else { + vm.ctx + .exceptions + .stop_iteration + .as_object() + .call(vec![value.clone()], vm)? + }; fire( vm, EVENT_STOP_ITERATION, code, offset, - &[vm.ctx.new_int(offset).into(), exception.clone()], + &[vm.ctx.new_int(offset).into(), exc], ) } pub(crate) fn fire_jump( vm: &VirtualMachine, - code: &PyRef, + code: &Py, offset: u32, destination: u32, ) -> PyResult<()> { @@ -1074,7 +1084,7 @@ pub(crate) fn fire_jump( pub(crate) fn fire_branch_left( vm: &VirtualMachine, - code: &PyRef, + code: &Py, offset: u32, destination: u32, ) -> PyResult<()> { @@ -1092,7 +1102,7 @@ pub(crate) fn fire_branch_left( pub(crate) fn fire_branch_right( vm: &VirtualMachine, - code: &PyRef, + code: &Py, offset: u32, destination: u32, ) -> PyResult<()> { diff --git a/crates/vm/src/stdlib/typevar.rs b/crates/vm/src/stdlib/typevar.rs index da018b552e1..3e2581406e8 100644 --- a/crates/vm/src/stdlib/typevar.rs +++ b/crates/vm/src/stdlib/typevar.rs @@ -47,20 +47,17 @@ pub(crate) mod typevar { /// /// Note: CPython's implementation (in typevarobject.c) gets the module from the /// frame's function object using PyFunction_GetModule(f->f_funcobj). However, - /// RustPython's Frame doesn't store a reference to the function object, so we + /// RustPython's FrameObject doesn't store a reference to the function object, so we /// get the module name from the frame's globals dictionary instead. fn caller(vm: &VirtualMachine) -> Option { - let frame = vm.current_frame()?; - - // In RustPython, we get the module name from frame's globals - // This is similar to CPython's sys._getframe().f_globals.get('__name__') - frame.globals.get_item("__name__", vm).ok() + let globals = crate::frame::current_globals()?; + globals.get_item("__name__", vm).ok() } /// Set __module__ attribute for an object based on the caller's module. /// This follows CPython's behavior for TypeVar and similar objects. fn set_module_from_caller(obj: &PyObject, vm: &VirtualMachine) -> PyResult<()> { - // Note: CPython gets module from frame->f_funcobj, but RustPython's Frame + // Note: CPython gets module from frame->f_funcobj, but RustPython's FrameObject // architecture is different - we use globals['__name__'] instead let module_value: PyObjectRef = if let Some(module_name) = caller(vm) { // Special handling for certain module names diff --git a/crates/vm/src/suggestion.rs b/crates/vm/src/suggestion.rs index 69ce8f5f6b9..57323ebeac9 100644 --- a/crates/vm/src/suggestion.rs +++ b/crates/vm/src/suggestion.rs @@ -71,17 +71,23 @@ pub fn offer_suggestions(exc: &Py, vm: &VirtualMachine) -> Opti let tb = exc.__traceback__()?; let tb = tb.iter().last().unwrap_or(tb); - let varnames = tb.frame.code.clone().co_varnames(vm); + let varnames = tb.frame.iframe().code().to_owned().co_varnames(vm); if let Some(suggestions) = calculate_suggestions(varnames.iter(), &name) { return Some(suggestions); }; - let globals: Vec<_> = tb.frame.globals.as_object().try_to_value(vm).ok()?; + let globals: Vec<_> = tb + .frame + .iframe() + .globals() + .as_object() + .try_to_value(vm) + .ok()?; if let Some(suggestions) = calculate_suggestions(globals.iter(), &name) { return Some(suggestions); }; - let builtins: Vec<_> = tb.frame.builtins.try_to_value(vm).ok()?; + let builtins: Vec<_> = tb.frame.iframe().builtins().try_to_value(vm).ok()?; calculate_suggestions(builtins.iter(), &name) } else if exc.class().fast_issubclass(vm.ctx.exceptions.import_error) { let mod_name = exc.as_object().get_attr("name", vm).ok()?; diff --git a/crates/vm/src/types/zoo.rs b/crates/vm/src/types/zoo.rs index 64807fc0973..c8667050bf0 100644 --- a/crates/vm/src/types/zoo.rs +++ b/crates/vm/src/types/zoo.rs @@ -178,7 +178,7 @@ impl TypeZoo { dict_itemiterator_type: dict::PyDictItemIterator::init_builtin_type(), dict_reverseitemiterator_type: dict::PyDictReverseItemIterator::init_builtin_type(), ellipsis_type: slice::PyEllipsis::init_builtin_type(), - frame_type: crate::frame::Frame::init_builtin_type(), + frame_type: crate::frame::FrameObject::init_builtin_type(), frame_locals_proxy_type: frame_locals_proxy::FrameLocalsProxy::init_builtin_type(), function_type: function::PyFunction::init_builtin_type(), generator_type: generator::PyGenerator::init_builtin_type(), diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 801b1297b74..a9091f895f6 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -33,7 +33,7 @@ use crate::{ common::{hash::HashSecret, lock::PyMutex, rc::PyRc}, convert::ToPyObject, exceptions::types::PyBaseException, - frame::{ExecutionResult, Frame, FrameRef}, + frame::{ExecutionResult, FrameObject, FrameObjectRef}, frozen::FrozenModule, function::{ArgMapping, FuncArgs, PySetterValue}, import, @@ -111,23 +111,23 @@ pub struct VirtualMachine { /// Non-owning frame pointer for the non-unix threading frames stack. /// The pointed-to frame is kept alive by the caller of with_frame/resume_gen_frame. /// Unix threading builds publish the top frame through `ThreadSlot::top_frame` -/// and walk the rest via `Frame::previous`, so they do not use this type. +/// and walk the rest via `FrameObject::previous`, so they do not use this type. #[cfg(all(not(unix), feature = "threading"))] #[derive(Copy, Clone)] -pub struct FramePtr(NonNull>); +pub struct FramePtr(NonNull>); #[cfg(all(not(unix), feature = "threading"))] impl FramePtr { /// # Safety /// The pointed-to frame must still be alive. #[must_use] - pub unsafe fn as_ref(&self) -> &Py { + pub unsafe fn as_ref(&self) -> &Py { unsafe { self.0.as_ref() } } } // SAFETY: FramePtr is only stored in a thread's shared frame stack -// (`ThreadSlot::frames`) while the corresponding FrameRef is alive on that +// (`ThreadSlot::frames`) while the corresponding FrameObjectRef is alive on that // thread's call stack; readers dereference it under the slot mutex. #[cfg(all(not(unix), feature = "threading"))] unsafe impl Send for FramePtr {} @@ -1332,7 +1332,16 @@ impl VirtualMachine { } #[inline(always)] - pub fn run_frame(&self, frame: FrameRef) -> PyResult { + /// Run a stack-allocated InterpreterFrame without heap allocation. + /// This is the fast path for regular (non-generator) function calls. + pub fn run_frame_fast(&self, iframe: &mut crate::frame::InterpreterFrame) -> PyResult { + match self.with_iframe(iframe, |iframe| crate::frame::run_iframe(iframe, self))? { + ExecutionResult::Return(value) => Ok(value), + _ => panic!("Got unexpected result from function"), + } + } + + pub fn run_frame(&self, frame: FrameObjectRef) -> PyResult { // Only ordinary (datastack) call frames reach `run_frame`; generator // and coroutine frames are resumed through `resume_gen_frame`. A // datastack frame is created untracked and is tracked lazily only when @@ -1580,8 +1589,10 @@ impl VirtualMachine { /// The margin is doubled for debug/sanitized builds because frame /// evaluation consumes more native stack in those configurations. #[cfg_attr(any(miri, target_env = "musl"), allow(dead_code))] + // 2× CPython's _PY_STACK_MARGIN_BYTES to account for both heavy and + // light frame native stack usage per recursion step. const STACK_MARGIN_BYTES: usize = - (if cfg!(debug_assertions) { 16384 } else { 2048 }) * core::mem::size_of::(); + (if cfg!(debug_assertions) { 16384 } else { 4096 }) * core::mem::size_of::(); /// Get the stack boundaries using platform-specific APIs. /// Returns (base, top) where base is the lowest address and top is the highest. @@ -1667,7 +1678,7 @@ impl VirtualMachine { /// single native frame can exceed the margin and step past it. #[cfg(all(not(miri), not(target_env = "musl")))] #[inline(always)] - fn check_c_stack_overflow(&self) -> bool { + pub(crate) fn check_c_stack_overflow(&self) -> bool { let current_sp = psm::stack_pointer() as usize; let soft_limit = self.c_stack_soft_limit.get(); current_sp < soft_limit @@ -1677,7 +1688,7 @@ impl VirtualMachine { /// the probe during stdlib bootstrap. #[cfg(any(miri, target_env = "musl"))] #[inline(always)] - fn check_c_stack_overflow(&self) -> bool { + pub(crate) fn check_c_stack_overflow(&self) -> bool { false } @@ -1697,68 +1708,230 @@ impl VirtualMachine { f() } - pub fn with_frame PyResult>( + pub fn with_frame PyResult>( &self, - frame: FrameRef, + frame: FrameObjectRef, f: F, ) -> PyResult { - self.with_recursion("", || { - // SAFETY: `frame` (FrameRef) stays alive for the entire closure scope, - // keeping the FramePtr valid. We pass a clone to `f` so that `f` - // consuming its FrameRef doesn't invalidate our pointer. - // Publish the frame for sys._current_frames() and faulthandler. - // On unix, set_current_frame below publishes the top frame into the - // thread slot; only non-unix builds maintain the mutex-guarded Vec. - #[cfg(all(not(unix), feature = "threading"))] - crate::vm::thread::push_thread_frame(FramePtr(NonNull::from(&*frame))); - // Link frame into the signal-safe frame chain (previous pointer). - // This chain is the single source for the current thread's frame - // stack (current_frame, sys._getframe, f_back, monitoring). - let old_frame = crate::vm::thread::set_current_frame((&**frame) as *const Frame); - frame.previous.store( - old_frame as *mut Frame, - core::sync::atomic::Ordering::Relaxed, - ); - // Normal frame calls share the caller's exc_info slot so that - // callees can see the caller's handled exception via sys.exc_info(). - // Save the current value to restore on exit — this prevents - // exc_info pollution from frames with unbalanced - // PUSH_EXC_INFO/POP_EXCEPT (e.g., exception escaping an except block - // whose cleanup entry is missing from the exception table). - // A callee whose bytecode never mutates the slot cannot pollute it, - // so the save/restore is skipped for it. - let save_exc = frame.code.has_exc_handling; - let saved_exc = if save_exc { - self.current_exception() - } else { - None + self.check_recursive_call("")?; + + // Check the native C stack periodically. The sampling interval + // (every 8th call) balances overhead against the risk of missing + // an overflow between checks, especially when light and heavy + // frames alternate (each recursion step uses different native + // stack amounts). + let depth = self.recursion_depth.get(); + if depth & 7 == 0 && self.check_c_stack_overflow() { + return Err(self.new_recursion_error(String::new())); + } + + self.recursion_depth.update(|d| d + 1); + // Decrement on all exit paths (including panic between here and + // the explicit decrement at the bottom). + let _depth_guard = scopeguard::guard((), |()| { + self.recursion_depth.update(|d| d.saturating_sub(1)) + }); + + #[cfg(all(not(unix), feature = "threading"))] + crate::vm::thread::push_thread_frame(FramePtr(NonNull::from(&*frame))); + let iframe = frame.iframe() as *const crate::frame::InterpreterFrame; + let old_chain = crate::vm::thread::set_current_frame(iframe); + { + #[allow(unused_imports)] + use rustpython_common::atomic::Radium; + frame + .iframe() + .previous + .store(old_chain as usize, core::sync::atomic::Ordering::Relaxed); + } + let save_exc = frame.iframe().code().has_exc_handling; + let saved_exc = if save_exc { + self.current_exception() + } else { + None + }; + let old_owner = frame.iframe().owner.swap( + crate::frame::FrameOwner::Thread as i8, + core::sync::atomic::Ordering::AcqRel, + ); + + let result = self.dispatch_traced_frame(&frame, |frame| f(frame.to_owned())); + + // Capture f_back before clearing previous so code holding a + // reference to this FrameObject can walk the chain after return. + if !old_chain.is_null() { + let strong = frame.as_object().strong_count(); + // Only set retained_back if someone else holds a reference (escaped) + // AND the caller already has a FrameObject. Materializing the caller + // here would add refcounts on its local variables, preventing timely + // __del__ / ResourceWarning on dealloc. If the caller hasn't been + // materialized, f_back will resolve via the TLS chain while the + // caller is still executing, or return None after it returns. + if strong > 1 { + let mut guard = frame.iframe().retained_back.lock(); + if guard.is_none() { + let prev_iframe = unsafe { &*old_chain }; + if let Some(fo) = prev_iframe.frame_obj() { + *guard = Some(fo.to_owned()); + } + } + } + } + + frame + .iframe() + .owner + .store(old_owner, core::sync::atomic::Ordering::Release); + if save_exc { + self.restore_exception(saved_exc); + } + // Clear previous before popping — it may point to a stack-allocated + // iframe that will be freed when the caller's with_iframe exits. + { + #[allow(unused_imports)] + use rustpython_common::atomic::Radium; + frame + .iframe() + .previous + .store(0, core::sync::atomic::Ordering::Relaxed); + } + let _ = crate::vm::thread::set_current_frame(old_chain); + #[cfg(all(not(unix), feature = "threading"))] + crate::vm::thread::pop_thread_frame(); + // Disarm the panic guard — normal decrement. + scopeguard::ScopeGuard::into_inner(_depth_guard); + self.recursion_depth.update(|d| d - 1); + + result + } + + /// Execute a stack-allocated InterpreterFrame without heap-allocating + /// a FrameObject. This is the fast path for regular function calls. + /// The frame is pushed onto the chain as a `*const InterpreterFrame`. + #[inline(always)] + pub fn with_iframe( + &self, + iframe: &mut crate::frame::InterpreterFrame, + f: impl FnOnce(&mut crate::frame::InterpreterFrame) -> PyResult, + ) -> PyResult { + self.check_recursive_call("")?; + + let depth = self.recursion_depth.get(); + if depth & 7 == 0 && self.check_c_stack_overflow() { + return Err(self.new_recursion_error(String::new())); + } + + self.recursion_depth.update(|d| d + 1); + + let iframe_ptr = iframe as *const crate::frame::InterpreterFrame; + let old_chain = crate::vm::thread::set_current_frame(iframe_ptr); + { + #[allow(unused_imports)] + use rustpython_common::atomic::Radium; + iframe + .previous + .store(old_chain as usize, core::sync::atomic::Ordering::Relaxed); + } + let save_exc = iframe.code().has_exc_handling; + let saved_exc = if save_exc { + self.current_exception() + } else { + None + }; + + let result = f(iframe); + + // If this iframe was materialized, capture f_back so that code + // holding a reference to the FrameObject (e.g. sys._getframe() + // return value, traceback frames) can walk the chain after return. + // + // Read materialized through the raw TLS pointer instead of the + // &mut iframe reference. During f(iframe), bytecode can + // materialize the frame via the TLS chain (a raw pointer alias); + // the &mut borrow lets LLVM assume no aliased writes, which can + // cause the store to be invisible through `iframe.materialized`. + { + // Use read_volatile through the original raw pointer to bypass + // LLVM's noalias assumptions on the &mut iframe borrow. + let mat_ptr = unsafe { + let field_ptr = core::ptr::addr_of!((*iframe_ptr).materialized); + core::ptr::read_volatile(field_ptr as *const usize) }; - let old_owner = frame.owner.swap( - crate::frame::FrameOwner::Thread as i8, - core::sync::atomic::Ordering::AcqRel, - ); + if mat_ptr != 0 { + let fo = unsafe { &*(mat_ptr as *const crate::Py) }; + // Sync localsplus, prev_line, lasti from the live iframe to + // the materialized FrameObject so f_locals, f_lineno, f_lasti + // reflect the final state after execution. + unsafe { + let live_iframe = &*iframe_ptr; + fo.iframe_mut() + .localsplus + .sync_fastlocals_from(&live_iframe.localsplus); + fo.iframe_mut().prev_line.set(live_iframe.prev_line.get()); + #[allow(unused_imports)] + use rustpython_common::atomic::Radium; + fo.iframe_mut().lasti.store( + live_iframe + .lasti + .load(core::sync::atomic::Ordering::Relaxed), + core::sync::atomic::Ordering::Relaxed, + ); + } + if !old_chain.is_null() { + let prev_iframe = unsafe { &*old_chain }; + // Use materialize_chain to avoid cloning localsplus, which + // would create extra refcounts on local variables. The + // lightweight frame has empty localsplus; live values are + // read through find_live_source_iframe when needed. + let back_fo = prev_iframe.materialize_chain(self); + *fo.iframe().retained_back.lock() = Some(back_fo); + } + // Set owner to FrameObject since this frame is no longer + // executing on a thread. + fo.iframe().owner.store( + crate::frame::FrameOwner::FrameObject as i8, + core::sync::atomic::Ordering::Release, + ); + } + } + + if save_exc { + self.restore_exception(saved_exc); + } + // Restore the frame chain BEFORE clearing temporary_refs, so + // top_frame no longer points at the materialized FrameObject + // when its last strong reference is released. + let _ = crate::vm::thread::set_current_frame(old_chain); + self.recursion_depth.update(|d| d - 1); - // Ensure cleanup on panic: restore owner, exc_info, and frame chain. - scopeguard::defer! { - frame.owner.store(old_owner, core::sync::atomic::Ordering::Release); - if save_exc { - self.restore_exception(saved_exc); + // Now that the frame is off the chain, track the materialized + // FrameObject in the GC and release temporary_refs so cycle + // collection can detect and reclaim reference cycles. + { + let mat_ptr = unsafe { + let field_ptr = core::ptr::addr_of!((*iframe_ptr).materialized); + core::ptr::read_volatile(field_ptr as *const usize) + }; + if mat_ptr != 0 { + let fo = unsafe { &*(mat_ptr as *const crate::Py) }; + unsafe { + crate::gc_state::gc_state() + .track_object(core::ptr::NonNull::from(fo.as_object())); + let live_iframe = &*iframe_ptr; + live_iframe.temporary_refs.lock().clear(); } - crate::vm::thread::set_current_frame(old_frame); - #[cfg(all(not(unix), feature = "threading"))] - crate::vm::thread::pop_thread_frame(); } + } - self.dispatch_traced_frame(&frame, |frame| f(frame.to_owned())) - }) + result } - /// Frame execution for generator/coroutine resume. + /// FrameObject execution for generator/coroutine resume. /// Pushes a new exc_info slot (gi_exc_state) onto the chain, /// linking the generator's saved handled-exception. - pub fn resume_gen_frame) -> PyResult>( + pub fn resume_gen_frame) -> PyResult>( &self, - frame: &FrameRef, + frame: &FrameObjectRef, exc: Option, f: F, ) -> PyResult { @@ -1768,19 +1941,22 @@ impl VirtualMachine { } self.recursion_depth.update(|d| d + 1); - // SAFETY: frame (&FrameRef) stays alive for the duration, so NonNull is valid until pop. + // SAFETY: frame (&FrameObjectRef) stays alive for the duration, so NonNull is valid until pop. #[cfg(all(not(unix), feature = "threading"))] crate::vm::thread::push_thread_frame(FramePtr(NonNull::from(&**frame))); - let old_frame = crate::vm::thread::set_current_frame((&***frame) as *const Frame); - frame.previous.store( - old_frame as *mut Frame, - core::sync::atomic::Ordering::Relaxed, - ); + let iframe = frame.iframe() as *const crate::frame::InterpreterFrame; + let old_chain = crate::vm::thread::set_current_frame(iframe); + { + #[allow(unused_imports)] + use rustpython_common::atomic::Radium; + frame + .iframe() + .previous + .store(old_chain as usize, core::sync::atomic::Ordering::Relaxed); + } // Push generator's exc_info slot onto the chain - // (gi_exc_state.previous_item = tstate->exc_info; - // tstate->exc_info = &gi_exc_state;) self.push_exception(exc); - let old_owner = frame.owner.swap( + let old_owner = frame.iframe().owner.swap( crate::frame::FrameOwner::Thread as i8, core::sync::atomic::Ordering::AcqRel, ); @@ -1788,9 +1964,16 @@ impl VirtualMachine { // Ensure cleanup on panic: restore owner, pop exc_info slot, frame chain, // frames Vec, and recursion depth. scopeguard::defer! { - frame.owner.store(old_owner, core::sync::atomic::Ordering::Release); + frame.iframe().owner.store(old_owner, core::sync::atomic::Ordering::Release); self.pop_exception(); - crate::vm::thread::set_current_frame(old_frame); + // Clear previous before popping — it may point to a stack-allocated + // iframe that will be freed when the caller's with_iframe exits. + { + #[allow(unused_imports)] + use rustpython_common::atomic::Radium; + frame.iframe().previous.store(0, core::sync::atomic::Ordering::Relaxed); + } + let _ = crate::vm::thread::set_current_frame(old_chain); #[cfg(all(not(unix), feature = "threading"))] crate::vm::thread::pop_thread_frame(); @@ -1809,9 +1992,9 @@ impl VirtualMachine { /// - Fire `TraceEvent::Return` on both normal return **and** exception /// unwind (`PY_UNWIND` → `PyTrace_RETURN` with `arg = None`). /// Propagate any trace-function error, replacing the original exception. - fn dispatch_traced_frame) -> PyResult>( + fn dispatch_traced_frame) -> PyResult>( &self, - frame: &Py, + frame: &Py, f: F, ) -> PyResult { use crate::protocol::TraceEvent; @@ -1819,7 +2002,7 @@ impl VirtualMachine { // Fire 'call' trace event. current_frame() now returns the callee. let trace_result = self.trace_event(TraceEvent::Call, None)?; if let Some(local_trace) = trace_result { - *frame.trace.lock() = local_trace; + *frame.iframe().trace.lock() = Some(local_trace); } let result = f(frame); @@ -1828,7 +2011,7 @@ impl VirtualMachine { // PY_UNWIND fires PyTrace_RETURN with arg=None — so we fire for // both Ok and Err, matching `call_trace_protected` behavior. if self.use_tracing.get() - && (!self.is_none(&frame.trace.lock()) || !self.is_none(&self.profile_func.borrow())) + && (frame.iframe().trace.lock().is_some() || !self.is_none(&self.profile_func.borrow())) { let ret_result = self.trace_event(TraceEvent::Return, None); // call_trace_protected: if trace function raises, its error @@ -1880,21 +2063,23 @@ impl VirtualMachine { } } - pub fn current_frame(&self) -> Option { - crate::frame::current_thread_frame() + pub fn current_frame(&self) -> Option { + crate::frame::current_thread_frame_materialize(self) } pub fn current_locals(&self) -> PyResult { - self.current_frame() + // Must include light frames so locals() returns the correct scope. + crate::frame::current_thread_frame_materialize(self) .expect("called current_locals but no frames on the stack") .locals(self) } pub fn current_globals(&self) -> PyDictRef { - self.current_frame() - .expect("called current_globals but no frames on the stack") - .globals - .clone() + let ptr = crate::vm::thread::get_current_frame(); + if !ptr.is_null() { + return unsafe { (*ptr).globals().to_owned() }; + } + crate::frame::current_globals().expect("called current_globals but no frames on the stack") } pub fn try_class(&self, module: &'static str, class: &'static str) -> PyResult { @@ -1953,11 +2138,14 @@ impl VirtualMachine { .get_attr(identifier!(self, __import__), self) .map_err(|_| self.new_import_error("__import__ not found", module.to_owned()))?; - let (locals, globals) = if let Some(frame) = self.current_frame() { - ( - Some(frame.locals.clone_mapping(self)), - Some(frame.globals.clone()), - ) + let (locals, globals) = if let Some(globals) = crate::frame::current_globals() { + // Locals fallback: use the heavy frame if available, otherwise + // use globals as locals (light frame locals are on the data stack). + let locals_mapping = self.current_frame().map_or_else( + || ArgMapping::from_dict_exact(globals.clone()), + |f| f.iframe().locals.clone_mapping(self), + ); + (Some(locals_mapping), Some(globals)) } else { (None, None) }; diff --git a/crates/vm/src/vm/thread.rs b/crates/vm/src/vm/thread.rs index 9948b936f9d..1520b2cd883 100644 --- a/crates/vm/src/vm/thread.rs +++ b/crates/vm/src/vm/thread.rs @@ -5,12 +5,21 @@ use crate::builtins::PyBaseExceptionRef; #[cfg(feature = "threading")] use alloc::sync::Arc; -use crate::frame::Frame; +#[cfg(all(unix, feature = "threading"))] +use crate::frame::FrameObject; +use crate::frame::InterpreterFrame; +#[cfg(all(unix, feature = "threading"))] +use crate::{AsObject, Py, PyObject, VirtualMachine}; +#[cfg(all(not(unix), feature = "threading"))] +use crate::{AsObject, PyObject, VirtualMachine}; +#[cfg(not(feature = "threading"))] use crate::{AsObject, PyObject, VirtualMachine}; +#[cfg(all(unix, feature = "threading"))] +use core::sync::atomic::AtomicPtr; use core::{ cell::{Cell, RefCell}, ptr::NonNull, - sync::atomic::{AtomicPtr, Ordering}, + sync::atomic::{AtomicUsize, Ordering}, }; use itertools::Itertools; use std::thread_local; @@ -38,9 +47,13 @@ pub struct ThreadSlot { /// thread at a safepoint and supplies the happens-before edge, so the /// pointer and the frames it reaches are quiescent and alive at read time. #[cfg(unix)] - pub top_frame: AtomicPtr, + pub top_frame: AtomicPtr, + /// Raw InterpreterFrame pointer, published alongside top_frame so + /// cross-thread readers (sys._current_frames) can materialize + /// stack-allocated frames that have no FrameObject. + pub top_iframe: AtomicUsize, /// Raw frame pointers, valid while the owning thread's call stack is active. - /// Readers must hold the Mutex and convert to FrameRef inside the lock. + /// Readers must hold the Mutex and convert to FrameObjectRef inside the lock. /// Used on non-unix threading builds, which have no stop-the-world. #[cfg(not(unix))] pub frames: parking_lot::Mutex>, @@ -82,12 +95,11 @@ thread_local! { static CURRENT_THREAD_SLOT: RefCell> = const { RefCell::new(None) }; /// Current top frame for signal-safe traceback walking. - /// Mirrors `PyThreadState.current_frame`. Read by faulthandler's signal - /// handler to dump tracebacks without accessing RefCell or locks. - /// Uses AtomicPtr for async-signal-safety (signal handlers may read this - /// while the owning thread is writing). - pub(crate) static CURRENT_FRAME: AtomicPtr = - const { AtomicPtr::new(core::ptr::null_mut()) }; + /// Stores a `*const InterpreterFrame` as `usize`. + /// Read by faulthandler's signal handler to dump tracebacks without + /// accessing RefCell or locks. Uses AtomicUsize for async-signal-safety. + pub(crate) static CURRENT_FRAME: AtomicUsize = + const { AtomicUsize::new(0) }; /// Cached pointer to this thread's `ThreadSlot::top_frame`, so the hot /// push/pop path can publish the top frame with a single relaxed store and @@ -95,7 +107,7 @@ thread_local! { /// initialized; the `Arc` in `CURRENT_THREAD_SLOT` keeps the /// pointee alive until `cleanup_current_thread_frames` clears this. #[cfg(all(unix, feature = "threading"))] - static CURRENT_TOP_FRAME_SLOT: Cell<*const AtomicPtr> = + static CURRENT_TOP_FRAME_SLOT: Cell<*const AtomicPtr> = const { Cell::new(core::ptr::null()) }; } @@ -339,6 +351,7 @@ fn init_thread_slot_if_needed(vm: &VirtualMachine) { let new_slot = Arc::new(ThreadSlot { #[cfg(unix)] top_frame: AtomicPtr::new(core::ptr::null_mut()), + top_iframe: AtomicUsize::new(0), #[cfg(not(unix))] frames: parking_lot::Mutex::new(Vec::new()), exception: crate::PyAtomicRef::from(None::), @@ -670,28 +683,56 @@ pub fn pop_thread_frame() { }); } -/// Set the current thread's top frame pointer for signal-safe traceback walking. -/// Returns the previous frame pointer so it can be restored on pop. -pub fn set_current_frame(frame: *const Frame) -> *const Frame { - // Publish the top frame for cross-thread readers. The relaxed store is - // ordered by stop-the-world at read time (see `ThreadSlot::top_frame`). - #[cfg(all(unix, feature = "threading"))] +/// Set the current thread's top InterpreterFrame pointer. +/// Returns the previous pointer so it can be restored on pop. +#[must_use] +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub fn set_current_frame(frame: *const InterpreterFrame) -> *const InterpreterFrame { + // Publish the top frame for cross-thread readers (faulthandler, + // sys._current_frames). + #[cfg(feature = "threading")] { - let slot_top = CURRENT_TOP_FRAME_SLOT.with(Cell::get); - if !slot_top.is_null() { - // SAFETY: points to this thread's `ThreadSlot::top_frame`, kept - // alive by the Arc in `CURRENT_THREAD_SLOT` for the thread's life. - unsafe { (*slot_top).store(frame as *mut Frame, Ordering::Relaxed) }; - } + CURRENT_THREAD_SLOT.with(|slot| { + if let Some(s) = slot.borrow().as_ref() { + if !frame.is_null() { + #[cfg(unix)] + { + let frame_obj = unsafe { (*frame).frame_obj() }; + let fo_ptr = match frame_obj { + Some(py) => { + py as *const Py as *const FrameObject + as *mut FrameObject + } + None => core::ptr::null_mut(), + }; + s.top_frame.store(fo_ptr, Ordering::Relaxed); + } + s.top_iframe.store(frame as usize, Ordering::Relaxed); + } else { + #[cfg(unix)] + s.top_frame.store(core::ptr::null_mut(), Ordering::Relaxed); + s.top_iframe.store(0, Ordering::Relaxed); + } + } + }); } - CURRENT_FRAME.with(|c| c.swap(frame as *mut Frame, Ordering::Relaxed) as *const Frame) + CURRENT_FRAME.with(|c| c.swap(frame as usize, Ordering::Relaxed)) as *const InterpreterFrame +} + +/// Lightweight version that only writes to TLS CURRENT_FRAME, returning +/// the previous value. Does not update cross-thread top_frame (that's +/// updated by `set_current_frame` for FrameObject-based calls). +#[inline(always)] +#[must_use] +pub fn set_current_frame_nosave(frame: *const InterpreterFrame) -> *const InterpreterFrame { + CURRENT_FRAME.with(|c| c.swap(frame as usize, Ordering::Relaxed)) as *const InterpreterFrame } -/// Get the current thread's top frame pointer. +/// Get the current thread's top InterpreterFrame pointer. /// Used by faulthandler's signal handler to start traceback walking. #[must_use] -pub fn get_current_frame() -> *const Frame { - CURRENT_FRAME.with(|c| c.load(Ordering::Relaxed) as *const Frame) +pub fn get_current_frame() -> *const InterpreterFrame { + CURRENT_FRAME.with(|c| c.load(Ordering::Relaxed)) as *const InterpreterFrame } /// Update the current thread's exception slot atomically (no locks). @@ -784,18 +825,36 @@ pub fn reinit_frame_slot_after_fork(vm: &VirtualMachine) { let mut cur = get_current_frame(); while !cur.is_null() { // SAFETY: the forking thread's chain frames are alive. - let py = unsafe { crate::Py::::from_payload_ptr(cur) }; - current_frames.push(FramePtr(unsafe { NonNull::new_unchecked(py as *mut _) })); - cur = unsafe { (*cur).previous_frame() }; + let iframe = unsafe { &*cur }; + if let Some(fo) = iframe.frame_obj() { + current_frames.push(FramePtr(unsafe { + NonNull::new_unchecked(fo as *const _ as *mut _) + })); + } + cur = iframe.previous.load(Ordering::Relaxed) as *const InterpreterFrame; } current_frames.reverse(); current_frames }; + #[cfg(unix)] + let top_fo_ptr = { + let top_iframe = get_current_frame(); + if top_iframe.is_null() { + core::ptr::null_mut() + } else { + match unsafe { (*top_iframe).frame_obj() } { + Some(fo) => fo as *const Py as *const FrameObject as *mut FrameObject, + None => core::ptr::null_mut(), + } + } + }; + let top_iframe_ptr = get_current_frame() as usize; let new_slot = Arc::new(ThreadSlot { - // The surviving child thread keeps executing its current frame chain, - // whose top is the signal-safe `get_current_frame()`. + // The surviving child thread keeps executing its current frame chain. + // Only publish heavy frames for signal safety. #[cfg(unix)] - top_frame: AtomicPtr::new(get_current_frame() as *mut Frame), + top_frame: AtomicPtr::new(top_fo_ptr), + top_iframe: AtomicUsize::new(top_iframe_ptr), #[cfg(not(unix))] frames: parking_lot::Mutex::new(current_frames), exception: crate::PyAtomicRef::from(vm.topmost_exception()), diff --git a/crates/vm/src/warn.rs b/crates/vm/src/warn.rs index 6500e8de0f6..d5729858dcd 100644 --- a/crates/vm/src/warn.rs +++ b/crates/vm/src/warn.rs @@ -513,7 +513,7 @@ fn show_warning( } /// Check if a frame's filename starts with any of the given prefixes. -fn is_filename_to_skip(frame: &crate::frame::Frame, prefixes: &PyTupleRef) -> bool { +fn is_filename_to_skip(frame: &crate::frame::FrameObject, prefixes: &PyTupleRef) -> bool { let filename = frame.f_code().co_filename(); let filename_bytes = filename.as_bytes(); prefixes.iter().any(|prefix| { @@ -523,15 +523,15 @@ fn is_filename_to_skip(frame: &crate::frame::Frame, prefixes: &PyTupleRef) -> bo }) } -/// Like Frame::next_external_frame but also skips frames matching prefixes. +/// Like FrameObject::next_external_frame but also skips frames matching prefixes. fn next_external_frame_with_skip( - frame: &crate::frame::FrameRef, + frame: &crate::frame::FrameObjectRef, skip_file_prefixes: Option<&PyTupleRef>, vm: &VirtualMachine, -) -> Option { +) -> Option { let mut f = frame.f_back(vm); loop { - let current: crate::frame::FrameRef = f.take()?; + let current: crate::frame::FrameObjectRef = f.take()?; if current.is_internal_frame() || skip_file_prefixes.is_some_and(|p| is_filename_to_skip(¤t, p)) { @@ -549,7 +549,9 @@ fn setup_context( skip_file_prefixes: Option<&PyTupleRef>, vm: &VirtualMachine, ) -> PyResult<(PyStrRef, usize, Option, PyObjectRef)> { - let mut f = vm.current_frame(); + // Materialize the topmost frame (including light frames) so stack + // level counting is correct across the full Python frame chain. + let mut f = crate::frame::current_thread_frame_materialize(vm); // Stack level comparisons to Python code is off by one as there is no // warnings-related stack level to avoid. @@ -576,10 +578,18 @@ fn setup_context( } let (globals, filename, lineno) = if let Some(f) = f { - (f.globals.clone(), f.code.source_path(), f.f_lineno()) + ( + f.iframe().globals().to_owned(), + f.iframe().code().source_path(), + f.f_lineno(), + ) } else if let Some(frame) = vm.current_frame() { // We have a frame but it wasn't found during stack walking - (frame.globals.clone(), vm.ctx.intern_str(""), 1) + ( + frame.iframe().globals().to_owned(), + vm.ctx.intern_str(""), + 1, + ) } else { // No frames on the stack - use sys.__dict__ (interp->sysdict) let globals = vm From 05f7f74248300a928e5fd2b82427bd252a77900c Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:28:00 +0900 Subject: [PATCH 217/351] fix(ssl): make Certificate immutable and non-instantiable (#8417) Assisted-by: Codex:gpt-5.6-sol --- Lib/test/test_ssl.py | 1 - crates/stdlib/src/openssl/cert.rs | 5 ++++- crates/stdlib/src/ssl.rs | 5 ++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 4808da82f20..aa1fdb8fb94 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -415,7 +415,6 @@ def test_options(self): value = getattr(ssl, name) self.assertGreaterEqual(value, 0, f"ssl.{name}") - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: TypeError not raised by Certificate def test_ssl_types(self): ssl_types = [ _ssl._SSLContext, diff --git a/crates/stdlib/src/openssl/cert.rs b/crates/stdlib/src/openssl/cert.rs index 7704e51f0a2..4e42c50f67e 100644 --- a/crates/stdlib/src/openssl/cert.rs +++ b/crates/stdlib/src/openssl/cert.rs @@ -67,7 +67,10 @@ pub(crate) mod ssl_cert { } } - #[pyclass(with(Comparable, Hashable, Representable))] + #[pyclass( + flags(IMMUTABLETYPE, DISALLOW_INSTANTIATION), + with(Comparable, Hashable, Representable) + )] impl PySSLCertificate { #[pymethod] fn public_bytes( diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 7e2b4c124d2..9387df94527 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -5040,7 +5040,10 @@ mod _ssl { } } - #[pyclass(with(Comparable, Hashable, Representable))] + #[pyclass( + flags(IMMUTABLETYPE, DISALLOW_INSTANTIATION), + with(Comparable, Hashable, Representable) + )] impl PySSLCertificate { #[pymethod] fn public_bytes( From 249ae08911735fc3dcfc7ac2f4081aec02f92117 Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:28:26 +0900 Subject: [PATCH 218/351] fix(ssl): emit missing deprecation warnings (#8418) Assisted-by: Codex:gpt-5.6-sol --- Lib/test/test_ssl.py | 1 - crates/stdlib/src/openssl.rs | 69 ++++++++++++++++++++++++++++++------ crates/stdlib/src/ssl.rs | 40 +++++++++++++++++---- 3 files changed, 93 insertions(+), 17 deletions(-) diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index aa1fdb8fb94..5b0c90e4d3a 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -650,7 +650,6 @@ def test_timeout(self): with test_wrap_socket(s) as ss: self.assertEqual(timeout, ss.gettimeout()) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_openssl111_deprecations(self): options = [ ssl.OP_NO_TLSv1, diff --git a/crates/stdlib/src/openssl.rs b/crates/stdlib/src/openssl.rs index 16ef8fdb19c..fe4a5298d12 100644 --- a/crates/stdlib/src/openssl.rs +++ b/crates/stdlib/src/openssl.rs @@ -81,6 +81,7 @@ mod _ssl { ArgBytesLike, ArgMemoryBuffer, ArgStrOrBytesLike, Either, FsPath, OptionalArg, PyComparisonValue, }, + stdlib::_warnings, types::{Comparable, Constructor, PyComparisonOp}, utils::ToCString, }, @@ -914,16 +915,24 @@ mod _ssl { ) -> PyResult { let proto = SslVersion::try_from(proto_version) .map_err(|_| vm.new_value_error("invalid protocol version"))?; - let method = match proto { + let (method, deprecated_protocol) = match proto { // SslVersion::Ssl3 => unsafe { ssl::SslMethod::from_ptr(sys::SSLv3_method()) }, - SslVersion::Tls => ssl::SslMethod::tls(), - SslVersion::Tls1 => ssl::SslMethod::tls(), - SslVersion::Tls1_1 => ssl::SslMethod::tls(), - SslVersion::Tls1_2 => ssl::SslMethod::tls(), - SslVersion::TlsClient => ssl::SslMethod::tls_client(), - SslVersion::TlsServer => ssl::SslMethod::tls_server(), + SslVersion::Tls => (ssl::SslMethod::tls(), Some("PROTOCOL_TLS")), + SslVersion::Tls1 => (ssl::SslMethod::tls(), Some("PROTOCOL_TLSv1")), + SslVersion::Tls1_1 => (ssl::SslMethod::tls(), Some("PROTOCOL_TLSv1_1")), + SslVersion::Tls1_2 => (ssl::SslMethod::tls(), Some("PROTOCOL_TLSv1_2")), + SslVersion::TlsClient => (ssl::SslMethod::tls_client(), None), + SslVersion::TlsServer => (ssl::SslMethod::tls_server(), None), _ => return Err(vm.new_value_error("invalid protocol version")), }; + if let Some(protocol_name) = deprecated_protocol { + _warnings::warn( + vm.ctx.exceptions.deprecation_warning, + format!("ssl.{protocol_name} is deprecated"), + 2, + vm, + )?; + } let mut builder = SslContextBuilder::new(method).map_err(|e| convert_openssl_error(vm, e))?; @@ -1012,6 +1021,24 @@ mod _ssl { #[pyclass(flags(BASETYPE, IMMUTABLETYPE), with(Constructor))] impl PySslContext { + fn warn_deprecated_tls_version(version: i32, vm: &VirtualMachine) -> PyResult<()> { + let version_name = match version { + PROTO_SSLv3 => Some("SSLv3"), + PROTO_TLSv1 => Some("TLSv1"), + PROTO_TLSv1_1 => Some("TLSv1_1"), + _ => None, + }; + if let Some(version_name) = version_name { + _warnings::warn( + vm.ctx.exceptions.deprecation_warning, + format!("ssl.TLSVersion.{version_name} is deprecated"), + 2, + vm, + )?; + } + Ok(()) + } + fn builder(&self) -> PyRwLockWriteGuard<'_, SslContextBuilder> { self.ctx.write() } @@ -1133,14 +1160,32 @@ mod _ssl { return Err(vm.new_value_error("invalid options value")); } let new_opts = new_opts as core::ffi::c_ulong; - let mut ctx = self.builder(); - // Get current options - let current = ctx.options().bits() as core::ffi::c_ulong; + let current = { + let ctx = self.ctx(); + unsafe { sys::SSL_CTX_get_options(ctx.as_ptr()) } + }; // Calculate options to clear and set let clear = current & !new_opts; let set = !current & new_opts; + let opt_no = sys::SSL_OP_NO_SSLv2 + | sys::SSL_OP_NO_SSLv3 + | sys::SSL_OP_NO_TLSv1 + | sys::SSL_OP_NO_TLSv1_1 + | sys::SSL_OP_NO_TLSv1_2; + #[cfg(ossl111)] + let opt_no = opt_no | sys::SSL_OP_NO_TLSv1_3; + if (set & opt_no) != 0 { + _warnings::warn( + vm.ctx.exceptions.deprecation_warning, + "ssl.OP_NO_SSL*/ssl.OP_NO_TLS* options are deprecated".to_owned(), + 2, + vm, + )?; + } + + let mut ctx = self.builder(); // Clear options first (using raw FFI since openssl crate doesn't expose clear_options) if clear != 0 { unsafe { @@ -1247,6 +1292,8 @@ mod _ssl { } #[pygetset(setter)] fn set_minimum_version(&self, value: i32, vm: &VirtualMachine) -> PyResult<()> { + Self::warn_deprecated_tls_version(value, vm)?; + // Handle special values let proto_version = match value { -2 => { @@ -1281,6 +1328,8 @@ mod _ssl { } #[pygetset(setter)] fn set_maximum_version(&self, value: i32, vm: &VirtualMachine) -> PyResult<()> { + Self::warn_deprecated_tls_version(value, vm)?; + // Handle special values let proto_version = match value { -1 => { diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 9387df94527..7c4257877d6 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -881,6 +881,24 @@ mod _ssl { #[pyclass(with(Constructor, Representable), flags(BASETYPE))] impl PySSLContext { + fn warn_deprecated_tls_version(version: i32, vm: &VirtualMachine) -> PyResult<()> { + let version_name = match version { + PROTO_SSLv3 => Some("SSLv3"), + PROTO_TLSv1 => Some("TLSv1"), + PROTO_TLSv1_1 => Some("TLSv1_1"), + _ => None, + }; + if let Some(version_name) = version_name { + _warnings::warn( + vm.ctx.exceptions.deprecation_warning, + format!("ssl.TLSVersion.{version_name} is deprecated"), + 2, + vm, + )?; + } + Ok(()) + } + // Helper method to convert DER certificate bytes to Python dict fn cert_der_to_dict(&self, vm: &VirtualMachine, cert_der: &[u8]) -> PyResult { cert::cert_der_to_dict_helper(vm, cert_der) @@ -1024,6 +1042,8 @@ mod _ssl { { return Err(vm.new_value_error(format!("invalid protocol version: {value}"))); } + Self::warn_deprecated_tls_version(value, vm)?; + // Convert special values to rustls actual supported versions // MINIMUM_SUPPORTED (-2) -> 0 (auto-negotiate) // MAXIMUM_SUPPORTED (-1) -> MAXIMUM_VERSION (TLSv1.3) @@ -1055,6 +1075,8 @@ mod _ssl { { return Err(vm.new_value_error(format!("invalid protocol version: {value}"))); } + Self::warn_deprecated_tls_version(value, vm)?; + // Convert special values to rustls actual supported versions // MAXIMUM_SUPPORTED (-1) -> 0 (auto-negotiate) // MINIMUM_SUPPORTED (-2) -> MINIMUM_VERSION (TLSv1.2) @@ -2214,12 +2236,10 @@ mod _ssl { ) -> PyResult { let crypto_ext = CryptoExt::get_ext(); - // Validate protocol - match protocol { - PROTOCOL_TLS | PROTOCOL_TLS_CLIENT | PROTOCOL_TLS_SERVER | PROTOCOL_TLSv1_2 - | PROTOCOL_TLSv1_3 => { - // Valid protocols - } + let deprecated_protocol = match protocol { + PROTOCOL_TLS => Some("PROTOCOL_TLS"), + PROTOCOL_TLSv1_2 => Some("PROTOCOL_TLSv1_2"), + PROTOCOL_TLS_CLIENT | PROTOCOL_TLS_SERVER | PROTOCOL_TLSv1_3 => None, PROTOCOL_TLSv1 | PROTOCOL_TLSv1_1 => { return Err(vm.new_value_error( "TLS 1.0 and 1.1 are not supported by rustls for security reasons", @@ -2228,6 +2248,14 @@ mod _ssl { _ => { return Err(vm.new_value_error(format!("invalid protocol version: {protocol}"))); } + }; + if let Some(protocol_name) = deprecated_protocol { + _warnings::warn( + vm.ctx.exceptions.deprecation_warning, + format!("ssl.{protocol_name} is deprecated"), + 2, + vm, + )?; } // Set default options From c9e48baebe03023982e8d954b5bd696f1073866f Mon Sep 17 00:00:00 2001 From: Chanho Lee Date: Sat, 1 Aug 2026 11:28:59 +0900 Subject: [PATCH 219/351] Fix sqlite autocommit cursor transactions (#8419) Assisted-by: Codex:gpt-5.6-sol --- Lib/test/test_sqlite3/test_transactions.py | 2 -- crates/stdlib/src/_sqlite3.rs | 12 +++++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_sqlite3/test_transactions.py b/Lib/test/test_sqlite3/test_transactions.py index 64a1621d42b..d777af0ffe6 100644 --- a/Lib/test/test_sqlite3/test_transactions.py +++ b/Lib/test/test_sqlite3/test_transactions.py @@ -488,7 +488,6 @@ def test_autocommit_compat_ctx_mgr(self): self.assertTrue(cx.in_transaction) self.assertFalse(cx.in_transaction) - @unittest.expectedFailure # TODO: RUSTPYTHON; autocommit behavior differs def test_autocommit_enabled_executescript(self): expected = ["BEGIN", "SELECT 1"] with memory_database(autocommit=True) as cx: @@ -498,7 +497,6 @@ def test_autocommit_enabled_executescript(self): cx.executescript("SELECT 1") self.assertTrue(cx.in_transaction) - @unittest.expectedFailure # TODO: RUSTPYTHON; autocommit behavior differs def test_autocommit_disabled_executescript(self): expected = ["SELECT 1"] with memory_database(autocommit=False) as cx: diff --git a/crates/stdlib/src/_sqlite3.rs b/crates/stdlib/src/_sqlite3.rs index e2b06adc465..5348cc1f5ec 100644 --- a/crates/stdlib/src/_sqlite3.rs +++ b/crates/stdlib/src/_sqlite3.rs @@ -1761,11 +1761,11 @@ mod _sqlite3 { let db = zelf.connection.db_lock(vm)?; - // Start implicit transaction for DML statements unless in autocommit mode + // Only legacy transaction control starts implicit DML transactions. if stmt.is_dml && db.is_autocommit() && zelf.connection.isolation_level.deref().is_some() - && *zelf.connection.autocommit.lock() != AutocommitMode::Enabled + && *zelf.connection.autocommit.lock() == AutocommitMode::Legacy { db.begin_transaction( zelf.connection @@ -1855,11 +1855,11 @@ mod _sqlite3 { let db = zelf.connection.db_lock(vm)?; - // Start implicit transaction for DML statements unless in autocommit mode + // Only legacy transaction control starts implicit DML transactions. if stmt.is_dml && db.is_autocommit() && zelf.connection.isolation_level.deref().is_some() - && *zelf.connection.autocommit.lock() != AutocommitMode::Enabled + && *zelf.connection.autocommit.lock() == AutocommitMode::Legacy { db.begin_transaction( zelf.connection @@ -1909,7 +1909,9 @@ mod _sqlite3 { db.sql_limit(script.byte_len(), vm)?; - db.implicit_commit(vm)?; + if *zelf.connection.autocommit.lock() == AutocommitMode::Legacy { + db.implicit_commit(vm)?; + } let script = script.to_cstring(vm)?; let mut ptr = script.as_ptr(); From 16bb018d31cd1f0af3130a34541727a48fc25562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B4=91=ED=9A=A8?= <87641474+widehyo1@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:35:37 +0900 Subject: [PATCH 220/351] csv: port CPython reader state machine (#8381) * csv: port CPython reader state machine Replace the csv-core reader and per-item quote scanner with one Rust implementation of CPython's nine-state reader parser. Keep parser state across iterator items and distinguish virtual item boundaries from true iterator exhaustion. Centralize field completion so quote provenance, empty-field None conversion, float conversion, strict parsing, field limits, blank rows, and escaped or quoted newlines share one path. Apply the existing reentrant-iterator generation check to every iterator item consumed by a record. Also accept empty ASCII line terminators like CPython, report a missing csv-core writer sentinel as _csv.Error instead of panicking, and name the FSM entry point process_parser_input. Remove the expected-failure markers from the eleven reader tests that now pass. Assisted-by: Codex:gpt-5.6-sol * csv: test CRLF boundaries with custom lineterminator --- .cspell.json | 1 + Lib/test/test_csv.py | 11 - crates/stdlib/src/csv.rs | 644 +++++++++++++---------------- extra_tests/snippets/stdlib_csv.py | 30 ++ 4 files changed, 311 insertions(+), 375 deletions(-) diff --git a/.cspell.json b/.cspell.json index 21d4015632b..2889a518409 100644 --- a/.cspell.json +++ b/.cspell.json @@ -59,6 +59,7 @@ "alnum", "csock", "coro", + "Crnl", "dedentations", "dedents", "deduped", diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py index 2b1de5d70d9..65093dc70c1 100644 --- a/Lib/test/test_csv.py +++ b/Lib/test/test_csv.py @@ -371,7 +371,6 @@ def _read_test(self, input, expect, **kwargs): result = list(reader) self.assertEqual(result, expect) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_read_oddinputs(self): self._read_test([], []) self._read_test([''], [[]]) @@ -382,7 +381,6 @@ def test_read_oddinputs(self): self.assertRaises(csv.Error, self._read_test, [b'abc'], None) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_read_eol(self): self._read_test(['a,b', 'c,d'], [['a','b'], ['c','d']]) self._read_test(['a,b\n', 'c,d\n'], [['a','b'], ['c','d']]) @@ -397,7 +395,6 @@ def test_read_eol(self): with self.assertRaisesRegex(csv.Error, errmsg): next(csv.reader(['a,b\r\nc,d'])) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_read_eof(self): self._read_test(['a,"'], [['a', '']]) self._read_test(['"a'], [['a']]) @@ -407,7 +404,6 @@ def test_read_eof(self): self.assertRaises(csv.Error, self._read_test, ['^'], [], escapechar='^', strict=True) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_read_nul(self): self._read_test(['\0'], [['\0']]) self._read_test(['a,\0b,c'], [['a', '\0b', 'c']]) @@ -420,7 +416,6 @@ def test_read_delimiter(self): self._read_test(['a;b;c'], [['a', 'b', 'c']], delimiter=';') self._read_test(['a\0b\0c'], [['a', 'b', 'c']], delimiter='\0') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_read_escape(self): self._read_test(['a,\\b,c'], [['a', 'b', 'c']], escapechar='\\') self._read_test(['a,b\\,c'], [['a', 'b,c']], escapechar='\\') @@ -433,7 +428,6 @@ def test_read_escape(self): self._read_test(['a,\\b,c'], [['a', '\\b', 'c']], escapechar=None) self._read_test(['a,\\b,c'], [['a', '\\b', 'c']]) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_read_quoting(self): self._read_test(['1,",3,",5'], [['1', ',3,', '5']]) self._read_test(['1,",3,",5'], [['1', '"', '3', '"', '5']], @@ -484,7 +478,6 @@ def test_read_skipinitialspace(self): [[None, None, None]], skipinitialspace=True, quoting=csv.QUOTE_STRINGS) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_read_space_delimiter(self): self._read_test(['a b', ' a ', ' ', ''], [['a', '', '', 'b'], ['', '', 'a', '', ''], ['', '', ''], []], @@ -524,7 +517,6 @@ def test_read_linenum(self): self.assertRaises(StopIteration, next, r) self.assertEqual(r.line_num, 3) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_roundtrip_quoteed_newlines(self): rows = [ ['\na', 'b\nc', 'd\n'], @@ -543,7 +535,6 @@ def test_roundtrip_quoteed_newlines(self): for i, row in enumerate(csv.reader(fileobj)): self.assertEqual(row, rows[i]) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_roundtrip_escaped_unquoted_newlines(self): rows = [ ['\na', 'b\nc', 'd\n'], @@ -807,7 +798,6 @@ def test_quoted_quote(self): '"I see," said the blind man', 'as he picked up his hammer and saw']]) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_quoted_nl(self): input = '''\ 1,2,3,"""I see,"" @@ -1078,7 +1068,6 @@ def test_read_multi(self): "s1": 'abc', "s2": 'def'}) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_read_with_blanks(self): reader = csv.DictReader(["1,2,abc,4,5,6\r\n","\r\n", "1,2,abc,4,5,6\r\n"], diff --git a/crates/stdlib/src/csv.rs b/crates/stdlib/src/csv.rs index cd065f634f2..4271d9af62c 100644 --- a/crates/stdlib/src/csv.rs +++ b/crates/stdlib/src/csv.rs @@ -9,8 +9,7 @@ mod _csv { builtins::{PyBaseExceptionRef, PyInt, PyNone, PyStr, PyType, PyTypeRef, PyUtf8StrRef}, function::{ArgIterable, ArgumentError, FromArgs, FuncArgs, OptionalArg}, protocol::{PyIter, PyIterReturn}, - raise_if_stop, - types::{Constructor, IterNext, Iterable, SelfIter}, + types::{Callable, Constructor, IterNext, Iterable, SelfIter}, }; use alloc::fmt; use csv_core::Terminator; @@ -267,9 +266,6 @@ mod _csv { // verbatim and the csv-core writer path appends it after a // sentinel terminator (see `writerow`). let value = ascii_lineterminator(vm, &s)?; - if value.is_empty() { - return Err(new_csv_error(vm, r#""lineterminator" must not be empty"#)); - } Ok(value.to_owned()) } attr => { @@ -448,10 +444,6 @@ mod _csv { Ok(Reader { iter, state: PyMutex::new(ReadState { - buffer: vec![0; 1024], - output_ends: vec![0; 16], - reader: options.to_reader(), - skipinitialspace: options.get_skipinitialspace(), line_num: 0, generation: 0, }), @@ -664,14 +656,6 @@ mod _csv { )) })?; let value = ascii_lineterminator(vm, s)?; - // Preserve the previous behavior of rejecting an empty terminator - // (full validation parity is deferred to a follow-up). Any - // non-empty string, including multi-character ones, is stored. - if value.is_empty() { - return Err(vm - .new_type_error(r#""lineterminator" must not be empty"#) - .into()); - } res.lineterminator = Some(value.to_owned()); }; @@ -803,28 +787,6 @@ mod _csv { } } - fn get_skipinitialspace(&self) -> bool { - let mut skipinitialspace = match &self.dialect { - DialectItem::Str(name) => { - let g = GLOBAL_HASHMAP.lock(); - if let Some(dialect) = g.get(name) { - dialect.skipinitialspace - // TODO: RUSTPYTHON; Perfecting the remaining attributes. - } else { - false - } - } - DialectItem::Obj(obj) => obj.skipinitialspace, - _ => false, - }; - - if let Some(attr) = self.skipinitialspace { - skipinitialspace = attr - } - - skipinitialspace - } - fn get_quoting(&self) -> QuoteStyle { let mut quoting = match &self.dialect { DialectItem::Str(name) => { @@ -846,62 +808,6 @@ mod _csv { quoting } - fn to_reader(&self) -> csv_core::Reader { - let dialect = match &self.dialect { - DialectItem::Str(name) => GLOBAL_HASHMAP.lock().get(name).cloned(), - DialectItem::Obj(obj) => Some(obj.clone()), - DialectItem::None => { - let g = GLOBAL_HASHMAP.lock(); - Some(g.get("excel").unwrap().clone()) - } - }; - - let mut builder = csv_core::ReaderBuilder::new(); - let mut reader = if let Some(dialect) = dialect { - let mut builder = builder - .delimiter(dialect.delimiter) - .double_quote(dialect.doublequote) - .escape(dialect.escapechar); - if let Some(quotechar) = dialect.quotechar { - builder = builder.quote(quotechar); - } - builder - } else { - &mut builder - }; - - if let Some(t) = self.delimiter { - reader = reader.delimiter(t); - } - - if let Some(t) = self.quotechar { - reader = if let Some(u) = t { - reader.quote(u) - } else { - reader.quoting(false) - } - } else { - reader = reader.quoting(self.quoting != Some(QuoteStyle::None)); - } - - if let Some(t) = self.doublequote { - reader = reader.double_quote(t); - } - - if self.escapechar.is_some() { - reader = reader.escape(self.escapechar); - } - - // CPython's reader ignores the dialect's `lineterminator` entirely and - // only recognizes `\r`, `\n`, and `\r\n` as record separators. Match - // that: always use CRLF mode. Feeding a multi-byte terminator's first - // byte here would otherwise split records mid-UTF-8 and raise a - // UnicodeDecodeError. - reader = reader.terminator(Terminator::CRLF); - - reader.build() - } - fn to_writer(&self) -> csv_core::Writer { let mut builder = csv_core::WriterBuilder::new(); let mut writer = match &self.dialect { @@ -962,10 +868,6 @@ mod _csv { } struct ReadState { - buffer: Vec, - output_ends: Vec, - reader: csv_core::Reader, - skipinitialspace: bool, line_num: u64, generation: u64, } @@ -1001,307 +903,318 @@ mod _csv { impl SelfIter for Reader {} - enum QuoteScanEvent { - InitialSpace, - StartQuotedField, - EndQuotedField, - Escaped(Option), - DoubleQuote(u8), - Delimiter, - RecordTerminator, - Data(u8), + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum ParserState { + StartRecord, + StartField, + EscapedChar, + InField, + InQuotedField, + EscapeInQuotedField, + QuoteInQuotedField, + EatCrnl, + AfterEscapedCrnl, } - struct QuoteScanState { - at_field_start: bool, - in_quoted_field: bool, + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum ParserInput { + Byte(u8), + Eol, } - impl QuoteScanState { - const fn new() -> Self { + const EOL: ParserInput = ParserInput::Eol; + + struct CsvParser { + state: ParserState, + fields: Vec, + field: Vec, + unquoted_field: bool, + field_limit: isize, + } + + impl CsvParser { + fn new(field_limit: isize) -> Self { Self { - at_field_start: true, - in_quoted_field: false, + state: ParserState::StartRecord, + fields: Vec::new(), + field: Vec::new(), + unquoted_field: false, + field_limit, } } - fn scan( - &mut self, - input: &[u8], - index: usize, - dialect: &PyDialect, - unquoted_escape: bool, - ) -> (QuoteScanEvent, usize) { - let byte = input[index]; - - if (self.in_quoted_field || unquoted_escape) && dialect.escapechar == Some(byte) { - self.at_field_start = false; - return match input.get(index + 1).copied() { - Some(escaped) => (QuoteScanEvent::Escaped(Some(escaped)), 2), - None => (QuoteScanEvent::Escaped(None), 1), - }; - } - - if self.in_quoted_field { - if dialect.quotechar == Some(byte) { - if dialect.doublequote && input.get(index + 1) == Some(&byte) { - return (QuoteScanEvent::DoubleQuote(byte), 2); - } - self.in_quoted_field = false; - return (QuoteScanEvent::EndQuotedField, 1); - } - return (QuoteScanEvent::Data(byte), 1); - } + fn into_result(self, vm: &VirtualMachine) -> PyIterReturn { + PyIterReturn::Return(vm.ctx.new_list(self.fields).into()) + } - if self.at_field_start && dialect.skipinitialspace && byte == b' ' { - return (QuoteScanEvent::InitialSpace, 1); + fn add_byte(&mut self, byte: u8, vm: &VirtualMachine) -> PyResult<()> { + if self.field_limit < 0 || self.field.len() >= self.field_limit as usize { + return Err(new_csv_error( + vm, + format!("field larger than field limit ({})", self.field_limit), + )); } + self.field.push(byte); + Ok(()) + } - if self.at_field_start - && dialect.quoting != QuoteStyle::None - && dialect.quotechar == Some(byte) + fn save_field(&mut self, quoting: QuoteStyle, vm: &VirtualMachine) -> PyResult<()> { + let field = if self.unquoted_field + && self.field.is_empty() + && matches!(quoting, QuoteStyle::Notnull | QuoteStyle::Strings) { - self.at_field_start = false; - self.in_quoted_field = true; - return (QuoteScanEvent::StartQuotedField, 1); - } - - if byte == dialect.delimiter { - self.at_field_start = true; - return (QuoteScanEvent::Delimiter, 1); - } - - self.at_field_start = false; - if matches!(byte, b'\r' | b'\n') { - (QuoteScanEvent::RecordTerminator, 1) + vm.ctx.none() } else { - (QuoteScanEvent::Data(byte), 1) - } + let value = core::str::from_utf8(&self.field) + .map_err(|e| new_not_utf8_error(vm, &self.field, e))?; + let field: PyObjectRef = vm.ctx.new_str(value).into(); + if self.unquoted_field + && !self.field.is_empty() + && matches!(quoting, QuoteStyle::Nonnumeric | QuoteStyle::Strings) + { + PyType::call(vm.ctx.types.float_type, vec![field].into(), vm)? + } else { + field + } + }; + self.fields.push(field); + self.field.clear(); + Ok(()) } - } - fn read_quote_record( - input: &[u8], - dialect: &PyDialect, - field_limit: isize, - vm: &VirtualMachine, - ) -> PyResult> { - // QUOTE_NOTNULL and QUOTE_STRINGS map empty unquoted fields to None, - // but preserve quoted empty fields as strings, so retain quote provenance. - let mut fields = vec![(Vec::new(), false)]; - let mut scan_state = QuoteScanState::new(); - let mut dangling_escape = false; - let mut index = 0; - - while index < input.len() { - let (event, consumed) = scan_state.scan(input, index, dialect, true); - match event { - QuoteScanEvent::InitialSpace | QuoteScanEvent::EndQuotedField => {} - QuoteScanEvent::StartQuotedField => fields.last_mut().unwrap().1 = true, - QuoteScanEvent::Escaped(Some(byte)) | QuoteScanEvent::DoubleQuote(byte) => { - fields.last_mut().unwrap().0.push(byte); + fn process_parser_input( + &mut self, + input: ParserInput, + dialect: &PyDialect, + vm: &VirtualMachine, + ) -> PyResult<()> { + match self.state { + ParserState::StartRecord => match input { + ParserInput::Eol => {} + ParserInput::Byte(b'\r' | b'\n') => self.state = ParserState::EatCrnl, + _ => { + self.state = ParserState::StartField; + return self.process_parser_input(input, dialect, vm); + } + }, + ParserState::StartField => { + self.unquoted_field = true; + match input { + ParserInput::Eol | ParserInput::Byte(b'\r' | b'\n') => { + self.save_field(dialect.quoting, vm)?; + self.state = state_after_record_end(input); + } + ParserInput::Byte(byte) + if dialect.quoting != QuoteStyle::None + && dialect.quotechar == Some(byte) => + { + self.unquoted_field = false; + self.state = ParserState::InQuotedField; + } + ParserInput::Byte(byte) if dialect.escapechar == Some(byte) => { + self.state = ParserState::EscapedChar; + } + ParserInput::Byte(b' ') if dialect.skipinitialspace => {} + ParserInput::Byte(byte) if byte == dialect.delimiter => { + self.save_field(dialect.quoting, vm)?; + } + ParserInput::Byte(byte) => { + self.add_byte(byte, vm)?; + self.state = ParserState::InField; + } + } } - QuoteScanEvent::Escaped(None) => dangling_escape = true, - QuoteScanEvent::Delimiter => fields.push((Vec::new(), false)), - QuoteScanEvent::RecordTerminator => { - if !input[index..] - .iter() - .all(|&byte| matches!(byte, b'\r' | b'\n')) + ParserState::EscapedChar => match input { + ParserInput::Byte(byte @ (b'\r' | b'\n')) => { + self.add_byte(byte, vm)?; + self.state = ParserState::AfterEscapedCrnl; + } + ParserInput::Eol => { + self.add_byte(b'\n', vm)?; + self.state = ParserState::InField; + } + ParserInput::Byte(byte) => { + self.add_byte(byte, vm)?; + self.state = ParserState::InField; + } + }, + ParserState::AfterEscapedCrnl => { + if input != ParserInput::Eol { + self.state = ParserState::InField; + return self.process_parser_input(input, dialect, vm); + } + } + ParserState::InField => match input { + ParserInput::Eol | ParserInput::Byte(b'\r' | b'\n') => { + self.save_field(dialect.quoting, vm)?; + self.state = state_after_record_end(input); + } + ParserInput::Byte(byte) if dialect.escapechar == Some(byte) => { + self.state = ParserState::EscapedChar; + } + ParserInput::Byte(byte) if byte == dialect.delimiter => { + self.save_field(dialect.quoting, vm)?; + self.state = ParserState::StartField; + } + ParserInput::Byte(byte) => self.add_byte(byte, vm)?, + }, + ParserState::InQuotedField => match input { + ParserInput::Eol => {} + ParserInput::Byte(byte) if dialect.escapechar == Some(byte) => { + self.state = ParserState::EscapeInQuotedField; + } + ParserInput::Byte(byte) + if dialect.quoting != QuoteStyle::None + && dialect.quotechar == Some(byte) => + { + self.state = if dialect.doublequote { + ParserState::QuoteInQuotedField + } else { + ParserState::InField + }; + } + ParserInput::Byte(byte) => self.add_byte(byte, vm)?, + }, + ParserState::EscapeInQuotedField => { + let byte = match input { + ParserInput::Eol => b'\n', + ParserInput::Byte(byte) => byte, + }; + self.add_byte(byte, vm)?; + self.state = ParserState::InQuotedField; + } + ParserState::QuoteInQuotedField => match input { + ParserInput::Byte(byte) + if dialect.quoting != QuoteStyle::None + && dialect.quotechar == Some(byte) => { + self.add_byte(byte, vm)?; + self.state = ParserState::InQuotedField; + } + ParserInput::Byte(byte) if byte == dialect.delimiter => { + self.save_field(dialect.quoting, vm)?; + self.state = ParserState::StartField; + } + ParserInput::Eol | ParserInput::Byte(b'\r' | b'\n') => { + self.save_field(dialect.quoting, vm)?; + self.state = state_after_record_end(input); + } + ParserInput::Byte(byte) if !dialect.strict => { + self.add_byte(byte, vm)?; + self.state = ParserState::InField; + } + ParserInput::Byte(_) => { + return Err(new_csv_error( + vm, + format!( + "'{}' expected after '{}'", + dialect.delimiter as char, + dialect.quotechar.unwrap_or_default() as char, + ), + )); + } + }, + ParserState::EatCrnl => match input { + ParserInput::Byte(b'\r' | b'\n') => {} + ParserInput::Eol => self.state = ParserState::StartRecord, + ParserInput::Byte(_) => { return Err(new_csv_error( vm, concat!( - "new-line character seen in unquoted field", - " - do you need to open the file in universal-newline mode?" + "new-line character seen in unquoted field - ", + "do you need to open the file with newline=''?" ), )); } - break; - } - QuoteScanEvent::Data(byte) => fields.last_mut().unwrap().0.push(byte), + }, } - index += consumed; + Ok(()) } + } - // CPython treats an escape character at the end of an iterator item - // as escaping the implicit newline at the end of that item. - if dangling_escape { - fields.last_mut().unwrap().0.push(b'\n'); + fn state_after_record_end(input: ParserInput) -> ParserState { + if input == ParserInput::Eol { + ParserState::StartRecord + } else { + ParserState::EatCrnl } + } - fields - .into_iter() - .map(|(field, was_quoted)| { - if field.len() > field_limit as usize { - return Err(new_csv_error(vm, "filed too long to read")); - } - if matches!(dialect.quoting, QuoteStyle::Notnull | QuoteStyle::Strings) - && !was_quoted - && field.is_empty() - { - return Ok(vm.ctx.none()); - } - let field = - core::str::from_utf8(&field).map_err(|e| new_not_utf8_error(vm, &field, e))?; - Ok(vm.ctx.new_str(field).into()) - }) - .collect() + fn next_input_item(zelf: &Py, vm: &VirtualMachine) -> PyResult { + let generation = zelf.state.lock().generation; + // Advancing user code may re-enter this reader, so do not hold its lock here. + let result = zelf.iter.next(vm)?; + let mut state = zelf.state.lock(); + if state.generation != generation { + return Err(new_csv_error( + vm, + "iterator has already advanced the reader", + )); + } + if matches!(result, PyIterReturn::Return(_)) { + state.generation += 1; + } + Ok(result) + } + + fn finish_at_true_eof( + mut parser: CsvParser, + dialect: &PyDialect, + vm: &VirtualMachine, + ) -> PyResult { + let has_unfinished_record = + !parser.field.is_empty() || parser.state == ParserState::InQuotedField; + if !has_unfinished_record { + return Ok(PyIterReturn::StopIteration(None)); + } + if dialect.strict { + return Err(new_csv_error(vm, "unexpected end of data")); + } + parser.save_field(dialect.quoting, vm)?; + Ok(parser.into_result(vm)) } impl IterNext for Reader { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { - let generation = zelf.state.lock().generation; - let string_obj = raise_if_stop!(zelf.iter.next(vm)?); - let mut state = zelf.state.lock(); - if state.generation != generation { - return Err(new_csv_error( - vm, - "iterator has already advanced the reader", - )); - } - state.generation += 1; + let mut parser = CsvParser::new(*GLOBAL_FIELD_LIMIT.lock()); - let string = string_obj.downcast::().map_err(|obj| { - new_csv_error( - vm, - format!( - "iterator should return strings, not {} (the file should be opened in text mode)", - obj.class().name() - ), - ) - })?; - let input = string.as_bytes(); - if input.is_empty() || input.starts_with(b"\n") { - return Ok(PyIterReturn::Return(vm.ctx.new_list(vec![]).into())); - } - let ReadState { - buffer, - output_ends, - reader, - skipinitialspace, - line_num, - generation: _, - } = &mut *state; - - let mut input_offset = 0; - let mut output_offset = 0; - let mut output_ends_offset = 0; - let field_limit = GLOBAL_FIELD_LIMIT.lock().to_owned(); - - let use_quote_record = matches!( - zelf.dialect.quoting, - QuoteStyle::Notnull | QuoteStyle::Strings - ) || (zelf.dialect.quoting == QuoteStyle::None - && zelf.dialect.escapechar.is_some()); - if use_quote_record { - let out = read_quote_record(input, &zelf.dialect, field_limit, vm)?; - *line_num += 1; - return Ok(PyIterReturn::Return(vm.ctx.new_list(out).into())); - } + loop { + match next_input_item(zelf, vm)? { + PyIterReturn::Return(obj) => { + let string = obj.downcast::().map_err(|obj| { + new_csv_error( + vm, + format!( + concat!( + "iterator should return strings, not {} ", + "(the file should be opened in text mode)" + ), + obj.class().name() + ), + ) + })?; + + zelf.state.lock().line_num += 1; + parser.field_limit = *GLOBAL_FIELD_LIMIT.lock(); + for &byte in string.as_bytes() { + parser.process_parser_input( + ParserInput::Byte(byte), + &zelf.dialect, + vm, + )?; + } - #[inline] - fn trim_initial_spaces(input: &[u8], dialect: &PyDialect) -> Vec { - let mut trimmed = Vec::with_capacity(input.len()); - let mut scan_state = QuoteScanState::new(); - let mut index = 0; - - // Delimiters inside quoted fields are data, so only skip spaces - // after delimiters encountered outside quotes. - while index < input.len() { - let (event, consumed) = scan_state.scan(input, index, dialect, false); - if !matches!(event, QuoteScanEvent::InitialSpace) { - trimmed.extend_from_slice(&input[index..index + consumed]); + // Virtual EOL marks an iterator-item boundary, not true EOF. + parser.process_parser_input(EOL, &zelf.dialect, vm)?; + if parser.state == ParserState::StartRecord { + return Ok(parser.into_result(vm)); + } } - index += consumed; - } - - trimmed - } - - #[inline] - fn trim_spaces(input: &[u8]) -> &[u8] { - let trimmed_start = input.iter().position(|&x| x != b' ').unwrap_or(input.len()); - let trimmed_end = input.iter().rposition(|&x| x != b' ').map_or(0, |i| i + 1); - if trimmed_start >= trimmed_end { - &input[input.len()..] - } else { - &input[trimmed_start..trimmed_end] - } - } - - let input = if *skipinitialspace { - String::from_utf8(trim_initial_spaces(input, &zelf.dialect)).unwrap() - } else { - String::from_utf8(input.to_vec()).unwrap() - }; - - loop { - let (res, n_read, n_written, n_ends) = reader.read_record( - &input.as_bytes()[input_offset..], - &mut buffer[output_offset..], - &mut output_ends[output_ends_offset..], - ); - input_offset += n_read; - output_offset += n_written; - output_ends_offset += n_ends; - match res { - csv_core::ReadRecordResult::InputEmpty => {} - csv_core::ReadRecordResult::OutputFull => resize_buf(buffer), - csv_core::ReadRecordResult::OutputEndsFull => resize_buf(output_ends), - csv_core::ReadRecordResult::Record => break, - csv_core::ReadRecordResult::End => { - return Ok(PyIterReturn::StopIteration(None)); + PyIterReturn::StopIteration(_) => { + return finish_at_true_eof(parser, &zelf.dialect, vm); } } } - - let rest = &input.as_bytes()[input_offset..]; - if !rest.iter().all(|&c| matches!(c, b'\r' | b'\n')) { - return Err(new_csv_error( - vm, - concat!( - "new-line character seen in unquoted field", - " - do you need to open the file in universal-newline mode?" - ), - )); - } - - let mut prev_end = 0; - let out: Vec = output_ends[..output_ends_offset] - .iter() - .map(|&end| { - let range = prev_end..end; - if range.len() > field_limit as usize { - return Err(new_csv_error(vm, "filed too long to read")); - } - - prev_end = end; - let s = core::str::from_utf8(&buffer[range.clone()]) - // not sure if this is possible - the input was all strings - .map_err(|e| new_not_utf8_error(vm, &buffer[range.clone()], e))?; - - // TODO: RUSTPYTHON; Incomplete implementation - if let QuoteStyle::Nonnumeric = zelf.dialect.quoting { - if let Ok(t) = String::from_utf8(trim_spaces(&buffer[range]).to_vec()) - .unwrap() - .parse::() - { - Ok(vm.ctx.new_int(t).into()) - } else { - Ok(vm.ctx.new_str(s).into()) - } - } else { - Ok(vm.ctx.new_str(s).into()) - } - }) - .collect::>()?; - // Removes the last null item before the line terminator, if there is a separator before the line terminator, - // todo! - // if out.last().unwrap().length(vm).unwrap() == 0 { - // out.pop(); - // } - *line_num += 1; - Ok(PyIterReturn::Return(vm.ctx.new_list(out).into())) } } @@ -1628,8 +1541,11 @@ mod _csv { // closing the final quote / emitting an empty record as needed). // Drop that sentinel byte and append the real, possibly // multi-character, line terminator. - assert_eq!(buffer[buffer_offset - 1], CSV_CORE_TERMINATOR_SENTINEL); - let mut output = buffer[..buffer_offset - 1].to_vec(); + let emitted = &buffer[..buffer_offset]; + let body = emitted + .strip_suffix(&[CSV_CORE_TERMINATOR_SENTINEL]) + .ok_or_else(|| new_csv_error(vm, "internal error: missing record terminator"))?; + let mut output = body.to_vec(); output.extend_from_slice(self.dialect.lineterminator.as_bytes()); let s = diff --git a/extra_tests/snippets/stdlib_csv.py b/extra_tests/snippets/stdlib_csv.py index 418d383d84a..2221832b5d1 100644 --- a/extra_tests/snippets/stdlib_csv.py +++ b/extra_tests/snippets/stdlib_csv.py @@ -254,6 +254,10 @@ def test_multichar_lineterminator(): assert list(csv.reader(io.StringIO("a,b!@#c,d!@#"), lineterminator="!@#")) == [ ["a", "b!@#c", "d!@#"] ] + assert list(csv.reader(io.StringIO("a,b\r\nc,d\r\n"), lineterminator="!@#")) == [ + ["a", "b"], + ["c", "d"], + ] test_multichar_lineterminator() @@ -295,6 +299,32 @@ class NonAsciiDialect(csv.excel): test_reject_non_ascii_lineterminator() +def test_empty_lineterminator(): + class EmptyLineTerminator(csv.excel): + lineterminator = "" + + keyword = io.StringIO() + csv.writer(keyword, lineterminator="").writerows([["a", "b"], ["c", "d"]]) + assert keyword.getvalue() == "a,bc,d" + + dialect = io.StringIO() + csv.writer(dialect, dialect=EmptyLineTerminator).writerows([["a", "b"], ["c", "d"]]) + assert dialect.getvalue() == "a,bc,d" + + source = "a,b\r\nc,d\n" + assert list(csv.reader(io.StringIO(source), lineterminator="")) == [ + ["a", "b"], + ["c", "d"], + ] + assert list(csv.reader(io.StringIO(source), dialect=EmptyLineTerminator)) == [ + ["a", "b"], + ["c", "d"], + ] + + +test_empty_lineterminator() + + def test_quote_minimal_writer_empty_fields(): buf = io.StringIO() writer = csv.writer(buf) From dc1cae490be07e2ed0964095bfa762e5ba6ce3cb Mon Sep 17 00:00:00 2001 From: Yubin Kim <80163835+devyubin@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:38:58 +0900 Subject: [PATCH 221/351] Bind method descriptor when __get__ owner is omitted (#8404) `method_descriptor.__get__(obj)` raised a TypeError when the owner (the optional second argument) was omitted, because the METHOD-flag branch required the owner to be a type. Match CPython: a missing owner binds to `obj`, while a non-type owner still raises "needs a type, not ...". Assisted-by: Claude Code:claude-opus-4-8 --- Lib/test/test_types.py | 1 - crates/vm/src/builtins/descriptor.rs | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_types.py b/Lib/test/test_types.py index 63bc0803e79..2b48e6789b6 100644 --- a/Lib/test/test_types.py +++ b/Lib/test/test_types.py @@ -660,7 +660,6 @@ def test_method_descriptor_types(self): self.assertIsInstance(int.from_bytes, types.BuiltinMethodType) self.assertIsInstance(int.__new__, types.BuiltinMethodType) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: descriptor 'read' needs a type, not 'StringIO', as arg 2 def test_method_descriptor_crash(self): # gh-132747: The default __get__() implementation in C was unable # to handle a second argument of None when called from Python diff --git a/crates/vm/src/builtins/descriptor.rs b/crates/vm/src/builtins/descriptor.rs index 537c2e39c9a..5c0662e9fef 100644 --- a/crates/vm/src/builtins/descriptor.rs +++ b/crates/vm/src/builtins/descriptor.rs @@ -79,7 +79,10 @@ impl GetDescriptor for PyMethodDescriptor { let bound = match obj { Some(obj) => { if descr.method.flags.contains(PyMethodFlags::METHOD) { - if cls.is_some_and(|c| c.fast_isinstance(vm.ctx.types.type_type)) { + if cls + .as_ref() + .is_none_or(|c| c.fast_isinstance(vm.ctx.types.type_type)) + { obj } else { return Err(vm.new_type_error(format!( From bd653da880a848ae991e8712f33724c9ebaaec3c Mon Sep 17 00:00:00 2001 From: Hanjeong Lee Date: Sat, 1 Aug 2026 18:05:23 +0900 Subject: [PATCH 222/351] docs: drop stale crate paths from AGENTS.md (#8425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Repository Structure" section listed pre-`crates/` paths (`vm/`, `compiler/`, `derive/`, etc.) that no longer exist; `CONTRIBUTING.md` already documents the current layout under "Code organization". Replace the stale list with a link to that section. Also generalized the title and intro from "GitHub Copilot Instructions" to "AI Agent Instructions" — the file was already renamed from `.github/copilot-instructions.md` to `AGENTS.md` in #6813 to follow the cross-vendor AGENTS.md standard (https://agents.md/), but the title text was never updated to match. Assisted-by: Claude Code:claude-sonnet-5 --- AGENTS.md | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c89b2a4d3a4..72a22471fb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ -# GitHub Copilot Instructions for RustPython +# AI Agent Instructions for RustPython -This document provides guidelines for working with GitHub Copilot when contributing to the RustPython project. +This document provides guidelines for AI coding agents (GitHub Copilot, Claude Code, Gemini, etc.) contributing to the RustPython project. ## Project Overview @@ -13,22 +13,7 @@ RustPython is a Python 3 interpreter written in Rust, implementing Python 3.14.0 ## Repository Structure -- `src/` - Top-level code for the RustPython binary -- `vm/` - The Python virtual machine implementation - - `builtins/` - Python built-in types and functions - - `stdlib/` - Essential standard library modules implemented in Rust, required to run the Python core -- `compiler/` - Python compiler components - - `parser/` - Parser for converting Python source to AST - - `core/` - Bytecode representation in Rust structures - - `codegen/` - AST to bytecode compiler -- `Lib/` - CPython's standard library in Python (copied from CPython). **IMPORTANT**: Do not edit this directory directly; The only allowed operation is copying files from CPython. -- `derive/` - Rust macros for RustPython -- `common/` - Common utilities -- `extra_tests/` - Integration tests and snippets -- `stdlib/` - Non-essential Python standard library modules implemented in Rust (useful but not required for core functionality) -- `wasm/` - WebAssembly support -- `jit/` - Experimental JIT compiler implementation -- `pylib/` - Python standard library packaging (do not modify this directory directly - its contents are generated automatically) +See the "Code organization" section in [CONTRIBUTING.md](CONTRIBUTING.md#code-organization) for the current directory layout. ## AI Agent Rules From e4dc580f9fed4d93255b71ca9aa0f34f0dfeb9ff Mon Sep 17 00:00:00 2001 From: Hanjeong Lee Date: Sat, 1 Aug 2026 18:05:42 +0900 Subject: [PATCH 223/351] docs: fix AGENTS.md's broken DEVELOPMENT.md references (#8426) DEVELOPMENT.md was renamed to CONTRIBUTING.md in #7914, but AGENTS.md wasn't updated. Repoint the two links with a clear target (Linux/macOS testing section, development guide). Drop the "CPython Version Upgrade Checklist" reference under venvlauncher, which never pointed to real content in any revision of DEVELOPMENT.md. Related to #8356. Assisted-by: Claude Code:claude-sonnet-5 --- AGENTS.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 72a22471fb5..2478a864968 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,12 +250,10 @@ cargo run --features jit ### Linux Build and Debug on macOS -See the "Testing on Linux from macOS" section in [DEVELOPMENT.md](DEVELOPMENT.md#testing-on-linux-from-macos). +See the "Testing on Linux from macOS" section in [CONTRIBUTING.md](CONTRIBUTING.md#testing-on-linux-from-macos). ### Building venvlauncher (Windows) -See DEVELOPMENT.md "CPython Version Upgrade Checklist" section. - **IMPORTANT**: All 4 venvlauncher binaries use the same source code. Do NOT add multiple `[[bin]]` entries to Cargo.toml. Build once and copy with different names. ## Test Code Modification Rules @@ -286,7 +284,7 @@ If you modify any file under `.github/workflows/`, the change must pass a [zizmo ## Documentation - Check the [architecture document](/architecture/architecture.md) for a high-level overview -- Read the [development guide](/DEVELOPMENT.md) for detailed setup instructions +- Read the [development guide](/CONTRIBUTING.md) for detailed setup instructions - Generate documentation with `cargo doc --no-deps --all` - Online documentation is available at [docs.rs/rustpython](https://docs.rs/rustpython/) - [How to update test files](https://github.com/RustPython/RustPython/wiki/How-to-update-test-files#checkout-cpython-source-code-initial-setup) — guide for syncing test cases from upstream CPython into the `Lib/` directory From 9e92f96929ecbeca15c8e33a4dfe3ebf99c5a253 Mon Sep 17 00:00:00 2001 From: Hanjeong Lee Date: Sat, 1 Aug 2026 20:17:49 +0900 Subject: [PATCH 224/351] docs: match CONTRIBUTING.md's test command to what CI runs (#8427) `cargo test --workspace ...` segfaults on rustpython-capi when run from the workspace root; it needs its own config that only applies inside crates/capi. CI already excludes it from the workspace run and tests it separately. Document both. Closes #8415 Assisted-by: Claude Code:claude-sonnet-5 --- CONTRIBUTING.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58954486eaf..637ecf0993a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,7 +89,15 @@ $ pytest -v Rust unit tests can be run with `cargo`: ```shell -$ cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher +$ cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher --exclude rustpython-capi +``` + +`rustpython-capi` needs to be tested from inside its own directory, since it has a +separate `cargo` config that only applies there: + +```shell +$ cd crates/capi +$ cargo test ``` Python unit tests can be run by compiling RustPython and running the test module: From aa4f98a76505e1378fee5ccf802abcaedd9247c3 Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:25:43 +0900 Subject: [PATCH 225/351] fix(socket): use object repr in deallocation warnings (#8422) Assisted-by: Codex:gpt-5.6-sol --- Lib/test/test_ssl.py | 1 - crates/stdlib/src/socket.rs | 22 +++++----------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 5b0c90e4d3a..107aec7e6ef 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -754,7 +754,6 @@ def test_tls_unique_channel_binding(self): with test_wrap_socket(s, server_side=True, certfile=CERTFILE) as ss: self.assertIsNone(ss.get_channel_binding("tls-unique")) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: "" not found in "unclosed " def test_dealloc_warn(self): ss = test_wrap_socket(socket.socket(socket.AF_INET)) r = repr(ss) diff --git a/crates/stdlib/src/socket.rs b/crates/stdlib/src/socket.rs index 968399ca782..4f85374b181 100644 --- a/crates/stdlib/src/socket.rs +++ b/crates/stdlib/src/socket.rs @@ -1378,23 +1378,11 @@ mod _socket { fn del(zelf: &Py, vm: &VirtualMachine) -> PyResult<()> { // Emit ResourceWarning if socket is still open if zelf.sock.read().is_some() { - let laddr = if let Ok(sock) = zelf.sock() - && let Ok(addr) = sock.local_addr() - && let Ok(repr) = get_addr_tuple(&addr, vm).repr(vm) - { - format!(", laddr={}", repr.as_wtf8()) - } else { - String::new() - }; - - let msg = format!( - "unclosed ", - zelf.fileno(), - zelf.family.load(), - zelf.kind.load(), - zelf.proto.load(), - laddr - ); + let repr = zelf + .as_object() + .repr(vm) + .unwrap_or_else(|_| vm.ctx.new_str("")); + let msg = format!("unclosed {}", repr.as_wtf8()); let _ = crate::vm::warn::warn( vm.ctx.new_str(msg).into(), Some(vm.ctx.exceptions.resource_warning.to_owned()), From 6131363474390b674857baacd8429881a5ede843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EB=8F=99=EC=95=88?= Date: Sun, 2 Aug 2026 16:07:18 +0900 Subject: [PATCH 226/351] Replace rust-timsort with an in-tree powersort (#8421) * Add powersort implementation for list sorting Add crates/vm/src/sorting.rs implementing Tim Peters' timsort with powersort's merge-ordering policy (CPython 3.11+): run detection, binary insertion for short runs, galloping merge (merge_lo/merge_hi), and power-based merge ordering (powerloop). Comparison is passed in as a fallible `is_lt` closure, so the algorithm stays generic over the element type and free of interpreter details. Not yet wired into list.sort(); replaces rust-timsort in a follow-up. * Use powersort for list.sort() and drop rust-timsort Wire list sorting through crate::sorting::timsort and remove the rust-timsort dependency. Fixes the O(N^2) behavior on random input (1M random floats: ~16min -> ~0.8s), now within ~4x of CPython. * Fix usize underflow in merge_hi Succeed path * Fix clippy warnings and formatting in sorting.rs * Drop redundant test_ prefixes in sorting tests * Restore buffered elements when a comparison fails mid-merge * Copy one run-A element per gallop round in merge_lo --- Cargo.lock | 7 - Cargo.toml | 1 - crates/vm/Cargo.toml | 1 - crates/vm/src/builtins/list.rs | 18 +- crates/vm/src/lib.rs | 1 + crates/vm/src/sorting.rs | 738 +++++++++++++++++++++++++++++++++ 6 files changed, 749 insertions(+), 17 deletions(-) create mode 100644 crates/vm/src/sorting.rs diff --git a/Cargo.lock b/Cargo.lock index 0acd840c079..5b9870a0b0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3716,7 +3716,6 @@ dependencies = [ "strum_macros", "thin-vec", "thiserror", - "timsort", "wasm-bindgen", "widestring", ] @@ -4285,12 +4284,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "timsort" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "639ce8ef6d2ba56be0383a94dd13b92138d58de44c62618303bb798fa92bdc00" - [[package]] name = "tinystr" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index ac1e35c5314..6bb22e36f04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -305,7 +305,6 @@ textwrap = { version = "0.16.2", default-features = false } termios = "0.3.3" thiserror = "2.0" thin-vec = "0.2.14" -timsort = "0.1.2" tk-sys = { git = "https://github.com/arihant2math/tkinter.git", tag = "v0.2.0" } icu_casemap = "2" icu_locale = "2" diff --git a/crates/vm/Cargo.toml b/crates/vm/Cargo.toml index ca92b9dfd76..6601fb03dc2 100644 --- a/crates/vm/Cargo.toml +++ b/crates/vm/Cargo.toml @@ -80,7 +80,6 @@ half = { workspace = true } psm = { workspace = true } optional = { workspace = true } result-like = { workspace = true } -timsort = { workspace = true } [target.'cfg(unix)'.dependencies] exitcode = { workspace = true } diff --git a/crates/vm/src/builtins/list.rs b/crates/vm/src/builtins/list.rs index 2bdbaf63cde..8a426685ad2 100644 --- a/crates/vm/src/builtins/list.rs +++ b/crates/vm/src/builtins/list.rs @@ -18,6 +18,7 @@ use crate::{ recursion::ReprGuard, sequence::{MutObjectSequenceOp, OptionalRangeArgs, SequenceExt, SequenceMutExt}, sliceable::{SequenceIndex, SliceableSequenceMutOp, SliceableSequenceOp}, + sorting::timsort, types::{ AsMapping, AsSequence, Comparable, Constructor, Initializer, IterNext, Iterable, PyComparisonOp, Representable, SelfIter, @@ -641,14 +642,15 @@ fn do_sort( reverse: bool, ) -> PyResult<()> { // CPython uses __lt__ for all comparisons in sort. - // try_sort_by_gt expects is_gt(a, b) = true when a should come AFTER b. - let cmp = |a: &PyObjectRef, b: &PyObjectRef| { + // `timsort` expects is_lt(a, b) = true when a must be placed BEFORE b. + // For reverse=True, swapping the operands yields a descending order that is + // still stable in the original relative order, matching CPython's + // reverse-sort-reverse approach. + let mut is_lt = |a: &PyObjectRef, b: &PyObjectRef| { if reverse { - // Descending: a comes after b when a < b - a.rich_compare_bool(b, PyComparisonOp::Lt, vm) - } else { - // Ascending: a comes after b when b < a b.rich_compare_bool(a, PyComparisonOp::Lt, vm) + } else { + a.rich_compare_bool(b, PyComparisonOp::Lt, vm) } }; @@ -657,10 +659,10 @@ fn do_sort( .iter() .map(|x| Ok((x.clone(), key_func.call((x.clone(),), vm)?))) .collect::, _>>()?; - timsort::try_sort_by_gt(&mut items, |a, b| cmp(&a.1, &b.1))?; + timsort(&mut items, &mut |a, b| is_lt(&a.1, &b.1))?; *values = items.into_iter().map(|(val, _)| val).collect(); } else { - timsort::try_sort_by_gt(values, cmp)?; + timsort(values, &mut is_lt)?; } Ok(()) diff --git a/crates/vm/src/lib.rs b/crates/vm/src/lib.rs index c353f8dfc46..df67c979739 100644 --- a/crates/vm/src/lib.rs +++ b/crates/vm/src/lib.rs @@ -92,6 +92,7 @@ pub mod scope; pub mod sequence; pub mod signal; pub mod sliceable; +pub mod sorting; pub mod stdlib; pub mod suggestion; pub mod types; diff --git a/crates/vm/src/sorting.rs b/crates/vm/src/sorting.rs new file mode 100644 index 00000000000..5f61d6624e1 --- /dev/null +++ b/crates/vm/src/sorting.rs @@ -0,0 +1,738 @@ +// TODO: MERGESTATE_TEMP_SIZE unused — buf is a dynamic Vec, not a fixed stack array. +const MIN_GALLOP: usize = 7; +const MAX_MINRUN: usize = 64; + +enum LoBreakout { + Succeed, + CopyB, +} + +enum HiBreakout { + Succeed, + CopyA, +} + +#[derive(Clone, Copy)] +struct Run { + base: usize, + len: usize, + power: u32, +} + +struct MergeState { + buf: Vec, + min_gallop: usize, + pending: Vec, +} + +impl MergeState { + fn merge_lo( + &mut self, + values: &mut [T], + is_lt: &mut F, + start_a: usize, + mut len_a: usize, + start_b: usize, + mut len_b: usize, + ) -> Result<(), E> + where + F: FnMut(&T, &T) -> Result, + { + debug_assert!(len_a > 0); + debug_assert!(len_b > 0); + debug_assert!(start_a + len_a == start_b); + + self.buf.clear(); + self.buf + .extend_from_slice(&values[start_a..start_a + len_a]); + + let mut cursor_a = 0; + let mut cursor_b = start_b; + let mut dest = start_a; + + values[dest] = values[cursor_b].clone(); + dest += 1; + cursor_b += 1; + len_b -= 1; + + if len_b == 0 { + values[dest..dest + len_a].clone_from_slice(&self.buf[cursor_a..cursor_a + len_a]); + return Ok(()); + } + if len_a == 1 { + copy_within_clone(values, cursor_b, dest, len_b); + values[dest + len_b] = self.buf[cursor_a].clone(); + return Ok(()); + } + + let mut min_gallop = self.min_gallop; + + let breakout: Result = 'merging: loop { + let mut a_count = 0; + let mut b_count = 0; + + loop { + let b_wins = match is_lt(&values[cursor_b], &self.buf[cursor_a]) { + Ok(v) => v, + Err(e) => break 'merging Err(e), + }; + if b_wins { + values[dest] = values[cursor_b].clone(); + dest += 1; + cursor_b += 1; + len_b -= 1; + b_count += 1; + a_count = 0; + if len_b == 0 { + break 'merging Ok(LoBreakout::Succeed); + } + if b_count >= min_gallop { + break; + } + } else { + values[dest] = self.buf[cursor_a].clone(); + dest += 1; + cursor_a += 1; + len_a -= 1; + a_count += 1; + b_count = 0; + if len_a == 1 { + break 'merging Ok(LoBreakout::CopyB); + } + if a_count >= min_gallop { + break; + } + } + } + + min_gallop += 1; + loop { + if min_gallop > 1 { + min_gallop -= 1; + } + self.min_gallop = min_gallop; + let mut k = + match gallop_right(&self.buf, is_lt, &values[cursor_b], cursor_a, len_a, 0) { + Ok(k) => k, + Err(e) => break 'merging Err(e), + }; + a_count = k; + if k > 0 { + values[dest..dest + k].clone_from_slice(&self.buf[cursor_a..cursor_a + k]); + dest += k; + cursor_a += k; + len_a -= k; + if len_a == 1 { + break 'merging Ok(LoBreakout::CopyB); + } + if len_a == 0 { + break 'merging Ok(LoBreakout::Succeed); + } + } + values[dest] = values[cursor_b].clone(); + dest += 1; + cursor_b += 1; + len_b -= 1; + if len_b == 0 { + break 'merging Ok(LoBreakout::Succeed); + } + k = match gallop_left(values, is_lt, &self.buf[cursor_a], cursor_b, len_b, 0) { + Ok(k) => k, + Err(e) => break 'merging Err(e), + }; + b_count = k; + if k > 0 { + copy_within_clone(values, cursor_b, dest, k); + dest += k; + cursor_b += k; + len_b -= k; + if len_b == 0 { + break 'merging Ok(LoBreakout::Succeed); + } + } + values[dest] = self.buf[cursor_a].clone(); + dest += 1; + cursor_a += 1; + len_a -= 1; + if len_a == 1 { + break 'merging Ok(LoBreakout::CopyB); + } + if a_count < MIN_GALLOP && b_count < MIN_GALLOP { + break; + } + } + + min_gallop += 1; + self.min_gallop = min_gallop; + }; + + match breakout { + Ok(LoBreakout::CopyB) => { + copy_within_clone(values, cursor_b, dest, len_b); + values[dest + len_b] = self.buf[cursor_a].clone(); + Ok(()) + } + other => { + if len_a > 0 { + values[dest..dest + len_a] + .clone_from_slice(&self.buf[cursor_a..cursor_a + len_a]); + } + other.map(|_| ()) + } + } + } + + fn merge_hi( + &mut self, + values: &mut [T], + is_lt: &mut F, + start_a: usize, + mut len_a: usize, + start_b: usize, + mut len_b: usize, + ) -> Result<(), E> + where + F: FnMut(&T, &T) -> Result, + { + debug_assert!(len_a > 0); + debug_assert!(len_b > 0); + debug_assert!(start_a + len_a == start_b); + + self.buf.clear(); + self.buf + .extend_from_slice(&values[start_b..start_b + len_b]); + + let mut dest = start_b + len_b - 1; + let mut cursor_a = start_a + len_a - 1; + let mut cursor_b = len_b - 1; + + values[dest] = values[cursor_a].clone(); + dest -= 1; + cursor_a -= 1; + len_a -= 1; + + if len_a == 0 { + values[(dest - len_b + 1)..=dest].clone_from_slice(&self.buf[0..len_b]); + return Ok(()); + } + if len_b == 1 { + let src = cursor_a + 1 - len_a; + let dst = dest + 1 - len_a; + copy_within_clone(values, src, dst, len_a); + values[dst - 1] = self.buf[cursor_b].clone(); + return Ok(()); + } + + let mut min_gallop = self.min_gallop; + let breakout: Result = 'merging: loop { + let mut a_count = 0; + let mut b_count = 0; + + loop { + let b_wins = match is_lt(&self.buf[cursor_b], &values[cursor_a]) { + Ok(v) => v, + Err(e) => break 'merging Err(e), + }; + if b_wins { + values[dest] = values[cursor_a].clone(); + dest -= 1; + len_a -= 1; + + if len_a == 0 { + break 'merging Ok(HiBreakout::Succeed); + } + + cursor_a -= 1; + a_count += 1; + b_count = 0; + + if a_count >= min_gallop { + break; + } + } else { + values[dest] = self.buf[cursor_b].clone(); + dest -= 1; + cursor_b -= 1; + len_b -= 1; + b_count += 1; + a_count = 0; + if len_b == 1 { + break 'merging Ok(HiBreakout::CopyA); + } + if b_count >= min_gallop { + break; + } + } + } + + min_gallop += 1; + loop { + if min_gallop > 1 { + min_gallop -= 1; + } + self.min_gallop = min_gallop; + let mut k = match gallop_right( + values, + is_lt, + &self.buf[cursor_b], + start_a, + len_a, + len_a - 1, + ) { + Ok(k) => k, + Err(e) => break 'merging Err(e), + }; + k = len_a - k; + a_count = k; + if k > 0 { + copy_within_clone(values, cursor_a + 1 - k, dest + 1 - k, k); + dest -= k; + len_a -= k; + if len_a == 0 { + break 'merging Ok(HiBreakout::Succeed); + } + cursor_a -= k; + } + values[dest] = self.buf[cursor_b].clone(); + dest -= 1; + cursor_b -= 1; + len_b -= 1; + if len_b == 1 { + break 'merging Ok(HiBreakout::CopyA); + } + k = match gallop_left(&self.buf, is_lt, &values[cursor_a], 0, len_b, len_b - 1) { + Ok(k) => k, + Err(e) => break 'merging Err(e), + }; + k = len_b - k; + b_count = k; + if k > 0 { + values[dest + 1 - k..=dest] + .clone_from_slice(&self.buf[cursor_b + 1 - k..=cursor_b]); + dest -= k; + len_b -= k; + + if len_b == 0 { + break 'merging Ok(HiBreakout::Succeed); + } + cursor_b -= k; + + if len_b == 1 { + break 'merging Ok(HiBreakout::CopyA); + } + } + values[dest] = values[cursor_a].clone(); + dest -= 1; + len_a -= 1; + + if len_a == 0 { + break 'merging Ok(HiBreakout::Succeed); + } + + cursor_a -= 1; + + if a_count < MIN_GALLOP && b_count < MIN_GALLOP { + break; + } + } + min_gallop += 1; + self.min_gallop = min_gallop; + }; + + match breakout { + Ok(HiBreakout::CopyA) => { + let src = cursor_a + 1 - len_a; + let dst = dest + 1 - len_a; + copy_within_clone(values, src, dst, len_a); + values[dst - 1] = self.buf[cursor_b].clone(); + Ok(()) + } + other => { + if len_b > 0 { + values[(dest + 1) - len_b..=dest].clone_from_slice(&self.buf[0..len_b]); + } + other.map(|_| ()) + } + } + } + + fn merge_at(&mut self, values: &mut [T], is_lt: &mut F, i: usize) -> Result<(), E> + where + F: FnMut(&T, &T) -> Result, + { + debug_assert!(self.pending.len() >= 2); + debug_assert!(i == self.pending.len() - 2 || i == self.pending.len() - 3); + + let mut start_a = self.pending[i].base; + let mut len_a = self.pending[i].len; + let start_b = self.pending[i + 1].base; + let mut len_b = self.pending[i + 1].len; + + debug_assert!(len_a > 0); + debug_assert!(len_b > 0); + debug_assert!(start_a + len_a == start_b); + + self.pending[i].len = len_a + len_b; + self.pending.remove(i + 1); + + let k = gallop_right(values, is_lt, &values[start_b], start_a, len_a, 0)?; + start_a += k; + len_a -= k; + + if len_a == 0 { + return Ok(()); + } + + len_b = gallop_left( + values, + is_lt, + &values[start_a + len_a - 1], + start_b, + len_b, + len_b - 1, + )?; + + if len_b == 0 { + return Ok(()); + } + + if len_a <= len_b { + self.merge_lo(values, is_lt, start_a, len_a, start_b, len_b)?; + } else { + self.merge_hi(values, is_lt, start_a, len_a, start_b, len_b)?; + } + Ok(()) + } + + fn found_new_run( + &mut self, + new_run_len: usize, + values: &mut [T], + is_lt: &mut F, + ) -> Result<(), E> + where + F: FnMut(&T, &T) -> Result, + { + if !self.pending.is_empty() { + let last = self.pending.len() - 1; + let s1 = self.pending[last].base; + let n1 = self.pending[last].len; + let power = powerloop(s1, n1, new_run_len, values.len()); + + while self.pending.len() > 1 && self.pending[self.pending.len() - 2].power > power { + self.merge_at(values, is_lt, self.pending.len() - 2)?; + } + + debug_assert!( + self.pending.len() < 2 || self.pending[self.pending.len() - 2].power < power + ); + let last = self.pending.len() - 1; + self.pending[last].power = power; + } + Ok(()) + } + + fn push_run(&mut self, base: usize, len: usize) { + self.pending.push(Run { + base, + len, + power: 0, + }) + } + + fn merge_force_collapse(&mut self, values: &mut [T], is_lt: &mut F) -> Result<(), E> + where + F: FnMut(&T, &T) -> Result, + { + while self.pending.len() > 1 { + let mut n = self.pending.len() - 2; + if n > 0 && self.pending[n - 1].len < self.pending[n + 1].len { + n -= 1; + } + self.merge_at(values, is_lt, n)?; + } + Ok(()) + } +} + +fn binary_insertion_sort(values: &mut [T], is_lt: &mut F, start: usize) -> Result<(), E> +where + F: FnMut(&T, &T) -> Result, +{ + for i in start..values.len() { + let mut l = 0; + let mut r = i; + + while l < r { + let m = (l + r) / 2; + if is_lt(&values[i], &values[m])? { + r = m; + } else { + l = m + 1; + } + } + values[l..=i].rotate_right(1); + } + Ok(()) +} + +fn copy_within_clone(values: &mut [T], src: usize, dest: usize, n: usize) { + if dest <= src { + for k in 0..n { + values[dest + k] = values[src + k].clone(); + } + } else { + for k in (0..n).rev() { + values[dest + k] = values[src + k].clone(); + } + } +} + +fn count_run(values: &[T], is_lt: &mut F) -> Result<(usize, bool), E> +where + F: FnMut(&T, &T) -> Result, +{ + let n = values.len(); + if n == 1 { + return Ok((1, false)); + } + let mut i = 2; + let descending = is_lt(&values[1], &values[0])?; + if descending { + while i < n && is_lt(&values[i], &values[i - 1])? { + i += 1; + } + } else { + while i < n && !is_lt(&values[i], &values[i - 1])? { + i += 1; + } + } + Ok((i, descending)) +} + +fn gallop_left( + values: &[T], + is_lt: &mut F, + key: &T, + base: usize, + len: usize, + hint: usize, +) -> Result +where + F: FnMut(&T, &T) -> Result, +{ + debug_assert!(hint < len); + let mut lastofs: isize = 0; + let mut ofs: isize = 1; + let hint_i = hint as isize; + let len_i = len as isize; + + if is_lt(&values[base + hint], key)? { + let maxofs = len_i - hint_i; + while ofs < maxofs && is_lt(&values[base + hint + ofs as usize], key)? { + lastofs = ofs; + ofs = (ofs * 2) + 1; + } + if ofs > maxofs { + ofs = maxofs; + } + lastofs += hint_i; + ofs += hint_i; + } else { + let maxofs = hint_i + 1; + while ofs < maxofs && !is_lt(&values[base + (hint_i - ofs) as usize], key)? { + lastofs = ofs; + ofs = (ofs * 2) + 1; + } + if ofs > maxofs { + ofs = maxofs; + } + (lastofs, ofs) = (hint_i - ofs, hint_i - lastofs); + } + lastofs += 1; + while lastofs < ofs { + let m = lastofs + ((ofs - lastofs) / 2); + if is_lt(&values[base + m as usize], key)? { + lastofs = m + 1; + } else { + ofs = m; + } + } + Ok(ofs as usize) +} + +fn gallop_right( + values: &[T], + is_lt: &mut F, + key: &T, + base: usize, + len: usize, + hint: usize, +) -> Result +where + F: FnMut(&T, &T) -> Result, +{ + debug_assert!(hint < len); + let mut lastofs: isize = 0; + let mut ofs: isize = 1; + let hint_i = hint as isize; + let len_i = len as isize; + + if is_lt(key, &values[base + hint])? { + let maxofs = hint_i + 1; + while ofs < maxofs && is_lt(key, &values[base + (hint_i - ofs) as usize])? { + lastofs = ofs; + ofs = (ofs * 2) + 1; + } + if ofs > maxofs { + ofs = maxofs; + } + (lastofs, ofs) = (hint_i - ofs, hint_i - lastofs); + } else { + let maxofs = len_i - hint_i; + while ofs < maxofs && !is_lt(key, &values[base + hint + ofs as usize])? { + lastofs = ofs; + ofs = (ofs * 2) + 1; + } + if ofs > maxofs { + ofs = maxofs; + } + lastofs += hint_i; + ofs += hint_i; + } + lastofs += 1; + while lastofs < ofs { + let m = lastofs + ((ofs - lastofs) / 2); + if is_lt(key, &values[base + m as usize])? { + ofs = m; + } else { + lastofs = m + 1; + } + } + Ok(ofs as usize) +} + +// TODO: consider CPython 3.12+'s incremental minrun (mr_current/mr_e/mr_mask) +// for a more precise minrun; current bit-shift version is the classic one. +fn merge_compute_minrun(mut n: usize) -> usize { + let mut r = 0; + while n >= MAX_MINRUN { + r |= n & 1; + n >>= 1; + } + n + r +} + +fn powerloop(s1: usize, n1: usize, n2: usize, n: usize) -> u32 { + let mut result: u32 = 0; + let mut a = 2 * s1 + n1; + let mut b = a + n1 + n2; + + loop { + result += 1; + if a >= n { + debug_assert!(b >= a); + a -= n; + b -= n; + } else if b >= n { + break; + } + debug_assert!(a < b && b < n); + a <<= 1; + b <<= 1; + } + result +} + +/// Stable adaptive mergesort (Tim Peters' timsort with powersort's +/// merge-ordering policy, matching CPython 3.11+). `is_lt` provides comparison. +pub(crate) fn timsort(values: &mut [T], is_lt: &mut F) -> Result<(), E> +where + T: Clone, + F: FnMut(&T, &T) -> Result, +{ + let n = values.len(); + let mut ms = MergeState { + buf: Vec::new(), + min_gallop: MIN_GALLOP, + pending: Vec::new(), + }; + + if n < 2 { + return Ok(()); + } + + if n < MAX_MINRUN { + let (l, desc) = count_run(values, is_lt)?; + if desc { + values[0..l].reverse(); + } + binary_insertion_sort(values, is_lt, l)?; + return Ok(()); + } + + let minrun = merge_compute_minrun(n); + let mut lo = 0; + + while lo < n { + let (mut l, desc) = count_run(&values[lo..n], is_lt)?; + if desc { + values[lo..lo + l].reverse(); + } + if l < minrun { + let force = minrun.min(n - lo); + binary_insertion_sort(&mut values[lo..lo + force], is_lt, l)?; + l = force; + } + ms.found_new_run(l, values, is_lt)?; + ms.push_run(lo, l); + lo += l; + } + ms.merge_force_collapse(values, is_lt)?; + debug_assert!(ms.pending.len() == 1 && ms.pending[0].len == n); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sort(mut v: Vec) -> Vec { + timsort(&mut v, &mut |a: &i32, b: &i32| Ok::(a < b)).unwrap(); + v + } + + #[test] + fn basic_examples() { + assert_eq!(sort(vec![3, 1, 2]), vec![1, 2, 3]); + assert_eq!(sort(Vec::::new()), Vec::::new()); + assert_eq!(sort(vec![1]), vec![1]); + assert_eq!(sort(vec![2, 1]), vec![1, 2]); + } + + #[test] + fn five_elements_forwards_and_backwards() { + assert_eq!(sort(vec![1, 2, 3, 4, 5]), vec![1, 2, 3, 4, 5]); + assert_eq!(sort(vec![5, 4, 3, 2, 1]), vec![1, 2, 3, 4, 5]); + } + + #[test] + fn six_elements_with_duplicates() { + assert_eq!(sort(vec![3, 1, 3, 1, 2, 2]), vec![1, 1, 2, 2, 3, 3]); + } + + #[test] + fn one_thousand_elements() { + let v: Vec = (0..1000).rev().collect(); // 999..0 + let sorted: Vec = (0..1000).collect(); + assert_eq!(sort(v), sorted); + } + + #[test] + fn pseudorandom_collection() { + let v: Vec = (0..500).map(|i| (i * 7919) % 500).collect(); + let mut expected = v.clone(); + expected.sort(); + assert_eq!(sort(v), expected); + } +} From 1306b71a65ff67fdc3155c88e44fb817ed66e432 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:21:11 +0900 Subject: [PATCH 227/351] __length_hint__ (#6636) * Initial plan * fix: prevent iterator length_hint deadlock Co-authored-by: youknowone <69878+youknowone@users.noreply.github.com> * fix * Auto-format: cargo fmt --all --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] --- crates/vm/src/builtins/iter.rs | 32 ++++++++----- extra_tests/snippets/builtin_iter.py | 71 ++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 extra_tests/snippets/builtin_iter.py diff --git a/crates/vm/src/builtins/iter.rs b/crates/vm/src/builtins/iter.rs index e9f1516b5bf..4e29df583a8 100644 --- a/crates/vm/src/builtins/iter.rs +++ b/crates/vm/src/builtins/iter.rs @@ -184,17 +184,27 @@ impl PySequenceIterator { } #[pymethod] - fn __length_hint__(&self, vm: &VirtualMachine) -> PyObjectRef { - let internal = self.internal.lock(); - if let IterStatus::Active(obj) = &internal.status { - let seq = obj.sequence_unchecked(); - seq.length(vm).map_or_else( - |_| vm.ctx.not_implemented(), - |x| PyInt::from(x).into_pyobject(vm), - ) - } else { - PyInt::from(0).into_pyobject(vm) - } + fn __length_hint__(&self, vm: &VirtualMachine) -> PyResult { + vm.with_recursion("in __length_hint__", || { + let (obj, position) = { + let internal = self.internal.lock(); + match &internal.status { + IterStatus::Active(obj) => (Some(obj.clone()), internal.position), + IterStatus::Exhausted => (None, 0), + } + }; + if let Some(obj) = obj { + let seq = obj.sequence_unchecked(); + match seq.length_opt(vm) { + Some(len) => { + len.map(|len| PyInt::from(len.saturating_sub(position)).into_pyobject(vm)) + } + None => Ok(vm.ctx.not_implemented()), + } + } else { + Ok(PyInt::from(0).into_pyobject(vm)) + } + }) } #[pymethod] diff --git a/extra_tests/snippets/builtin_iter.py b/extra_tests/snippets/builtin_iter.py new file mode 100644 index 00000000000..02d469a47ee --- /dev/null +++ b/extra_tests/snippets/builtin_iter.py @@ -0,0 +1,71 @@ +import queue +import threading + + +def make_iterator(): + holder = {} + + class Evil: + def __getitem__(self, index): + if index == 0: + return 0 + raise IndexError + + def __len__(self): + return holder["it"].__length_hint__() + + obj = Evil() + holder["it"] = iter(obj) + return holder["it"] + + +it = make_iterator() +q = queue.Queue() + + +def run(): + try: + it.__length_hint__() + except Exception as exc: # noqa: BLE001 + q.put(exc) + else: + q.put(None) + + +t = threading.Thread(target=run, daemon=True) +t.start() +t.join(1) + +assert not t.is_alive(), "iterator.__length_hint__ deadlocked" +err = q.get_nowait() +assert isinstance(err, RecursionError) + + +class NoLen: + def __getitem__(self, index): + if index < 3: + return index + raise IndexError + + +no_len_it = iter(NoLen()) +assert no_len_it.__length_hint__() is NotImplemented +next(no_len_it) +assert no_len_it.__length_hint__() is NotImplemented + + +class Seq: + def __init__(self): + self.items = [1, 2, 3] + + def __getitem__(self, index): + return self.items[index] + + def __len__(self): + return len(self.items) + + +seq_it = iter(Seq()) +assert seq_it.__length_hint__() == 3 +next(seq_it) +assert seq_it.__length_hint__() == 2 From 95f9d17dc2868131fcd0d6dcaa6899e7ea97820a Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:12:30 +0900 Subject: [PATCH 228/351] Fix miri UB: use transmute_copy for function pointer identity checks (#8432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nightly miri now flags `fn_ptr as usize` and direct `fn_ptr == fn_ptr` as UB because both paths go through `FnPtr::addr()`, which attempts to dereference a function pointer's provenance — function items have no backing allocation in miri's model. Add `fn_addr(f: T) -> usize` that uses `transmute_copy` to read the address as plain integer bytes without triggering provenance checks. Replace all `f as usize` slot comparison patterns across the codebase with `fn_addr(f)`. Also add `-Zmiri-permissive-provenance` to CI MIRIFLAGS as a safety net for any remaining integer-pointer round-trips elsewhere. Assisted-by: Claude --- .github/workflows/ci.yaml | 5 ++++- crates/vm/src/builtins/object.rs | 8 ++++++-- crates/vm/src/builtins/set.rs | 8 ++++++-- crates/vm/src/builtins/type.rs | 6 ++++-- crates/vm/src/class.rs | 12 +++++------- crates/vm/src/frame.rs | 22 ++++++++++------------ crates/vm/src/stdlib/_thread.rs | 4 ++-- crates/vm/src/types/slot.rs | 29 +++++++++++++++++++++++++---- crates/vm/src/types/slot_defs.rs | 4 ++-- crates/vm/src/vm/method.rs | 4 ++-- crates/vm/src/vm/vm_ops.rs | 12 ++++++------ 11 files changed, 72 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7d0ab2e7c40..dc7a1a3e77e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -648,7 +648,10 @@ jobs: env: # miri-ignore-leaks because the type-object circular reference means that there will always be # a memory leak, at least until we have proper cyclic gc - MIRIFLAGS: "-Zmiri-ignore-leaks" + # miri-permissive-provenance because function pointer identity checks (slot comparisons) + # cast fn pointers to usize, which strips provenance — this is the standard pattern for + # fn pointer comparison in Rust and not a soundness issue + MIRIFLAGS: "-Zmiri-ignore-leaks -Zmiri-permissive-provenance" wasm: if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') }} diff --git a/crates/vm/src/builtins/object.rs b/crates/vm/src/builtins/object.rs index 633eaf48a44..151b753b2a0 100644 --- a/crates/vm/src/builtins/object.rs +++ b/crates/vm/src/builtins/object.rs @@ -122,8 +122,12 @@ impl Initializer for PyBaseObject { let typ = zelf.class(); let object_type = &vm.ctx.types.object_type; - let typ_init = typ.slots.init.load().map(|f| f as usize); - let object_init = object_type.slots.init.load().map(|f| f as usize); + let typ_init = typ.slots.init.load().map(|f| crate::types::fn_addr(f)); + let object_init = object_type + .slots + .init + .load() + .map(|f| crate::types::fn_addr(f)); // if (type->tp_init != object_init) → first error if typ_init != object_init { diff --git a/crates/vm/src/builtins/set.rs b/crates/vm/src/builtins/set.rs index 1481bc1b391..cd724abc5c1 100644 --- a/crates/vm/src/builtins/set.rs +++ b/crates/vm/src/builtins/set.rs @@ -960,7 +960,11 @@ impl Constructor for PyFrozenSet { fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { let is_exact_frozenset = cls.is(vm.ctx.types.frozenset_type); let is_frozenset_init = { - let cls_init = cls.slots.init.load().map(|init| init as usize); + let cls_init = cls + .slots + .init + .load() + .map(|init| crate::types::fn_addr(init)); let frozenset_init = vm .ctx .types @@ -968,7 +972,7 @@ impl Constructor for PyFrozenSet { .slots .init .load() - .map(|init| init as usize); + .map(|init| crate::types::fn_addr(init)); cls_init == frozenset_init }; diff --git a/crates/vm/src/builtins/type.rs b/crates/vm/src/builtins/type.rs index 0ccfe155dc2..e2af7d21df4 100644 --- a/crates/vm/src/builtins/type.rs +++ b/crates/vm/src/builtins/type.rs @@ -2829,7 +2829,8 @@ impl Callable for PyType { // path incorrectly. if zelf.slots.init.load().is_none() && !zelf.is(vm.ctx.types.type_type) - && slot_new as usize != crate::types::new_wrapper as crate::types::NewFunc as usize + && crate::types::fn_addr(slot_new) + != crate::types::fn_addr(crate::types::new_wrapper as crate::types::NewFunc) { return slot_new(zelf.to_owned(), args, vm); } @@ -3066,7 +3067,8 @@ pub(crate) fn call_slot_new( // Check if staticbase's tp_new differs from typ's tp_new let typ_new = typ.slots.new.load(); let staticbase_new = staticbase.slots.new.load(); - if typ_new.map(|f| f as usize) != staticbase_new.map(|f| f as usize) { + if typ_new.map(|f| crate::types::fn_addr(f)) != staticbase_new.map(|f| crate::types::fn_addr(f)) + { return Err(vm.new_type_error(format!( "{}.__new__({}) is not safe, use {}.__new__()", typ.slot_name(), diff --git a/crates/vm/src/class.rs b/crates/vm/src/class.rs index 364ae721159..d9f8b848d2a 100644 --- a/crates/vm/src/class.rs +++ b/crates/vm/src/class.rs @@ -5,7 +5,7 @@ use crate::{ builtins::{PyBaseObject, PyType, PyTypeRef, descriptor::PyWrapper}, function::PyMethodDef, object::Py, - types::{PyTypeFlags, PyTypeSlots, SLOT_DEFS, hash_not_implemented}, + types::{PyTypeFlags, PyTypeSlots, SLOT_DEFS, fn_addr, hash_not_implemented}, vm::Context, }; use rustpython_common::static_cell; @@ -24,11 +24,9 @@ pub fn add_operators(class: &'static Py, ctx: &Context) { // Special handling for __hash__ = None if def.name == "__hash__" - && class - .slots - .hash - .load() - .is_some_and(|h| h as usize == hash_not_implemented as *const () as usize) + && class.slots.hash.load().is_some_and(|h| { + fn_addr(h) == fn_addr(hash_not_implemented as crate::types::HashFunc) + }) { class.set_attr(ctx.names.__hash__, ctx.none.clone().into()); continue; @@ -205,7 +203,7 @@ pub trait PyClassImpl: PyClassDef { let object_new = ctx.types.object_type.slots.new.load(); let is_object_itself = core::ptr::eq(class, ctx.types.object_type); let is_inherited_from_object = !is_object_itself - && object_new.is_some_and(|obj_new| slot_new as usize == obj_new as usize); + && object_new.is_some_and(|obj_new| fn_addr(slot_new) == fn_addr(obj_new)); if !is_inherited_from_object { let bound_new = diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index 5c21ad29c8b..a05fc7184c8 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -8932,11 +8932,10 @@ impl ExecutingFrame<'_> { } // Only specialize if getattro is the default (PyBaseObject::getattro) - let is_default_getattro = cls - .slots - .getattro - .load() - .is_some_and(|f| f as usize == PyBaseObject::getattro as *const () as usize); + let is_default_getattro = cls.slots.getattro.load().is_some_and(|f| { + crate::types::fn_addr(f) + == crate::types::fn_addr(PyBaseObject::getattro as crate::types::GetattroFunc) + }); if !is_default_getattro { let getattribute = cls.get_attr(identifier!(_vm, __getattribute__)); if !oparg.is_method() @@ -9953,8 +9952,8 @@ impl ExecutingFrame<'_> { let cls_alloc = cls.slots.alloc.load(); if let (Some(cls_new_fn), Some(obj_new_fn), Some(cls_alloc_fn), Some(obj_alloc_fn)) = (cls_new, object_new, cls_alloc, object_alloc) - && cls_new_fn as usize == obj_new_fn as usize - && cls_alloc_fn as usize == obj_alloc_fn as usize + && crate::types::fn_addr(cls_new_fn) == crate::types::fn_addr(obj_new_fn) + && crate::types::fn_addr(cls_alloc_fn) == crate::types::fn_addr(obj_alloc_fn) { if type_version == 0 { unsafe { @@ -10614,11 +10613,10 @@ impl ExecutingFrame<'_> { } // Only specialize if setattr is the default (generic_setattr) - let is_default_setattr = cls - .slots - .setattro - .load() - .is_some_and(|f| f as usize == PyBaseObject::slot_setattro as *const () as usize); + let is_default_setattr = cls.slots.setattro.load().is_some_and(|f| { + crate::types::fn_addr(f) + == crate::types::fn_addr(PyBaseObject::slot_setattro as crate::types::SetattroFunc) + }); if !is_default_setattr { unsafe { self.code.instructions.write_adaptive_counter( diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index e9d75dc02e9..64e5c596ba7 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -997,8 +997,8 @@ pub(crate) mod _thread { .slots .init .load() - .map(|init| init as usize); - (Some(cls_init as usize) != object_init).then_some(cls_init) + .map(|init| crate::types::fn_addr(init)); + (Some(crate::types::fn_addr(cls_init)) != object_init).then_some(cls_init) } fn create_dict(&self, vm: &VirtualMachine) -> (PyDictRef, bool) { diff --git a/crates/vm/src/types/slot.rs b/crates/vm/src/types/slot.rs index a5d7a41d3fd..d834406cf80 100644 --- a/crates/vm/src/types/slot.rs +++ b/crates/vm/src/types/slot.rs @@ -877,8 +877,8 @@ impl PyType { .iter() .find(|cls| cls.attributes.read().contains_key(name)) .is_some_and(|cls| { - cls.slots.new.load().map(|f| f as usize) - == Some(new_wrapper as NewFunc as usize) + cls.slots.new.load().map(|f| fn_addr(f)) + == Some(fn_addr(new_wrapper as NewFunc)) }) }; if needs_wrapper { @@ -953,7 +953,7 @@ impl PyType { self.slots.setattro.store(Some(setattro_wrapper)); } (NativeSlot(set), NativeSlot(del)) => { - let func = if set as usize == del as usize { + let func = if fn_addr(set) == fn_addr(del) { set } else { setattro_wrapper @@ -988,7 +988,7 @@ impl PyType { self.slots.descr_set.store(Some(descr_set_wrapper)); } (NativeSlot(set), NativeSlot(delete)) => { - let func = if set as usize == delete as usize { + let func = if fn_addr(set) == fn_addr(delete) { set } else { descr_set_wrapper @@ -2171,3 +2171,24 @@ where debug_assert!(prev.is_some()); // slot_iter would be set } } + +/// Extract the raw address of a function pointer as `usize` without +/// triggering miri's "pointer not dereferenceable" UB. +/// +/// The standard `fn_ptr as usize` cast goes through `FnPtr::addr()` +/// which attempts to dereference the function pointer's provenance — +/// miri considers this UB for function items. `transmute_copy` bypasses +/// that path and reads the address as plain integer bytes. +/// +/// The result is suitable for identity comparison only: two function +/// pointers with the same address are the same function. The converse +/// is not always guaranteed (the compiler may merge identical function +/// bodies), but this matches CPython's slot comparison semantics. +#[inline(always)] +pub(crate) fn fn_addr(f: T) -> usize { + assert!( + core::mem::size_of::() == core::mem::size_of::(), + "fn_addr: T must be pointer-sized" + ); + unsafe { core::mem::transmute_copy::(&f) } +} diff --git a/crates/vm/src/types/slot_defs.rs b/crates/vm/src/types/slot_defs.rs index 34d62147e0c..69c7bb61045 100644 --- a/crates/vm/src/types/slot_defs.rs +++ b/crates/vm/src/types/slot_defs.rs @@ -2,7 +2,7 @@ //! //! This module provides a centralized array of all slot definitions, -use super::{PyComparisonOp, PyTypeSlots}; +use super::{PyComparisonOp, PyTypeSlots, fn_addr}; use crate::builtins::descriptor::SlotFunc; /// Slot operation type @@ -609,7 +609,7 @@ impl SlotAccessor { && let Some(base_val) = base.slots.init.load() { let slot_defined = base.base.deref().is_none_or(|bb| { - bb.slots.init.load().map(|v| v as usize) != Some(base_val as usize) + bb.slots.init.load().map(|v| fn_addr(v)) != Some(fn_addr(base_val)) }); if slot_defined { typ.slots.init.store(Some(base_val)); diff --git a/crates/vm/src/vm/method.rs b/crates/vm/src/vm/method.rs index 9e4f7185552..2beeb95ceb5 100644 --- a/crates/vm/src/vm/method.rs +++ b/crates/vm/src/vm/method.rs @@ -6,7 +6,7 @@ use crate::{ builtins::{PyBaseObject, PyStr, PyStrInterned, descriptor::PyMethodDescriptor}, function::{IntoFuncArgs, PyMethodFlags}, object::{AsObject, Py, PyObject, PyObjectRef, PyResult}, - types::PyTypeFlags, + types::{GetattroFunc, PyTypeFlags, fn_addr}, }; #[derive(Debug)] @@ -22,7 +22,7 @@ impl PyMethod { pub(crate) fn get(obj: PyObjectRef, name: &Py, vm: &VirtualMachine) -> PyResult { let cls = obj.class(); let getattro = cls.slots.getattro.load().unwrap(); - if getattro as usize != PyBaseObject::getattro as *const () as usize { + if fn_addr(getattro) != fn_addr(PyBaseObject::getattro as GetattroFunc) { return obj.get_attr(name, vm).map(Self::Attribute); } diff --git a/crates/vm/src/vm/vm_ops.rs b/crates/vm/src/vm/vm_ops.rs index 8cb00d4a10d..692444fc7de 100644 --- a/crates/vm/src/vm/vm_ops.rs +++ b/crates/vm/src/vm/vm_ops.rs @@ -180,13 +180,13 @@ impl VirtualMachine { // Number slots are inherited, direct access is O(1) let slot_a = class_a.slots.as_number.left_binary_op(op_slot); - let slot_a_addr = slot_a.map(|x| x as usize); + let slot_a_addr = slot_a.map(|x| crate::types::fn_addr(x)); let mut slot_b = None; let left_b_addr = if class_a.is(class_b) { slot_a_addr } else { let slot_bb = class_b.slots.as_number.right_binary_op(op_slot); - if slot_bb.map(|x| x as usize) != slot_a_addr { + if slot_bb.map(|x| crate::types::fn_addr(x)) != slot_a_addr { slot_b = slot_bb; } @@ -194,7 +194,7 @@ impl VirtualMachine { .slots .as_number .left_binary_op(op_slot) - .map(|x| x as usize) + .map(|x| crate::types::fn_addr(x)) }; if let Some(slot_a) = slot_a { @@ -302,13 +302,13 @@ impl VirtualMachine { // Number slots are inherited, direct access is O(1) let slot_a = class_a.slots.as_number.left_ternary_op(op_slot); - let slot_a_addr = slot_a.map(|x| x as usize); + let slot_a_addr = slot_a.map(|x| crate::types::fn_addr(x)); let mut slot_b = None; let left_b_addr = if class_a.is(class_b) { slot_a_addr } else { let slot_bb = class_b.slots.as_number.right_ternary_op(op_slot); - if slot_bb.map(|x| x as usize) != slot_a_addr { + if slot_bb.map(|x| crate::types::fn_addr(x)) != slot_a_addr { slot_b = slot_bb; } @@ -316,7 +316,7 @@ impl VirtualMachine { .slots .as_number .left_ternary_op(op_slot) - .map(|x| x as usize) + .map(|x| crate::types::fn_addr(x)) }; if let Some(slot_a) = slot_a { From 12f3646e7be9e094a69aabe3fc7ea282adf9c432 Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Mon, 3 Aug 2026 01:38:44 +0900 Subject: [PATCH 229/351] ssl: store SSL socket owners as weak references (#8423) Assisted-by: Codex:gpt-5.6-sol --- Lib/test/test_asyncio/test_ssl.py | 2 -- Lib/test/test_ssl.py | 1 - crates/stdlib/src/ssl.rs | 39 ++++++++++++++++++++++++------- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/Lib/test/test_asyncio/test_ssl.py b/Lib/test/test_asyncio/test_ssl.py index 932b1dace4f..ca15fc3bdd4 100644 --- a/Lib/test/test_asyncio/test_ssl.py +++ b/Lib/test/test_asyncio/test_ssl.py @@ -738,7 +738,6 @@ async def client(addr): asyncio.wait_for(client(srv.addr), timeout=support.SHORT_TIMEOUT)) - @unittest.expectedFailure # TODO: RUSTPYTHON; - gc.collect() doesn't release SSLContext properly def test_create_connection_memory_leak(self): HELLO_MSG = b'1' * self.PAYLOAD_SIZE @@ -1617,7 +1616,6 @@ async def test(): else: self.fail('Unexpected ResourceWarning: {}'.format(cm.warning)) - @unittest.expectedFailure # TODO: RUSTPYTHON; - gc.collect() doesn't release SSLContext properly def test_handshake_timeout_handler_leak(self): s = socket.socket(socket.AF_INET) s.bind(('127.0.0.1', 0)) diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 107aec7e6ef..8dad1ba7382 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -1479,7 +1479,6 @@ def dummycallback(sock, servername, ctx): ctx.set_servername_callback(None) ctx.set_servername_callback(dummycallback) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Expected 'mock' to not have been called. Called 1 times. def test_sni_callback_on_dead_references(self): # See https://github.com/python/cpython/issues/146080. c_ctx = make_test_context() diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 7c4257877d6..454fdcba899 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -48,7 +48,7 @@ mod _ssl { VirtualMachine, builtins::{ PyBaseExceptionRef, PyByteArray, PyBytesRef, PyListRef, PyStrRef, PyType, - PyTypeRef, PyUtf8StrRef, + PyTypeRef, PyUtf8StrRef, PyWeak, }, convert::IntoPyException, function::{ @@ -1920,7 +1920,12 @@ mod _ssl { connection: PyMutex::new(None), handshake_done: PyMutex::new(false), session_was_reused: PyMutex::new(false), - owner: PyRwLock::new(args.owner.into_option()), + owner: PyRwLock::new( + args.owner + .into_option() + .map(|o| o.downgrade(None, vm)) + .transpose()?, + ), // Filter out Python None objects - only store actual SSLSession objects session: PyRwLock::new(args.session.into_option().filter(|s| !vm.is_none(s))), incoming_bio: None, @@ -1997,7 +2002,12 @@ mod _ssl { connection: PyMutex::new(None), handshake_done: PyMutex::new(false), session_was_reused: PyMutex::new(false), - owner: PyRwLock::new(args.owner.into_option()), + owner: PyRwLock::new( + args.owner + .into_option() + .map(|o| o.downgrade(None, vm)) + .transpose()?, + ), // Filter out Python None objects - only store actual SSLSession objects session: PyRwLock::new(args.session.into_option().filter(|s| !vm.is_none(s))), incoming_bio: Some(args.incoming), @@ -2377,7 +2387,7 @@ mod _ssl { #[pytraverse(skip)] session_was_reused: PyMutex, // Owner (SSLSocket instance that owns this _SSLSocket) - owner: PyRwLock>, + owner: PyRwLock>>, // Session for resumption session: PyRwLock>, // MemoryBIO mode (optional) @@ -2734,7 +2744,19 @@ mod _ssl { return Ok(()); }; - let ssl_sock = self.owner.read().clone().unwrap_or_else(|| vm.ctx.none()); + let ssl_sock = self + .owner + .read() + .as_ref() + .and_then(|owner| owner.upgrade()) + .ok_or_else(|| { + super::compat::SslError::create_ssl_error_with_reason( + vm, + Some("SSL"), + "CALLBACK_FAILED", + "[SSL: CALLBACK_FAILED] callback failed", + ) + })?; let server_name_py: PyObjectRef = match sni_name { Some(name) => vm.ctx.new_str(name.to_string()).into(), None => vm.ctx.none(), @@ -3955,12 +3977,13 @@ mod _ssl { #[pygetset] fn owner(&self) -> Option { - self.owner.read().clone() + self.owner.read().as_ref().and_then(|owner| owner.upgrade()) } #[pygetset(setter)] - fn set_owner(&self, owner: PyObjectRef, _vm: &VirtualMachine) { - *self.owner.write() = Some(owner); + fn set_owner(&self, owner: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { + *self.owner.write() = Some(owner.downgrade(None, vm)?); + Ok(()) } #[pygetset] From 9dff4d3a99cd06cea8062ec8b3bff4cd3f712163 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:00:04 +0900 Subject: [PATCH 230/351] Split InterpreterFrame hot/cold fields into FrameColdData (#8434) Move 10 rarely-used fields (trace, trace_lines, trace_opcodes, temporary_refs, f_locals_hidden_overlay, f_extra_locals, escaped, retained_back, pending_stack_pops, pending_unwind_from_stack) from InterpreterFrame into a lazily-allocated FrameColdData struct. InterpreterFrame now carries a single UnsafeCell>> (8 bytes) instead of ~200+ bytes of cold fields. The cold() accessor allocates on first access; frames that never trigger tracing or debugging pay no allocation cost. GC traversal skips cold data when it has not been allocated. Assisted-by: Claude --- crates/vm/src/builtins/frame.rs | 44 ++++--- crates/vm/src/builtins/type.rs | 7 +- crates/vm/src/frame.rs | 185 ++++++++++++++++------------- crates/vm/src/object/ext.rs | 8 +- crates/vm/src/protocol/callable.rs | 9 +- crates/vm/src/stdlib/_thread.rs | 4 +- crates/vm/src/vm/mod.rs | 14 ++- 7 files changed, 154 insertions(+), 117 deletions(-) diff --git a/crates/vm/src/builtins/frame.rs b/crates/vm/src/builtins/frame.rs index 70ff9997946..94ec827b7a6 100644 --- a/crates/vm/src/builtins/frame.rs +++ b/crates/vm/src/builtins/frame.rs @@ -636,8 +636,14 @@ impl FrameObject { } else { self.iframe() }; - target.pending_stack_pops.store(pop_count as u32, Relaxed); - target.pending_unwind_from_stack.store(start_stack, Relaxed); + target + .cold() + .pending_stack_pops + .store(pop_count as u32, Relaxed); + target + .cold() + .pending_unwind_from_stack + .store(start_stack, Relaxed); target.lasti.store(best_addr as u32, Relaxed); Ok(()) } @@ -647,9 +653,9 @@ impl FrameObject { // Read from live source iframe if available. let live = self.find_live_source_iframe(); let trace = if !live.is_null() { - unsafe { &*live }.trace.lock().clone() + unsafe { &*live }.cold().trace.lock().clone() } else { - self.iframe().trace.lock().clone() + self.iframe().cold().trace.lock().clone() }; trace.unwrap_or_else(|| vm.ctx.none()) } @@ -667,13 +673,13 @@ impl FrameObject { PySetterValue::Delete => None, }; // Set on the materialized FrameObject. - (*self.iframe().trace.lock()).clone_from(&trace); + (*self.iframe().cold().trace.lock()).clone_from(&trace); // Also propagate to the live source iframe if this is a // materialized copy of a stack-allocated frame, so pdb's // f_trace assignment takes effect on the executing frame. let live = self.find_live_source_iframe(); if !live.is_null() { - *unsafe { &*live }.trace.lock() = trace; + *unsafe { &*live }.cold().trace.lock() = trace; } } @@ -682,7 +688,7 @@ impl FrameObject { fn f_trace_lines(vm: &VirtualMachine, zelf: PyObjectRef) -> PyResult { let zelf: FrameObjectRef = zelf.downcast().unwrap_or_else(|_| unreachable!()); - let boxed = zelf.iframe().trace_lines.lock(); + let boxed = zelf.iframe().cold().trace_lines.lock(); Ok(vm.ctx.new_bool(*boxed).into()) } @@ -701,11 +707,11 @@ impl FrameObject { .map_err(|_| vm.new_type_error("attribute value type must be bool"))?; let val = !value.as_bigint().is_zero(); - *zelf.iframe().trace_lines.lock() = val; + *zelf.iframe().cold().trace_lines.lock() = val; // Propagate to live source iframe. let live = zelf.find_live_source_iframe(); if !live.is_null() { - *unsafe { &*live }.trace_lines.lock() = val; + *unsafe { &*live }.cold().trace_lines.lock() = val; } Ok(()) @@ -718,7 +724,7 @@ impl FrameObject { #[pymember(type = "bool")] fn f_trace_opcodes(vm: &VirtualMachine, zelf: PyObjectRef) -> PyResult { let zelf: FrameObjectRef = zelf.downcast().unwrap_or_else(|_| unreachable!()); - let trace_opcodes = zelf.iframe().trace_opcodes.lock(); + let trace_opcodes = zelf.iframe().cold().trace_opcodes.lock(); Ok(vm.ctx.new_bool(*trace_opcodes).into()) } @@ -737,11 +743,11 @@ impl FrameObject { .map_err(|_| vm.new_type_error("attribute value type must be bool"))?; let val = !value.as_bigint().is_zero(); - *zelf.iframe().trace_opcodes.lock() = val; + *zelf.iframe().cold().trace_opcodes.lock() = val; // Propagate to live source iframe. let live = zelf.find_live_source_iframe(); if !live.is_null() { - *unsafe { &*live }.trace_opcodes.lock() = val; + *unsafe { &*live }.cold().trace_opcodes.lock() = val; } // TODO: Implement the equivalent of _PyEval_SetOpcodeTrace() @@ -798,10 +804,10 @@ impl Py { self.clear_stack_and_cells(); // Clear temporary refs - self.iframe().temporary_refs.lock().clear(); - self.iframe().f_locals_hidden_overlay.lock().take(); - self.iframe().f_extra_locals.lock().take(); - self.iframe().retained_back.lock().take(); + self.iframe().cold().temporary_refs.lock().clear(); + self.iframe().cold().f_locals_hidden_overlay.lock().take(); + self.iframe().cold().f_extra_locals.lock().take(); + self.iframe().cold().retained_back.lock().take(); Ok(()) } @@ -853,7 +859,7 @@ impl Py { } if prev.is_null() { // Check retained_back for frames whose callers have returned - let retained = self.iframe().retained_back.lock().clone(); + let retained = self.iframe().cold().retained_back.lock().clone(); if let Some(frame) = retained { frame.mark_escaped(); return Some(frame); @@ -879,7 +885,7 @@ impl Py { } // The caller already returned — check retained_back - let retained = self.iframe().retained_back.lock().clone(); + let retained = self.iframe().cold().retained_back.lock().clone(); if let Some(frame) = retained { frame.mark_escaped(); return Some(frame); @@ -906,7 +912,7 @@ impl Py { let iframe = unsafe { &*cur }; let fo = iframe.materialize(vm).to_owned(); if let Some(child) = child_fo.take() { - let mut guard = child.iframe().retained_back.lock(); + let mut guard = child.iframe().cold().retained_back.lock(); if guard.is_none() { *guard = Some(fo.clone()); } diff --git a/crates/vm/src/builtins/type.rs b/crates/vm/src/builtins/type.rs index e2af7d21df4..1776270751e 100644 --- a/crates/vm/src/builtins/type.rs +++ b/crates/vm/src/builtins/type.rs @@ -1513,7 +1513,12 @@ impl PyType { // temporary refs so they never see a dangling pointer. let keep_alive = |type_ref: PyTypeRef, retired: &mut Vec| { if let Some(frame) = vm.current_frame() { - frame.iframe().temporary_refs.lock().push(type_ref.into()); + frame + .iframe() + .cold() + .temporary_refs + .lock() + .push(type_ref.into()); } else { retired.push(type_ref.into()); } diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index a05fc7184c8..bf276ec45e0 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -792,6 +792,39 @@ unsafe impl Traverse for FrameLocals { } } +/// Cold fields of InterpreterFrame that are only accessed during tracing, +/// debugging, frame inspection, or GC. Lazily allocated on first access +/// to keep the hot InterpreterFrame small. +pub(crate) struct FrameColdData { + pub trace: PyMutex>, + pub trace_lines: PyMutex, + pub trace_opcodes: PyMutex, + pub temporary_refs: PyMutex>, + pub f_locals_hidden_overlay: PyMutex>, + pub f_extra_locals: PyMutex>, + pub escaped: atomic::AtomicBool, + pub retained_back: PyMutex>, + pub pending_stack_pops: PyAtomic, + pub pending_unwind_from_stack: PyAtomic, +} + +impl Default for FrameColdData { + fn default() -> Self { + Self { + trace: PyMutex::new(None), + trace_lines: PyMutex::new(true), + trace_opcodes: PyMutex::new(false), + temporary_refs: PyMutex::new(Vec::new()), + f_locals_hidden_overlay: PyMutex::new(None), + f_extra_locals: PyMutex::new(None), + escaped: atomic::AtomicBool::new(false), + retained_back: PyMutex::new(None), + pending_stack_pops: Default::default(), + pending_unwind_from_stack: Default::default(), + } + } +} + /// Lightweight execution frame. Not a PyObject. /// Analogous to CPython's `_PyInterpreterFrame`. /// @@ -813,18 +846,10 @@ pub struct InterpreterFrame { /// index of last instruction ran pub lasti: PyAtomic, - /// Per-frame tracer function. `None` means no per-frame trace is set - /// (equivalent to `f_trace = None` in Python). This avoids a refcounted - /// None clone on every frame init. - pub trace: PyMutex>, /// Previous line number for LINE event suppression. pub(crate) prev_line: core::cell::Cell, - // member - pub trace_lines: PyMutex, - pub trace_opcodes: PyMutex, - pub temporary_refs: PyMutex>, /// Back-reference to owning generator/coroutine/async generator. /// Borrowed reference (not ref-counted) to avoid Generator↔FrameObject cycle. /// Cleared by the generator's Drop impl. @@ -836,32 +861,14 @@ pub struct InterpreterFrame { /// Used by `frame.clear()` to reject clearing an executing frame, /// even when called from a different thread. pub(crate) owner: atomic::AtomicI8, - /// Persistent overlay for `frame.f_locals` when hidden locals need a - /// snapshot separate from the backing locals mapping. - pub(crate) f_locals_hidden_overlay: PyMutex>, - /// Side storage for `f_locals` proxy keys that do not name a fast local. - /// Lazily created on first non-fast-key write. Mirrors `f_extra_locals`. - pub(crate) f_extra_locals: PyMutex>, - /// Set once a durable Python-level reference to this frame is handed out - /// (`f_locals` proxy, `sys._getframe`, `f_back`). A closed generator keeps - /// its locals alive while this is set, mirroring `frame_obj` ownership. - pub(crate) escaped: atomic::AtomicBool, - /// Strong reference to the caller frame, captured when this frame escapes - /// its execution so `f_back` still resolves after the caller returns and - /// leaves the live frame chain. - pub(crate) retained_back: PyMutex>, - /// Number of stack entries to pop after set_f_lineno returns to the - /// execution loop. set_f_lineno cannot pop directly because the - /// execution loop holds the state mutex. - pub(crate) pending_stack_pops: PyAtomic, - /// The encoded stack state that set_f_lineno wants to unwind *from*. - /// Used together with `pending_stack_pops` to identify Except entries - /// that need special exception-state handling. - pub(crate) pending_unwind_from_stack: PyAtomic, /// Pointer to the owning `Py`, or null for stack-allocated /// frames that have not been materialized yet. /// Stored as `usize` for `PyAtomic` compatibility. pub(crate) materialized: PyAtomic, + + /// Lazily-allocated cold data (tracing, debugging, frame inspection). + /// Not allocated until first access via `cold()`. + pub(crate) cold: OnceCell>, } // Raw pointers make InterpreterFrame !Send+!Sync by default. @@ -934,20 +941,11 @@ impl InterpreterFrame { locals, lasti: Radium::new(0), prev_line: core::cell::Cell::new(prev_line), - trace: PyMutex::new(None), - trace_lines: PyMutex::new(true), - trace_opcodes: PyMutex::new(false), - temporary_refs: PyMutex::new(vec![]), generator: PyAtomicBorrow::new(), previous: Radium::new(0), owner: atomic::AtomicI8::new(owner as i8), - f_locals_hidden_overlay: PyMutex::new(None), - f_extra_locals: PyMutex::new(None), - escaped: atomic::AtomicBool::new(false), - retained_back: PyMutex::new(None), - pending_stack_pops: Default::default(), - pending_unwind_from_stack: Default::default(), materialized: Radium::new(0), + cold: OnceCell::new(), } } @@ -1031,10 +1029,6 @@ impl InterpreterFrame { locals, lasti: Radium::new(self.lasti.load(Relaxed)), prev_line: core::cell::Cell::new(self.prev_line.get()), - trace: PyMutex::new(None), - trace_lines: PyMutex::new(true), - trace_opcodes: PyMutex::new(false), - temporary_refs: PyMutex::new(vec![]), generator: PyAtomicBorrow::new(), // Do NOT copy previous — it may point to stack-allocated frames // that become dangling after their call returns. The f_back chain @@ -1044,13 +1038,11 @@ impl InterpreterFrame { // If we copied Thread from the source iframe, frame.clear() would // reject the frame with "cannot clear an executing frame". owner: atomic::AtomicI8::new(FrameOwner::FrameObject as i8), - f_locals_hidden_overlay: PyMutex::new(None), - f_extra_locals: PyMutex::new(None), - escaped: atomic::AtomicBool::new(true), - retained_back: PyMutex::new(None), - pending_stack_pops: Default::default(), - pending_unwind_from_stack: Default::default(), materialized: Radium::new(0), + cold: OnceCell::from(Box::new(FrameColdData { + escaped: atomic::AtomicBool::new(true), + ..FrameColdData::default() + })), }; let frame_obj = FrameObject { @@ -1079,7 +1071,10 @@ impl InterpreterFrame { // is no longer executing and temporary_refs is cleared — at that // point the FrameObject is self-sustaining and GC can safely // traverse and collect it. - self.temporary_refs.lock().push(frame_ref.clone().into()); + self.cold() + .temporary_refs + .lock() + .push(frame_ref.clone().into()); // SAFETY: the pointer we stored above remains valid because // temporary_refs holds a strong reference. @@ -1119,20 +1114,14 @@ impl InterpreterFrame { locals, lasti: Radium::new(self.lasti.load(Relaxed)), prev_line: core::cell::Cell::new(self.prev_line.get()), - trace: PyMutex::new(None), - trace_lines: PyMutex::new(true), - trace_opcodes: PyMutex::new(false), - temporary_refs: PyMutex::new(vec![]), generator: PyAtomicBorrow::new(), previous: Radium::new(0), owner: atomic::AtomicI8::new(FrameOwner::FrameObject as i8), - f_locals_hidden_overlay: PyMutex::new(None), - f_extra_locals: PyMutex::new(None), - escaped: atomic::AtomicBool::new(true), - retained_back: PyMutex::new(None), - pending_stack_pops: Default::default(), - pending_unwind_from_stack: Default::default(), materialized: Radium::new(0), + cold: OnceCell::from(Box::new(FrameColdData { + escaped: atomic::AtomicBool::new(true), + ..FrameColdData::default() + })), }; let frame_obj = FrameObject { @@ -1175,6 +1164,19 @@ impl InterpreterFrame { Some(unsafe { &*self.func_obj }) } } + + /// Access the lazily-allocated cold data, allocating on first use. + #[inline] + pub(crate) fn cold(&self) -> &FrameColdData { + self.cold.get_or_init(|| Box::new(FrameColdData::default())) + } + + /// Access cold data without allocating. Returns `None` if cold data + /// has not been allocated yet. + #[inline] + pub(crate) fn cold_opt(&self) -> Option<&FrameColdData> { + self.cold.get().map(|b| &**b) + } } /// Python-visible frame object. Currently always wraps an `InterpreterFrame`. @@ -1323,11 +1325,13 @@ unsafe impl Traverse for FrameObject { }; iframe.localsplus.traverse(tracer_fn); iframe.locals.traverse(tracer_fn); - iframe.trace.traverse(tracer_fn); - iframe.temporary_refs.traverse(tracer_fn); - iframe.f_locals_hidden_overlay.traverse(tracer_fn); - iframe.f_extra_locals.traverse(tracer_fn); - iframe.retained_back.traverse(tracer_fn); + if let Some(cold) = iframe.cold_opt() { + cold.trace.traverse(tracer_fn); + cold.temporary_refs.traverse(tracer_fn); + cold.f_locals_hidden_overlay.traverse(tracer_fn); + cold.f_extra_locals.traverse(tracer_fn); + cold.retained_back.traverse(tracer_fn); + } } fn clear(&mut self, _out: &mut Vec) { @@ -1505,8 +1509,8 @@ impl FrameObject { for slot in fastlocals.iter_mut() { *slot = None; } - self.iframe().f_locals_hidden_overlay.lock().take(); - self.iframe().f_extra_locals.lock().take(); + self.iframe().cold().f_locals_hidden_overlay.lock().take(); + self.iframe().cold().f_extra_locals.lock().take(); } /// Store a borrowed back-reference to the owning generator/coroutine. @@ -1578,12 +1582,17 @@ impl FrameObject { /// Record that a durable Python-level reference to this frame escaped. pub(crate) fn mark_escaped(&self) { - self.iframe().escaped.store(true, atomic::Ordering::Release); + self.iframe() + .cold() + .escaped + .store(true, atomic::Ordering::Release); } /// Whether a durable reference to this frame has escaped. pub(crate) fn has_escaped(&self) -> bool { - self.iframe().escaped.load(atomic::Ordering::Acquire) + self.iframe() + .cold_opt() + .is_some_and(|c| c.escaped.load(atomic::Ordering::Acquire)) } pub fn lasti(&self) -> u32 { @@ -1769,12 +1778,12 @@ impl FrameObject { pub fn f_locals_mapping(&self, vm: &VirtualMachine) -> PyResult { self.check_locals_access(vm)?; if !self.has_active_hidden_locals() { - self.iframe().f_locals_hidden_overlay.lock().take(); + self.iframe().cold().f_locals_hidden_overlay.lock().take(); return self.locals(vm); } let overlay_dict = { - let mut overlay = self.iframe().f_locals_hidden_overlay.lock(); + let mut overlay = self.iframe().cold().f_locals_hidden_overlay.lock(); match overlay.as_ref() { Some(dict) => dict.clone(), None => { @@ -1808,7 +1817,7 @@ impl FrameObject { /// Copy the frame's extra-locals side storage (proxy keys that are not /// fast locals) into `mapping`. No-op when nothing was ever stored. fn fold_extra_locals(&self, mapping: &ArgMapping, vm: &VirtualMachine) -> PyResult<()> { - let extra = self.iframe().f_extra_locals.lock().clone(); + let extra = self.iframe().cold().f_extra_locals.lock().clone(); if let Some(extra) = extra { for (key, value) in &extra { mapping.mapping().ass_subscript(&key, Some(value), vm)?; @@ -1934,7 +1943,7 @@ impl FrameObject { { return Ok(value); } - let extra = self.iframe().f_extra_locals.lock().clone(); + let extra = self.iframe().cold().f_extra_locals.lock().clone(); if let Some(extra) = extra && let Some(value) = extra.get_item_opt(&*key, vm)? { @@ -1953,7 +1962,7 @@ impl FrameObject { if self.framelocalsproxy_getkeyindex(&key, true, vm)?.is_some() { return Ok(true); } - let extra = self.iframe().f_extra_locals.lock().clone(); + let extra = self.iframe().cold().f_extra_locals.lock().clone(); if let Some(extra) = extra { return Ok(extra.get_item_opt(&*key, vm)?.is_some()); } @@ -1991,7 +2000,7 @@ impl FrameObject { { return Err(vm.new_value_error("cannot remove local variables from FrameLocalsProxy")); } - let extra = self.iframe().f_extra_locals.lock().clone(); + let extra = self.iframe().cold().f_extra_locals.lock().clone(); if let Some(extra) = extra && extra.get_item_opt(&*key, vm)?.is_some() { @@ -2014,7 +2023,7 @@ impl FrameObject { { return Err(vm.new_value_error("cannot remove local variables from FrameLocalsProxy")); } - let extra = self.iframe().f_extra_locals.lock().clone(); + let extra = self.iframe().cold().f_extra_locals.lock().clone(); if let Some(extra) = extra && let Some(value) = extra.pop_item(&*key, vm)? { @@ -2041,7 +2050,7 @@ impl FrameObject { } fn extra_locals_get_or_create(&self, vm: &VirtualMachine) -> PyDictRef { - let mut extra = self.iframe().f_extra_locals.lock(); + let mut extra = self.iframe().cold().f_extra_locals.lock(); extra.get_or_insert_with(|| vm.ctx.new_dict()).clone() } } @@ -2372,7 +2381,7 @@ pub(crate) fn release_datastack_frame(frame: &Py, vm: &VirtualMachi // executing here (this frame is unwinding back into it), so its payload // pointer is live. { - let mut guard = frame.iframe().retained_back.lock(); + let mut guard = frame.iframe().cold().retained_back.lock(); if guard.is_none() { let prev = frame.previous_iframe(); *guard = unsafe { owned_chain_frame(prev) }; @@ -2613,31 +2622,39 @@ impl ExecutingFrame<'_> { /// Whether this frame has a per-frame trace function set. #[inline] fn trace_is_set(&self, _vm: &VirtualMachine) -> bool { - self.iframe().trace.lock().is_some() + self.iframe() + .cold_opt() + .is_some_and(|c| c.trace.lock().is_some()) } /// Access the frame's trace_opcodes lock. #[inline] fn trace_opcodes_is_set(&self) -> bool { - *self.iframe().trace_opcodes.lock() + self.iframe() + .cold_opt() + .is_some_and(|c| *c.trace_opcodes.lock()) } /// Get pending_stack_pops from the frame. #[inline] fn pending_stack_pops(&self) -> u32 { - self.iframe().pending_stack_pops.load(Relaxed) + self.iframe() + .cold_opt() + .map_or(0, |c| c.pending_stack_pops.load(Relaxed)) } /// Get pending_unwind_from_stack from the frame. #[inline] fn pending_unwind_from_stack(&self) -> i64 { - self.iframe().pending_unwind_from_stack.load(Relaxed) + self.iframe() + .cold_opt() + .map_or(0, |c| c.pending_unwind_from_stack.load(Relaxed)) } /// Set pending_stack_pops on the frame. #[inline] fn set_pending_stack_pops(&self, val: u32) { - self.iframe().pending_stack_pops.store(val, Relaxed); + self.iframe().cold().pending_stack_pops.store(val, Relaxed); } /// Run `__init__` for the tp_new specialization. `args` holds the diff --git a/crates/vm/src/object/ext.rs b/crates/vm/src/object/ext.rs index c4732d1cb3f..69ee0e3c510 100644 --- a/crates/vm/src/object/ext.rs +++ b/crates/vm/src/object/ext.rs @@ -333,7 +333,7 @@ impl PyAtomicRef { pub fn swap_to_temporary_refs(&self, pyref: PyRef, vm: &VirtualMachine) { let old = unsafe { self.swap(pyref) }; if let Some(frame) = vm.current_frame() { - frame.iframe().temporary_refs.lock().push(old.into()); + frame.iframe().cold().temporary_refs.lock().push(old.into()); } } } @@ -409,7 +409,7 @@ impl PyAtomicRef> { return; }; if let Some(frame) = vm.current_frame() { - frame.iframe().temporary_refs.lock().push(old.into()); + frame.iframe().cold().temporary_refs.lock().push(old.into()); } } } @@ -452,7 +452,7 @@ impl PyAtomicRef { pub fn swap_to_temporary_refs(&self, obj: PyObjectRef, vm: &VirtualMachine) { let old = unsafe { self.swap(obj) }; if let Some(frame) = vm.current_frame() { - frame.iframe().temporary_refs.lock().push(old); + frame.iframe().cold().temporary_refs.lock().push(old); } } } @@ -499,7 +499,7 @@ impl PyAtomicRef> { return; }; if let Some(frame) = vm.current_frame() { - frame.iframe().temporary_refs.lock().push(old); + frame.iframe().cold().temporary_refs.lock().push(old); } } } diff --git a/crates/vm/src/protocol/callable.rs b/crates/vm/src/protocol/callable.rs index 9e80c5b2c25..5a1605d4b5e 100644 --- a/crates/vm/src/protocol/callable.rs +++ b/crates/vm/src/protocol/callable.rs @@ -233,7 +233,12 @@ impl VirtualMachine { }; // Opcode events are only dispatched when f_trace_opcodes is set. - if is_opcode_event && !*frame_ref.iframe().trace_opcodes.lock() { + if is_opcode_event + && !frame_ref + .iframe() + .cold_opt() + .is_some_and(|c| *c.trace_opcodes.lock()) + { return Ok(None); } @@ -261,7 +266,7 @@ impl VirtualMachine { // trace_trampoline behavior: clear per-frame f_trace // and propagate the error. if let Some(frame_ref) = self.current_frame() { - *frame_ref.iframe().trace.lock() = None; + *frame_ref.iframe().cold().trace.lock() = None; } return Err(e); } diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index 64e5c596ba7..0a80285dbe3 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -1206,7 +1206,7 @@ pub(crate) mod _thread { let iframe = unsafe { &*cur }; let fo = iframe.materialize(vm).to_owned(); if let Some(child) = child_fo.take() { - let mut guard = child.iframe().retained_back.lock(); + let mut guard = child.iframe().cold().retained_back.lock(); if guard.is_none() { *guard = Some(fo.clone()); } @@ -1253,7 +1253,7 @@ pub(crate) mod _thread { let iframe = unsafe { &*cur }; let fo = iframe.materialize(vm).to_owned(); if let Some(child) = child_fo.take() { - let mut guard = child.iframe().retained_back.lock(); + let mut guard = child.iframe().cold().retained_back.lock(); if guard.is_none() { *guard = Some(fo.clone()); } diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index a9091f895f6..a48ce56c14a 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -1768,7 +1768,7 @@ impl VirtualMachine { // materialized, f_back will resolve via the TLS chain while the // caller is still executing, or return None after it returns. if strong > 1 { - let mut guard = frame.iframe().retained_back.lock(); + let mut guard = frame.iframe().cold().retained_back.lock(); if guard.is_none() { let prev_iframe = unsafe { &*old_chain }; if let Some(fo) = prev_iframe.frame_obj() { @@ -1884,7 +1884,7 @@ impl VirtualMachine { // lightweight frame has empty localsplus; live values are // read through find_live_source_iframe when needed. let back_fo = prev_iframe.materialize_chain(self); - *fo.iframe().retained_back.lock() = Some(back_fo); + *fo.iframe().cold().retained_back.lock() = Some(back_fo); } // Set owner to FrameObject since this frame is no longer // executing on a thread. @@ -1918,7 +1918,7 @@ impl VirtualMachine { crate::gc_state::gc_state() .track_object(core::ptr::NonNull::from(fo.as_object())); let live_iframe = &*iframe_ptr; - live_iframe.temporary_refs.lock().clear(); + live_iframe.cold().temporary_refs.lock().clear(); } } } @@ -2002,7 +2002,7 @@ impl VirtualMachine { // Fire 'call' trace event. current_frame() now returns the callee. let trace_result = self.trace_event(TraceEvent::Call, None)?; if let Some(local_trace) = trace_result { - *frame.iframe().trace.lock() = Some(local_trace); + *frame.iframe().cold().trace.lock() = Some(local_trace); } let result = f(frame); @@ -2011,7 +2011,11 @@ impl VirtualMachine { // PY_UNWIND fires PyTrace_RETURN with arg=None — so we fire for // both Ok and Err, matching `call_trace_protected` behavior. if self.use_tracing.get() - && (frame.iframe().trace.lock().is_some() || !self.is_none(&self.profile_func.borrow())) + && (!self.is_none(&self.profile_func.borrow()) + || frame + .iframe() + .cold_opt() + .is_some_and(|c| c.trace.lock().is_some())) { let ret_result = self.trace_event(TraceEvent::Return, None); // call_trace_protected: if trace function raises, its error From 74ae2d4711af242be9de55ec5d5c5364b9d26991 Mon Sep 17 00:00:00 2001 From: Hanif Ariffin Date: Mon, 3 Aug 2026 16:52:42 +0800 Subject: [PATCH 231/351] Fix bytes.isspace (#5655) * Fix implementation of bytes.isspace to match the implementation in CPython. * Fix implementation of bytes.istitle so that test_bigmem now passes. Also added some extra_test to capture this. * Fix some clippy issues Signed-off-by: Hanif Ariffin * bytes.istitle returns false when the bytes is empty. * Fix some edges of istitle with non-alphanumeric characters * Disable a line because python 3.12 errors on bad escape sequence * Match CPython's istitle a little closer * Fix clippy warning * Rewrite bytes.istitle along _Py_bytes_istitle The lookahead-based check accepted b"Not--a Titlecase String", which test_bytes covers. Track whether the previous byte was cased instead. isspace compares bytes directly rather than converting to char. Restore the b"omkmok\Xaa" assert: 3.14 reports an invalid escape sequence as a SyntaxWarning, not an error. Assisted-by: Claude --------- Signed-off-by: Hanif Ariffin Co-authored-by: Jeong YunWon --- Lib/test/test_bigmem.py | 16 ---------- crates/vm/src/bytes_inner.rs | 46 ++++++++++++--------------- extra_tests/snippets/builtin_bytes.py | 39 +++++++++++++++++++++++ 3 files changed, 60 insertions(+), 41 deletions(-) diff --git a/Lib/test/test_bigmem.py b/Lib/test/test_bigmem.py index ea76b1282ba..12b221a66e3 100644 --- a/Lib/test/test_bigmem.py +++ b/Lib/test/test_bigmem.py @@ -789,14 +789,6 @@ def test_title(self, size): def test_swapcase(self, size): self._test_swapcase(size) - @unittest.expectedFailure # TODO: RUSTPYTHON - def test_isspace(self): - return super().test_isspace() - - @unittest.expectedFailure # TODO: RUSTPYTHON - def test_istitle(self): - return super().test_istitle() - class BytearrayTest(unittest.TestCase, BaseStrTest): @@ -823,14 +815,6 @@ def test_swapcase(self, size): test_hash = None test_split_large = None - @unittest.expectedFailure # TODO: RUSTPYTHON - def test_isspace(self): - return super().test_isspace() - - @unittest.expectedFailure # TODO: RUSTPYTHON - def test_istitle(self): - return super().test_istitle() - class TupleTest(unittest.TestCase): # Tuples have a small, fixed-sized head and an array of pointers to diff --git a/crates/vm/src/bytes_inner.rs b/crates/vm/src/bytes_inner.rs index d144004d66f..6c76808c5ec 100644 --- a/crates/vm/src/bytes_inner.rs +++ b/crates/vm/src/bytes_inner.rs @@ -397,44 +397,40 @@ impl PyBytesInner { self.elements.py_isupper() } + // _Py_bytes_isspace pub fn isspace(&self) -> bool { + // is_ascii_whitespace excludes vertical tab, while Py_ISSPACE accepts it !self.elements.is_empty() && self .elements .iter() - .all(|x| char::from(*x).is_ascii_whitespace()) + .all(|x| x.is_ascii_whitespace() || *x == b'\x0b') } + // _Py_bytes_istitle pub fn istitle(&self) -> bool { - if self.elements.is_empty() { - return false; - } + let mut cased = false; + let mut previous_is_cased = false; - let mut iter = self.elements.iter().peekable(); - let mut prev_cased = false; - - while let Some(c) = iter.next() { - let current = char::from(*c); - let next = if let Some(k) = iter.peek() { - char::from(**k) - } else if current.is_uppercase() { - return !prev_cased; + for byte in &self.elements { + if byte.is_ascii_uppercase() { + if previous_is_cased { + return false; + } + previous_is_cased = true; + cased = true; + } else if byte.is_ascii_lowercase() { + if !previous_is_cased { + return false; + } + previous_is_cased = true; + cased = true; } else { - return prev_cased; - }; - - let is_cased = current.to_uppercase().next().unwrap() != current - || current.to_lowercase().next().unwrap() != current; - if (is_cased && next.is_uppercase() && !prev_cased) - || (!is_cased && next.is_lowercase()) - { - return false; + previous_is_cased = false; } - - prev_cased = is_cased; } - true + cased } pub fn lower(&self) -> Vec { diff --git a/extra_tests/snippets/builtin_bytes.py b/extra_tests/snippets/builtin_bytes.py index 2cb4c317f49..4f861364488 100644 --- a/extra_tests/snippets/builtin_bytes.py +++ b/extra_tests/snippets/builtin_bytes.py @@ -708,3 +708,42 @@ def __new__(cls, value): assert b.foo == "bar" skip_if_unsupported(3, 11, test__bytes__) + +assert " \f\n\r\t\v".encode("utf-8").isspace() +assert " \f\n\r\t\v".encode("latin-1").isspace() + +# bytes.istitle tests +s = b"Aa6A" +assert s.istitle(), f"{s}" +s = b"Aa6aA" +assert not s.istitle(), f"{s}" +s = b"Python Is Fun" +assert s.istitle(), f"{s}" +s = b"Python is fun" +assert not s.istitle(), f"{s}" +s = b"PYTHON IS FUN" +assert not s.istitle(), f"{s}" +s = b"Python 3.9 Is Awesome!" +assert s.istitle(), f"{s}" +s = b"" +assert not s.istitle(), f"{s}" +s = b"Hello Is Amazing" +assert s.istitle(), f"{s}" +s = b"Not--a Titlecase String" +assert not s.istitle(), f"{s}" +s = b"123A" +assert s.istitle(), f"{s}" +s = b"123a" +assert not s.istitle(), f"{s}" +s = b"123A\ta" +assert not s.istitle(), f"{s}" +SUBSTR = b"123456" +s = b"".join([b"A", b"a" * 64, SUBSTR]) +assert s.istitle(), f"{s}" +s += b"A" +assert s.istitle(), f"{s}" +s += b"aA" +assert not s.istitle(), f"{s}" +assert "123A".istitle(), f"{s}" +assert not "123a".istitle(), f"{s}" +assert not "123A\ta".istitle(), f"{s}" From a635d0b536fbab754671536b7908c81d6d53645d Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:46:27 +0900 Subject: [PATCH 232/351] socket: accept a filesystem-encoded hostname in sethostname (#8437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `socket.sethostname` took `PyUtf8StrRef`, so it rejected `bytes` outright and refused a `str` carrying a surrogate escape. `socketmodule.c socket_sethostname` accepts a bytes object directly, falls back to `PyUnicode_FSConverter` for anything else, and hands the syscall the resulting buffer and its length — the name is never required to be UTF-8. Take `FsPath`, the converter `if_nametoindex` in this same module already uses, and pass its bytes down. `host_env::socket::sethostname` correspondingly takes `&[u8]` and builds the `OsStr` from them; `nix::unistd::sethostname` accepts `AsRef` and passes pointer and length to the syscall, so nothing on the path needs a NUL terminator or valid UTF-8. `Lib/test/test_socket.py test_sethostname` covers this: it calls `socket.sethostname(b'bar')` and asserts the hostname changed. The test is skipped unless run as root, which is why the gap went unnoticed. Assisted-by: Claude --- crates/host_env/src/socket.rs | 10 ++++++++-- crates/stdlib/src/socket.rs | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/host_env/src/socket.rs b/crates/host_env/src/socket.rs index af4af2717cd..542031e6fa8 100644 --- a/crates/host_env/src/socket.rs +++ b/crates/host_env/src/socket.rs @@ -44,9 +44,15 @@ pub use libc::{AF_ALG, AF_CAN}; #[cfg(target_os = "linux")] pub use libc::{sockaddr_alg, sockaddr_can}; +/// Set the system's hostname from its filesystem-encoded bytes. +/// +/// `socketmodule.c socket_sethostname` reads the argument as a buffer and +/// passes `buf.buf`/`buf.len` straight to the syscall, so a name is not +/// required to be UTF-8; taking `&[u8]` keeps that true here as well. #[cfg(all(unix, not(target_os = "redox")))] -pub fn sethostname(hostname: &str) -> io::Result<()> { - nix::unistd::sethostname(hostname).map_err(io::Error::from) +pub fn sethostname(hostname: &[u8]) -> io::Result<()> { + use std::os::unix::ffi::OsStrExt; + nix::unistd::sethostname(std::ffi::OsStr::from_bytes(hostname)).map_err(io::Error::from) } #[cfg(unix)] diff --git a/crates/stdlib/src/socket.rs b/crates/stdlib/src/socket.rs index 4f85374b181..f78bec69dc5 100644 --- a/crates/stdlib/src/socket.rs +++ b/crates/stdlib/src/socket.rs @@ -2313,8 +2313,8 @@ mod _socket { #[cfg(all(unix, not(any(target_os = "redox", target_os = "android"))))] #[pyfunction] - fn sethostname(hostname: PyUtf8StrRef) -> std::io::Result<()> { - host_socket::sethostname(hostname.as_str()) + fn sethostname(hostname: FsPath) -> std::io::Result<()> { + host_socket::sethostname(hostname.as_bytes()) } #[pyfunction] From 654f3a5a9b3d243e91fc36689c8285f26b3574d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:00:15 +0900 Subject: [PATCH 233/351] build(deps): bump https://github.com/astral-sh/ruff-pre-commit (#8439) Bumps [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit) from v0.15.22 to 0.16.0. - [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases) - [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.22...v0.16.0) --- updated-dependencies: - dependency-name: https://github.com/astral-sh/ruff-pre-commit dependency-version: 0.16.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 80177744b79..87bee5bd76b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: priority: 0 - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.22 + rev: v0.16.0 hooks: - id: ruff-format priority: 0 From b2219b3bd723e4c7d2344c82cc4e6ebd07b6663f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:00:26 +0900 Subject: [PATCH 234/351] build(deps): bump github/gh-aw/actions/setup from 0.82.14 to 0.83.4 (#8441) Bumps [github/gh-aw/actions/setup](https://github.com/github/gh-aw) from 0.82.14 to 0.83.4. - [Release notes](https://github.com/github/gh-aw/releases) - [Changelog](https://github.com/github/gh-aw/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/gh-aw/compare/8b820ae1073f301991aaf2f307f7e271f618bb9f...bbb8042878459948333b15b66f27113f4b5c1b9a) --- updated-dependencies: - dependency-name: github/gh-aw/actions/setup dependency-version: 0.83.4 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/upgrade-pylib.lock.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/upgrade-pylib.lock.yml b/.github/workflows/upgrade-pylib.lock.yml index 31a7ac26f1d..4f2944408f2 100644 --- a/.github/workflows/upgrade-pylib.lock.yml +++ b/.github/workflows/upgrade-pylib.lock.yml @@ -58,7 +58,7 @@ jobs: comment_repo: "" steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@8b820ae1073f301991aaf2f307f7e271f618bb9f # v0.82.14 + uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 with: destination: /opt/gh-aw/actions - name: Check workflow file timestamps @@ -99,7 +99,7 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@8b820ae1073f301991aaf2f307f7e271f618bb9f # v0.82.14 + uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 with: destination: /opt/gh-aw/actions - name: Checkout repository @@ -806,7 +806,7 @@ jobs: total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@8b820ae1073f301991aaf2f307f7e271f618bb9f # v0.82.14 + uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 with: destination: /opt/gh-aw/actions - name: Download agent output artifact @@ -927,7 +927,7 @@ jobs: success: ${{ steps.parse_results.outputs.success }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@8b820ae1073f301991aaf2f307f7e271f618bb9f # v0.82.14 + uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 with: destination: /opt/gh-aw/actions - name: Download agent artifacts @@ -1039,7 +1039,7 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@8b820ae1073f301991aaf2f307f7e271f618bb9f # v0.82.14 + uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 with: destination: /opt/gh-aw/actions - name: Download agent output artifact From 7d0f5885b96520627428a3b59d7869a3af012a47 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:00:34 +0900 Subject: [PATCH 235/351] build(deps): bump rustls from 0.23.42 to 0.23.43 (#8445) Bumps [rustls](https://github.com/rustls/rustls) from 0.23.42 to 0.23.43. - [Release notes](https://github.com/rustls/rustls/releases) - [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md) - [Commits](https://github.com/rustls/rustls/compare/v/0.23.42...v/0.23.43) --- updated-dependencies: - dependency-name: rustls dependency-version: 0.23.43 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5b9870a0b0d..70d975cd7c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3174,9 +3174,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "once_cell", @@ -4196,7 +4196,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", From 8fd0785e1b3ddf86f84c85145965b61e768ec267 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:01:00 +0900 Subject: [PATCH 236/351] build(deps): bump taiki-e/install-action from 2.84.0 to 2.85.2 (#8438) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.84.0 to 2.85.2. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9...41049aa56687c35e0afa74eed4f09cec4f9afabf) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.85.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cron-ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cron-ci.yaml b/.github/workflows/cron-ci.yaml index c6eebbdf88c..b93a90e8d94 100644 --- a/.github/workflows/cron-ci.yaml +++ b/.github/workflows/cron-ci.yaml @@ -33,7 +33,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - uses: taiki-e/install-action@a6b2e2dcd845ddd7f509ce4f3ed3d922b80cc5d9 # v2.84.0 + - uses: taiki-e/install-action@41049aa56687c35e0afa74eed4f09cec4f9afabf # v2.85.2 with: tool: cargo-llvm-cov From c69e6751530437b1f371d4fe5bf095f319a6e065 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:01:34 +0900 Subject: [PATCH 237/351] build(deps): bump proc-macro2 from 1.0.106 to 1.0.107 (#8444) Bumps [proc-macro2](https://github.com/dtolnay/proc-macro2) from 1.0.106 to 1.0.107. - [Release notes](https://github.com/dtolnay/proc-macro2/releases) - [Commits](https://github.com/dtolnay/proc-macro2/compare/1.0.106...1.0.107) --- updated-dependencies: - dependency-name: proc-macro2 dependency-version: 1.0.107 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 70d975cd7c1..e989c684111 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2773,9 +2773,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] From 8b131ac43e801450147dd066f3df24db953f50d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:01:52 +0900 Subject: [PATCH 238/351] build(deps): bump bstr from 1.12.3 to 1.13.0 (#8442) Bumps [bstr](https://github.com/BurntSushi/bstr) from 1.12.3 to 1.13.0. - [Commits](https://github.com/BurntSushi/bstr/compare/1.12.3...1.13.0) --- updated-dependencies: - dependency-name: bstr dependency-version: 1.13.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e989c684111..58520470c64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -402,9 +402,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", "regex-automata", From 640154a8d78ebdee71b38cfe548b489f23fba067 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:02:44 +0900 Subject: [PATCH 239/351] build(deps): bump zizmorcore/zizmor-action from 0.5.7 to 0.6.1 (#8443) Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.5.7 to 0.6.1. - [Release notes](https://github.com/zizmorcore/zizmor-action/releases) - [Commits](https://github.com/zizmorcore/zizmor-action/compare/192e21d79ab29983730a13d1382995c2307fbcaa...6fc4b006235f201fdab3722e17240ab420d580e5) --- updated-dependencies: - dependency-name: zizmorcore/zizmor-action dependency-version: 0.6.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dc7a1a3e77e..777dad42ef6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -557,7 +557,7 @@ jobs: uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1.72.0 - name: zizmor - uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 + uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 - name: restore prek cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 From 4172eb101d92f18a797a18b7608bde4580fdfa75 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:03:09 +0900 Subject: [PATCH 240/351] build(deps): bump reviewdog/action-actionlint from 1.72.0 to 1.73.0 (#8447) Bumps [reviewdog/action-actionlint](https://github.com/reviewdog/action-actionlint) from 1.72.0 to 1.73.0. - [Release notes](https://github.com/reviewdog/action-actionlint/releases) - [Commits](https://github.com/reviewdog/action-actionlint/compare/6fb7acc99f4a1008869fa8a0f09cfca740837d9d...50842263c20a7c46bd0065b9e624d3c569db061e) --- updated-dependencies: - dependency-name: reviewdog/action-actionlint dependency-version: 1.73.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 777dad42ef6..bcd60c6a012 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -554,7 +554,7 @@ jobs: components: rustfmt - name: actionlint - uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1.72.0 + uses: reviewdog/action-actionlint@50842263c20a7c46bd0065b9e624d3c569db061e # v1.73.0 - name: zizmor uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 From 77b7b842394b74d1f8dd00c2e102d04fb7ac48ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:13:46 +0900 Subject: [PATCH 241/351] build(deps): bump https://github.com/rbubley/mirrors-prettier (#8440) Bumps [https://github.com/rbubley/mirrors-prettier](https://github.com/rbubley/mirrors-prettier) from v3.9.5 to 3.9.6. - [Commits](https://github.com/rbubley/mirrors-prettier/compare/v3.9.5...v3.9.6) --- updated-dependencies: - dependency-name: https://github.com/rbubley/mirrors-prettier dependency-version: 3.9.6 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 87bee5bd76b..ee90488c741 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -77,7 +77,7 @@ repos: priority: 0 - repo: https://github.com/rbubley/mirrors-prettier - rev: v3.9.5 + rev: v3.9.6 hooks: - id: prettier files: '^wasm/.*$' From 51d7b50a6e68249f7b0fe8b867847b608283d44e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:14:12 +0900 Subject: [PATCH 242/351] build(deps): bump j178/prek-action from 2.0.5 to 2.0.6 (#8446) Bumps [j178/prek-action](https://github.com/j178/prek-action) from 2.0.5 to 2.0.6. - [Release notes](https://github.com/j178/prek-action/releases) - [Commits](https://github.com/j178/prek-action/compare/e98a699c41eb69ab013a45817a0406469a748f8d...5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16) --- updated-dependencies: - dependency-name: j178/prek-action dependency-version: 2.0.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bcd60c6a012..ae82353c284 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -567,7 +567,7 @@ jobs: - name: install prek id: prek - uses: j178/prek-action@e98a699c41eb69ab013a45817a0406469a748f8d # v2.0.5 + uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6 with: cache: false show-verbose-logs: false From 331c3b14c87a8ea2fdd536f69a22b1f242dcf1e6 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:31:06 +0900 Subject: [PATCH 243/351] Use datastack path for __init__ dispatch in CallAllocAndEnterInit (#8448) specialization_run_init used prepare_exact_args_frame which heap-allocates a FrameObject with 4-5 atomic refcount bumps per call. Switch to invoke_prepared_exact_args which uses InterpreterFrame::new_on_datastack with zero refcount bumps. CallAllocAndEnterInit already guards against tracing (eval_frame_active) and recursion limits before reaching this path. Assisted-by: Claude --- crates/vm/src/builtins/function.rs | 2 +- crates/vm/src/frame.rs | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/vm/src/builtins/function.rs b/crates/vm/src/builtins/function.rs index 39195202af7..89fab4db188 100644 --- a/crates/vm/src/builtins/function.rs +++ b/crates/vm/src/builtins/function.rs @@ -791,7 +791,7 @@ impl Py { frame } - fn invoke_prepared_exact_args( + pub(crate) fn invoke_prepared_exact_args( &self, args: impl ExactSizeIterator, vm: &VirtualMachine, diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index bf276ec45e0..bd5b66f8a19 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -2673,10 +2673,7 @@ impl ExecutingFrame<'_> { .iter_mut() .map(|slot| slot.take().expect("arg slot must be filled")); - let init_frame = init_func.prepare_exact_args_frame(taken, vm); - let init_result = vm.run_frame(init_frame.clone()); - release_datastack_frame(&init_frame, vm); - let init_result = init_result?; + let init_result = init_func.invoke_prepared_exact_args(taken, vm)?; if !vm.is_none(&init_result) { return Err(vm.new_type_error(format!( From 184db1191ba7c5a234a6c1ca93d011925451ef42 Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:50:22 -0400 Subject: [PATCH 244/351] Replace deprecated `chrono` with `jiff` (#8436) See: chronotope/chrono#1768 --- Cargo.lock | 122 ++++++---------------------------- Cargo.toml | 2 +- crates/stdlib/Cargo.toml | 2 +- crates/stdlib/src/ssl/cert.rs | 14 ++-- crates/vm/Cargo.toml | 6 +- crates/vm/build.rs | 22 +++--- crates/vm/src/stdlib/time.rs | 104 +++++++++++++---------------- 7 files changed, 91 insertions(+), 181 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 58520470c64..3acba335146 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,15 +73,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anes" version = "0.1.6" @@ -509,19 +500,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - [[package]] name = "ciborium" version = "0.2.2" @@ -1567,30 +1545,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_casemap" version = "2.2.0" @@ -1820,10 +1774,14 @@ checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" dependencies = [ "defmt", "jiff-static", + "jiff-tzdb-platform", + "js-sys", "log", "portable-atomic", "portable-atomic-util", "serde_core", + "wasm-bindgen", + "windows-link", ] [[package]] @@ -1837,6 +1795,21 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -3575,7 +3548,6 @@ dependencies = [ "base64", "blake2", "bzip2", - "chrono", "constant_time_eq", "crc32fast", "crossbeam-utils", @@ -3591,6 +3563,7 @@ dependencies = [ "indexmap", "insta", "itertools 0.15.0", + "jiff", "libc", "libsqlite3-sys", "libz-rs-sys", @@ -3669,7 +3642,6 @@ dependencies = [ "ascii", "bitflags 2.13.1", "bstr", - "chrono", "constant_time_eq", "crossbeam-utils", "exitcode", @@ -3682,6 +3654,7 @@ dependencies = [ "is-macro", "itertools 0.15.0", "itoa", + "jiff", "libc", "log", "malachite-bigint", @@ -4713,65 +4686,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 6bb22e36f04..3b489a88687 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -200,7 +200,6 @@ bitflags = "2.11.0" bitflagset = "0.0.3" bstr = { version = "1", default-features = false, features = ["unicode"] } bzip2 = "0.6" -chrono = { version = "0.4.44", default-features = false, features = ["clock", "std"] } console_error_panic_hook = "0.1" constant_time_eq = "0.5" cranelift = "0.132.0" @@ -231,6 +230,7 @@ insta = "1.47" itertools = { version = "0.15.0", default-features = false, features = ["use_alloc"] } itoa = "1" is-macro = "0.3.7" +jiff = "0.2" js-sys = "0.3" junction = "2.0.0" lexical-parse-float = "1.0.6" diff --git a/crates/stdlib/Cargo.toml b/crates/stdlib/Cargo.toml index d32e7ce8b35..98a6677de16 100644 --- a/crates/stdlib/Cargo.toml +++ b/crates/stdlib/Cargo.toml @@ -86,10 +86,10 @@ libz-rs-sys = { workspace = true } bzip2 = { workspace = true } # tkinter +jiff = { workspace = true } tk-sys = { workspace = true, optional = true } tcl-sys = { workspace = true, optional = true } widestring = { workspace = true, optional = true } -chrono.workspace = true # uuid [target.'cfg(not(any(target_os = "ios", target_os = "android", target_os = "windows", target_arch = "wasm32", target_os = "redox")))'.dependencies] diff --git a/crates/stdlib/src/ssl/cert.rs b/crates/stdlib/src/ssl/cert.rs index e304781b644..f12f4307239 100644 --- a/crates/stdlib/src/ssl/cert.rs +++ b/crates/stdlib/src/ssl/cert.rs @@ -10,7 +10,7 @@ //! - Loading certificates from files, directories, and bytes use alloc::sync::Arc; -use chrono::{DateTime, Utc}; +use jiff::{Timestamp, Zoned, tz::TimeZone}; use parking_lot::RwLock as ParkingRwLock; use rustls::{ DigitallySignedStruct, RootCertStore, SignatureScheme, @@ -201,10 +201,10 @@ fn format_ip_address(ip: &[u8]) -> String { /// Formats certificate validity dates in the format: /// "Mon DD HH:MM:SS YYYY GMT" fn format_asn1_time(time: &x509_parser::time::ASN1Time) -> String { - let timestamp = time.timestamp(); - DateTime::::from_timestamp(timestamp, 0) - .expect("ASN1Time must be valid timestamp") - .format("%b %e %H:%M:%S %Y GMT") + let timestamp = + Timestamp::from_second(time.timestamp()).expect("ASN1Time must be valid timestamp"); + Zoned::new(timestamp, TimeZone::UTC) + .strftime("%b %e %H:%M:%S %Y GMT") .to_string() } @@ -341,7 +341,7 @@ pub(super) fn cert_to_dict( let serial = format_serial_number(&cert.serial); dict.set_item("serialNumber", vm.ctx.new_str(serial).into(), vm)?; - // Validity dates - format with GMT using chrono + // Validity dates - format with GMT using jiff dict.set_item( "notBefore", vm.ctx @@ -414,7 +414,7 @@ pub(super) fn cert_der_to_dict_helper( // CPython ordering: issuer, notAfter, notBefore, serialNumber, subject, version dict.set_item("issuer", name_to_tuple(cert.issuer())?, vm)?; - // Validity - format with GMT using chrono + // Validity - format with GMT using jiff dict.set_item( "notAfter", vm.ctx diff --git a/crates/vm/Cargo.toml b/crates/vm/Cargo.toml index 6601fb03dc2..cc7c8dec2f3 100644 --- a/crates/vm/Cargo.toml +++ b/crates/vm/Cargo.toml @@ -26,7 +26,7 @@ ast = ["ruff_python_ast", "ruff_text_size"] codegen = ["rustpython-codegen", "ast"] parser = ["ast"] serde = ["dep:serde"] -wasmbind = ["rustpython-common/wasm_js", "chrono/wasmbind", "wasm-bindgen"] +wasmbind = ["rustpython-common/wasm_js", "jiff/js", "wasm-bindgen"] [dependencies] rustpython-compiler = { workspace = true, optional = true } @@ -48,7 +48,6 @@ ascii = { workspace = true } bitflags = { workspace = true } bstr = { workspace = true } crossbeam-utils = { workspace = true } -chrono = { workspace = true } constant_time_eq = { workspace = true } flame = { workspace = true, optional = true } hex = { workspace = true } @@ -56,6 +55,7 @@ indexmap = { workspace = true } itertools = { workspace = true } itoa = { workspace = true } is-macro = { workspace = true } +jiff = { workspace = true } libc = { workspace = true } log = { workspace = true } malachite-bigint = { workspace = true } @@ -91,9 +91,9 @@ widestring = { workspace = true } wasm-bindgen = { workspace = true, optional = true } [build-dependencies] -chrono = { workspace = true } glob = { workspace = true } itertools = { workspace = true } +jiff = { workspace = true } [lints] workspace = true diff --git a/crates/vm/build.rs b/crates/vm/build.rs index 36e7a5d9d27..2846adfe5f9 100644 --- a/crates/vm/build.rs +++ b/crates/vm/build.rs @@ -3,9 +3,8 @@ reason = "build scripts cannot use rustpython-host_env" )] -use chrono::{Local, prelude::DateTime}; -use core::time::Duration; use itertools::Itertools; +use jiff::{Timestamp, Zoned, tz::TimeZone}; use std::{ env, io::{self, prelude::*}, @@ -123,24 +122,27 @@ fn git_identifier() -> String { } } -fn get_git_timestamp_datetime() -> DateTime { - let timestamp = git_timestamp().parse::().unwrap_or_default(); - let datetime = UNIX_EPOCH + Duration::from_secs(timestamp); - datetime.into() +fn get_git_timestamp_raw() -> Option { + let git_timestamp = git_timestamp().parse::().ok()?; + Timestamp::from_second(git_timestamp).ok() +} + +fn get_git_timestamp_datetime() -> Zoned { + get_git_timestamp_raw().map_or_else(Zoned::now, |timestamp| { + Zoned::new(timestamp, TimeZone::system()) + }) } #[must_use] fn get_git_date() -> String { let datetime = get_git_timestamp_datetime(); - - datetime.format("%b %e %Y").to_string() + datetime.strftime("%b %e %Y").to_string() } #[must_use] fn get_git_time() -> String { let datetime = get_git_timestamp_datetime(); - - datetime.format("%H:%M:%S").to_string() + datetime.strftime("%H:%M:%S").to_string() } fn rustc_version() -> String { diff --git a/crates/vm/src/stdlib/time.rs b/crates/vm/src/stdlib/time.rs index 226432654bb..54ccd46d74e 100644 --- a/crates/vm/src/stdlib/time.rs +++ b/crates/vm/src/stdlib/time.rs @@ -25,12 +25,9 @@ mod decl { common::wtf8::Wtf8Buf, convert::{ToPyException, ToPyObject}, }; - #[cfg(not(any(unix, windows)))] - use chrono::{ - DateTime, Datelike, TimeZone, Timelike, - naive::{NaiveDate, NaiveDateTime, NaiveTime}, - }; use core::time::Duration; + #[cfg(not(any(unix, windows)))] + use jiff::{Timestamp, Zoned, civil::DateTime, tz::TimeZone}; #[cfg(target_os = "wasi")] use rustpython_host_env::time::ClockId; #[cfg(any(unix, windows))] @@ -289,10 +286,7 @@ mod decl { } #[cfg(not(any(unix, windows)))] - fn pyobj_to_date_time( - value: Either, - vm: &VirtualMachine, - ) -> PyResult> { + fn pyobj_to_timestamp(value: Either, vm: &VirtualMachine) -> PyResult { let secs = match value { Either::A(float) => { if !float.is_finite() { @@ -302,19 +296,19 @@ mod decl { } Either::B(int) => int, }; - DateTime::::from_timestamp(secs, 0) - .ok_or_else(|| vm.new_overflow_error("timestamp out of range for platform time_t")) + Timestamp::from_second(secs) + .map_err(|_| vm.new_overflow_error("timestamp out of range for platform time_t")) } #[cfg(not(any(unix, windows)))] impl OptionalArg>> { /// Construct a localtime from the optional seconds, or get the current local time. - fn naive_or_local(self, vm: &VirtualMachine) -> PyResult { + fn naive_or_local(self, vm: &VirtualMachine) -> PyResult { Ok(match self { - Self::Present(Some(secs)) => pyobj_to_date_time(secs, vm)? - .with_timezone(&chrono::Local) - .naive_local(), - Self::Present(None) | Self::Missing => chrono::offset::Local::now().naive_local(), + Self::Present(Some(secs)) => { + pyobj_to_timestamp(secs, vm)?.to_zoned(TimeZone::system()) + } + Self::Present(None) | Self::Missing => Zoned::now(), }) } } @@ -432,10 +426,10 @@ mod decl { #[cfg(not(any(unix, windows)))] impl OptionalArg { - fn naive_or_local(self, vm: &VirtualMachine) -> PyResult { + fn naive_or_local(self, vm: &VirtualMachine) -> PyResult { Ok(match self { Self::Present(t) => t.to_date_time(vm)?, - Self::Missing => chrono::offset::Local::now().naive_local(), + Self::Missing => Zoned::now().datetime(), }) } } @@ -456,9 +450,9 @@ mod decl { } _ => { let instant = match secs { - OptionalArg::Present(Some(secs)) => pyobj_to_date_time(secs, vm)?.naive_utc(), + OptionalArg::Present(Some(secs)) => pyobj_to_timestamp(secs, vm)?.to_zoned(TimeZone::UTC), OptionalArg::Present(None) | OptionalArg::Missing => { - chrono::offset::Utc::now().naive_utc() + Zoned::now().with_time_zone(TimeZone::UTC) } }; Ok(StructTimeData::new_utc(vm, instant)) @@ -481,7 +475,7 @@ mod decl { } _ => { let instant = secs.naive_or_local(vm)?; - Ok(StructTimeData::new_local(vm, instant, 0)) + StructTimeData::new_local(vm, instant.into(), 0) } } } @@ -502,11 +496,10 @@ mod decl { { let datetime = t.to_date_time(vm)?; // mktime interprets struct_time as local time - let local_dt = chrono::Local - .from_local_datetime(&datetime) - .single() - .ok_or_else(|| vm.new_overflow_error("mktime argument out of range"))?; - let seconds_since_epoch = local_dt.timestamp() as f64; + let local_dt = datetime + .to_zoned(TimeZone::system()) + .map_err(|_| vm.new_overflow_error("mktime argument out of range"))?; + let seconds_since_epoch = local_dt.timestamp().as_second() as f64; Ok(seconds_since_epoch) } } @@ -534,7 +527,7 @@ mod decl { #[cfg(not(any(unix, windows)))] { let instant = t.naive_or_local(vm)?; - let formatted_time = instant.format(CFMT).to_string(); + let formatted_time = instant.strftime(CFMT).to_string(); Ok(vm.ctx.new_str(formatted_time).into()) } } @@ -555,7 +548,7 @@ mod decl { #[cfg(not(any(unix, windows)))] { let instant = secs.naive_or_local(vm)?; - Ok(instant.format(CFMT).to_string()) + Ok(instant.strftime(CFMT).to_string()) } } @@ -645,7 +638,7 @@ mod decl { }; let mut formatted_time = String::new(); - write!(&mut formatted_time, "{}", instant.format(&fmt_lossy)) + write!(&mut formatted_time, "{}", instant.strftime(&*fmt_lossy)) .unwrap_or_else(|_| formatted_time = format.to_string()); Ok(vm.ctx.new_str(formatted_time).into()) } @@ -757,13 +750,7 @@ mod decl { impl StructTimeData { #[cfg(not(any(unix, windows)))] - fn new_inner( - vm: &VirtualMachine, - tm: NaiveDateTime, - isdst: i32, - gmtoff: i32, - zone: &str, - ) -> Self { + fn new_inner(vm: &VirtualMachine, tm: Zoned, isdst: i32) -> Self { Self { tm_year: vm.ctx.new_int(tm.year()).into(), tm_mon: vm.ctx.new_int(tm.month()).into(), @@ -771,46 +758,47 @@ mod decl { tm_hour: vm.ctx.new_int(tm.hour()).into(), tm_min: vm.ctx.new_int(tm.minute()).into(), tm_sec: vm.ctx.new_int(tm.second()).into(), - tm_wday: vm.ctx.new_int(tm.weekday().num_days_from_monday()).into(), - tm_yday: vm.ctx.new_int(tm.ordinal()).into(), + tm_wday: vm.ctx.new_int(tm.weekday().to_sunday_zero_offset()).into(), + tm_yday: vm.ctx.new_int(tm.day_of_year()).into(), tm_isdst: vm.ctx.new_int(isdst).into(), - tm_zone: vm.ctx.new_str(zone).into(), - tm_gmtoff: vm.ctx.new_int(gmtoff).into(), + tm_zone: vm.ctx.new_str(tm.strftime("%Z").to_string()).into(), + tm_gmtoff: vm.ctx.new_int(tm.offset().seconds()).into(), } } /// Create struct_time for UTC (gmtime) #[cfg(not(any(unix, windows)))] - fn new_utc(vm: &VirtualMachine, tm: NaiveDateTime) -> Self { - Self::new_inner(vm, tm, 0, 0, "UTC") + fn new_utc(vm: &VirtualMachine, tm: Zoned) -> Self { + Self::new_inner(vm, tm, 0) } /// Create struct_time for local timezone (localtime) #[cfg(not(any(unix, windows)))] - fn new_local(vm: &VirtualMachine, tm: NaiveDateTime, isdst: i32) -> Self { - let local_time = chrono::Local.from_local_datetime(&tm).unwrap(); - let offset_seconds = local_time.offset().local_minus_utc(); - let tz_abbr = local_time.format("%Z").to_string(); - Self::new_inner(vm, tm, isdst, offset_seconds, &tz_abbr) + fn new_local(vm: &VirtualMachine, tm: DateTime, isdst: i32) -> PyResult { + tm.to_zoned(TimeZone::system()) + .map(|tm| Self::new_inner(vm, tm, isdst)) + .map_err(|_| { + vm.new_overflow_error("timestamp is ambiguous for the system timezone") + }) } #[cfg(not(any(unix, windows)))] - fn to_date_time(&self, vm: &VirtualMachine) -> PyResult { - let invalid_overflow = || vm.new_overflow_error("mktime argument out of range"); - let invalid_value = || vm.new_value_error("invalid struct_time parameter"); - + fn to_date_time(&self, vm: &VirtualMachine) -> PyResult { macro_rules! field { ($field:ident) => { self.$field.clone().try_into_value(vm)? }; } - let dt = NaiveDateTime::new( - NaiveDate::from_ymd_opt(field!(tm_year), field!(tm_mon), field!(tm_mday)) - .ok_or_else(invalid_value)?, - NaiveTime::from_hms_opt(field!(tm_hour), field!(tm_min), field!(tm_sec)) - .ok_or_else(invalid_overflow)?, - ); - Ok(dt) + DateTime::new( + field!(tm_year), + field!(tm_mon), + field!(tm_mday), + field!(tm_hour), + field!(tm_min), + field!(tm_sec), + 0, + ) + .map_err(|_| vm.new_overflow_error("mktime argument out of range")) } } From 5febd2eedbcea5085d967490c33e73cb78e71ec8 Mon Sep 17 00:00:00 2001 From: Kevin Turcios <106575910+KRRT7@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:12:22 -0500 Subject: [PATCH 245/351] Optimize list removal lock usage (#8451) Assisted-by: WarpForgeAgent:DeepSeek-v4-flash --- .pre-commit-config.yaml | 4 ++-- crates/vm/src/builtins/list.rs | 9 ++++++--- scripts/check_redundant_patches.py | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ee90488c741..db9869bb3a5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,7 +42,7 @@ repos: - id: generate-rs-opcode-metadata name: generate rust opcode metadata - entry: python tools/opcode_metadata/generate_rs_opcode_metadata.py + entry: python3 tools/opcode_metadata/generate_rs_opcode_metadata.py files: '^(crates/compiler-core/src/bytecode/instruction\.rs|tools/opcode_metadata/*)$' pass_filenames: false language: system @@ -53,7 +53,7 @@ repos: - id: generate-py-opcode-metadata name: generate python opcode metadata - entry: python tools/opcode_metadata/generate_py_opcode_metadata.py + entry: python3 tools/opcode_metadata/generate_py_opcode_metadata.py files: '^(crates/compiler-core/src/bytecode/instruction\.rs|tools/opcode_metadata/*)$' pass_filenames: false language: system diff --git a/crates/vm/src/builtins/list.rs b/crates/vm/src/builtins/list.rs index 8a426685ad2..2b83a5c1007 100644 --- a/crates/vm/src/builtins/list.rs +++ b/crates/vm/src/builtins/list.rs @@ -372,12 +372,15 @@ impl PyList { if let Some(index) = index.into() { // defer delete out of borrow - let is_inside_range = index < self.borrow_vec().len(); - Ok(is_inside_range.then(|| self.borrow_vec_mut().remove(index))) + let removed = { + let mut elements = self.borrow_vec_mut(); + (index < elements.len()).then(|| elements.remove(index)) + }; + drop(removed); + Ok(()) } else { Err(vm.new_value_error(format!("'{}' is not in list", needle.str(vm)?))) } - .map(drop) } fn _delitem(&self, needle: &PyObject, vm: &VirtualMachine) -> PyResult<()> { diff --git a/scripts/check_redundant_patches.py b/scripts/check_redundant_patches.py index 4bc89a573d4..e9981e38d60 100644 --- a/scripts/check_redundant_patches.py +++ b/scripts/check_redundant_patches.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import argparse import ast import glob From b03e0328373742fbb164b81ed57e16b6ced74ae9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:12:39 +0900 Subject: [PATCH 246/351] build(deps-dev): bump fast-uri from 3.1.4 to 3.1.5 in /wasm/demo (#8452) Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- wasm/demo/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wasm/demo/package-lock.json b/wasm/demo/package-lock.json index 9cfd625aaa7..b114d92ebb8 100644 --- a/wasm/demo/package-lock.json +++ b/wasm/demo/package-lock.json @@ -2559,9 +2559,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { From 6eea4fd9d4b3e02180e4986fb91d901215ad1c14 Mon Sep 17 00:00:00 2001 From: Seonghun An <53287605+shAn-kor@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:13:43 +0900 Subject: [PATCH 247/351] Fix complex zero-padding error priority (#8453) Assisted-by: Codex:gpt-5 --- crates/common/src/format.rs | 65 ++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/crates/common/src/format.rs b/crates/common/src/format.rs index b4b25b2739a..b5f062f97a6 100644 --- a/crates/common/src/format.rs +++ b/crates/common/src/format.rs @@ -505,6 +505,14 @@ impl FormatSpec { Ok(()) } + fn validate_complex_padding_and_alignment(&self) -> Result<(), FormatSpecError> { + match &self.fill.unwrap_or_else(|| ' '.into()).to_char() { + Some('0') => Err(FormatSpecError::ZeroPadding), + _ if self.align == Some(FormatAlign::AfterSign) => Err(FormatSpecError::AlignmentFlag), + _ => Ok(()), + } + } + const fn get_separator_interval(&self) -> usize { match self.format_type { Some(FormatType::Binary | FormatType::Octal | FormatType::Hex(_)) => 4, @@ -784,6 +792,7 @@ impl FormatSpec { // No parentheses for 'n' format (CPython: add_parens=0) let magnitude_str = format!("{grouped_re}{grouped_im}"); + self.validate_complex_padding_and_alignment()?; Ok(self.format_sign_and_align(&AsciiStr::new(&magnitude_str), "", FormatAlign::Right)) } @@ -1057,17 +1066,8 @@ impl FormatSpec { } else { format!("{formatted_re}{formatted_im}") }; - if let Some(FormatAlign::AfterSign) = &self.align { - return Err(FormatSpecError::AlignmentFlag); - } - match &self.fill.unwrap_or_else(|| ' '.into()).to_char() { - Some('0') => Err(FormatSpecError::ZeroPadding), - _ => Ok(self.format_sign_and_align( - &AsciiStr::new(&magnitude_str), - "", - FormatAlign::Right, - )), - } + self.validate_complex_padding_and_alignment()?; + Ok(self.format_sign_and_align(&AsciiStr::new(&magnitude_str), "", FormatAlign::Right)) } fn format_complex_re_im(&self, num: &Complex64) -> Result<(String, String), FormatSpecError> { @@ -1726,6 +1726,26 @@ mod tests { ); } + #[test] + fn format_complex_rejects_zero_padding_before_after_sign_alignment() { + for text in [ + "08.1f", "=08.1f", "0=8.1f", "#08.1f", "0>8.1f", "0<8.1f", "0^8.1f", + ] { + let spec = FormatSpec::parse(text).unwrap(); + assert_eq!( + spec.format_complex(&Complex64::new(1.0, 2.0)), + Err(FormatSpecError::ZeroPadding), + "{text}" + ); + } + + let spec = FormatSpec::parse("=8.1f").unwrap(); + assert_eq!( + spec.format_complex(&Complex64::new(1.0, 2.0)), + Err(FormatSpecError::AlignmentFlag) + ); + } + #[test] fn format_int_zero_padding_stays_after_sign() { let spec = FormatSpec::parse("08").unwrap(); @@ -1736,6 +1756,29 @@ mod tests { ); } + #[test] + fn format_complex_locale_rejects_zero_padding_before_after_sign_alignment() { + let locale = LocaleInfo { + thousands_sep: String::new(), + decimal_point: ".".to_owned(), + grouping: vec![], + }; + for text in ["08n", "=08n", "0=8n", "#08n", "0>8n", "0<8n", "0^8n"] { + let spec = FormatSpec::parse(text).unwrap(); + assert_eq!( + spec.format_complex_locale(&Complex64::new(1.0, 2.0), &locale), + Err(FormatSpecError::ZeroPadding), + "{text}" + ); + } + + let spec = FormatSpec::parse("=8n").unwrap(); + assert_eq!( + spec.format_complex_locale(&Complex64::new(1.0, 2.0), &locale), + Err(FormatSpecError::AlignmentFlag) + ); + } + #[test] fn format_int() { assert_eq!( From 1fa76a3ae0b93a09142377880ff40fb0b98b55a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B4=91=ED=9A=A8?= <87641474+widehyo1@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:15:27 +0900 Subject: [PATCH 248/351] ast: enforce integer string limits in AST-only parsing (#8454) Validate decimal integer literals from the parser token stream before lowering the parsed AST. Reuse the validator for normal compilation and the `_ast` parsing path so `PyCF_ONLY_AST`, `ast.parse()`, and `ast.literal_eval()` honor `int_max_str_digits` consistently. Remove the now-passing `test_literal_eval_str_int_limit` expected-failure marker. The token-based validator excludes non-decimal integers, floats, complex literals, strings, and comments without duplicating lexer logic. Assisted-by: Codex:gpt-5.6-sol --- Lib/test/test_ast/test_ast.py | 1 - crates/compiler/src/lib.rs | 159 ++++++++++++++-------------------- crates/vm/src/stdlib/_ast.rs | 8 ++ 3 files changed, 71 insertions(+), 97 deletions(-) diff --git a/Lib/test/test_ast/test_ast.py b/Lib/test/test_ast/test_ast.py index a3ce5703424..699a5ec0b04 100644 --- a/Lib/test/test_ast/test_ast.py +++ b/Lib/test/test_ast/test_ast.py @@ -1888,7 +1888,6 @@ def test_literal_eval(self): self.assertRaises(ValueError, ast.literal_eval, '+True') self.assertRaises(ValueError, ast.literal_eval, '2+3') - @unittest.expectedFailure # TODO: RUSTPYTHON; SyntaxError not raised def test_literal_eval_str_int_limit(self): with support.adjust_int_max_str_digits(4000): ast.literal_eval('3'*4000) # no error diff --git a/crates/compiler/src/lib.rs b/crates/compiler/src/lib.rs index 2cebfcb8bb5..43f7174d804 100644 --- a/crates/compiler/src/lib.rs +++ b/crates/compiler/src/lib.rs @@ -1,4 +1,4 @@ -pub use ruff_python_ast::token::TokenKind; +pub use ruff_python_ast::token::{TokenKind, Tokens}; use ruff_python_parser::ParseErrorType; use ruff_source_file::{PositionEncoding, SourceFile, SourceFileBuilder, SourceLocation}; use ruff_text_size::{Ranged, TextSize, TextSlice}; @@ -4752,97 +4752,45 @@ fn invalid_legacy_statement_error(source: &str) -> Option<(String, usize, usize) None } -fn long_decimal_integer_literal_error( - source: &str, +/// Return the syntax error for a decimal integer literal exceeding the configured limit. +/// +/// The parser has already distinguished integer literals from strings, comments, floats, and +/// complex numbers. Inspecting its tokens keeps the limit consistent for every source parsing +/// entry point without reimplementing Python's lexer here. +#[must_use] +pub fn long_decimal_integer_literal_error( + source_file: &SourceFile, + tokens: &Tokens, max_str_digits: usize, -) -> Option<(String, usize, usize)> { +) -> Option { if max_str_digits == 0 { return None; } - let bytes = source.as_bytes(); - let mut index = 0; - while index < bytes.len() { - match bytes[index] { - b'#' => { - while index < bytes.len() && bytes[index] != b'\n' { - index += 1; - } - } - b'\'' | b'"' => { - index = skip_quoted_string(bytes, index); - } - byte if byte >= 0x80 || byte == b'_' || byte.is_ascii_alphabetic() => { - index += 1; - while index < bytes.len() - && (bytes[index] >= 0x80 || is_ascii_identifier_char(bytes[index])) - { - index += 1; - } - } - b'.' => { - if bytes - .get(index + 1) - .is_some_and(|byte| byte.is_ascii_digit()) - { - let (_, end) = number_literal_end(bytes, index)?; - index = end.max(index + 1); - } else { - index += 1; - } - } - b'0'..=b'9' => { - if bytes.get(index) == Some(&b'0') - && matches!( - bytes.get(index + 1), - Some(b'x' | b'X' | b'o' | b'O' | b'b' | b'B') - ) - { - let Some((_, end)) = number_literal_end(bytes, index) else { - index += 1; - continue; - }; - index = end.max(index + 1); - continue; - } - - let start = index; - let mut digits = 0usize; - while index < bytes.len() { - match bytes[index] { - b'0'..=b'9' => { - digits += 1; - index += 1; - } - b'_' if bytes - .get(index + 1) - .is_some_and(|byte| byte.is_ascii_digit()) => - { - index += 1; - } - _ => break, - } - } - if matches!(bytes.get(index), Some(b'.' | b'e' | b'E' | b'j' | b'J')) { - let Some((_, end)) = number_literal_end(bytes, start) else { - continue; - }; - index = end.max(index + 1); - continue; - } - if digits > max_str_digits { - return Some(( - format!( - "Exceeds the limit ({max_str_digits} digits) for integer string conversion: value has {digits} digits; use sys.set_int_max_str_digits() to increase the limit - Consider hexadecimal for huge integer literals to avoid decimal conversion limits." - ), - start, - start, - )); - } - } - _ => index += 1, + tokens.iter().find_map(|token| { + if token.kind() != TokenKind::Int { + return None; } - } - None + let literal = source_file.source_text().slice(token.range()); + if literal + .as_bytes() + .get(..2) + .is_some_and(|prefix| matches!(prefix, b"0x" | b"0X" | b"0o" | b"0O" | b"0b" | b"0B")) + { + return None; + } + let digits = literal.bytes().filter(u8::is_ascii_digit).count(); + (digits > max_str_digits).then(|| { + let start = token.range().start().to_usize(); + CompileError::from_source_error( + source_file, + format!( + "Exceeds the limit ({max_str_digits} digits) for integer string conversion: value has {digits} digits; use sys.set_int_max_str_digits() to increase the limit - Consider hexadecimal for huge integer literals to avoid decimal conversion limits." + ), + start, + start, + ) + }) + }) } fn invalid_parenthesized_import_star_error(source: &str) -> Option<(String, usize, usize)> { @@ -4951,12 +4899,27 @@ fn invalid_unparenthesized_yield_after_comma_error(source: &str) -> Option<(Stri None } -fn post_parse_source_error(source_file: &SourceFile, opts: &CompileOpts) -> Option { - too_many_nested_parentheses_error(source_file.source_text()) - .or_else(|| { - long_decimal_integer_literal_error(source_file.source_text(), opts.int_max_str_digits) - }) - .or_else(|| invalid_call_argument_error(source_file.source_text())) +fn post_parse_source_error( + source_file: &SourceFile, + tokens: &Tokens, + opts: &CompileOpts, +) -> Option { + if let Some((message, start, end)) = + too_many_nested_parentheses_error(source_file.source_text()) + { + return Some(CompileError::from_source_error( + source_file, + message, + start, + end, + )); + } + if let Some(error) = + long_decimal_integer_literal_error(source_file, tokens, opts.int_max_str_digits) + { + return Some(error); + } + invalid_call_argument_error(source_file.source_text()) .or_else(|| invalid_match_mapping_rest_wildcard_error(source_file.source_text())) .or_else(|| invalid_match_as_target_error(source_file.source_text())) .or_else(|| invalid_unparenthesized_yield_after_comma_error(source_file.source_text())) @@ -5220,7 +5183,7 @@ fn _compile_with_syntax_warning_handler<'a>( { return Err(error); } - if let Some(error) = post_parse_source_error(&source_file, &opts) { + if let Some(error) = post_parse_source_error(&source_file, parsed.tokens(), &opts) { return Err(error); } let ast = parsed.into_syntax(); @@ -5273,7 +5236,9 @@ pub fn _compile_symtable( Mode::Exec | Mode::Single | Mode::BlockExpr => { let ast = ruff_python_parser::parse_module(source_file.source_text()) .map_err(|e| CompileError::from_ruff_parse_error(e, &source_file))?; - if let Some(error) = post_parse_source_error(&source_file, &CompileOpts::default()) { + if let Some(error) = + post_parse_source_error(&source_file, ast.tokens(), &CompileOpts::default()) + { return Err(error); } let ast = ast.into_syntax(); @@ -5290,7 +5255,9 @@ pub fn _compile_symtable( parser::Mode::Expression.into(), ) .map_err(|e| CompileError::from_ruff_parse_error(e, &source_file))?; - if let Some(error) = post_parse_source_error(&source_file, &CompileOpts::default()) { + if let Some(error) = + post_parse_source_error(&source_file, ast.tokens(), &CompileOpts::default()) + { return Err(error); } symboltable::SymbolTable::scan_expr( diff --git a/crates/vm/src/stdlib/_ast.rs b/crates/vm/src/stdlib/_ast.rs index a04def22524..b36277f5456 100644 --- a/crates/vm/src/stdlib/_ast.rs +++ b/crates/vm/src/stdlib/_ast.rs @@ -1861,6 +1861,14 @@ pub(crate) fn parse( .into()); } + if let Some(error) = rustpython_compiler::long_decimal_integer_literal_error( + &source_file, + parsed.tokens(), + vm.state.int_max_str_digits.load(), + ) { + return Err(error); + } + let mut top = parsed.into_syntax(); if let Some(error) = ipython_escape_command_syntax_error(&top, &source_file) { return Err(error); From 1c7759c4556623ef546f1a228091e8f8d11a1086 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:10:14 +0900 Subject: [PATCH 249/351] Flatten Python eval loop with trampoline for reduced call overhead (#8431) * Factor with_iframe into enter_iframe/exit_iframe helpers Extract the frame entry (recursion check, TLS link, exception save) and exit (materialization sync, TLS restore, GC tracking) logic from with_iframe into standalone enter_iframe/exit_iframe methods. with_iframe now calls them, with no behavioral change. This prepares for the trampoline loop where enter/exit are called individually rather than wrapped around a closure. Assisted-by: Claude * Allocate InterpreterFrame and LocalsPlus together on the datastack Add InterpreterFrame::new_on_datastack() that bump-allocates both the InterpreterFrame struct and its LocalsPlus data array in a single datastack push, eliminating one allocation per function call. Update datastack_frame_size_bytes_for_code() to include InterpreterFrame size. Convert invoke_prepared_exact_args() and the invoke() fast path to use the combined allocation. Add release_datastack_frame() method on InterpreterFrame that drops all localsplus values, runs field destructors (trace, temporary_refs, retained_back, etc.), and returns the datastack base pointer for pop. Assisted-by: Claude * Implement trampoline loop for Python-to-Python calls Add ExecutionResult::TailCall variant and a trampoline in run_frame_fast that flattens Python-to-Python calls into a single Rust stack frame instead of recursing through the eval loop. CallPyExactArgs now prepares the callee frame on the datastack and returns TailCall when tailcall_enabled is set (run_iframe path only). The trampoline dispatches via a state machine (EnterCallee / ReturnValue / Unwind) in a single loop, avoiding mutual recursion between helper functions that would exhaust the C stack. Exception propagation through suspended frames uses trampoline_handle_exception which adds traceback entries and calls unwind_blocks on each caller. Assisted-by: Claude * Optimize trampoline: skip recursion check, avoid temporary_refs mutex, add bound method TailCall - Add enter_iframe_unchecked for trampoline callee entry (recursion already checked by specialization_call_recursion_guard) - Move callable ownership from per-frame temporary_refs mutex to trampoline-local SuspendedFrame.owned_refs via VM side channel - Add TailCall support for CallBoundMethodExactArgs - Move args directly from caller stack to callee fastlocals - Read materialized pointer once in exit_iframe Incremental call overhead: ~55 ns -> ~35 ns Assisted-by: Claude * Use UnsafeCell for pending_tailcall_refs The VM is per-thread so RefCell's runtime borrow checking is unnecessary overhead. Replace with UnsafeCell for direct access. Assisted-by: Claude * Remove .claude/settings.json from tracking Assisted-by: Claude * Replace SendPtr with Option for type-safe pending tailcall Use NonNull + Option instead of raw *mut T with manual null checks. The compiler enforces non-null via the type system, and Option has the same size as a raw pointer thanks to niche optimization. Also extract take_pending_tailcall helper to deduplicate the pattern. Assisted-by: Claude * Restrict PendingFrame to private, expose only set/take methods Rename SendNonNull to PendingFrame and make it fully private: the struct, its field, and the pending_tailcall_frame Cell are all non-pub. External code accesses the side channel only through set_pending_tailcall (pub(crate)) and take_pending_tailcall (private). This ensures the unsafe Send+Sync impl cannot be reused elsewhere without justifying a new safety argument. Assisted-by: Claude * Restore C-stack overflow check in trampoline enter_iframe_unchecked enter_iframe_unchecked was skipping C-stack checks under the assumption that the trampoline stays in one Rust stack frame. But each run_iframe call still consumes Rust stack, so deep Python recursion through the trampoline can exhaust the C stack (observed as STATUS_STACK_OVERFLOW on Windows CI). Keep the C-stack check (every 8th call) while still skipping the Python recursion depth check (already done by specialization_call_recursion_guard). Assisted-by: Claude * Address code review: entry frame double-free, panic safety, cleanup - Fix double-free: mark entry frame with is_entry flag in SuspendedFrame so the trampoline skips its datastack release (the caller owns that cleanup) - Clear iframe.previous in exit_iframe before unlinking the chain, matching with_frame and resume_gen_frame behavior - Add scopeguard in with_iframe for panic safety - Use saturating_sub(1) for lasti in trampoline_handle_exception - Extract datastack_iframe_localsplus_offset helper to avoid duplicated alignment computation Assisted-by: Claude --- .claude/settings.json | 15 - crates/vm/src/builtins/function.rs | 41 +-- crates/vm/src/coroutine.rs | 2 + crates/vm/src/frame.rs | 347 +++++++++++++++++++++- crates/vm/src/vm/mod.rs | 455 ++++++++++++++++++++++++++--- crates/vm/src/vm/thread.rs | 2 + 6 files changed, 783 insertions(+), 79 deletions(-) delete mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 22f0a9a8a01..00000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "hooks": { - "SessionStart": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "bash .claude/scripts/setup-env.sh" - } - ] - } - ] - } -} diff --git a/crates/vm/src/builtins/function.rs b/crates/vm/src/builtins/function.rs index 89fab4db188..d1fa5222393 100644 --- a/crates/vm/src/builtins/function.rs +++ b/crates/vm/src/builtins/function.rs @@ -64,9 +64,9 @@ fn format_missing_args( #[pyclass(module = false, name = "function", traverse = "manual")] #[derive(Debug)] pub struct PyFunction { - code: PyAtomicRef, - globals: PyDictRef, - builtins: PyObjectRef, + pub(crate) code: PyAtomicRef, + pub(crate) globals: PyDictRef, + pub(crate) builtins: PyObjectRef, pub(crate) closure: Option>>, defaults_and_kwdefaults: PyMutex<(Option, Option)>, name: PyMutex, @@ -617,11 +617,6 @@ impl Py { // Fast path: stack-allocated InterpreterFrame, no FrameObject. // No refcount inc for code — it's alive via self.code for the call duration. - let nlocalsplus = code.localspluskinds.len(); - let max_stackdepth = code.max_stackdepth as usize; - let localsplus = - crate::frame::LocalsPlus::new_on_datastack(nlocalsplus, max_stackdepth, vm); - let locals = if code.flags.contains(bytecode::CodeFlags::NEWLOCALS) { crate::frame::FrameLocals::lazy() } else if let Some(locals) = locals { @@ -634,22 +629,21 @@ impl Py { // Use self.as_object() as raw pointer — no refcount inc/dec. // The function is alive on the caller's stack for the call duration. - let mut iframe = crate::frame::InterpreterFrame::new( + let iframe = crate::frame::InterpreterFrame::new_on_datastack( &self.code, &self.globals, &self.builtins, Some(self.as_object()), - localsplus, locals, self.closure.as_ref().map_or(&[], |c| c.as_slice()), - crate::frame::FrameOwner::Thread, + vm, ); let result = self - .fill_locals_from_args_iframe(&mut iframe, func_args, vm) - .and_then(|()| vm.run_frame_fast(&mut iframe)); + .fill_locals_from_args_iframe(iframe, func_args, vm) + .and_then(|()| vm.run_frame_fast(iframe)); // Release data stack memory — must happen on both success and error. unsafe { - if let Some(base) = iframe.localsplus.release_datastack() { + if let Some(base) = iframe.release_datastack_frame() { vm.datastack_pop(base); } } @@ -797,10 +791,6 @@ impl Py { vm: &VirtualMachine, ) -> PyResult { let code = &*self.code; - let nlocalsplus = code.localspluskinds.len(); - let max_stackdepth = code.max_stackdepth as usize; - let localsplus = - crate::frame::LocalsPlus::new_on_datastack(nlocalsplus, max_stackdepth, vm); let locals = if code.flags.contains(bytecode::CodeFlags::NEWLOCALS) { crate::frame::FrameLocals::lazy() @@ -810,15 +800,14 @@ impl Py { )) }; - let mut iframe = crate::frame::InterpreterFrame::new( + let iframe = crate::frame::InterpreterFrame::new_on_datastack( code, &self.globals, &self.builtins, Some(self.as_object()), - localsplus, locals, self.closure.as_ref().map_or(&[], |c| c.as_slice()), - crate::frame::FrameOwner::Thread, + vm, ); // Fill arguments directly into fastlocals @@ -829,9 +818,9 @@ impl Py { } } - let result = vm.run_frame_fast(&mut iframe); + let result = vm.run_frame_fast(iframe); unsafe { - if let Some(base) = iframe.localsplus.release_datastack() { + if let Some(base) = iframe.release_datastack_frame() { vm.datastack_pop(base); } } @@ -887,8 +876,10 @@ pub(crate) fn datastack_frame_size_bytes_for_code(code: &Py) -> Option()) + Some(crate::frame::datastack_iframe_total_bytes( + nlocalsplus, + code.max_stackdepth as usize, + )) } impl PyPayload for PyFunction { diff --git a/crates/vm/src/coroutine.rs b/crates/vm/src/coroutine.rs index c7252c66d12..43a28320e00 100644 --- a/crates/vm/src/coroutine.rs +++ b/crates/vm/src/coroutine.rs @@ -23,6 +23,7 @@ impl ExecutionResult { }; PyIterReturn::StopIteration(arg) } + Self::TailCall => unreachable!("TailCall in generator/coroutine"), } } } @@ -104,6 +105,7 @@ impl Coro { self.clear_frame_locals_on_close(); } Ok(ExecutionResult::Yield(_)) => {} + Ok(ExecutionResult::TailCall) => unreachable!("TailCall in generator/coroutine"), } } diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index bd5b66f8a19..e1836ad02f1 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -544,6 +544,13 @@ impl LocalsPlus { Ok(()) } + /// Push a PyObjectRef onto the evaluation stack. + /// Panics on overflow. + pub(crate) fn push_stack(&mut self, value: PyObjectRef) { + self.stack_try_push(Some(PyStackRef::new_owned(value))) + .expect("stack overflow in push_stack"); + } + /// Pop a value from the evaluation stack. #[inline(always)] fn stack_pop(&mut self) -> Option { @@ -861,6 +868,9 @@ pub struct InterpreterFrame { /// Used by `frame.clear()` to reject clearing an executing frame, /// even when called from a different thread. pub(crate) owner: atomic::AtomicI8, + /// Base pointer of the datastack allocation when this frame and its + /// localsplus are bump-allocated together. Null for heap-backed frames. + pub(crate) datastack_base: *mut u8, /// Pointer to the owning `Py`, or null for stack-allocated /// frames that have not been materialized yet. /// Stored as `usize` for `PyAtomic` compatibility. @@ -944,11 +954,110 @@ impl InterpreterFrame { generator: PyAtomicBorrow::new(), previous: Radium::new(0), owner: atomic::AtomicI8::new(owner as i8), + datastack_base: core::ptr::null_mut(), materialized: Radium::new(0), cold: OnceCell::new(), } } + /// Allocate an InterpreterFrame and its LocalsPlus data together on the + /// thread data stack in a single bump allocation. + /// + /// Layout: `[InterpreterFrame | localsplus usize×capacity]` + /// + /// Returns a mutable reference whose lifetime is bounded by the data + /// stack's LIFO discipline. The caller must call + /// `release_datastack_frame()` (unsafe) when done, then + /// `vm.datastack_pop(base)`. The reference must not be used after + /// `release_datastack_frame` returns. + #[allow(clippy::too_many_arguments)] + pub(crate) fn new_on_datastack<'a>( + code: &Py, + globals: &Py, + builtins: &PyObject, + func_obj: Option<&PyObject>, + locals: FrameLocals, + closure: &[PyCellRef], + vm: &VirtualMachine, + ) -> &'a mut Self { + let nlocalsplus = code.localspluskinds.len(); + let stacksize = code.max_stackdepth as usize; + let capacity = nlocalsplus + .checked_add(stacksize) + .expect("LocalsPlus capacity overflow"); + + let total_bytes = datastack_iframe_total_bytes(nlocalsplus, stacksize); + let base = vm.datastack_push(total_bytes); + + // InterpreterFrame lives at the start of the allocation. + let iframe_ptr = base as *mut Self; + // LocalsPlus data follows the InterpreterFrame, aligned to usize. + let localsplus_data_ptr = + unsafe { base.add(datastack_iframe_localsplus_offset()) } as *mut usize; + + // Zero-initialize localsplus data. + unsafe { core::ptr::write_bytes(localsplus_data_ptr, 0, capacity) }; + + let nlocalsplus_u32 = u32::try_from(nlocalsplus).expect("nlocalsplus exceeds u32"); + let localsplus = LocalsPlus { + data: LocalsPlusData::DataStack { + ptr: localsplus_data_ptr, + capacity, + }, + nlocalsplus: nlocalsplus_u32, + stack_top: 0, + }; + + let mut iframe = Self::new( + code, + globals, + builtins, + func_obj, + localsplus, + locals, + closure, + FrameOwner::Thread, + ); + iframe.datastack_base = base; + + // Write the fully initialized InterpreterFrame into the datastack. + unsafe { + core::ptr::write(iframe_ptr, iframe); + &mut *iframe_ptr + } + } + + /// Release this datastack-allocated frame's resources and return the + /// base pointer for `vm.datastack_pop()`. + /// + /// Drops all localsplus values, runs destructors for all frame fields + /// (trace, temporary_refs, retained_back, etc.), and detaches the + /// backing store. + /// Returns `None` if this frame is not datastack-allocated. + /// + /// After this call, the InterpreterFrame at `self` is logically dead — + /// the caller must not use `self` again except to pass the returned + /// base to `vm.datastack_pop()`. + pub(crate) unsafe fn release_datastack_frame(&mut self) -> Option<*mut u8> { + let base = self.datastack_base; + if base.is_null() { + return None; + } + self.datastack_base = core::ptr::null_mut(); + // Drop all localsplus values while the backing store is still valid. + self.localsplus.drop_values(); + // Detach from the data stack so further accesses see an empty frame. + self.localsplus.data = LocalsPlusData::Heap(Box::default()); + self.localsplus.nlocalsplus = 0; + // Drop remaining frame fields (trace, temporary_refs, retained_back, + // etc.) by running destructors in place. The localsplus is already + // empty/heap-backed, so this only drops non-localsplus fields. + // SAFETY: `self` points to valid, initialized memory on the data + // stack. After this call the memory is logically dead. + unsafe { core::ptr::drop_in_place(self) }; + Some(base) + } + /// Get the last instruction index. #[inline(always)] pub fn get_lasti(&self) -> u32 { @@ -1038,6 +1147,7 @@ impl InterpreterFrame { // If we copied Thread from the source iframe, frame.clear() would // reject the frame with "cannot clear an executing frame". owner: atomic::AtomicI8::new(FrameOwner::FrameObject as i8), + datastack_base: core::ptr::null_mut(), materialized: Radium::new(0), cold: OnceCell::from(Box::new(FrameColdData { escaped: atomic::AtomicBool::new(true), @@ -1117,6 +1227,7 @@ impl InterpreterFrame { generator: PyAtomicBorrow::new(), previous: Radium::new(0), owner: atomic::AtomicI8::new(FrameOwner::FrameObject as i8), + datastack_base: core::ptr::null_mut(), materialized: Radium::new(0), cold: OnceCell::from(Box::new(FrameColdData { escaped: atomic::AtomicBool::new(true), @@ -1350,6 +1461,10 @@ unsafe impl Traverse for FrameObject { pub enum ExecutionResult { Return(PyObjectRef), Yield(PyObjectRef), + /// The bytecode loop wants to tail-call into a new frame that has + /// already been prepared on the datastack. The trampoline reads the + /// pending frame pointer from `vm.pending_tailcall_frame`. + TailCall, } /// A valid execution result, or an exception @@ -2094,6 +2209,7 @@ impl Py { func_obj, prev_line: &iframe.prev_line, monitoring_mask: 0, + tailcall_enabled: false, }; f(exec) } @@ -2156,6 +2272,7 @@ impl Py { func_obj, prev_line: &iframe.prev_line, monitoring_mask: 0, + tailcall_enabled: false, }; exec.yield_from_target().map(PyObject::to_owned) } @@ -2179,6 +2296,92 @@ impl Py { } } +/// Byte offset from the start of a datastack allocation to the LocalsPlus data, +/// accounting for alignment padding after the InterpreterFrame header. +#[inline] +fn datastack_iframe_localsplus_offset() -> usize { + let iframe_size = core::mem::size_of::(); + (iframe_size + core::mem::align_of::() - 1) & !(core::mem::align_of::() - 1) +} + +/// Total bytes needed to co-allocate an InterpreterFrame and its LocalsPlus +/// data on the thread data stack. +pub(crate) fn datastack_iframe_total_bytes(nlocalsplus: usize, stacksize: usize) -> usize { + let iframe_padded = datastack_iframe_localsplus_offset(); + let capacity = nlocalsplus + .checked_add(stacksize) + .expect("LocalsPlus capacity overflow"); + let data_bytes = capacity + .checked_mul(core::mem::size_of::()) + .expect("LocalsPlus byte size overflow"); + iframe_padded + .checked_add(data_bytes) + .expect("datastack iframe total size overflow") +} + +/// Handle an exception propagating into a suspended caller frame in the +/// trampoline. Adds a traceback entry at the caller's call site, then +/// tries the caller's exception table via `unwind_blocks`. +/// +/// Returns: +/// - `Ok(None)` — handler found, the caller's `run_iframe` can be re-entered +/// - `Ok(Some(result))` — handler returned a result (break from the run loop) +/// - `Err(exc)` — no handler, exception propagates to the next caller +pub(crate) fn trampoline_handle_exception( + iframe: &mut InterpreterFrame, + exception: &PyBaseExceptionRef, + vm: &VirtualMachine, +) -> FrameResult { + let code: &Py = unsafe { &*iframe.code }; + let globals: &Py = unsafe { &*iframe.globals }; + let builtins: &PyObject = unsafe { &*iframe.builtins }; + let func_obj: Option<&PyObject> = if iframe.func_obj.is_null() { + None + } else { + Some(unsafe { &*iframe.func_obj }) + }; + let builtins_dict = if globals.class().is(vm.ctx.types.dict_type) { + builtins + .downcast_ref_if_exact::(vm) + .map(|d| unsafe { PyExact::ref_unchecked(d) }) + } else { + None + }; + let iframe_ptr = iframe as *const InterpreterFrame; + let mut exec = ExecutingFrame { + code, + localsplus: &mut iframe.localsplus, + locals: &iframe.locals, + globals, + builtins, + builtins_dict, + lasti: &iframe.lasti, + iframe: iframe_ptr, + func_obj, + prev_line: &mut iframe.prev_line, + monitoring_mask: 0, + tailcall_enabled: false, + }; + + // lasti points past the CallPyExactArgs instruction (+ cache entries). + // The exception occurred at the previous instruction (the call site). + let idx = (exec.lasti() as usize).saturating_sub(1); + + // Add traceback entry at the call site. + if let Some((loc, _end_loc)) = exec.code.locations.get(idx) { + let next = exception.__traceback__(); + let new_traceback = PyTraceback::new(next, exec.frame_object(vm), idx as u32 * 2, loc.line); + exception.set_traceback_typed(Some(new_traceback.into_ref(&vm.ctx))); + } + + exec.unwind_blocks( + vm, + UnwindReason::Raising { + exception: exception.clone(), + }, + ) +} + /// Execute an InterpreterFrame's bytecode directly, without a FrameObject. /// /// # Safety @@ -2217,6 +2420,7 @@ pub(crate) fn run_iframe( func_obj, prev_line: &mut iframe.prev_line, monitoring_mask: 0, + tailcall_enabled: true, }; exec.run(vm) } @@ -2246,6 +2450,9 @@ pub(crate) struct ExecutingFrame<'a> { prev_line: &'a core::cell::Cell, /// Cached monitoring events mask. Reloaded at Resume instruction only, monitoring_mask: u32, + /// Whether TailCall is allowed. True when running under the trampoline + /// (`run_frame_fast`), false for FrameObject-based execution. + tailcall_enabled: bool, } #[inline] @@ -5644,7 +5851,11 @@ impl ExecutingFrame<'_> { if self.specialization_call_recursion_guard(vm) { return self.execute_call_vectorcall(nargs, vm); } - // Stage args without a per-call Vec: [self?, arg1, ..., argN] + if self.tailcall_enabled && !func.is_generator_like() { + self.tailcall_prepare_frame(nargs, self_or_null_is_some, vm); + return Ok(Some(ExecutionResult::TailCall)); + } + // Recursive path: pop args and call. let base = usize::from(self_or_null_is_some); let mut arg_buf = CallArgBuffer::new(nargs as usize + base); let args = arg_buf.slots(); @@ -5701,7 +5912,16 @@ impl ExecutingFrame<'_> { if self.specialization_call_recursion_guard(vm) { return self.execute_call_vectorcall(nargs, vm); } - // Stage args without a per-call Vec: + if self.tailcall_enabled && !func.is_generator_like() { + self.tailcall_prepare_bound_method_frame( + nargs, + bound_function, + bound_self, + vm, + ); + return Ok(Some(ExecutionResult::TailCall)); + } + // Recursive path: stage args without a per-call Vec. // [bound_self, arg1, ..., argN] let mut arg_buf = CallArgBuffer::new(nargs as usize + 1); let args = arg_buf.slots(); @@ -10400,6 +10620,129 @@ impl ExecutingFrame<'_> { >= vm.recursion_limit.get() } + /// Prepare a callee frame on the datastack for a TailCall. + /// Pops args, self_or_null, and callable from the caller's stack, + /// builds the callee InterpreterFrame, and stores its pointer in + /// `vm.pending_tailcall_frame`. + /// + /// The callable must be at stack position `nargs + 1` (already validated). + fn tailcall_prepare_frame( + &mut self, + nargs: u32, + self_or_null_is_some: bool, + vm: &VirtualMachine, + ) { + let base = usize::from(self_or_null_is_some); + let effective_nargs = nargs as usize + base; + + // Peek at the callable (still on the stack) to build the callee + // frame. The callable stays on the caller's stack until we're done + // constructing the callee. + let callable = self.nth_value(nargs + 1); + let func = callable.downcast_ref_if_exact::(vm).unwrap(); + + let code: &Py = &func.code; + + let locals = if code.flags.contains(bytecode::CodeFlags::NEWLOCALS) { + FrameLocals::lazy() + } else { + FrameLocals::with_locals(crate::function::ArgMapping::from_dict_exact( + func.globals.clone(), + )) + }; + + let callee_iframe = InterpreterFrame::new_on_datastack( + code, + &func.globals, + &func.builtins, + Some(func.as_object()), + locals, + func.closure.as_ref().map_or(&[], |c| c.as_slice()), + vm, + ); + + // Move args directly from the caller's stack into callee fastlocals, + // avoiding an intermediate buffer. + { + let fastlocals = callee_iframe.localsplus.fastlocals_mut(); + for (dst, arg) in fastlocals[base..effective_nargs] + .iter_mut() + .zip(self.pop_multiple(nargs as usize)) + { + *dst = Some(arg); + } + let self_or_null = self.pop_value_opt(); + debug_assert_eq!(self_or_null.is_some(), self_or_null_is_some); + if self_or_null.is_some() { + fastlocals[0] = self_or_null; + } + } + + // Pop the callable and transfer ownership to the trampoline via + // the VM side channel, avoiding a per-frame mutex lock on + // temporary_refs. + let callable = self.pop_value(); + unsafe { &mut *vm.pending_tailcall_refs.get() }.push(callable); + + vm.set_pending_tailcall(callee_iframe); + } + + /// Prepare a callee frame for a bound method TailCall. + /// Pops args, self_or_null (null), and callable from the caller's stack, + /// builds the callee InterpreterFrame with bound_self prepended, and + /// stores its pointer in `vm.pending_tailcall_frame`. + fn tailcall_prepare_bound_method_frame( + &mut self, + nargs: u32, + bound_function: PyObjectRef, + bound_self: PyObjectRef, + vm: &VirtualMachine, + ) { + let effective_nargs = nargs as usize + 1; // +1 for bound_self + + let func = bound_function + .downcast_ref_if_exact::(vm) + .unwrap(); + let code: &Py = &func.code; + + let locals = if code.flags.contains(bytecode::CodeFlags::NEWLOCALS) { + FrameLocals::lazy() + } else { + FrameLocals::with_locals(crate::function::ArgMapping::from_dict_exact( + func.globals.clone(), + )) + }; + + let callee_iframe = InterpreterFrame::new_on_datastack( + code, + &func.globals, + &func.builtins, + Some(func.as_object()), + locals, + func.closure.as_ref().map_or(&[], |c| c.as_slice()), + vm, + ); + + // Move args directly from the caller's stack into callee fastlocals. + let fastlocals = callee_iframe.localsplus.fastlocals_mut(); + for (dst, arg) in fastlocals[1..effective_nargs] + .iter_mut() + .zip(self.pop_multiple(nargs as usize)) + { + *dst = Some(arg); + } + self.pop_value_opt(); // null (self_or_null) + let callable = self.pop_value(); // callable (bound method) + fastlocals[0] = Some(bound_self); + + // Transfer ownership to the trampoline via the VM side channel. + let refs = unsafe { &mut *vm.pending_tailcall_refs.get() }; + refs.push(bound_function); + refs.push(callable); + + vm.set_pending_tailcall(callee_iframe); + } + #[inline] fn for_iter_has_end_for_shape(&self, instr_idx: usize, jump_delta: u32) -> bool { let target_idx = instr_idx diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index a48ce56c14a..e0a086c10db 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -106,6 +106,15 @@ pub struct VirtualMachine { pub asyncio_running_task: RefCell>, pub(crate) callable_cache: CallableCache, pub(crate) audit_hooks: RefCell>, + /// Side channel for TailCall: the bytecode loop stores the new frame + /// pointer here before returning `ExecutionResult::TailCall`. + /// Access only via `set_pending_tailcall` / `take_pending_tailcall`. + pending_tailcall_frame: Cell>, + /// Owned references that keep callee raw pointers valid during TailCall. + /// Set by `tailcall_prepare_frame`, drained by the trampoline into + /// its local `owned_refs` Vec. Uses UnsafeCell because the VM is + /// per-thread and this field is only accessed on the owning thread. + pub(crate) pending_tailcall_refs: core::cell::UnsafeCell>, } /// Non-owning frame pointer for the non-unix threading frames stack. @@ -777,6 +786,60 @@ pub fn process_hash_secret_seed() -> u32 { *SEED.get_or_init(|| u32::from_ne_bytes(rustpython_common::rand::os_random())) } +/// A `NonNull` wrapper that implements `Send + Sync`. +/// +/// # Safety contract +/// +/// This type bypasses Rust's `Send`/`Sync` bounds on `NonNull`. It is +/// sound **only** when the pointer is exclusively accessed by one thread +/// at a time. In this codebase, that invariant is upheld because +/// `VirtualMachine` is per-thread. +/// +/// **Do not use this type outside `pending_tailcall_frame`.** It exists +/// solely to let a `Cell>` field on the per-thread +/// VM satisfy `Send + Sync`. If you need a `Send`-able pointer +/// elsewhere, justify and document the safety invariant at that site. +#[repr(transparent)] +struct PendingFrame(core::ptr::NonNull); + +impl Copy for PendingFrame {} +impl Clone for PendingFrame { + fn clone(&self) -> Self { + *self + } +} + +// SAFETY: VirtualMachine is per-thread; the pointer is only ever +// accessed on the thread that wrote it. The pointed-to InterpreterFrame +// lives on that thread's datastack and is valid from set to take. +unsafe impl Send for PendingFrame {} +unsafe impl Sync for PendingFrame {} + +/// Saved state from `enter_iframe`, needed by `exit_iframe` to restore +/// the previous frame chain and exception state. +pub(crate) struct IframeEntryState { + pub(crate) iframe_ptr: *const crate::frame::InterpreterFrame, + pub(crate) old_chain: *const crate::frame::InterpreterFrame, + pub(crate) saved_exc: Option, + pub(crate) save_exc: bool, +} + +/// Caller frame suspended by a TailCall in the trampoline. +struct SuspendedFrame { + iframe: *mut crate::frame::InterpreterFrame, + entry_state: IframeEntryState, + /// Owned references that keep callee's raw pointers (code, globals, + /// builtins borrowed from PyFunction) valid. Drained from + /// `vm.pending_tailcall_refs` when the callee's TailCall is consumed. + /// Dropped when this SuspendedFrame is popped (after callee returns/errors). + owned_refs: Vec, + /// True for the initial frame passed into the trampoline by the caller. + /// The caller owns the datastack allocation for the entry frame, so the + /// trampoline must NOT release it — only callee-allocated frames are + /// released here. + is_entry: bool, +} + impl VirtualMachine { fn init_callable_cache(&mut self) -> PyResult<()> { self.callable_cache.len = Some(self.builtins.get_attr("len", self)?); @@ -891,6 +954,8 @@ impl VirtualMachine { asyncio_running_task: RefCell::new(None), callable_cache: CallableCache::default(), audit_hooks: RefCell::new(vec![]), + pending_tailcall_frame: Cell::new(None), + pending_tailcall_refs: core::cell::UnsafeCell::new(Vec::with_capacity(2)), }; if vm.state.hash_secret.hash_str("") @@ -1331,13 +1396,294 @@ impl VirtualMachine { } } + /// Store a callee frame pointer for the trampoline to pick up after + /// `TailCall` is returned. The pointed-to InterpreterFrame must live + /// on the current thread's datastack and remain valid until the + /// trampoline calls `take_pending_tailcall`. + #[inline(always)] + pub(crate) fn set_pending_tailcall(&self, iframe: &mut crate::frame::InterpreterFrame) { + self.pending_tailcall_frame + .set(Some(PendingFrame(core::ptr::NonNull::from(iframe)))); + } + + /// Take the pending tailcall frame pointer, resetting the side channel. + #[inline(always)] + fn take_pending_tailcall(&self) -> *mut crate::frame::InterpreterFrame { + self.pending_tailcall_frame + .take() + .expect("TailCall without pending frame") + .0 + .as_ptr() + } + #[inline(always)] /// Run a stack-allocated InterpreterFrame without heap allocation. - /// This is the fast path for regular (non-generator) function calls. + /// Uses a trampoline loop to flatten Python-to-Python calls: when the + /// bytecode loop returns `TailCall`, the trampoline swaps to the new + /// frame without adding a Rust stack frame. pub fn run_frame_fast(&self, iframe: &mut crate::frame::InterpreterFrame) -> PyResult { - match self.with_iframe(iframe, |iframe| crate::frame::run_iframe(iframe, self))? { - ExecutionResult::Return(value) => Ok(value), - _ => panic!("Got unexpected result from function"), + use crate::frame::ExecutionResult; + + let entry_state = self.enter_iframe(iframe)?; + let result = crate::frame::run_iframe(iframe, self); + + match result { + Ok(ExecutionResult::Return(value)) => { + self.exit_iframe(entry_state); + Ok(value) + } + Ok(ExecutionResult::TailCall) => self.run_frame_fast_trampoline(iframe, entry_state), + Ok(ExecutionResult::Yield(_)) => panic!("Yield in non-generator frame"), + Err(exc) => { + self.exit_iframe(entry_state); + Err(exc) + } + } + } + + /// Cold path: at least one TailCall was issued. Run the trampoline. + /// All frame dispatch happens in this single loop — no mutual recursion + /// between helper functions, so C stack depth is bounded. + #[cold] + #[inline(never)] + fn run_frame_fast_trampoline( + &self, + iframe: &mut crate::frame::InterpreterFrame, + entry_state: IframeEntryState, + ) -> PyResult { + use crate::frame::ExecutionResult; + + let mut frame_stack: Vec = Vec::with_capacity(8); + + // What we need to do next. + enum Action { + /// Enter and run a new callee frame (pointer from pending_tailcall_frame). + EnterCallee(*mut crate::frame::InterpreterFrame), + /// Push a return value onto the next caller and re-enter it. + ReturnValue(PyObjectRef), + /// Propagate an exception through suspended callers. + Unwind(PyBaseExceptionRef), + } + + let initial_ptr = self.take_pending_tailcall(); + // Drain the refs that keep the initial callee's raw pointers alive. + let initial_refs = unsafe { &mut *self.pending_tailcall_refs.get() } + .drain(..) + .collect(); + frame_stack.push(SuspendedFrame { + iframe: iframe as *mut crate::frame::InterpreterFrame, + entry_state, + owned_refs: initial_refs, + is_entry: true, + }); + let mut action = Action::EnterCallee(initial_ptr); + + loop { + match action { + Action::EnterCallee(callee_ptr) => { + let callee = unsafe { &mut *callee_ptr }; + let callee_entry = match self.enter_iframe_unchecked(callee) { + Ok(state) => state, + Err(exc) => { + unsafe { + if let Some(base) = callee.release_datastack_frame() { + self.datastack_pop(base); + } + } + action = Action::Unwind(exc); + continue; + } + }; + + let result = crate::frame::run_iframe(callee, self); + match result { + Ok(ExecutionResult::TailCall) => { + let refs = unsafe { &mut *self.pending_tailcall_refs.get() } + .drain(..) + .collect(); + frame_stack.push(SuspendedFrame { + iframe: callee_ptr, + entry_state: callee_entry, + owned_refs: refs, + is_entry: false, + }); + action = Action::EnterCallee(self.take_pending_tailcall()); + } + Ok(ExecutionResult::Return(value)) => { + self.exit_iframe(callee_entry); + unsafe { + if let Some(base) = callee.release_datastack_frame() { + self.datastack_pop(base); + } + } + action = Action::ReturnValue(value); + } + Ok(ExecutionResult::Yield(_)) => panic!("Yield in non-generator frame"), + Err(exc) => { + self.exit_iframe(callee_entry); + unsafe { + if let Some(base) = callee.release_datastack_frame() { + self.datastack_pop(base); + } + } + action = Action::Unwind(exc); + } + } + } + + Action::ReturnValue(value) => { + let Some(caller) = frame_stack.pop() else { + // All frames consumed — this is the final return. + return Ok(value); + }; + let SuspendedFrame { + iframe: caller_iframe_ptr, + entry_state: caller_entry, + owned_refs: _caller_refs, + is_entry: caller_is_entry, + } = caller; + let caller_iframe = unsafe { &mut *caller_iframe_ptr }; + caller_iframe.localsplus.push_stack(value); + + let result = crate::frame::run_iframe(caller_iframe, self); + match result { + Ok(ExecutionResult::TailCall) => { + let refs = unsafe { &mut *self.pending_tailcall_refs.get() } + .drain(..) + .collect(); + drop(_caller_refs); + frame_stack.push(SuspendedFrame { + iframe: caller_iframe_ptr, + entry_state: caller_entry, + owned_refs: refs, + is_entry: caller_is_entry, + }); + action = Action::EnterCallee(self.take_pending_tailcall()); + } + Ok(ExecutionResult::Return(value)) => { + drop(_caller_refs); + self.exit_iframe(caller_entry); + if !caller_is_entry { + unsafe { + if let Some(base) = caller_iframe.release_datastack_frame() { + self.datastack_pop(base); + } + } + } + action = Action::ReturnValue(value); + } + Ok(ExecutionResult::Yield(_)) => panic!("Yield in non-generator frame"), + Err(exc) => { + drop(_caller_refs); + self.exit_iframe(caller_entry); + if !caller_is_entry { + unsafe { + if let Some(base) = caller_iframe.release_datastack_frame() { + self.datastack_pop(base); + } + } + } + action = Action::Unwind(exc); + } + } + } + + Action::Unwind(exc) => { + let Some(caller) = frame_stack.pop() else { + return Err(exc); + }; + let SuspendedFrame { + iframe: caller_iframe_ptr, + entry_state: caller_entry, + owned_refs: _caller_refs, + is_entry: caller_is_entry, + } = caller; + let caller_iframe = unsafe { &mut *caller_iframe_ptr }; + + let handled = + crate::frame::trampoline_handle_exception(caller_iframe, &exc, self); + + match handled { + Ok(None) => { + // Handler found — resume the caller's dispatch loop. + let result = crate::frame::run_iframe(caller_iframe, self); + match result { + Ok(ExecutionResult::TailCall) => { + let refs = unsafe { &mut *self.pending_tailcall_refs.get() } + .drain(..) + .collect(); + drop(_caller_refs); + frame_stack.push(SuspendedFrame { + iframe: caller_iframe_ptr, + entry_state: caller_entry, + owned_refs: refs, + is_entry: caller_is_entry, + }); + action = Action::EnterCallee(self.take_pending_tailcall()); + } + Ok(ExecutionResult::Return(value)) => { + drop(_caller_refs); + self.exit_iframe(caller_entry); + if !caller_is_entry { + unsafe { + if let Some(base) = + caller_iframe.release_datastack_frame() + { + self.datastack_pop(base); + } + } + } + action = Action::ReturnValue(value); + } + Ok(ExecutionResult::Yield(_)) => { + panic!("Yield in non-generator frame") + } + Err(new_exc) => { + drop(_caller_refs); + self.exit_iframe(caller_entry); + if !caller_is_entry { + unsafe { + if let Some(base) = + caller_iframe.release_datastack_frame() + { + self.datastack_pop(base); + } + } + } + action = Action::Unwind(new_exc); + } + } + } + Ok(Some(ExecutionResult::Return(value))) => { + drop(_caller_refs); + self.exit_iframe(caller_entry); + if !caller_is_entry { + unsafe { + if let Some(base) = caller_iframe.release_datastack_frame() { + self.datastack_pop(base); + } + } + } + action = Action::ReturnValue(value); + } + Ok(Some(_)) => { + panic!("Unexpected execution result in trampoline unwind") + } + Err(new_exc) => { + drop(_caller_refs); + self.exit_iframe(caller_entry); + if !caller_is_entry { + unsafe { + if let Some(base) = caller_iframe.release_datastack_frame() { + self.datastack_pop(base); + } + } + } + action = Action::Unwind(new_exc); + } + } + } + } } } @@ -1805,15 +2151,14 @@ impl VirtualMachine { result } - /// Execute a stack-allocated InterpreterFrame without heap-allocating - /// a FrameObject. This is the fast path for regular function calls. - /// The frame is pushed onto the chain as a `*const InterpreterFrame`. - #[inline(always)] - pub fn with_iframe( + /// Push `iframe` onto the frame chain: recursion/C-stack check, TLS + /// link, exception save. Returns the saved state needed by + /// `exit_iframe`. + #[inline] + pub(crate) fn enter_iframe( &self, iframe: &mut crate::frame::InterpreterFrame, - f: impl FnOnce(&mut crate::frame::InterpreterFrame) -> PyResult, - ) -> PyResult { + ) -> PyResult { self.check_recursive_call("")?; let depth = self.recursion_depth.get(); @@ -1821,6 +2166,23 @@ impl VirtualMachine { return Err(self.new_recursion_error(String::new())); } + self.enter_iframe_unchecked(iframe) + } + + /// Like `enter_iframe` but skips the Python recursion depth check + /// (already verified by `specialization_call_recursion_guard`). + /// Still checks C-stack overflow since each `run_iframe` call + /// consumes Rust stack space. + #[inline(always)] + pub(crate) fn enter_iframe_unchecked( + &self, + iframe: &mut crate::frame::InterpreterFrame, + ) -> PyResult { + let depth = self.recursion_depth.get(); + if depth & 7 == 0 && self.check_c_stack_overflow() { + return Err(self.new_recursion_error(String::new())); + } + self.recursion_depth.update(|d| d + 1); let iframe_ptr = iframe as *const crate::frame::InterpreterFrame; @@ -1839,29 +2201,35 @@ impl VirtualMachine { None }; - let result = f(iframe); + Ok(IframeEntryState { + iframe_ptr, + old_chain, + saved_exc, + save_exc, + }) + } + + /// Pop `iframe` from the frame chain: sync materialized state, restore + /// exception, TLS unlink, GC tracking. + pub(crate) fn exit_iframe(&self, state: IframeEntryState) { + let IframeEntryState { + iframe_ptr, + old_chain, + saved_exc, + save_exc, + } = state; // If this iframe was materialized, capture f_back so that code - // holding a reference to the FrameObject (e.g. sys._getframe() - // return value, traceback frames) can walk the chain after return. - // - // Read materialized through the raw TLS pointer instead of the - // &mut iframe reference. During f(iframe), bytecode can - // materialize the frame via the TLS chain (a raw pointer alias); - // the &mut borrow lets LLVM assume no aliased writes, which can - // cause the store to be invisible through `iframe.materialized`. + // holding a reference to the FrameObject can walk the chain after + // return. Read materialized through read_volatile to bypass + // LLVM's noalias on the &mut iframe borrow. { - // Use read_volatile through the original raw pointer to bypass - // LLVM's noalias assumptions on the &mut iframe borrow. let mat_ptr = unsafe { let field_ptr = core::ptr::addr_of!((*iframe_ptr).materialized); core::ptr::read_volatile(field_ptr as *const usize) }; if mat_ptr != 0 { let fo = unsafe { &*(mat_ptr as *const crate::Py) }; - // Sync localsplus, prev_line, lasti from the live iframe to - // the materialized FrameObject so f_locals, f_lineno, f_lasti - // reflect the final state after execution. unsafe { let live_iframe = &*iframe_ptr; fo.iframe_mut() @@ -1879,15 +2247,9 @@ impl VirtualMachine { } if !old_chain.is_null() { let prev_iframe = unsafe { &*old_chain }; - // Use materialize_chain to avoid cloning localsplus, which - // would create extra refcounts on local variables. The - // lightweight frame has empty localsplus; live values are - // read through find_live_source_iframe when needed. let back_fo = prev_iframe.materialize_chain(self); *fo.iframe().cold().retained_back.lock() = Some(back_fo); } - // Set owner to FrameObject since this frame is no longer - // executing on a thread. fo.iframe().owner.store( crate::frame::FrameOwner::FrameObject as i8, core::sync::atomic::Ordering::Release, @@ -1898,15 +2260,22 @@ impl VirtualMachine { if save_exc { self.restore_exception(saved_exc); } - // Restore the frame chain BEFORE clearing temporary_refs, so - // top_frame no longer points at the materialized FrameObject - // when its last strong reference is released. + // Clear previous before popping — it may point to a stack-allocated + // iframe that will be freed when the caller's with_iframe exits. + { + #[allow(unused_imports)] + use rustpython_common::atomic::Radium; + unsafe { + (*iframe_ptr) + .previous + .store(0, core::sync::atomic::Ordering::Relaxed); + } + } let _ = crate::vm::thread::set_current_frame(old_chain); self.recursion_depth.update(|d| d - 1); - // Now that the frame is off the chain, track the materialized - // FrameObject in the GC and release temporary_refs so cycle - // collection can detect and reclaim reference cycles. + // Track the materialized FrameObject in GC and release + // temporary_refs after the frame is off the chain. { let mat_ptr = unsafe { let field_ptr = core::ptr::addr_of!((*iframe_ptr).materialized); @@ -1922,7 +2291,19 @@ impl VirtualMachine { } } } + } + pub fn with_iframe( + &self, + iframe: &mut crate::frame::InterpreterFrame, + f: impl FnOnce(&mut crate::frame::InterpreterFrame) -> PyResult, + ) -> PyResult { + let state = self.enter_iframe(iframe)?; + // Ensure exit_iframe runs even if f(iframe) panics. + let guard = scopeguard::guard(state, |s| self.exit_iframe(s)); + let result = f(iframe); + let state = scopeguard::ScopeGuard::into_inner(guard); + self.exit_iframe(state); result } diff --git a/crates/vm/src/vm/thread.rs b/crates/vm/src/vm/thread.rs index 1520b2cd883..1bab539a0a6 100644 --- a/crates/vm/src/vm/thread.rs +++ b/crates/vm/src/vm/thread.rs @@ -1022,6 +1022,8 @@ impl VirtualMachine { asyncio_running_task: RefCell::new(None), callable_cache: self.callable_cache.clone(), audit_hooks: RefCell::new(vec![]), + pending_tailcall_frame: Cell::new(None), + pending_tailcall_refs: core::cell::UnsafeCell::new(Vec::with_capacity(2)), }; ThreadedVirtualMachine { vm } } From daafafa137a292ad0fdfd439de4c9ef40907ac35 Mon Sep 17 00:00:00 2001 From: fanninpm <27117322+fanninpm@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:22:30 -0400 Subject: [PATCH 250/351] Update Python version to 3.14.7 (#8456) --- .github/workflows/update-doc-db.yml | 2 +- .github/workflows/upgrade-pylib.md | 2 +- .python-version | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/update-doc-db.yml b/.github/workflows/update-doc-db.yml index e9fa285ae0a..c42235c86df 100644 --- a/.github/workflows/update-doc-db.yml +++ b/.github/workflows/update-doc-db.yml @@ -8,7 +8,7 @@ on: python-version: description: Target python version to generate doc db for type: string - default: "3.14.3" + default: "3.14.7" base-ref: description: Base branch to create the update branch from type: string diff --git a/.github/workflows/upgrade-pylib.md b/.github/workflows/upgrade-pylib.md index ac71f3d7244..cd05eb77734 100644 --- a/.github/workflows/upgrade-pylib.md +++ b/.github/workflows/upgrade-pylib.md @@ -52,7 +52,7 @@ cache: - cpython-lib- env: - PYTHON_VERSION: "v3.14.6" + PYTHON_VERSION: "v3.14.7" ISSUE_ID: "6839" --- diff --git a/.python-version b/.python-version index 3f0a10fda70..a128d5c0d97 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.14.6 +3.14.7 From 9bab06cb7777c13c737268b1e66add95e75b3d5a Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:23:05 -0400 Subject: [PATCH 251/351] Fix a few WASI lints (#8457) I left one lint (a clippy deny) unfixed because it's a lot more involved, so I'll work on it in a different patch. --- crates/vm/src/frame.rs | 1 + crates/vm/src/gc_state.rs | 12 ++++++------ crates/vm/src/stdlib/_signal.rs | 7 +++++++ crates/vm/src/stdlib/posix_compat.rs | 2 +- crates/vm/src/stdlib/time.rs | 1 + 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index e1836ad02f1..e7141525ff0 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -3046,6 +3046,7 @@ impl ExecutingFrame<'_> { } } + #[cfg_attr(not(feature = "threading"), allow(clippy::collapsible_if))] if vm.eval_breaker_tripped() { if let Err(exception) = vm.check_signals() { #[cold] diff --git a/crates/vm/src/gc_state.rs b/crates/vm/src/gc_state.rs index 5843dfe24c7..9744d4ae992 100644 --- a/crates/vm/src/gc_state.rs +++ b/crates/vm/src/gc_state.rs @@ -11,8 +11,8 @@ use core::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; use std::collections::HashSet; fn elapsed_secs( - #[cfg(target_arch = "wasm32")] _start: &(), - #[cfg(not(target_arch = "wasm32"))] start: &std::time::Instant, + #[cfg(target_arch = "wasm32")] _start: (), + #[cfg(not(target_arch = "wasm32"))] start: std::time::Instant, ) -> f64 { cfg_select! { target_arch = "wasm32" => 0.0, @@ -528,7 +528,7 @@ impl GcState { self.generations[i].count.store(0, Ordering::SeqCst); } - let duration = elapsed_secs(&start_time); + let duration = elapsed_secs(start_time); self.generations[generation].update_stats(0, 0, 0, duration); return CollectResult { @@ -704,7 +704,7 @@ impl GcState { self.generations[i].count.store(0, Ordering::SeqCst); } - let duration = elapsed_secs(&start_time); + let duration = elapsed_secs(start_time); self.generations[generation].update_stats(0, 0, candidates, duration); return CollectResult { @@ -727,7 +727,7 @@ impl GcState { self.generations[i].count.store(0, Ordering::SeqCst); } - let duration = elapsed_secs(&start_time); + let duration = elapsed_secs(start_time); self.generations[generation].update_stats(0, 0, candidates, duration); return CollectResult { @@ -953,7 +953,7 @@ impl GcState { self.generations[i].count.store(0, Ordering::SeqCst); } - let duration = elapsed_secs(&start_time); + let duration = elapsed_secs(start_time); self.generations[generation].update_stats(collected, 0, candidates, duration); diff --git a/crates/vm/src/stdlib/_signal.rs b/crates/vm/src/stdlib/_signal.rs index d5083962bf2..5879f877676 100644 --- a/crates/vm/src/stdlib/_signal.rs +++ b/crates/vm/src/stdlib/_signal.rs @@ -429,6 +429,13 @@ pub(crate) mod _signal { } #[pyfunction] + #[cfg_attr( + not(any(unix, windows)), + expect( + clippy::unnecessary_wraps, + reason = "WASI does not support signals yet" + ) + )] fn valid_signals(vm: &VirtualMachine) -> PyResult { use crate::PyPayload; use crate::builtins::PySet; diff --git a/crates/vm/src/stdlib/posix_compat.rs b/crates/vm/src/stdlib/posix_compat.rs index c50134a33a4..9afea821c9b 100644 --- a/crates/vm/src/stdlib/posix_compat.rs +++ b/crates/vm/src/stdlib/posix_compat.rs @@ -60,7 +60,7 @@ pub(crate) mod module { #[allow(dead_code)] fn os_unimpl(func: &str, vm: &VirtualMachine) -> PyResult { - Err(vm.new_os_error(format!("{} is not supported on this platform", func))) + Err(vm.new_os_error(format!("{func} is not supported on this platform"))) } pub(crate) fn support_funcs() -> Vec { diff --git a/crates/vm/src/stdlib/time.rs b/crates/vm/src/stdlib/time.rs index 54ccd46d74e..3d777c24b89 100644 --- a/crates/vm/src/stdlib/time.rs +++ b/crates/vm/src/stdlib/time.rs @@ -609,6 +609,7 @@ mod decl { } #[pyfunction] + #[cfg_attr(not(any(unix, windows)), expect(clippy::unnecessary_wraps,))] fn strftime(format: PyStrRef, t: OptionalArg, vm: &VirtualMachine) -> PyResult { #[cfg(any(unix, windows))] { From bb75624a590003960cddeb10b769f4eeceb1b607 Mon Sep 17 00:00:00 2001 From: OkJa <151524504+name-of-okja@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:25:21 +0900 Subject: [PATCH 252/351] Implement cell comparison and repr (#8458) The cell type filled neither the richcompare nor the repr slot, so object's address-based defaults showed through: cell(1) == cell(1) was False, ordering raised TypeError, and repr rendered rather than . Compare cells by contents, with empty cells ordering before everything else, and render CPython's repr for both the filled and empty cases. The comparison fills the richcompare slot directly rather than going through Comparable, whose cmp() can only answer with a bool. CPython returns whatever PyObject_RichCompare produced, so a contained __eq__ that yields a non-bool must pass through untouched; coercing it would also call __bool__ and surface exceptions CPython never raises. The empty-cell branch still answers with a bool, so both arms are needed and the slot returns Either. The repr truncates the contained type name the way "%.80s" does: at most 80 bytes, dropping a character the cut would leave incomplete. Mark the type unhashable. Content-based equality combined with the inherited identity hash would break the hash/eq contract, and hashing the contents is not possible either because cell_contents is writable. CPython gets this implicitly, because defining tp_richcompare suppresses tp_hash inheritance. Reference: CPython Objects/cellobject.c, cell_richcompare and cell_repr. Assisted-by: Claude Code:claude-opus-5 --- Lib/test/test_funcattrs.py | 1 - Lib/test/test_reprlib.py | 1 - crates/vm/src/builtins/function.rs | 48 ++++++++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_funcattrs.py b/Lib/test/test_funcattrs.py index ff696c5c153..bb9c88efec6 100644 --- a/Lib/test/test_funcattrs.py +++ b/Lib/test/test_funcattrs.py @@ -432,7 +432,6 @@ def f(): class CellTest(unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON def test_comparison(self): # These tests are here simply to exercise the comparison code; # their presence should not be interpreted as providing any diff --git a/Lib/test/test_reprlib.py b/Lib/test/test_reprlib.py index db3d87bd17a..22a55b57c07 100644 --- a/Lib/test/test_reprlib.py +++ b/Lib/test/test_reprlib.py @@ -237,7 +237,6 @@ def test_nesting(self): eq(r([[[[[[{}]]]]]]), "[[[[[[{}]]]]]]") eq(r([[[[[[[{}]]]]]]]), "[[[[[[[...]]]]]]]") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_cell(self): def get_cell(): x = 42 diff --git a/crates/vm/src/builtins/function.rs b/crates/vm/src/builtins/function.rs index d1fa5222393..90315bcb194 100644 --- a/crates/vm/src/builtins/function.rs +++ b/crates/vm/src/builtins/function.rs @@ -15,7 +15,7 @@ use crate::{ class::PyClassImpl, common::wtf8::{Wtf8Buf, wtf8_concat}, frame::{FrameObject, FrameObjectRef}, - function::{FuncArgs, OptionalArg, PyComparisonValue, PySetterValue}, + function::{Either, FuncArgs, OptionalArg, PyComparisonValue, PySetterValue}, scope::Scope, types::{ Callable, Comparable, Constructor, GetAttr, GetDescriptor, Hashable, PyComparisonOp, @@ -1507,7 +1507,7 @@ impl Representable for PyBoundMethod { } } -#[pyclass(module = false, name = "cell", traverse)] +#[pyclass(module = false, name = "cell", unhashable = true, traverse)] #[derive(Debug, Default)] pub(crate) struct PyCell { contents: PyMutex>, @@ -1530,8 +1530,26 @@ impl Constructor for PyCell { } } -#[pyclass(with(Constructor))] +#[pyclass(with(Constructor, Representable))] impl PyCell { + #[pyslot] + fn slot_richcompare( + zelf: &PyObject, + other: &PyObject, + op: PyComparisonOp, + vm: &VirtualMachine, + ) -> PyResult> { + let (Some(zelf), Some(other)) = (zelf.downcast_ref::(), other.downcast_ref::()) + else { + return Ok(Either::B(PyComparisonValue::NotImplemented)); + }; + // compare cells by contents; empty cells come before anything else + match (zelf.get(), other.get()) { + (Some(a), Some(b)) => a.rich_compare(b, op, vm).map(Either::A), + (a, b) => Ok(Either::B(op.eval_ord(b.is_none().cmp(&a.is_none())).into())), + } + } + pub(crate) const fn new(contents: Option) -> Self { Self { contents: PyMutex::new(contents), @@ -1561,6 +1579,30 @@ impl PyCell { } } +impl Representable for PyCell { + #[inline] + fn repr_str(zelf: &Py, _vm: &VirtualMachine) -> PyResult { + let id = zelf.get_id(); + Ok(match zelf.get() { + Some(value) => { + let type_name = value.class().slot_name(); + // CPython renders the type name with "%.80s", which reads at + // most 80 bytes and drops a character left incomplete by the cut. + let mut end = type_name.len().min(80); + while !type_name.is_char_boundary(end) { + end -= 1; + } + format!( + "", + &type_name[..end], + value.get_id() + ) + } + None => format!(""), + }) + } +} + /// Vectorcall implementation for PyFunction (PEP 590). /// Takes owned args to avoid cloning when filling fastlocals. pub(crate) fn vectorcall_function( From 4530ecd49c30020eac2285e32ab538b6cd6c1bf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EB=8F=99=EC=95=88?= Date: Sat, 8 Aug 2026 22:30:05 +0900 Subject: [PATCH 253/351] Specialize list.sort() comparisons for homogeneous lists (#8464) * Use type-specialized comparators for homogeneous list sorts Scan the sort keys once before sorting; when every element is exactly str, int, or float, compare wtf8 bytes / BigInt / f64 directly instead of going through rich_compare_bool dispatch, mirroring CPython's pre-sort check in listsort.c (unsafe_latin_compare and friends). Subclasses and mixed-type lists keep the generic __lt__ path. * Add edge-case tests for specialized list sorts * Cache the richcompare slot for homogeneous list sorts * Specialize tuple sorts on their first elements * Apply ruff formatting to the new list sort tests * Deduplicate the reverse swap across sort dispatch arms --- crates/vm/src/builtins/list.rs | 228 ++++++++++++++++++++++++--- extra_tests/snippets/builtin_list.py | 27 ++++ 2 files changed, 237 insertions(+), 18 deletions(-) diff --git a/crates/vm/src/builtins/list.rs b/crates/vm/src/builtins/list.rs index 2b83a5c1007..c2059e28806 100644 --- a/crates/vm/src/builtins/list.rs +++ b/crates/vm/src/builtins/list.rs @@ -9,10 +9,10 @@ use crate::common::lock::{ use crate::object::{Traverse, TraverseFn}; use crate::{ AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, - builtins::PyStr, + builtins::{PyFloat, PyInt, PyStr, PyTuple}, class::PyClassImpl, convert::ToPyObject, - function::{ArgSize, FuncArgs, OptionalArg, PyComparisonValue}, + function::{ArgSize, Either, FuncArgs, OptionalArg, PyComparisonValue}, iter::PyExactSizeIterator, protocol::{PyIterReturn, PyMappingMethods, PySequenceMethods}, recursion::ReprGuard, @@ -21,7 +21,7 @@ use crate::{ sorting::timsort, types::{ AsMapping, AsSequence, Comparable, Constructor, Initializer, IterNext, Iterable, - PyComparisonOp, Representable, SelfIter, + PyComparisonOp, Representable, RichCompareFunc, SelfIter, }, vm::VirtualMachine, }; @@ -638,34 +638,226 @@ impl Representable for PyList { } } +enum Elem { + Str, + Int, + Float, + Object(RichCompareFunc), + Generic, +} + +enum PreSort { + Str, + Int, + Float, + Object(RichCompareFunc), + Tuple(Elem), + Generic, +} + +impl From for PreSort { + fn from(e: Elem) -> Self { + match e { + Elem::Str => Self::Str, + Elem::Int => Self::Int, + Elem::Float => Self::Float, + Elem::Object(f) => Self::Object(f), + Elem::Generic => Self::Generic, + } + } +} + +fn classify(class: &Py, vm: &VirtualMachine) -> Elem { + if class.is(vm.ctx.types.str_type) { + Elem::Str + } else if class.is(vm.ctx.types.int_type) { + Elem::Int + } else if class.is(vm.ctx.types.float_type) { + Elem::Float + } else if let Some(f) = class.slots.richcompare.load() { + Elem::Object(f) + } else { + Elem::Generic + } +} + +fn pre_sort_check<'a>( + mut keys: impl Iterator, + vm: &VirtualMachine, +) -> PreSort { + let Some(first) = keys.next() else { + return PreSort::Generic; + }; + + if let Some(t) = first + .downcast_ref_if_exact::(vm) + .filter(|t| !t.as_slice().is_empty()) + { + pre_sort_check_tuples(&t.as_slice()[0], keys, vm) + } else { + let class = first.class(); + if keys.all(|k| k.class().is(class)) { + classify(class, vm).into() + } else { + PreSort::Generic + } + } +} + +fn pre_sort_check_tuples<'a>( + first_elem: &PyObjectRef, + keys: impl Iterator, + vm: &VirtualMachine, +) -> PreSort { + let class = first_elem.class(); + let mut all_same_type = true; + + for k in keys { + let Some(t) = k + .downcast_ref_if_exact::(vm) + .filter(|t| !t.as_slice().is_empty()) + else { + return PreSort::Generic; + }; + if all_same_type && !t.as_slice()[0].class().is(class) { + all_same_type = false; + } + } + + let elem = if !all_same_type || class.is(vm.ctx.types.tuple_type) { + Elem::Generic + } else { + classify(class, vm) + }; + PreSort::Tuple(elem) +} + +fn str_lt(a: &PyObjectRef, b: &PyObjectRef) -> bool { + a.downcast_ref::().unwrap().as_bytes() < b.downcast_ref::().unwrap().as_bytes() +} + +fn int_lt(a: &PyObjectRef, b: &PyObjectRef) -> bool { + a.downcast_ref::().unwrap().as_bigint() < b.downcast_ref::().unwrap().as_bigint() +} + +fn float_lt(a: &PyObjectRef, b: &PyObjectRef) -> bool { + a.downcast_ref::().unwrap().to_f64() < b.downcast_ref::().unwrap().to_f64() +} + +fn object_lt( + cmp: RichCompareFunc, + a: &PyObjectRef, + b: &PyObjectRef, + vm: &VirtualMachine, +) -> PyResult { + #[allow(unpredictable_function_pointer_comparisons)] + if a.class().slots.richcompare.load() != Some(cmp) { + return a.rich_compare_bool(b, PyComparisonOp::Lt, vm); + } + match cmp(a, b, PyComparisonOp::Lt, vm)? { + Either::B(PyComparisonValue::Implemented(v)) => Ok(v), + Either::B(PyComparisonValue::NotImplemented) => { + a.rich_compare_bool(b, PyComparisonOp::Lt, vm) + } + Either::A(obj) => { + if obj.is(&vm.ctx.not_implemented) { + a.rich_compare_bool(b, PyComparisonOp::Lt, vm) + } else { + obj.try_to_bool(vm) + } + } + } +} + +fn elem_lt(elem: &Elem, a: &PyObjectRef, b: &PyObjectRef, vm: &VirtualMachine) -> PyResult { + match elem { + Elem::Str => Ok(str_lt(a, b)), + Elem::Int => Ok(int_lt(a, b)), + Elem::Float => Ok(float_lt(a, b)), + Elem::Object(f) => object_lt(*f, a, b, vm), + Elem::Generic => a.rich_compare_bool(b, PyComparisonOp::Lt, vm), + } +} + +fn tuple_lt(elem: &Elem, a: &PyObjectRef, b: &PyObjectRef, vm: &VirtualMachine) -> PyResult { + let a = a.downcast_ref::().unwrap().as_slice(); + let b = b.downcast_ref::().unwrap().as_slice(); + + let mut i = 0; + while i < a.len() && i < b.len() { + if !a[i].rich_compare_bool(&b[i], PyComparisonOp::Eq, vm)? { + break; + } + i += 1; + } + if i >= a.len() || i >= b.len() { + return Ok(a.len() < b.len()); + } + if i == 0 { + elem_lt(elem, &a[0], &b[0], vm) + } else { + a[i].rich_compare_bool(&b[i], PyComparisonOp::Lt, vm) + } +} + +fn timsort_by(items: &mut [T], reverse: bool, key: &K, mut lt: L) -> PyResult<()> +where + T: Clone, + K: Fn(&T) -> &PyObjectRef, + L: FnMut(&PyObjectRef, &PyObjectRef) -> PyResult, +{ + timsort(items, &mut |a, b| { + let (a, b) = if reverse { + (key(b), key(a)) + } else { + (key(a), key(b)) + }; + lt(a, b) + }) +} + +fn timsort_specialized( + vm: &VirtualMachine, + items: &mut [T], + reverse: bool, + key: K, +) -> PyResult<()> +where + T: Clone, + K: Fn(&T) -> &PyObjectRef, +{ + match pre_sort_check(items.iter().map(&key), vm) { + PreSort::Str => timsort_by(items, reverse, &key, |a, b| Ok(str_lt(a, b))), + PreSort::Int => timsort_by(items, reverse, &key, |a, b| Ok(int_lt(a, b))), + PreSort::Float => timsort_by(items, reverse, &key, |a, b| Ok(float_lt(a, b))), + PreSort::Object(cmp) => timsort_by(items, reverse, &key, |a, b| object_lt(cmp, a, b, vm)), + PreSort::Tuple(elem) => timsort_by(items, reverse, &key, |a, b| tuple_lt(&elem, a, b, vm)), + PreSort::Generic => timsort_by(items, reverse, &key, |a, b| { + a.rich_compare_bool(b, PyComparisonOp::Lt, vm) + }), + } +} + fn do_sort( vm: &VirtualMachine, values: &mut Vec, key_func: Option, reverse: bool, ) -> PyResult<()> { - // CPython uses __lt__ for all comparisons in sort. - // `timsort` expects is_lt(a, b) = true when a must be placed BEFORE b. - // For reverse=True, swapping the operands yields a descending order that is - // still stable in the original relative order, matching CPython's - // reverse-sort-reverse approach. - let mut is_lt = |a: &PyObjectRef, b: &PyObjectRef| { - if reverse { - b.rich_compare_bool(a, PyComparisonOp::Lt, vm) - } else { - a.rich_compare_bool(b, PyComparisonOp::Lt, vm) - } - }; - if let Some(ref key_func) = key_func { let mut items = values .iter() .map(|x| Ok((x.clone(), key_func.call((x.clone(),), vm)?))) .collect::, _>>()?; - timsort(&mut items, &mut |a, b| is_lt(&a.1, &b.1))?; + timsort_specialized( + vm, + &mut items, + reverse, + |item: &(PyObjectRef, PyObjectRef)| &item.1, + )?; *values = items.into_iter().map(|(val, _)| val).collect(); } else { - timsort(values, &mut is_lt)?; + timsort_specialized(vm, values, reverse, |x: &PyObjectRef| x)? } Ok(()) diff --git a/extra_tests/snippets/builtin_list.py b/extra_tests/snippets/builtin_list.py index d4afbffa1cb..d62cae03b50 100644 --- a/extra_tests/snippets/builtin_list.py +++ b/extra_tests/snippets/builtin_list.py @@ -242,6 +242,33 @@ def __eq__(self, x): assert sorted([(1, 2, 3), (0, 3, 6)], key=lambda x: x[1]) == [(1, 2, 3), (0, 3, 6)] assert sorted([(1, 2), (), (5,)], key=len) == [(), (5,), (1, 2)] +assert sorted(["b", "a", "é", "z\U0001f600", "z"]) == [ + "a", + "b", + "z", + "z\U0001f600", + "é", +] +assert sorted([10**30, -(10**30), 5, 0]) == [-(10**30), 0, 5, 10**30] +assert sorted([True, False, True]) == [False, True, True] + + +class IntSub(int): + pass + + +assert sorted([IntSub(2), 3, IntSub(1)]) == [1, 2, 3] +assert sorted([2.5, 1, 3.0, 2]) == [1, 2, 2.5, 3.0] +assert_raises(TypeError, sorted, [1, "a"]) +nan = float("nan") +assert repr(sorted([nan, 1.0, 2.0])) == "[nan, 1.0, 2.0]" +assert sorted([b"b", b"a", b"c"]) == [b"a", b"b", b"c"] +assert sorted([(2, 9), (1, 5), (2, 1)]) == [(1, 5), (2, 1), (2, 9)] +assert sorted([(1, "b"), (1, "a")]) == [(1, "a"), (1, "b")] +assert sorted([(1,), (1, 2), ()]) == [(), (1,), (1, 2)] +assert sorted([((2,), "x"), ((1,), "y")]) == [((1,), "y"), ((2,), "x")] +assert sorted([(1, "a"), (2.5, "b"), (0, "c")]) == [(0, "c"), (1, "a"), (2.5, "b")] + lst = [3, 1, 5, 2, 4] From cbaa589715c6c9fe87acc23cade4e49321133b08 Mon Sep 17 00:00:00 2001 From: lms0806 Date: Sat, 8 Aug 2026 22:30:43 +0900 Subject: [PATCH 254/351] Implement TclObject support in tkinter varname_converter (#8465) --- crates/stdlib/src/tkinter.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/stdlib/src/tkinter.rs b/crates/stdlib/src/tkinter.rs index 0ccc0a97f9e..653d6edb71d 100644 --- a/crates/stdlib/src/tkinter.rs +++ b/crates/stdlib/src/tkinter.rs @@ -160,10 +160,13 @@ mod _tkinter { return Ok(varname); } - if let Some(_tcl_obj) = obj.downcast_ref::() { - // Assume that the Tcl object has a method to retrieve a string. - // return tcl_obj. - todo!(); + if let Some(tcl_obj) = obj.downcast_ref::() { + let c_str = unsafe { tk_sys::Tcl_GetString(tcl_obj.value) }; + let varname = unsafe { ffi::CStr::from_ptr(c_str as _) } + .to_str() + .map_err(|e| vm.new_unicode_decode_error(e.to_string()))? + .to_owned(); + return Ok(varname); } // Construct an error message using the type name (truncated to 50 characters). From cf688760ca5ff2cdd182aad7f2565c87026d6ee2 Mon Sep 17 00:00:00 2001 From: Sumi Jeong <125195487+sigmaith@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:32:13 +0900 Subject: [PATCH 255/351] Fix locals()/vars() corrupting/leaking __conditional_annotations__ outside function scope (#8467) CPython's _PyFrame_GetLocals only syncs fastlocals/cells into locals() for function scope; module/class scope just returns the namespace dict as-is. sync_visible_locals_to_mapping did this for every scope, so the implicit __conditional_annotations__ cell (used for PEP 649/749 deferred annotations) broke two ways: at module scope its cell is always empty, so syncing overwrote the dict's real value with None, causing NameError on the next annotated statement; at class scope its cell is the only real value, so syncing leaked it into locals()/dir(), unlike CPython. Skip cell/free slots outside function scope to match CPython, and add a regression snippet. Closes #8379 Assisted-by: Claude Code:claude-sonnet-5 --- crates/vm/src/frame.rs | 7 +-- .../snippets/syntax_annotations_locals.py | 50 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 extra_tests/snippets/syntax_annotations_locals.py diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index e7141525ff0..ebb158c8f71 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -1794,9 +1794,10 @@ impl FrameObject { } } - // Free variables only included for optimized (function-like) scopes. - // Class/module scopes should not expose free vars in locals(). - if kind == CO_FAST_FREE && !is_optimized { + // CPython only syncs fastlocals/cells into locals() for function + // scope; class/module scope just returns the namespace dict as-is + // (_PyFrame_GetLocals, Objects/frameobject.c). + if !is_optimized && kind & (CO_FAST_CELL | CO_FAST_FREE) != 0 { continue; } diff --git a/extra_tests/snippets/syntax_annotations_locals.py b/extra_tests/snippets/syntax_annotations_locals.py new file mode 100644 index 00000000000..8669d94b2d3 --- /dev/null +++ b/extra_tests/snippets/syntax_annotations_locals.py @@ -0,0 +1,50 @@ +"""Module/class-scope locals() must not corrupt or leak __conditional_annotations__. + +CPython's _PyFrame_GetLocals never syncs cell variables into a module/class +scope's namespace dict, it just returns the dict directly (verified against +CPython 3.14.6). __conditional_annotations__ is a cell in both scopes, but +only module codegen also writes it into the dict (StoreName); class codegen +only ever uses the cell (StoreDeref). So it's visible via locals()/dir() at +module scope and absent at class scope. + +RustPython's fast-locals-to-mapping sync used to read every cellvar's value +straight from the cell regardless of scope. At module scope the cell is +always empty, so this overwrote the dict's real value with None -- deleting +it, and the next annotated statement raised NameError. At class scope it +leaked __conditional_annotations__ into locals()/dir(), which CPython never +does. +""" + +count: int = 1 +_ = locals() +maybe: int = None # used to raise NameError before the fix +assert maybe is None + +assert "__conditional_annotations__" in dir(), ( + "module-level annotation should expose __conditional_annotations__, matching CPython" +) + +exec("a: int = 1\nlocals()\nb: int = 2") + +if True: + x: int = 1 +vars() +if True: + y: int = 2 +assert (x, y) == (1, 2) + + +class C: + if True: + cx: int = 1 + locals() + if True: + cy: int = 2 + assert "__conditional_annotations__" not in dir(), ( + "class-level locals() should not leak __conditional_annotations__, matching CPython" + ) + + +assert (C.cx, C.cy) == (1, 2) + +print("ok") From 695dda4642718bf007f871b1b72a73d9655bbb98 Mon Sep 17 00:00:00 2001 From: Lee Dogeon Date: Sat, 8 Aug 2026 22:33:44 +0900 Subject: [PATCH 256/351] Allow automated commits and fix C-API test instructions (#8468) * Allow automated commits through pre-commit hooks * Fix agent C-API test instructions Exclude rustpython-capi from root workspace test commands and run its tests from the crate directory so its Cargo configuration applies. Document the project AI policy and required commit trailer. Assisted-by: Codex:gpt-5 --- AGENTS.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2478a864968..694fc7be65c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,17 +17,22 @@ See the "Code organization" section in [CONTRIBUTING.md](CONTRIBUTING.md#code-or ## AI Agent Rules +**CRITICAL: AI Policy** + +- Follow RustPython's [AI Policy](https://github.com/RustPython/.github/blob/main/AI_POLICY.md) for every AI-assisted contribution. +- Disclose AI assistance in commit messages with an `Assisted-by: AGENT_NAME:MODEL_VERSION` trailer. Use one trailer per AI tool, and never use `Co-authored-by` for an AI assistant. + **CRITICAL: Git Operations** - NEVER create pull requests directly without explicit user permission - NEVER push commits to remote without explicit user permission - Always ask the user before performing any git operations that affect the remote repository - Commits can be created locally when requested, but pushing and PR creation require explicit approval -**CRITICAL: Pre-commit Checks** -- Before creating ANY commit, you MUST run `prek run --all-files` (or `pre-commit run --all-files`) AND the full test suite. Both must pass — do not commit if either fails. -- Test commands are documented in the [Testing](#testing) section below. At minimum run `cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher`; if the change touches `extra_tests/snippets/` run `pytest -v` there too, and if it touches `Lib/` or interpreter behavior, run the relevant `cargo run --release -- -m test ` modules. -- If a hook auto-fixes files (e.g. `ruff-format`, `rustfmt`), re-stage the fixes, re-run `prek` until it reports a clean pass, then re-run the tests, then commit. -- NEVER bypass these checks with `--no-verify`, `--no-gpg-sign`, or by skipping tests "because the change is small". If a hook or test fails, fix the underlying issue and create a new commit — do not amend or force the failing commit through. +**CRITICAL: Commit Hooks and Validation** +- Install the repository's pre-commit hook with `prek install` (or `pre-commit install`) after cloning the repository. +- Every commit must run the configured pre-commit hook. NEVER bypass it with `--no-verify`. Automated workflows that use a normal `git commit`, such as `scripts/update_lib quick`, should be allowed to create local commits through the hook. +- If a hook auto-fixes files (e.g. `ruff-format`, `rustfmt`), re-stage the fixes and retry the commit. Do not amend or force a failing commit through. +- Before completing a task, run the tests appropriate for the change. Test commands are documented in the [Testing](#testing) section below. At minimum run `cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher --exclude rustpython-capi`, then run `cargo test` from `crates/capi`; if the change touches `extra_tests/snippets/` run `pytest -v` there too, and if it touches `Lib/` or interpreter behavior, run the relevant `cargo run --release -- -m test ` modules. ## Important Development Notes @@ -113,7 +118,10 @@ rm -r target/debug/build/rustpython-* && find . | grep -E "\.pyc$" | xargs rm -r ```bash # Run Rust unit tests -cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher +cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher --exclude rustpython-capi + +# Run C-API tests from their directory so their separate Cargo config applies +(cd crates/capi && cargo test) # Run Python snippets tests (debug mode recommended for faster compilation) cargo run -- extra_tests/snippets/builtin_bytes.py From 1819677e72f6a40ef1f1eb40db11a827f16f1f32 Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:43:05 -0400 Subject: [PATCH 257/351] host_env: Simplify stat, fstatat with Rustix (#8455) According to POSIX, `time_t` should be 64 bits. `musl` changed its `time_t` to an `i64` over five years ago. `glibc` provides compatibility features that declare `time_t` as either `i32` or `i64`. Rustix uses the raw Linux syscall for stat which returns an `i64`. For our purposes, an `i64` makes sense because it's modern and avoids the year 2038 problem. It also reduces our dependency on what `libc` defines. Sources: * https://www.man7.org/linux/man-pages/man3/time_t.3type.html * https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/sys_types.h.html --- crates/host_env/src/fileutils.rs | 12 ++----- crates/host_env/src/io.rs | 7 ++-- crates/host_env/src/posix.rs | 44 -------------------------- crates/host_env/src/posix_unix_like.rs | 23 ++++++++++++-- crates/host_env/src/posix_wasi.rs | 33 ------------------- crates/vm/src/stdlib/os.rs | 44 +++++++++++--------------- 6 files changed, 47 insertions(+), 116 deletions(-) diff --git a/crates/host_env/src/fileutils.rs b/crates/host_env/src/fileutils.rs index 1713370a942..a4922e7a2fe 100644 --- a/crates/host_env/src/fileutils.rs +++ b/crates/host_env/src/fileutils.rs @@ -2,22 +2,14 @@ #![allow(non_snake_case)] #[cfg(not(windows))] -pub use libc::stat as StatStruct; +pub use rustix::fs::Stat as StatStruct; #[cfg(windows)] pub use windows::{StatStruct, fstat}; #[cfg(not(windows))] pub fn fstat(fd: crate::crt_fd::Borrowed<'_>) -> std::io::Result { - let mut stat = core::mem::MaybeUninit::uninit(); - unsafe { - let ret = libc::fstat(fd.as_raw(), stat.as_mut_ptr()); - if ret == -1 { - Err(crate::os::errno_io_error()) - } else { - Ok(stat.assume_init()) - } - } + rustix::fs::fstat(fd).map_err(Into::into) } #[cfg(windows)] diff --git a/crates/host_env/src/io.rs b/crates/host_env/src/io.rs index 4ae1e4b3641..6df29bcd6bc 100644 --- a/crates/host_env/src/io.rs +++ b/crates/host_env/src/io.rs @@ -2,6 +2,9 @@ use core::ffi::CStr; use std::io; +#[cfg(any(unix, target_os = "wasi"))] +use rustix::{fs::FileType, io::Errno}; + #[cfg(any(unix, target_os = "wasi"))] use crate::fileutils; use crate::{crt_fd, os}; @@ -148,8 +151,8 @@ pub struct FileTargetInfo { #[cfg(any(unix, target_os = "wasi"))] pub fn inspect_file_target(fd: crt_fd::Borrowed<'_>) -> io::Result { let status = fileutils::fstat(fd)?; - if (status.st_mode & libc::S_IFMT) == libc::S_IFDIR { - return Err(io::Error::from_raw_os_error(libc::EISDIR)); + if FileType::from_raw_mode(status.st_mode).is_dir() { + return Err(io::Error::from(Errno::ISDIR)); } #[allow(clippy::useless_conversion, reason = "needed for 32-bit platforms")] let blksize = (status.st_blksize > 1).then(|| i64::from(status.st_blksize)); diff --git a/crates/host_env/src/posix.rs b/crates/host_env/src/posix.rs index e20accee715..50d3f52a674 100644 --- a/crates/host_env/src/posix.rs +++ b/crates/host_env/src/posix.rs @@ -304,50 +304,6 @@ pub fn fchown(fd: BorrowedFd<'_>, uid: Option, gid: Option) -> std::io .map_err(std::io::Error::from) } -#[cfg(not(windows))] -#[expect( - clippy::std_instead_of_core, - reason = "false positive: core::io::ErrorKind is unstable (core_io)" -)] -pub fn stat_path( - path: &OsStr, - dir_fd: Option, - follow_symlinks: bool, -) -> std::io::Result> { - use crate::os::ffi::OsStrExt; - - let path = match CString::new(path.as_bytes()) { - Ok(path) => path, - Err(_) => return Err(std::io::Error::from(std::io::ErrorKind::InvalidInput)), - }; - - let mut stat = core::mem::MaybeUninit::uninit(); - #[cfg(not(target_os = "redox"))] - if let Some(dir_fd) = dir_fd { - let flags = if follow_symlinks { - 0 - } else { - libc::AT_SYMLINK_NOFOLLOW - }; - let ret = unsafe { libc::fstatat(dir_fd, path.as_ptr(), stat.as_mut_ptr(), flags) }; - if ret < 0 { - return Err(std::io::Error::last_os_error()); - } - return Ok(Some(unsafe { stat.assume_init() })); - } - - let ret = if follow_symlinks { - unsafe { libc::stat(path.as_ptr(), stat.as_mut_ptr()) } - } else { - unsafe { libc::lstat(path.as_ptr(), stat.as_mut_ptr()) } - }; - if ret < 0 { - Err(std::io::Error::last_os_error()) - } else { - Ok(Some(unsafe { stat.assume_init() })) - } -} - #[cfg(not(windows))] pub fn stat_fd(fd: crate::crt_fd::Borrowed<'_>) -> std::io::Result { crate::fileutils::fstat(fd) diff --git a/crates/host_env/src/posix_unix_like.rs b/crates/host_env/src/posix_unix_like.rs index 9bb29c41c84..183feb5316b 100644 --- a/crates/host_env/src/posix_unix_like.rs +++ b/crates/host_env/src/posix_unix_like.rs @@ -2,11 +2,14 @@ use std::{io, path::Path}; -use rustix::{fd::AsFd, fs}; +use rustix::{ + fd::AsFd, + fs::{self, AtFlags}, +}; pub use rustix::fs::RawMode; -use crate::crt_fd; +use crate::{crt_fd, fileutils::StatStruct}; /// https://pubs.opengroup.org/onlinepubs/9799919799/functions/mkdir.html pub fn make_dir( @@ -45,3 +48,19 @@ pub fn replace( ) -> io::Result<()> { rename(from, from_fd, to, to_fd) } + +pub fn stat_path( + path: impl AsRef, + dir_fd: Option>, + follow_symlinks: bool, +) -> io::Result> { + let flags = if follow_symlinks { + AtFlags::empty() + } else { + AtFlags::SYMLINK_NOFOLLOW + }; + let dir_fd = dir_fd.as_ref().map_or(fs::CWD, AsFd::as_fd); + fs::statat(dir_fd, path.as_ref(), flags) + .map(Option::Some) + .map_err(Into::into) +} diff --git a/crates/host_env/src/posix_wasi.rs b/crates/host_env/src/posix_wasi.rs index 23c3be2fb91..791991e981d 100644 --- a/crates/host_env/src/posix_wasi.rs +++ b/crates/host_env/src/posix_wasi.rs @@ -12,39 +12,6 @@ pub fn remove_dir_at(dir_fd: i32, path: &CStr) -> io::Result<()> { Ok(()) } -pub fn stat_path( - path: &OsStr, - dir_fd: Option, - follow_symlinks: bool, -) -> io::Result> { - use crate::os::ffi::OsStrExt; - - let path = match CString::new(path.as_bytes()) { - Ok(path) => path, - Err(_) => return Err(io::Error::from(io::ErrorKind::InvalidInput)), - }; - - let mut stat = core::mem::MaybeUninit::uninit(); - if let Some(dir_fd) = dir_fd { - let flags = if follow_symlinks { - 0 - } else { - libc::AT_SYMLINK_NOFOLLOW - }; - unsafe { libc::fstatat(dir_fd, path.as_ptr(), stat.as_mut_ptr(), flags) } - .check_libc_neg()?; - return Ok(Some(unsafe { stat.assume_init() })); - } - - let ret = if follow_symlinks { - unsafe { libc::stat(path.as_ptr(), stat.as_mut_ptr()) } - } else { - unsafe { libc::lstat(path.as_ptr(), stat.as_mut_ptr()) } - }; - ret.check_libc_neg()?; - Ok(Some(unsafe { stat.assume_init() })) -} - pub fn stat_fd(fd: crate::crt_fd::Borrowed<'_>) -> io::Result { crate::fileutils::fstat(fd) } diff --git a/crates/vm/src/stdlib/os.rs b/crates/vm/src/stdlib/os.rs index a41e9990f12..a934c6d812f 100644 --- a/crates/vm/src/stdlib/os.rs +++ b/crates/vm/src/stdlib/os.rs @@ -1183,18 +1183,15 @@ pub(super) mod _os { pub st_gid: PyIntRef, pub st_size: PyIntRef, // Indices 7-9: integer seconds - #[cfg_attr(target_env = "musl", allow(deprecated))] #[pyarg(positional, default)] #[pystruct_sequence(unnamed)] - pub st_atime_int: libc::time_t, - #[cfg_attr(target_env = "musl", allow(deprecated))] + pub st_atime_int: i64, #[pyarg(positional, default)] #[pystruct_sequence(unnamed)] - pub st_mtime_int: libc::time_t, - #[cfg_attr(target_env = "musl", allow(deprecated))] + pub st_mtime_int: i64, #[pyarg(positional, default)] #[pystruct_sequence(unnamed)] - pub st_ctime_int: libc::time_t, + pub st_ctime_int: i64, // Float time attributes #[pyarg(any, default)] #[pystruct_sequence(skip)] @@ -1219,11 +1216,11 @@ pub(super) mod _os { #[cfg(not(windows))] #[pyarg(any, default)] #[pystruct_sequence(skip)] - pub st_blksize: i64, + pub st_blksize: u64, #[cfg(not(windows))] #[pyarg(any, default)] #[pystruct_sequence(skip)] - pub st_blocks: i64, + pub st_blocks: u64, #[cfg(windows)] #[pyarg(any, default)] #[pystruct_sequence(skip)] @@ -1237,19 +1234,12 @@ pub(super) mod _os { impl StatResultData { fn from_stat(stat: &StatStruct, vm: &VirtualMachine) -> Self { let (atime, mtime, ctime); - #[cfg(any(unix, windows))] - #[cfg(not(any(target_os = "netbsd", target_os = "wasi")))] + #[cfg(all(any(unix, windows), not(target_os = "wasi")))] { atime = (stat.st_atime, stat.st_atime_nsec); mtime = (stat.st_mtime, stat.st_mtime_nsec); ctime = (stat.st_ctime, stat.st_ctime_nsec); } - #[cfg(target_os = "netbsd")] - { - atime = (stat.st_atime, stat.st_atimensec); - mtime = (stat.st_mtime, stat.st_mtimensec); - ctime = (stat.st_ctime, stat.st_ctimensec); - } #[cfg(target_os = "wasi")] { atime = (stat.st_atim.tv_sec, stat.st_atim.tv_nsec); @@ -1274,12 +1264,18 @@ pub(super) mod _os { let st_ino = stat.st_ino; #[cfg(not(windows))] - #[allow(clippy::useless_conversion, reason = "needed for 32-bit platforms")] - let st_blksize = i64::from(stat.st_blksize); + #[allow( + clippy::useless_conversion, + reason = "signedness differs between platforms" + )] + let st_blksize = stat.st_blksize.try_into().unwrap_or(4096); #[cfg(not(windows))] - #[allow(clippy::useless_conversion, reason = "needed for 32-bit platforms")] - let st_blocks = i64::from(stat.st_blocks); + #[allow( + clippy::useless_conversion, + reason = "signedness differs between platforms" + )] + let st_blocks = stat.st_blocks.try_into().unwrap_or_default(); Self { st_mode: vm.ctx.new_pyref(stat.st_mode), @@ -1360,11 +1356,9 @@ pub(super) mod _os { follow_symlinks: FollowSymlinks, ) -> io::Result> { match file { - OsPathOrFd::Path(path) => host_posix::stat_path( - path.as_ref().as_os_str(), - dir_fd.raw_opt(), - follow_symlinks.0, - ), + OsPathOrFd::Path(path) => { + host_posix::stat_path(path, dir_fd.get_opt(), follow_symlinks.0) + } OsPathOrFd::Fd(fd) => host_posix::stat_fd(fd).map(Some), } } From 557bec1adf1c9c64e17c7fea42cbc36d8c09f8fb Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:48:48 +0900 Subject: [PATCH 258/351] sre_engine: resume the tail match at prefix_skip, not past the whole prefix (#8473) `search_info_literal`'s len>1 arm reset the tail-match cursor by advancing one character past the matched prefix. That position equals the INFO block's prefix_skip boundary only when the prefix ends where the skip does; for a pattern like `ab(cd)`, `_get_literal_prefix` reports prefix_len=4 with prefix_skip=2, so the tail resumed two characters too far. `state.cursor` already holds `req.start + skip`, which is what `sre_lib.h` SRE(search) computes as `ptr - (prefix_len - prefix_skip - 1)`, and which the len==1 arm above already uses. Take it unconditionally. Searching "xabcdcd" for `ab(cd)` returned span (1, 7) instead of (1, 5); over a 600-case corpus stamped with CPython's own answers, 94 cases disagreed before this change and none after. The wrong resume also dropped matches outright, not only widened them. Assisted-by: Claude --- crates/sre_engine/src/engine.rs | 10 +++++----- crates/sre_engine/tests/tests.rs | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/sre_engine/src/engine.rs b/crates/sre_engine/src/engine.rs index a110a75d65f..690801e0d9d 100644 --- a/crates/sre_engine/src/engine.rs +++ b/crates/sre_engine/src/engine.rs @@ -998,12 +998,12 @@ fn search_info_literal( return true; } + // `state.cursor` is `req.start + skip`, the position the + // tail match resumes from; advancing past the prefix + // instead would resume at `req.start + len` and only + // agree when the prefix ends at the skip boundary. let mut next_ctx = ctx; - if skip != 0 { - next_ctx.advance_char::(); - } else { - next_ctx.cursor = state.cursor; - } + next_ctx.cursor = state.cursor; if _match(req, state, next_ctx) { return true; diff --git a/crates/sre_engine/tests/tests.rs b/crates/sre_engine/tests/tests.rs index 53f5225d4ad..0a9f45c374e 100644 --- a/crates/sre_engine/tests/tests.rs +++ b/crates/sre_engine/tests/tests.rs @@ -252,4 +252,19 @@ mod tests { #[rustfmt::skip] let p = Pattern { pattern: "\u{e0}+", code: &[14, 4, 0, 1, 4294967295, 24, 6, 1, 4294967295, 16, 224, 1, 1] }; // END GENERATED } + + #[test] + fn search_literal_prefix_longer_than_skip() { + // The INFO block carries prefix_len=4 and prefix_skip=2, so the tail + // match has to resume at the skip boundary rather than past the whole + // prefix. + // pattern p = re.compile('ab(cd)') + // START GENERATED by generate_tests.py + #[rustfmt::skip] let p = Pattern { pattern: "ab(cd)", code: &[14, 14, 1, 4, 4, 4, 2, 97, 98, 99, 100, 0, 0, 0, 0, 16, 97, 16, 98, 17, 0, 16, 99, 16, 100, 17, 1, 1] }; + // END GENERATED + let (req, mut state) = p.state("xabcdcd"); + assert!(state.search(req)); + assert_eq!(state.start, 1); + assert_eq!(state.cursor.position, 5); + } } From 12a0a1ed8b9b4cd3292ca936d01cb18e3dd9779a Mon Sep 17 00:00:00 2001 From: Seonghun An <53287605+shAn-kor@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:58:07 +0900 Subject: [PATCH 259/351] Implement negative zero coercion format option (#8475) Assisted-by: Codex:gpt-5 --- Lib/test/test_format.py | 1 - crates/common/src/format.rs | 186 ++++++++++++++++++++++++++++++++---- crates/vm/src/format.rs | 6 ++ 3 files changed, 173 insertions(+), 20 deletions(-) diff --git a/Lib/test/test_format.py b/Lib/test/test_format.py index f6452341e1e..aa28108312e 100644 --- a/Lib/test/test_format.py +++ b/Lib/test/test_format.py @@ -558,7 +558,6 @@ def test_unicode_in_error_message(self): with self.assertRaisesRegex(ValueError, str_err): "{a:%ЫйЯЧ}".format(a='a') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_negative_zero(self): ## default behavior self.assertEqual(f"{-0.:.1f}", "-0.0") diff --git a/crates/common/src/format.rs b/crates/common/src/format.rs index b5f062f97a6..1c5c0a9c9de 100644 --- a/crates/common/src/format.rs +++ b/crates/common/src/format.rs @@ -229,6 +229,7 @@ pub struct FormatSpec { align: Option, align_specified: bool, sign: Option, + no_neg_0: bool, alternate_form: bool, width: Option, grouping_option: Option, @@ -285,6 +286,14 @@ fn parse_alternate_form(text: &Wtf8) -> (bool, &Wtf8) { } } +fn parse_no_negative_zero(text: &Wtf8) -> (bool, &Wtf8) { + let mut chars = text.code_points(); + match chars.next().and_then(CodePoint::to_char) { + Some('z') => (true, chars.as_wtf8()), + _ => (false, text), + } +} + fn parse_zero(text: &Wtf8) -> (bool, &Wtf8) { let mut chars = text.code_points(); match chars.next().and_then(CodePoint::to_char) { @@ -349,6 +358,7 @@ impl FormatSpec { let (mut fill, mut align, text) = parse_fill_and_align(text); let align_specified = align.is_some(); let (sign, text) = FormatSign::parse(text); + let (no_neg_0, text) = parse_no_negative_zero(text); let (alternate_form, text) = parse_alternate_form(text); let (zero, text) = parse_zero(text); let (width, text) = parse_number(text)?; @@ -378,6 +388,7 @@ impl FormatSpec { align, align_specified, sign, + no_neg_0, alternate_form, width, grouping_option, @@ -505,6 +516,25 @@ impl FormatSpec { Ok(()) } + fn formatted_magnitude_is_zero(magnitude: &str) -> bool { + let mut saw_digit = false; + for byte in magnitude.bytes() { + if byte.is_ascii_digit() { + saw_digit = true; + if byte != b'0' { + return false; + } + } + } + saw_digit + } + + fn is_negative_after_zero_coercion(&self, num: f64, magnitude: &str) -> bool { + num.is_sign_negative() + && !num.is_nan() + && !(self.no_neg_0 && Self::formatted_magnitude_is_zero(magnitude)) + } + fn validate_complex_padding_and_alignment(&self) -> Result<(), FormatSpecError> { match &self.fill.unwrap_or_else(|| ' '.into()).to_char() { Some('0') => Err(FormatSpecError::ZeroPadding), @@ -674,6 +704,9 @@ impl FormatSpec { Some(FormatType::Number(Case::Lower)) => self.format_int_radix(magnitude, 10), _ => return self.format_int(num), }?; + if self.no_neg_0 { + return Err(FormatSpecError::NegativeZeroCoercionNotAllowed("integer")); + } let magnitude_str = Self::apply_locale_formatting(raw_magnitude_str, locale); @@ -723,7 +756,7 @@ impl FormatSpec { let magnitude_str = Self::apply_locale_formatting(raw_magnitude_str, locale); let format_sign = self.sign.unwrap_or(FormatSign::Minus); - let sign_str = if num.is_sign_negative() && !num.is_nan() { + let sign_str = if self.is_negative_after_zero_coercion(num, &magnitude_str) { "-" } else { match format_sign { @@ -812,11 +845,16 @@ impl FormatSpec { self.format_float(x as f64) } None => { + if self.no_neg_0 { + return Err(FormatSpecError::NegativeZeroCoercionNotAllowed("integer")); + } let first_letter = (input.to_string().as_bytes()[0] as char).to_uppercase(); Ok(first_letter.collect::() + &input.to_string()[1..]) } - Some(FormatType::Unknown(c)) => Err(FormatSpecError::UnknownFormatCode(*c, "int")), - _ => Err(FormatSpecError::InvalidFormatSpecifier), + Some(format_type) => { + let ch = char::from(format_type); + Err(FormatSpecError::UnknownFormatCode(ch, "bool")) + } } } @@ -925,8 +963,9 @@ impl FormatSpec { }, }, }; + let raw_magnitude_str = raw_magnitude_str?; let format_sign = self.sign.unwrap_or(FormatSign::Minus); - let sign_str = if num.is_sign_negative() && !num.is_nan() { + let sign_str = if self.is_negative_after_zero_coercion(num, &raw_magnitude_str) { "-" } else { match format_sign { @@ -935,7 +974,7 @@ impl FormatSpec { FormatSign::MinusOrSpace => " ", } }; - let magnitude_str = self.add_magnitude_separators(raw_magnitude_str?, sign_str); + let magnitude_str = self.add_magnitude_separators(raw_magnitude_str, sign_str); let magnitude_str = self.add_frac_separators(magnitude_str); Ok( self.format_sign_and_align( @@ -986,15 +1025,24 @@ impl FormatSpec { Err(FormatSpecError::UnknownFormatCode('N', "int")) } Some(FormatType::String) => Err(FormatSpecError::UnknownFormatCode('s', "int")), - Some(FormatType::Character) => match (self.precision, self.sign, self.alternate_form) { - (Some(_), _, _) => Err(FormatSpecError::PrecisionNotAllowed), - (_, Some(_), _) => Err(FormatSpecError::NotAllowed("Sign")), - (_, _, true) => Err(FormatSpecError::NotAllowed("Alternate form (#)")), - (_, _, _) => match num.to_u32() { - Some(n) if n <= 0x10ffff => Ok(core::char::from_u32(n).unwrap().to_string()), - Some(_) | None => Err(FormatSpecError::CodeNotInRange), - }, - }, + Some(FormatType::Character) => { + if self.precision.is_some() { + Err(FormatSpecError::PrecisionNotAllowed) + } else if self.no_neg_0 { + Err(FormatSpecError::NegativeZeroCoercionNotAllowed("integer")) + } else { + match (self.sign, self.alternate_form) { + (Some(_), _) => Err(FormatSpecError::NotAllowed("Sign")), + (_, true) => Err(FormatSpecError::NotAllowed("Alternate form (#)")), + _ => match num.to_u32() { + Some(n) if n <= 0x10ffff => { + Ok(core::char::from_u32(n).unwrap().to_string()) + } + Some(_) | None => Err(FormatSpecError::CodeNotInRange), + }, + } + } + } Some( FormatType::GeneralFormat(_) | FormatType::FixedPoint(_) @@ -1007,6 +1055,9 @@ impl FormatSpec { Some(FormatType::Unknown(c)) => Err(FormatSpecError::UnknownFormatCode(c, "int")), None => self.format_int_radix(magnitude, 10), }?; + if self.no_neg_0 { + return Err(FormatSpecError::NegativeZeroCoercionNotAllowed("integer")); + } let format_sign = self.sign.unwrap_or(FormatSign::Minus); let sign_str = match num.sign() { Sign::Minus => "-", @@ -1032,6 +1083,9 @@ impl FormatSpec { self.validate_format(FormatType::String)?; match self.format_type { Some(FormatType::String) | None => { + if self.no_neg_0 { + return Err(FormatSpecError::NegativeZeroCoercionNotAllowed("string")); + } if self.align == Some(FormatAlign::AfterSign) && self.align_specified { return Err(FormatSpecError::StringAlignmentFlag); } @@ -1074,7 +1128,8 @@ impl FormatSpec { // Format real part let formatted_re = if num.re != 0.0 || num.re.is_negative_zero() || self.format_type.is_some() { - let sign_re = if num.re.is_sign_negative() && !num.is_nan() { + let re = self.format_complex_float(num.re)?; + let sign_re = if self.is_negative_after_zero_coercion(num.re, &re) { "-" } else { match self.sign.unwrap_or(FormatSign::Minus) { @@ -1083,21 +1138,24 @@ impl FormatSpec { FormatSign::MinusOrSpace => " ", } }; - let re = self.format_complex_float(num.re)?; format!("{sign_re}{re}") } else { String::new() }; // Format imaginary part - let sign_im = if num.im.is_sign_negative() && !num.im.is_nan() { + let im = self.format_complex_float(num.im)?; + let sign_im = if self.is_negative_after_zero_coercion(num.im, &im) { "-" } else if formatted_re.is_empty() { - "" + match self.sign.unwrap_or(FormatSign::Minus) { + FormatSign::Plus => "+", + FormatSign::Minus => "", + FormatSign::MinusOrSpace => " ", + } } else { "+" }; - let im = self.format_complex_float(num.im)?; Ok((formatted_re, format!("{sign_im}{im}j"))) } @@ -1274,6 +1332,7 @@ pub enum FormatSpecError { CodeNotInRange, ZeroPadding, AlignmentFlag, + NegativeZeroCoercionNotAllowed(&'static str), StringAlignmentFlag, NotImplemented(char, &'static str), } @@ -1625,6 +1684,7 @@ mod tests { align: None, align_specified: false, sign: None, + no_neg_0: false, alternate_form: false, width: Some(33), grouping_option: None, @@ -1643,6 +1703,7 @@ mod tests { align: Some(FormatAlign::Right), align_specified: true, sign: None, + no_neg_0: false, alternate_form: false, width: Some(33), grouping_option: None, @@ -1661,6 +1722,7 @@ mod tests { align: Some(FormatAlign::Right), align_specified: true, sign: Some(FormatSign::Minus), + no_neg_0: false, alternate_form: true, width: Some(23), grouping_option: Some(FormatGrouping::Comma), @@ -1779,6 +1841,92 @@ mod tests { ); } + #[test] + fn format_negative_zero_coercion() { + let int_spec = FormatSpec::parse("z8").unwrap(); + assert_eq!( + int_spec.format_int(&BigInt::from(-42)), + Err(FormatSpecError::NegativeZeroCoercionNotAllowed("integer")) + ); + assert_eq!( + FormatSpec::parse("zs") + .unwrap() + .format_int(&BigInt::from(0)), + Err(FormatSpecError::UnknownFormatCode('s', "int")) + ); + assert_eq!( + FormatSpec::parse("z.1d") + .unwrap() + .format_int(&BigInt::from(0)), + Err(FormatSpecError::PrecisionNotAllowed) + ); + assert_eq!( + FormatSpec::parse("+zc") + .unwrap() + .format_int(&BigInt::from(0)), + Err(FormatSpecError::NegativeZeroCoercionNotAllowed("integer")) + ); + + let float_spec = FormatSpec::parse("z.2f").unwrap(); + assert_eq!(float_spec.format_float(-0.0001), Ok("0.00".to_owned())); + + let complex_spec = FormatSpec::parse("z").unwrap(); + assert_eq!( + complex_spec.format_complex(&Complex64::new(-0.0, -0.0)), + Ok("(0+0j)".to_owned()) + ); + let pure_imaginary = Complex64::new(0.0, -0.0); + assert_eq!( + FormatSpec::parse("+z") + .unwrap() + .format_complex(&pure_imaginary), + Ok("+0j".to_owned()) + ); + assert_eq!( + FormatSpec::parse(" z") + .unwrap() + .format_complex(&pure_imaginary), + Ok(" 0j".to_owned()) + ); + + let string_value = "value".to_owned(); + assert_eq!( + FormatSpec::parse("z").unwrap().format_string(&string_value), + Err(FormatSpecError::NegativeZeroCoercionNotAllowed("string")) + ); + assert_eq!( + FormatSpec::parse("zd") + .unwrap() + .format_string(&string_value), + Err(FormatSpecError::UnknownFormatCode('d', "str")) + ); + assert_eq!( + FormatSpec::parse("zs").unwrap().format_bool(false), + Err(FormatSpecError::UnknownFormatCode('s', "bool")) + ); + + let locale = LocaleInfo { + thousands_sep: ",".to_owned(), + decimal_point: ".".to_owned(), + grouping: vec![3, 0], + }; + let locale_spec = FormatSpec::parse("zn").unwrap(); + assert_eq!( + locale_spec.format_float_locale(-0.0, &locale), + Ok("0".to_owned()) + ); + assert_eq!( + locale_spec.format_complex_locale(&Complex64::new(-0.0, -0.0), &locale), + Ok("0+0j".to_owned()) + ); + assert_eq!( + FormatSpec::parse("z.1n") + .unwrap() + .format_int_locale(&BigInt::from(0), &locale), + Err(FormatSpecError::PrecisionNotAllowed) + ); + } + #[test] fn format_int() { assert_eq!( diff --git a/crates/vm/src/format.rs b/crates/vm/src/format.rs index 80b906505bf..657601e1470 100644 --- a/crates/vm/src/format.rs +++ b/crates/vm/src/format.rs @@ -77,6 +77,12 @@ impl IntoPyException for FormatSpecError { Self::AlignmentFlag => { vm.new_value_error("'=' alignment flag is not allowed in complex format specifier") } + Self::NegativeZeroCoercionNotAllowed(type_name) => { + let msg = format!( + "Negative zero coercion (z) not allowed in {type_name} format specifier" + ); + vm.new_value_error(msg) + } Self::StringAlignmentFlag => { vm.new_value_error("'=' alignment not allowed in string format specifier") } From 6ec43604a293dd207730bf05317dec58edaff9a8 Mon Sep 17 00:00:00 2001 From: chestnut1717 <62554639+chestnut1717@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:58:53 +0900 Subject: [PATCH 260/351] Fix EOF SyntaxError diagnostics (#8429) * Fix EOF SyntaxError diagnostics * Fix Scope EOF SyntaxError location conversion * Fix Additional Unexpected Test * Docs Add Annotation source_location_in_code_points() --- Lib/test/test_eof.py | 5 --- Lib/test/test_exceptions.py | 1 - Lib/test/test_tokenize.py | 1 - crates/compiler/src/lib.rs | 59 ++++++++++++++++++++++++++++++---- crates/vm/src/exceptions.rs | 6 +++- crates/vm/src/stdlib/sys.rs | 19 +++++++++++ crates/vm/src/vm/python_run.rs | 4 +++ crates/vm/src/vm/vm_new.rs | 26 +++++++++++---- 8 files changed, 100 insertions(+), 21 deletions(-) diff --git a/Lib/test/test_eof.py b/Lib/test/test_eof.py index f5a0bc56958..582e5b6de6e 100644 --- a/Lib/test/test_eof.py +++ b/Lib/test/test_eof.py @@ -18,7 +18,6 @@ def test_EOF_single_quote(self): self.assertEqual(str(cm.exception), expect) self.assertEqual(cm.exception.offset, 1) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_EOFS(self): expect = ("unterminated triple-quoted string literal (detected at line 3) (, line 1)") with self.assertRaises(SyntaxError) as cm: @@ -45,7 +44,6 @@ def test_EOFS(self): self.assertEqual(cm.exception.text, "ä = '''thîs is ") self.assertEqual(cm.exception.offset, 5) - @unittest.expectedFailure # TODO: RUSTPYTHON @force_not_colorized def test_EOFS_with_file(self): expect = ("(, line 1)") @@ -86,7 +84,6 @@ def test_EOFS_with_file(self): ' ^', 'SyntaxError: unterminated triple-quoted string literal (detected at line 4)']) - @unittest.expectedFailure # TODO: RUSTPYTHON @warnings_helper.ignore_warnings(category=SyntaxWarning) def test_eof_with_line_continuation(self): expect = "unexpected EOF while parsing (, line 1)" @@ -94,7 +91,6 @@ def test_eof_with_line_continuation(self): compile('"\\Xhh" \\', '', 'exec') self.assertEqual(str(cm.exception), expect) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_line_continuation_EOF(self): """A continuation at the end of input must be an error; bpo2180.""" expect = 'unexpected EOF while parsing (, line 1)' @@ -127,7 +123,6 @@ def test_line_continuation_EOF(self): exec('\\') self.assertEqual(str(cm.exception), expect) - @unittest.expectedFailure # TODO: RUSTPYTHON @unittest.skipIf(not sys.executable, "sys.executable required") @force_not_colorized def test_line_continuation_EOF_from_file_bpo2180(self): diff --git a/Lib/test/test_exceptions.py b/Lib/test/test_exceptions.py index 7ab4c810a08..7c81c4b3905 100644 --- a/Lib/test/test_exceptions.py +++ b/Lib/test/test_exceptions.py @@ -2145,7 +2145,6 @@ class AssertionErrorTests(unittest.TestCase): def tearDown(self): unlink(TESTFN) - @unittest.expectedFailure # TODO: RUSTPYTHON @force_not_colorized def test_assertion_error_location(self): cases = [ diff --git a/Lib/test/test_tokenize.py b/Lib/test/test_tokenize.py index 5ed844c34f0..0e81c6f6db2 100644 --- a/Lib/test/test_tokenize.py +++ b/Lib/test/test_tokenize.py @@ -1922,7 +1922,6 @@ def test_newline_and_space_at_the_end_of_the_source_without_newline(self): tokens = list(tokenize.tokenize(BytesIO(source.encode('utf-8')).readline)) self.assertEqual(tokens, expected_tokens) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: b'SyntaxError' not found in b'OSError: stream did not contain valid UTF-8\n' def test_invalid_character_in_fstring_middle(self): # See gh-103824 script = b'''F""" diff --git a/crates/compiler/src/lib.rs b/crates/compiler/src/lib.rs index 43f7174d804..7562e8939b9 100644 --- a/crates/compiler/src/lib.rs +++ b/crates/compiler/src/lib.rs @@ -50,9 +50,13 @@ pub enum CompileError { impl CompileError { #[must_use] - pub fn from_ruff_parse_error(error: parser::ParseError, source_file: &SourceFile) -> Self { + pub fn from_ruff_parse_error( + error: parser::ParseError, + source_file: &SourceFile, + mode: Mode, + ) -> Self { let raw_location = error.location; - let diagnostic = match cpython_parse_diagnostic_override(&error, source_file) { + let diagnostic = match cpython_parse_diagnostic_override(&error, source_file, mode) { Some(diagnostic) => diagnostic, None => default_parse_diagnostic(error, source_file), }; @@ -129,6 +133,13 @@ fn source_location(source_file: &SourceFile, offset: TextSize) -> SourceLocation .source_location(offset, PositionEncoding::Utf8) } +// Call only with UTF-8 character boundaries for Python-facing offsets. +fn source_location_in_code_points(source_file: &SourceFile, offset: TextSize) -> SourceLocation { + source_file + .to_source_code() + .source_location(offset, PositionEncoding::Utf32) +} + fn source_locations( source_file: &SourceFile, start: TextSize, @@ -175,6 +186,21 @@ impl NormalizedParseDiagnostic { ) } + fn other_in_code_points( + source_file: &SourceFile, + message: String, + start: usize, + end: usize, + ) -> Self { + let start = TextSize::new(start as u32); + let end = TextSize::new(end as u32); + Self::new( + parser::ParseErrorType::OtherError(message), + source_location_in_code_points(source_file, start), + source_location_in_code_points(source_file, end), + ) + } + const fn with_unclosed_bracket(mut self, is_unclosed_bracket: bool) -> Self { self.is_unclosed_bracket = is_unclosed_bracket; self @@ -184,6 +210,7 @@ impl NormalizedParseDiagnostic { fn cpython_parse_diagnostic_override( error: &parser::ParseError, source_file: &SourceFile, + mode: Mode, ) -> Option { let source_text = source_file.source_text(); @@ -223,6 +250,18 @@ fn cpython_parse_diagnostic_override( &error.error, parser::ParseErrorType::Lexical(parser::LexicalErrorType::LineContinuationError) ) { + // Only a backslash at the end of the source is an EOF error. + let terminal_backslash = source_text.len().checked_sub(1); + if !matches!(mode, Mode::Eval) + && terminal_backslash == Some(error.location.start().to_usize()) + { + let loc = source_line_end_location(source_file, error.location.start()); + return Some(NormalizedParseDiagnostic::new( + parser::ParseErrorType::OtherError("unexpected EOF while parsing".to_owned()), + loc, + loc, + )); + } let loc = source_location(source_file, error.location.start() + TextSize::from(1)); return Some(NormalizedParseDiagnostic::new( error.error.clone(), @@ -231,7 +270,15 @@ fn cpython_parse_diagnostic_override( )); } - source_error!(unterminated_string_error(source_text)); + if let Some((message, start, end)) = unterminated_string_error(source_text) { + // The scanner reports quote positions, which are UTF-8 character boundaries. + return Some(NormalizedParseDiagnostic::other_in_code_points( + source_file, + message, + start, + end, + )); + } source_error!(expected_indented_block_error(error, source_text)); if matches!( @@ -5176,7 +5223,7 @@ fn _compile_with_syntax_warning_handler<'a>( }; let parser_options = parser::ParseOptions::from(parser_mode); let parsed = parser::parse(source_file.source_text(), parser_options) - .map_err(|err| CompileError::from_ruff_parse_error(err, &source_file))?; + .map_err(|err| CompileError::from_ruff_parse_error(err, &source_file, mode))?; if opts.dont_imply_dedent && matches!(mode, Mode::Single) && let Some(error) = dont_imply_dedent_source_error(&source_file) @@ -5235,7 +5282,7 @@ pub fn _compile_symtable( let res = match mode { Mode::Exec | Mode::Single | Mode::BlockExpr => { let ast = ruff_python_parser::parse_module(source_file.source_text()) - .map_err(|e| CompileError::from_ruff_parse_error(e, &source_file))?; + .map_err(|e| CompileError::from_ruff_parse_error(e, &source_file, mode))?; if let Some(error) = post_parse_source_error(&source_file, ast.tokens(), &CompileOpts::default()) { @@ -5254,7 +5301,7 @@ pub fn _compile_symtable( source_file.source_text(), parser::Mode::Expression.into(), ) - .map_err(|e| CompileError::from_ruff_parse_error(e, &source_file))?; + .map_err(|e| CompileError::from_ruff_parse_error(e, &source_file, mode))?; if let Some(error) = post_parse_source_error(&source_file, ast.tokens(), &CompileOpts::default()) { diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index 4facffee15d..ffe5a6f0a41 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -245,7 +245,11 @@ impl VirtualMachine { _ => true, }; - if same_line { + // A lone continuation at EOF has no highlighted source span. + let lone_line_continuation = + maybe_end_offset == Some(-1) && l_text.to_string_lossy() == "\\"; + + if same_line && !lone_line_continuation { let mut end_offset = match maybe_end_offset { Some(0) | None => offset, Some(end_offset) => end_offset, diff --git a/crates/vm/src/stdlib/sys.rs b/crates/vm/src/stdlib/sys.rs index 8f264d7a739..71aeccf34da 100644 --- a/crates/vm/src/stdlib/sys.rs +++ b/crates/vm/src/stdlib/sys.rs @@ -818,8 +818,27 @@ pub mod sys { vm: &VirtualMachine, ) -> PyResult<()> { let stderr = super::get_stderr(vm)?; + // Keep runtime SyntaxErrors on the normal traceback path. + let has_traceback = !vm.is_none(&exc_tb); match vm.normalize_exception(exc_type, exc_val.clone(), exc_tb) { Ok(exc) => { + let native_syntax_error_display = !has_traceback + && exc.fast_isinstance(vm.ctx.exceptions.syntax_error) + && exc + .as_object() + .get_attr("msg", vm) + .ok() + .and_then(|msg| msg.downcast::().ok()) + .is_some_and(|msg| msg.to_string_lossy() == "unexpected EOF while parsing") + && exc + .as_object() + .get_attr("text", vm) + .ok() + .and_then(|text| text.downcast::().ok()) + .is_some_and(|text| text.to_string_lossy().trim_end() == "\\"); + if native_syntax_error_display { + return vm.write_exception(&mut crate::py_io::PyWriter(stderr, vm), &exc); + } // PyErr_Display: try traceback._print_exception_bltin first if let Ok(tb_mod) = vm.import("traceback", 0) && let Ok(print_exc_builtin) = tb_mod.get_attr("_print_exception_bltin", vm) diff --git a/crates/vm/src/vm/python_run.rs b/crates/vm/src/vm/python_run.rs index 91d5885e740..a1c2552cef4 100644 --- a/crates/vm/src/vm/python_run.rs +++ b/crates/vm/src/vm/python_run.rs @@ -113,6 +113,10 @@ mod file_run { "source code cannot contain null bytes".into(), )); } + #[cfg(feature = "parser")] + // Match compile() by honoring BOMs and encoding cookies in files. + let source = self.decode_source_bytes(&source_bytes, path, false)?; + #[cfg(not(feature = "parser"))] let source = String::from_utf8(source_bytes) .map_err(|err| self.new_os_error(err.to_string()))?; let code_obj = self diff --git a/crates/vm/src/vm/vm_new.rs b/crates/vm/src/vm/vm_new.rs index 48909c1a41e..4df3c639182 100644 --- a/crates/vm/src/vm/vm_new.rs +++ b/crates/vm/src/vm/vm_new.rs @@ -752,7 +752,7 @@ impl VirtualMachine { Some(line + "\n") } - let statement = source.and_then(|src| get_statement(src, error.location())); + let mut statement = source.and_then(|src| get_statement(src, error.location())); let mut msg = error.to_string(); if !msg.starts_with("Exceeds the limit ") @@ -799,6 +799,16 @@ impl VirtualMachine { } let SyntaxErrorInfo { msg, narrow_caret } = syntax_error_info; + let unterminated_triple_quoted_string = + msg.starts_with("unterminated triple-quoted string literal"); + let unexpected_eof_error = msg == "unexpected EOF while parsing"; + if unterminated_triple_quoted_string + && let Some(statement) = statement.as_mut() + && statement.ends_with('\n') + { + // CPython omits the parser-added final newline from SyntaxError.text. + statement.pop(); + } let check_version_suite_error = msg.starts_with("Async functions are") || msg.starts_with("Async for loops are") || msg.starts_with("Async with statements are") @@ -820,12 +830,14 @@ impl VirtualMachine { // Set end_lineno and end_offset if available if let Some((end_lineno, end_offset)) = error.python_end_location() { - let (end_lineno, end_offset) = if check_version_suite_error - && statement - .as_deref() - .and_then(|line| line.chars().next()) - .is_some_and(|ch| ch.is_ascii_whitespace()) - { + // EOF errors have no source span in CPython. + let no_end_offset = unexpected_eof_error + || (check_version_suite_error + && statement + .as_deref() + .and_then(|line| line.chars().next()) + .is_some_and(|ch| ch.is_ascii_whitespace())); + let (end_lineno, end_offset) = if no_end_offset { (end_lineno, -1) } else if line_end_binary_operator_error && end_offset == offset_raw { (end_lineno, (end_offset + 1) as isize) From 5b3eb41b49daf29a64947ee40aa9332f32ea7001 Mon Sep 17 00:00:00 2001 From: MsfPablo <129399053+MsfPablo@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:06:48 +0200 Subject: [PATCH 261/351] Fix panic in code.replace() with non-interned strings (#8471) code.replace() called as_interned_str().unwrap() on its string arguments, which panics when the caller passes a string that has not been interned. modulefinder's replace_paths_in_code() builds a fresh co_filename, so any use of ModuleFinder(replace_paths=...) aborted the interpreter. Intern the incoming strings instead, and raise TypeError rather than panicking when a non-string appears in co_names/co_varnames/ co_cellvars/co_freevars. Unskips test_modulefinder.test_replace_paths. Co-authored-by: Pablo Garcia --- Lib/test/test_modulefinder.py | 2 -- crates/vm/src/builtins/code.rs | 38 +++++++++++++++++----------------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/Lib/test/test_modulefinder.py b/Lib/test/test_modulefinder.py index 51f7fd257e0..b64e684f805 100644 --- a/Lib/test/test_modulefinder.py +++ b/Lib/test/test_modulefinder.py @@ -390,8 +390,6 @@ def test_bytecode(self): os.remove(source_path) self._do_test(bytecode_test) - # TODO: RUSTPYTHON; panics at code.rs with 'called Option::unwrap() on a None value' - @unittest.skip("TODO: RUSTPYTHON; panics in co_filename replacement") def test_replace_paths(self): old_path = os.path.join(self.test_dir, 'a', 'module.py') new_path = os.path.join(self.test_dir, 'a', 'spam.py') diff --git a/crates/vm/src/builtins/code.rs b/crates/vm/src/builtins/code.rs index b1132a55a20..b25c9d5c499 100644 --- a/crates/vm/src/builtins/code.rs +++ b/crates/vm/src/builtins/code.rs @@ -1366,19 +1366,25 @@ impl PyCode { OptionalArg::Missing => self.code.instructions.clone(), }; + let intern_all = |objs: Vec, field: &str| -> PyResult> { + objs.into_iter() + .map(|o| { + let s = o.downcast_ref::().ok_or_else(|| { + vm.new_type_error(format!("{field} must be a tuple of strings")) + })?; + Ok(vm.ctx.intern_str(s.as_wtf8())) + }) + .collect::>>() + .map(Vec::into_boxed_slice) + }; + let cellvars = match co_cellvars { - OptionalArg::Present(cellvars) => cellvars - .into_iter() - .map(|o| o.as_interned_str(vm).unwrap()) - .collect(), + OptionalArg::Present(cellvars) => intern_all(cellvars, "co_cellvars")?, OptionalArg::Missing => self.code.cellvars.clone(), }; let freevars = match co_freevars { - OptionalArg::Present(freevars) => freevars - .into_iter() - .map(|o| o.as_interned_str(vm).unwrap()) - .collect(), + OptionalArg::Present(freevars) => intern_all(freevars, "co_freevars")?, OptionalArg::Missing => self.code.freevars.clone(), }; @@ -1411,10 +1417,10 @@ impl PyCode { posonlyarg_count, arg_count, kwonlyarg_count, - source_path: source_path.as_object().as_interned_str(vm).unwrap(), + source_path: vm.ctx.intern_str(source_path.as_wtf8()), first_line_number, - obj_name: obj_name.as_object().as_interned_str(vm).unwrap(), - qualname: qualname.as_object().as_interned_str(vm).unwrap(), + obj_name: vm.ctx.intern_str(obj_name.as_wtf8()), + qualname: vm.ctx.intern_str(qualname.as_wtf8()), max_stackdepth, instructions, @@ -1422,14 +1428,8 @@ impl PyCode { // It can be removed once we move every other code to use linetable only. locations: self.code.locations.clone(), constants: constants.into_iter().map(Literal).collect(), - names: names - .into_iter() - .map(|o| o.as_interned_str(vm).unwrap()) - .collect(), - varnames: varnames - .into_iter() - .map(|o| o.as_interned_str(vm).unwrap()) - .collect(), + names: intern_all(names, "co_names")?, + varnames: intern_all(varnames, "co_varnames")?, cellvars, freevars, localspluskinds: self.code.localspluskinds.clone(), From 3a1caa1eb5c553dc321be18b0b051ff11116f72b Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:08:01 -0400 Subject: [PATCH 262/351] Use C string literals and w! instead of allocs (#8474) Rust supports C string literals which automatically create a CStr with a trailing NUL. `windows-sys` provides an analogous macro for wide strings. Both of these avoid allocations which is nice for constants. --- crates/host_env/src/fileutils.rs | 18 ++++++------- crates/host_env/src/nt.rs | 37 ++++++++++++++------------- crates/host_env/src/winapi.rs | 5 ++-- crates/host_env/src/windows.rs | 43 +++++++++++++++++--------------- crates/host_env/src/wmi.rs | 24 +++++++++--------- 5 files changed, 65 insertions(+), 62 deletions(-) diff --git a/crates/host_env/src/fileutils.rs b/crates/host_env/src/fileutils.rs index a4922e7a2fe..a8e56bb1c0b 100644 --- a/crates/host_env/src/fileutils.rs +++ b/crates/host_env/src/fileutils.rs @@ -1,6 +1,8 @@ // Python/fileutils.c in CPython #![allow(non_snake_case)] +use alloc::ffi::CString; + #[cfg(not(windows))] pub use rustix::fs::Stat as StatStruct; @@ -16,9 +18,8 @@ pub fn fstat(fd: crate::crt_fd::Borrowed<'_>) -> std::io::Result { pub mod windows { use crate::crt_fd; use crate::windows::ToWideString; - use alloc::ffi::CString; use libc::{S_IFCHR, S_IFDIR, S_IFMT}; - use std::ffi::{OsStr, OsString}; + use std::ffi::OsStr; use std::os::windows::io::AsRawHandle; use std::sync::OnceLock; use windows_sys::Win32::Foundation::{ @@ -33,6 +34,7 @@ pub mod windows { use windows_sys::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW}; use windows_sys::Win32::System::SystemServices::IO_REPARSE_TAG_SYMLINK; use windows_sys::core::PCWSTR; + use windows_sys::w; pub const S_IFIFO: libc::c_int = 0o010000; pub const S_IFLNK: libc::c_int = 0o120000; @@ -302,16 +304,13 @@ pub mod windows { let GetFileInformationByName = GET_FILE_INFORMATION_BY_NAME .get_or_init(|| { - let library_name = - OsString::from("api-ms-win-core-file-l2-1-4.dll").to_wide_with_nul(); - let module = unsafe { LoadLibraryW(library_name.as_ptr()) }; + let library_name = w!("api-ms-win-core-file-l2-1-4.dll"); + let module = unsafe { LoadLibraryW(library_name) }; if module.is_null() { return None; } - let name = CString::new("GetFileInformationByName").unwrap(); - if let Some(proc) = - unsafe { GetProcAddress(module, name.as_bytes_with_nul().as_ptr()) } - { + let name = c"GetFileInformationByName"; + if let Some(proc) = unsafe { GetProcAddress(module, name.as_ptr().cast()) } { Some(unsafe { core::mem::transmute::< unsafe extern "system" fn() -> isize, @@ -458,7 +457,6 @@ pub unsafe fn fclose(fp: *mut CFile) -> core::ffi::c_int { reason = "false positive: core::io::ErrorKind is unstable (core_io)" )] pub fn fopen(path: &std::path::Path, mode: &str) -> std::io::Result<*mut CFile> { - use alloc::ffi::CString; use std::fs::File; // Currently only supports read mode diff --git a/crates/host_env/src/nt.rs b/crates/host_env/src/nt.rs index 780a75910ea..7e0591600b1 100644 --- a/crates/host_env/src/nt.rs +++ b/crates/host_env/src/nt.rs @@ -22,19 +22,22 @@ use crate::{ windows::{CheckWin32Bool, CheckWin32Handle, CheckWin32Sentinel, HandleToOwned, ToWideString}, }; use libc::intptr_t; -use windows_sys::Win32::{ - Foundation::{ - CloseHandle, ERROR_INVALID_HANDLE, GetLastError, HANDLE, INVALID_HANDLE_VALUE, MAX_PATH, - }, - Globalization::{CP_UTF8, MultiByteToWideChar, WideCharToMultiByte}, - Storage::FileSystem::{ - CreateFileW, FILE_BASIC_INFO, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, - FILE_READ_ATTRIBUTES, FILE_TYPE_UNKNOWN, FileBasicInfo, FindClose, FindFirstFileW, - GetFileAttributesW, GetFileInformationByHandleEx, GetFileType, GetFullPathNameW, - INVALID_FILE_ATTRIBUTES, OPEN_EXISTING, SetFileAttributesW, SetFileInformationByHandle, - WIN32_FIND_DATAW, +use windows_sys::{ + Win32::{ + Foundation::{ + CloseHandle, ERROR_INVALID_HANDLE, GetLastError, HANDLE, INVALID_HANDLE_VALUE, MAX_PATH, + }, + Globalization::{CP_UTF8, MultiByteToWideChar, WideCharToMultiByte}, + Storage::FileSystem::{ + CreateFileW, FILE_BASIC_INFO, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, + FILE_READ_ATTRIBUTES, FILE_TYPE_UNKNOWN, FileBasicInfo, FindClose, FindFirstFileW, + GetFileAttributesW, GetFileInformationByHandleEx, GetFileType, GetFullPathNameW, + INVALID_FILE_ATTRIBUTES, OPEN_EXISTING, SetFileAttributesW, SetFileInformationByHandle, + WIN32_FIND_DATAW, + }, + System::{Console, Threading}, }, - System::{Console, Threading}, + w, }; pub type Handle = HANDLE; @@ -1172,12 +1175,10 @@ pub fn mkdir(path: &widestring::WideCStr, mode: i32) -> io::Result<()> { lpSecurityDescriptor: core::ptr::null_mut(), bInheritHandle: 0, }; - let sddl: Vec = "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;OW)\0" - .encode_utf16() - .collect(); + let sddl = w!("D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;OW)"); unsafe { ConvertStringSecurityDescriptorToSecurityDescriptorW( - sddl.as_ptr(), + sddl, SDDL_REVISION_1, &mut sec_attr.lpSecurityDescriptor, core::ptr::null_mut(), @@ -1699,10 +1700,10 @@ pub fn get_terminal_size_handle(h: HANDLE) -> io::Result<(usize, usize)> { if err != windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED { return Err(io::Error::last_os_error()); } - let conout: Vec = "CONOUT$\0".encode_utf16().collect(); + let conout = w!("CONOUT$"); let console_handle = unsafe { CreateFileW( - conout.as_ptr(), + conout, windows_sys::Win32::Foundation::GENERIC_READ | windows_sys::Win32::Foundation::GENERIC_WRITE, windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ diff --git a/crates/host_env/src/winapi.rs b/crates/host_env/src/winapi.rs index af53910089e..19e18f32d3f 100644 --- a/crates/host_env/src/winapi.rs +++ b/crates/host_env/src/winapi.rs @@ -65,6 +65,7 @@ pub use windows_sys::Win32::{ }, UI::WindowsAndMessaging::SW_HIDE, }; +use windows_sys::w; pub type Handle = HANDLE; pub type StdHandle = windows_sys::Win32::System::Console::STD_HANDLE; @@ -1093,14 +1094,14 @@ where return Err(MimeRegistryReadError::Os(err)); } - let content_type_key: Vec = "Content Type\0".encode_utf16().collect(); + let content_type_key = w!("Content Type"); let mut type_buf = [0u16; 256]; let mut cb_type = (type_buf.len() * 2) as u32; let mut reg_type = 0; let err = unsafe { RegQueryValueExW( subkey, - content_type_key.as_ptr(), + content_type_key, core::ptr::null_mut(), &mut reg_type, type_buf.as_mut_ptr().cast(), diff --git a/crates/host_env/src/windows.rs b/crates/host_env/src/windows.rs index bde8d679737..635f12f3f38 100644 --- a/crates/host_env/src/windows.rs +++ b/crates/host_env/src/windows.rs @@ -4,24 +4,27 @@ use std::{ io, os::windows::ffi::{OsStrExt, OsStringExt}, }; -use windows_sys::Win32::{ - Foundation::{ - E_POINTER, ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_FLAGS, ERROR_NO_UNICODE_TRANSLATION, - MAX_PATH, S_OK, - }, - Networking::WinSock::WSAStartup, - Storage::FileSystem::{ - GetFileVersionInfoSizeW, GetFileVersionInfoW, VS_FIXEDFILEINFO, VerQueryValueW, - }, - System::{ - Diagnostics::Debug::{ - FORMAT_MESSAGE_ALLOCATE_BUFFER, FORMAT_MESSAGE_FROM_SYSTEM, - FORMAT_MESSAGE_IGNORE_INSERTS, FormatMessageW, +use windows_sys::{ + Win32::{ + Foundation::{ + E_POINTER, ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_FLAGS, + ERROR_NO_UNICODE_TRANSLATION, MAX_PATH, S_OK, + }, + Networking::WinSock::WSAStartup, + Storage::FileSystem::{ + GetFileVersionInfoSizeW, GetFileVersionInfoW, VS_FIXEDFILEINFO, VerQueryValueW, + }, + System::{ + Diagnostics::Debug::{ + FORMAT_MESSAGE_ALLOCATE_BUFFER, FORMAT_MESSAGE_FROM_SYSTEM, + FORMAT_MESSAGE_IGNORE_INSERTS, FormatMessageW, + }, + LibraryLoader::{GetModuleFileNameW, GetModuleHandleW}, + SystemInformation::{GetVersionExW, OSVERSIONINFOEXW, OSVERSIONINFOW}, + Threading::{GetCurrentThreadStackLimits, SetThreadStackGuarantee}, }, - LibraryLoader::{GetModuleFileNameW, GetModuleHandleW}, - SystemInformation::{GetVersionExW, OSVERSIONINFOEXW, OSVERSIONINFOW}, - Threading::{GetCurrentThreadStackLimits, SetThreadStackGuarantee}, }, + w, }; /// _MAX_ENV from Windows CRT stdlib.h - maximum environment variable size @@ -154,8 +157,8 @@ pub struct WindowsVersionInfo { fn get_kernel32_version() -> io::Result<(u32, u32, u32)> { unsafe { - let module_name: Vec = OsStr::new("kernel32.dll").to_wide_with_nul(); - let h_kernel32 = GetModuleHandleW(module_name.as_ptr()).check_nonnull()?; + let module_name = w!("kernel32.dll"); + let h_kernel32 = GetModuleHandleW(module_name).check_nonnull()?; let mut kernel32_path = [0u16; MAX_PATH as usize]; let len = GetModuleFileNameW( @@ -181,13 +184,13 @@ fn get_kernel32_version() -> io::Result<(u32, u32, u32)> { ) .check_win32_bool()?; - let sub_block: Vec = OsStr::new("").to_wide_with_nul(); + let sub_block = w!(""); let mut ffi_ptr: *mut VS_FIXEDFILEINFO = core::ptr::null_mut(); let mut ffi_len: u32 = 0; VerQueryValueW( ver_block.as_ptr() as *const _, - sub_block.as_ptr(), + sub_block, &mut ffi_ptr as *mut *mut VS_FIXEDFILEINFO as *mut *mut _, &mut ffi_len as *mut u32, ) diff --git a/crates/host_env/src/wmi.rs b/crates/host_env/src/wmi.rs index 592ffd1dc23..2b46eebcbe5 100644 --- a/crates/host_env/src/wmi.rs +++ b/crates/host_env/src/wmi.rs @@ -7,15 +7,17 @@ use core::ffi::c_void; use core::ptr::{NonNull, null, null_mut}; +use widestring::WideCString; use windows_sys::Win32::Foundation::{ - CloseHandle, ERROR_BROKEN_PIPE, ERROR_MORE_DATA, ERROR_NOT_ENOUGH_MEMORY, GetLastError, HANDLE, - WAIT_OBJECT_0, WAIT_TIMEOUT, + CloseHandle, ERROR_BROKEN_PIPE, ERROR_INVALID_NAME, ERROR_MORE_DATA, ERROR_NOT_ENOUGH_MEMORY, + GetLastError, HANDLE, WAIT_OBJECT_0, WAIT_TIMEOUT, }; use windows_sys::Win32::Storage::FileSystem::{ReadFile, WriteFile}; use windows_sys::Win32::System::Pipes::CreatePipe; use windows_sys::Win32::System::Threading::{ CreateEventW, CreateThread, GetExitCodeThread, SetEvent, WaitForSingleObject, }; +use windows_sys::w; use crate::ctypes::wcslen; @@ -256,10 +258,6 @@ const fn failed(hr: HRESULT) -> bool { hr < 0 } -fn wide_str(s: &str) -> Vec { - s.encode_utf16().chain(core::iter::once(0)).collect() -} - unsafe fn wait_event(event: HANDLE, timeout: u32) -> u32 { match unsafe { WaitForSingleObject(event, timeout) } { WAIT_OBJECT_0 => 0, @@ -346,8 +344,8 @@ unsafe fn query_thread_impl(param: *mut c_void) -> u32 { } if succeeded(hr) { - let root_cimv2 = wide_str("ROOT\\CIMV2"); - let bstr_root = unsafe { SysAllocString(root_cimv2.as_ptr()) }; + let root_cimv2 = w!("ROOT\\CIMV2"); + let bstr_root = unsafe { SysAllocString(root_cimv2) }; hr = unsafe { locator_connect_server( locator, @@ -384,8 +382,8 @@ unsafe fn query_thread_impl(param: *mut c_void) -> u32 { }; } if succeeded(hr) { - let wql = wide_str("WQL"); - let bstr_wql = unsafe { SysAllocString(wql.as_ptr()) }; + let wql = w!("WQL"); + let bstr_wql = unsafe { SysAllocString(wql) }; hr = unsafe { services_exec_query( services, @@ -557,7 +555,9 @@ unsafe fn query_thread_impl(param: *mut c_void) -> u32 { } pub fn exec_query(query_str: &str) -> Result { - let query_wide = wide_str(query_str); + let query = WideCString::from_str(query_str) + .map_err(|_| ExecQueryError::Code(ERROR_INVALID_NAME))? + .into(); let mut h_thread: HANDLE = null_mut(); let mut err: u32 = 0; @@ -579,7 +579,7 @@ pub fn exec_query(query_str: &str) -> Result { err = GetLastError(); } else { let thread_data = Box::new(QueryThreadData { - query: query_wide, + query, write_pipe, init_event, connect_event, From 689c8b57e7cc8f9b834dfd041cd5fa493c8018d2 Mon Sep 17 00:00:00 2001 From: Jiwoo Ahn Date: Sun, 9 Aug 2026 15:09:18 +0900 Subject: [PATCH 263/351] wasi: fix os.environb byte handling (#8476) Part of: #4583 This will enable -m test for wasi preview 1 Signed-off-by: Jiwoo Ahn --- crates/host_env/src/os.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/host_env/src/os.rs b/crates/host_env/src/os.rs index 01711687c6c..7af8f586110 100644 --- a/crates/host_env/src/os.rs +++ b/crates/host_env/src/os.rs @@ -519,13 +519,14 @@ pub fn set_errno(value: i32) { #[cfg(not(any(unix, windows, target_os = "wasi")))] pub fn set_errno(_value: i32) {} -#[cfg(unix)] +// WASIp1, like Unix, provides byte-preserving OsStr conversions. +#[cfg(any(unix, all(target_os = "wasi", not(target_env = "p2"))))] pub fn bytes_as_os_str(b: &[u8]) -> Result<&std::ffi::OsStr, Utf8Error> { - use std::os::unix::ffi::OsStrExt; + use self::ffi::OsStrExt; Ok(std::ffi::OsStr::from_bytes(b)) } -#[cfg(not(unix))] +#[cfg(not(any(unix, all(target_os = "wasi", not(target_env = "p2")))))] pub fn bytes_as_os_str(b: &[u8]) -> Result<&std::ffi::OsStr, Utf8Error> { Ok(core::str::from_utf8(b)?.as_ref()) } From bcf5c4b193d92244b9229842c50b2d76b5f66804 Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:43:44 +0900 Subject: [PATCH 264/351] ssl: support explicit session reuse (#8466) * ssl: support explicit session reuse Assisted-by: Codex-5.6-sol * ssl: support explicit TLS 1.3 session reuse Assisted-by: Codex-5.6-sol --- Lib/test/test_ssl.py | 2 - crates/stdlib/src/ssl.rs | 388 +++++++++++++++++++++++++-------------- 2 files changed, 247 insertions(+), 143 deletions(-) diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 8dad1ba7382..e1759f1aa6b 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -4556,7 +4556,6 @@ def test_sendfile(self): s.sendfile(file) self.assertEqual(s.recv(1024), TEST_DATA) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_session(self): client_context, server_context, hostname = testing_context() # TODO: sessions aren't compatible with TLSv1.3 yet @@ -4614,7 +4613,6 @@ def test_session(self): self.assertEqual(sess_stat['accept'], 4) self.assertEqual(sess_stat['hits'], 2) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: False != True def test_session_handling(self): client_context, server_context, hostname = testing_context() client_context2, _, _ = testing_context() diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 454fdcba899..04b905d544e 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -137,6 +137,8 @@ mod _ssl { #[pyattr] const PROTOCOL_TLSv1_3: i32 = 6; + static NEXT_SSL_SESSION_NONCE: AtomicUsize = AtomicUsize::new(1); + // Protocol version constants for TLSVersion enum #[pyattr] const PROTO_SSLv3: i32 = 0x0300; @@ -439,6 +441,31 @@ mod _ssl { lifetime: u64, } + impl SessionData { + // NOTE: This is NOT the actual TLS session ID, just a unique identifier. + fn new(server_name: &str, lifetime: u64) -> Self { + let creation_time = SystemTime::now(); + let nonce = NEXT_SSL_SESSION_NONCE.fetch_add(1, Ordering::Relaxed); + let mut hasher = Sha256::new(); + hasher.update(server_name.as_bytes()); + hasher.update( + creation_time + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .to_le_bytes(), + ); + hasher.update(nonce.to_le_bytes()); + + Self { + _server_name: server_name.to_owned(), + session_id: hasher.finalize()[..16].to_vec(), + creation_time, + lifetime, + } + } + } + // Type alias to simplify complex session cache type type SessionCache = Arc, Arc>>>>; @@ -466,20 +493,6 @@ mod _ssl { // ✓ session_reused - tracked via handshake_kind() // ✗ Actual TLS session ID/ticket data - NOT ACCESSIBLE - // Generate a synthetic session ID from server name and timestamp - // NOTE: This is NOT the actual TLS session ID, just a unique identifier - fn generate_session_id_from_metadata(server_name: &str, time: SystemTime) -> Vec { - let mut hasher = Sha256::new(); - hasher.update(server_name.as_bytes()); - hasher.update( - time.duration_since(SystemTime::UNIX_EPOCH) - .unwrap() - .as_secs() - .to_le_bytes(), - ); - hasher.finalize()[..16].to_vec() - } - // Custom ClientSessionStore that tracks session metadata for Python access // NOTE: This wraps ClientSessionMemoryCache and records metadata when sessions are stored #[derive(Debug)] @@ -488,6 +501,39 @@ mod _ssl { session_cache: SessionCache, } + impl PythonClientSessionStore { + fn new(session_cache: SessionCache) -> Self { + Self { + inner: Arc::new(ClientSessionMemoryCache::new(SSL_SESSION_CACHE_SIZE)), + session_cache, + } + } + + fn transfer_session( + &self, + target: &Self, + server_name: &ServerName<'static>, + kind: ClientSessionKind, + ) { + if let Some(group) = self.kx_hint(server_name) { + target.set_kx_hint(server_name.clone(), group); + } + + match kind { + ClientSessionKind::Tls12 => { + if let Some(session) = self.tls12_session(server_name) { + target.set_tls12_session(server_name.clone(), session); + } + } + ClientSessionKind::Tls13 => { + if let Some(ticket) = self.take_tls13_ticket(server_name) { + target.insert_tls13_ticket(server_name.clone(), ticket); + } + } + } + } + } + impl ClientSessionStore for PythonClientSessionStore { fn set_kx_hint(&self, server_name: ServerName<'static>, group: rustls::NamedGroup) { self.inner.set_kx_hint(server_name, group); @@ -508,17 +554,8 @@ mod _ssl { // Record metadata in Python-accessible cache // NOTE: We can't access value.session_id or value.ticket (private fields) // So we generate a synthetic ID from metadata - let creation_time = SystemTime::now(); let server_name_str = server_name.to_str(); - let session_data = SessionData { - _server_name: server_name_str.as_ref().to_string(), - session_id: generate_session_id_from_metadata( - server_name_str.as_ref(), - creation_time, - ), - creation_time, - lifetime: 7200, // TLS 1.2 default session lifetime - }; + let session_data = SessionData::new(server_name_str.as_ref(), 7200); let key = server_name_str.as_bytes().to_vec(); self.session_cache @@ -552,17 +589,8 @@ mod _ssl { // Record metadata in Python-accessible cache // NOTE: We can't access value.ticket or value.lifetime_secs (private fields) // So we use default values - let creation_time = SystemTime::now(); let server_name_str = server_name.to_str(); - let session_data = SessionData { - _server_name: server_name_str.to_string(), - session_id: generate_session_id_from_metadata( - server_name_str.as_ref(), - creation_time, - ), - creation_time, - lifetime: 7200, // Default TLS 1.3 ticket lifetime (Rustls uses this) - }; + let session_data = SessionData::new(server_name_str.as_ref(), 7200); let key = server_name_str.as_bytes().to_vec(); self.session_cache @@ -745,6 +773,8 @@ mod _ssl { #[pyclass(name = "_SSLContext", module = "ssl", traverse)] #[derive(Debug, PyPayload)] struct PySSLContext { + #[pytraverse(skip)] + context_identity: Arc<()>, #[pytraverse(skip)] protocol: i32, #[pytraverse(skip)] @@ -807,9 +837,6 @@ mod _ssl { // Session management #[pytraverse(skip)] client_session_cache: SessionCache, - // Rustls session store for actual TLS session resumption - #[pytraverse(skip)] - rustls_session_store: Arc, // Rustls server session store for server-side session resumption #[pytraverse(skip)] rustls_server_session_store: Arc, @@ -1926,8 +1953,9 @@ mod _ssl { .map(|o| o.downgrade(None, vm)) .transpose()?, ), - // Filter out Python None objects - only store actual SSLSession objects - session: PyRwLock::new(args.session.into_option().filter(|s| !vm.is_none(s))), + session: PyRwLock::new(None), + client_config: PyRwLock::new(None), + client_session_store: PyRwLock::new(None), incoming_bio: None, outgoing_bio: None, sni_state: PyRwLock::new(None), @@ -1945,6 +1973,12 @@ mod _ssl { .into_ref_with_type(vm, vm.class("_ssl", "_SSLSocket")) .map_err(|_| vm.new_type_error("Failed to create SSLSocket"))?; + if let Some(session) = args.session.into_option() + && !vm.is_none(&session) + { + ssl_socket_ref.set_session(session, vm)?; + } + Ok(ssl_socket_ref) } @@ -2008,8 +2042,9 @@ mod _ssl { .map(|o| o.downgrade(None, vm)) .transpose()?, ), - // Filter out Python None objects - only store actual SSLSession objects - session: PyRwLock::new(args.session.into_option().filter(|s| !vm.is_none(s))), + session: PyRwLock::new(None), + client_config: PyRwLock::new(None), + client_session_store: PyRwLock::new(None), incoming_bio: Some(args.incoming), outgoing_bio: Some(args.outgoing), sni_state: PyRwLock::new(None), @@ -2026,6 +2061,12 @@ mod _ssl { .into_ref_with_type(vm, vm.class("_ssl", "_SSLSocket")) .map_err(|_| vm.new_type_error("Failed to create SSLSocket"))?; + if let Some(session) = args.session.into_option() + && !vm.is_none(&session) + { + ssl_socket_ref.set_session(session, vm)?; + } + Ok(ssl_socket_ref) } @@ -2304,19 +2345,11 @@ mod _ssl { _ => (PROTO_MINIMUM_SUPPORTED, PROTO_MAXIMUM_SUPPORTED), // Auto-negotiate }; - // IMPORTANT: Create shared session cache BEFORE PySSLContext - // Both client_session_cache and PythonClientSessionStore.session_cache - // MUST point to the same HashMap to ensure Python-level and Rustls-level - // sessions are synchronized + // Session metadata is scoped to the SSLContext. Each per-connection + // rustls session store records into this shared cache. let shared_session_cache = Arc::new(ParkingRwLock::new(HashMap::new())); - let rustls_client_store = Arc::new(PythonClientSessionStore { - inner: Arc::new(rustls::client::ClientSessionMemoryCache::new( - SSL_SESSION_CACHE_SIZE, - )), - session_cache: shared_session_cache.clone(), - }); - Ok(Self { + context_identity: Arc::new(()), protocol, check_hostname: PyRwLock::new(protocol == PROTOCOL_TLS_CLIENT), verify_mode: PyRwLock::new(default_verify_mode), @@ -2340,7 +2373,6 @@ mod _ssl { x509_cert_count: PyRwLock::new(0), // Use the shared cache created above client_session_cache: shared_session_cache, - rustls_session_store: rustls_client_store, rustls_server_session_store: rustls::server::ServerSessionMemoryCache::new( SSL_SESSION_CACHE_SIZE, ), @@ -2390,6 +2422,13 @@ mod _ssl { owner: PyRwLock>>, // Session for resumption session: PyRwLock>, + // Client configuration used by this connection. Retained so the resulting + // SSLSession can reuse the same verifier and client credentials. + #[pytraverse(skip)] + client_config: PyRwLock>>, + // Per-connection store containing the session selected by this connection. + #[pytraverse(skip)] + client_session_store: PyRwLock>>, // MemoryBIO mode (optional) incoming_bio: Option>, outgoing_bio: Option>, @@ -2487,31 +2526,27 @@ mod _ssl { } // Create and store a session object after successful handshake - fn create_session_after_handshake(&self, vm: &VirtualMachine) { + fn create_session_after_handshake(&self, was_resumed: bool, vm: &VirtualMachine) { // Only create session for client-side connections if self.server_side { return; } - // Check if session already exists - let session_opt = self.session.read().clone(); - if let Some(ref s) = session_opt { - if vm.is_none(s) { - } else { - return; - } - } - // Get server hostname let server_name = self.server_hostname.read().clone(); + let previous_session = self.session.read().clone(); // Try to get session data from context's session cache // IMPORTANT: Acquire and release locks quickly to avoid deadlock - let context = self.context.read(); - let session_cache_arc = context.client_session_cache.clone(); - drop(context); // Release context lock ASAP + let (context_identity, session_cache_arc) = { + let context = self.context.read(); + ( + context.context_identity.clone(), + context.client_session_cache.clone(), + ) + }; - let (session_id, creation_time, lifetime) = if let Some(ref name) = server_name { + let cached_session_data = if let Some(ref name) = server_name { let key = name.as_bytes().to_vec(); // Clone the data we need while holding the lock, then immediately release @@ -2521,29 +2556,57 @@ mod _ssl { }; // Lock released here if let Some(session_data_arc) = session_data_opt { - let data = session_data_arc.lock(); - let result = (data.session_id.clone(), data.creation_time, data.lifetime); - drop(data); // Explicit unlock - result + session_data_arc.lock().clone() } else { - // Create new session ID if not in cache - let time = std::time::SystemTime::now(); - (generate_session_id_from_metadata(name, time), time, 7200) + SessionData::new(name, 7200) } } else { - // No server name, use defaults - let time = std::time::SystemTime::now(); - (vec![0; 16], time, 7200) + SessionData::new("", 7200) + }; + + let session_data = if was_resumed { + previous_session + .as_ref() + .and_then(|session| session.downcast_ref::()) + .map_or(cached_session_data, |session| SessionData { + _server_name: server_name.clone().unwrap_or_default(), + session_id: session.session_id.clone(), + creation_time: session.creation_time, + lifetime: session.lifetime, + }) + } else { + cached_session_data + }; + + let rustls_server_name = server_name.and_then(|name| ServerName::try_from(name).ok()); + let protocol_version = self + .connection + .lock() + .as_ref() + .and_then(|connection| connection.protocol_version()); + let session_kind = match protocol_version { + Some(rustls::ProtocolVersion::TLSv1_2) => ClientSessionKind::Tls12, + Some(rustls::ProtocolVersion::TLSv1_3) => ClientSessionKind::Tls13, + _ => return, + }; + + let Some(client_config) = self.client_config.write().take() else { + return; + }; + let Some(session_store) = self.client_session_store.write().take() else { + return; }; - // Create a new SSLSession object with real metadata let session = PySSLSession { - // Use dummy session data to indicate we have a ticket - // TLS 1.2+ always uses session tickets/resumption - session_data: vec![1], // Non-empty to indicate has_ticket=True - session_id, - creation_time, - lifetime, + context_identity, + client_config, + session_store, + server_name: rustls_server_name, + kind: session_kind, + session_id: session_data.session_id, + creation_time: session_data.creation_time, + lifetime: session_data.lifetime, + has_ticket: true, }; let py_session = session.into_pyobject(vm); @@ -2638,7 +2701,7 @@ mod _ssl { let _ = self.track_used_ca_from_capath(); } - self.create_session_after_handshake(vm); + self.create_session_after_handshake(was_resumed, vm); } // Internal implementation with timeout control @@ -3486,9 +3549,9 @@ mod _ssl { let check_hostname = *ctx.check_hostname.read(); let verify_flags = *ctx.verify_flags.read(); + let context_identity = ctx.context_identity.clone(); - // Get session store before dropping ctx - let session_store = ctx.rustls_session_store.clone(); + let session_cache = ctx.client_session_cache.clone(); // Get CRLs for revocation checking let crls_clone = ctx.crls.read().clone(); @@ -3496,31 +3559,6 @@ mod _ssl { // Drop ctx early to avoid borrow conflicts drop(ctx); - // Build client config using compat helper - let config_options = ClientConfigOptions { - protocol_settings, - root_store: if verify_mode != CERT_NONE { - Some(root_store_clone) - } else { - None - }, - ca_certs_der: ca_certs_der_clone, - cert_chain: if !cert_chain_clone.is_empty() { - Some(cert_chain_clone) - } else { - None - }, - private_key: private_key_opt, - verify_server_cert: verify_mode != CERT_NONE, - check_hostname, - verify_flags, - session_store: Some(session_store), - crls: crls_clone, - }; - - let config = - create_client_config(config_options).map_err(|e| vm.new_value_error(e))?; - // Parse server name for SNI // Convert to ServerName use rustls::pki_types::ServerName; @@ -3539,10 +3577,61 @@ mod _ssl { ) }; - let conn = ClientConnection::new(Arc::new(config), server_name.clone()) - .map_err(|e| { - vm.new_value_error(format!("Failed to create client connection: {e}")) - })?; + let explicit_session = self.session.read().clone(); + let session_store = Arc::new(PythonClientSessionStore::new(session_cache)); + let config = if let Some(session) = explicit_session { + let session = session + .downcast_ref::() + .ok_or_else(|| vm.new_type_error("Value is not a SSLSession."))?; + if !Arc::ptr_eq(&session.context_identity, &context_identity) { + return Err( + vm.new_value_error("Session refers to a different SSLContext.") + ); + } + if session.server_name.as_ref() == Some(&server_name) { + session.session_store.transfer_session( + &session_store, + &server_name, + session.kind, + ); + } + let mut config = (*session.client_config).clone(); + config.resumption = + rustls::client::Resumption::store(session_store.clone()); + Arc::new(config) + } else { + let config_options = ClientConfigOptions { + protocol_settings, + root_store: if verify_mode != CERT_NONE { + Some(root_store_clone) + } else { + None + }, + ca_certs_der: ca_certs_der_clone, + cert_chain: if !cert_chain_clone.is_empty() { + Some(cert_chain_clone) + } else { + None + }, + private_key: private_key_opt, + verify_server_cert: verify_mode != CERT_NONE, + check_hostname, + verify_flags, + session_store: Some(session_store.clone()), + crls: crls_clone, + }; + Arc::new( + create_client_config(config_options) + .map_err(|e| vm.new_value_error(e))?, + ) + }; + + *self.client_config.write() = Some(config.clone()); + *self.client_session_store.write() = Some(session_store); + + let conn = ClientConnection::new(config, server_name).map_err(|e| { + vm.new_value_error(format!("Failed to create client connection: {e}")) + })?; *conn_guard = Some(Connection::Client(conn)); } @@ -4046,12 +4135,15 @@ mod _ssl { #[pygetset(setter)] fn set_session(&self, value: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { - // Validate that value is an SSLSession - if !value.is(vm.ctx.types.none_type) { - // Try to downcast to SSLSession to validate - let _ = value - .downcast_ref::() - .ok_or_else(|| vm.new_type_error("Value is not a SSLSession."))?; + let session = value + .downcast_ref::() + .ok_or_else(|| vm.new_type_error("Value is not a SSLSession."))?; + + if !Arc::ptr_eq( + &session.context_identity, + &self.context.read().context_identity, + ) { + return Err(vm.new_value_error("Session refers to a different SSLContext.")); } // Check if this is a client socket @@ -4065,11 +4157,7 @@ mod _ssl { } // Store the session for potential use during handshake - *self.session.write() = if value.is(vm.ctx.types.none_type) { - None - } else { - Some(value) - }; + *self.session.write() = Some(value); Ok(()) } @@ -4751,22 +4839,31 @@ mod _ssl { // SSLSession - represents a cached SSL session // NOTE: This is an EMULATION - actual session data is managed by Rustls internally + #[derive(Debug, Clone, Copy)] + enum ClientSessionKind { + Tls12, + Tls13, + } + #[pyattr] #[pyclass(name = "SSLSession", module = "ssl")] #[derive(Debug, PyPayload)] struct PySSLSession { - // Session data - serialized rustls session (EMULATED - kept empty) - session_data: Vec, + context_identity: Arc<()>, + client_config: Arc, + session_store: Arc, + server_name: Option>, + kind: ClientSessionKind, // Session ID - synthetic ID generated from metadata (NOT actual TLS session ID) - #[allow(dead_code)] session_id: Vec, // Session metadata creation_time: std::time::SystemTime, // Lifetime in seconds (default 7200 = 2 hours) lifetime: u64, + has_ticket: bool, } - #[pyclass(flags(BASETYPE))] + #[pyclass(flags(BASETYPE), with(Comparable))] impl PySSLSession { #[pygetset] fn time(&self) -> i64 { @@ -4791,20 +4888,29 @@ mod _ssl { #[pygetset] fn id(&self, vm: &VirtualMachine) -> PyBytesRef { - // Return session ID (hash of session data for uniqueness) - - let mut hasher = DefaultHasher::new(); - self.session_data.hash(&mut hasher); - let hash = hasher.finish(); - - // Convert hash to bytes - vm.ctx.new_bytes(hash.to_be_bytes().to_vec()) + vm.ctx.new_bytes(self.session_id.clone()) } #[pygetset] fn has_ticket(&self) -> bool { - // For rustls, if we have session data, we have a ticket - !self.session_data.is_empty() + self.has_ticket + } + } + + impl Comparable for PySSLSession { + fn cmp( + zelf: &Py, + other: &PyObject, + op: PyComparisonOp, + _vm: &VirtualMachine, + ) -> PyResult { + op.eq_only(|| { + if let Some(other_session) = other.downcast_ref::() { + Ok((zelf.session_id == other_session.session_id).into()) + } else { + Ok(PyComparisonValue::NotImplemented) + } + }) } } From dca9b09cb3053795864075104989cf49920d8146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EB=8F=99=EC=95=88?= Date: Mon, 10 Aug 2026 00:50:46 +0900 Subject: [PATCH 265/351] Add new_payload_exception helper for constructing built-in payload exceptions (#8403) * Add new_payload_exception helper for built-in payload exceptions * Use new_payload_exception for SystemExit raise sites * Use new_payload_exception for BlockingIoError raise sites * Use new_payload_exception for OSError errno dispatch in slot_new * Run rustfmt * Run clippy * Add type guard to new_payload_exception and extract new_system_exit * Extract new_system_exit and fix stale expect messages The four SystemExit raise sites repeated the same construction, so route them through a new_system_exit helper next to new_stop_iteration. The expect messages still described invoke_exception's downcast failure, which no longer applies after moving to new_payload_exception. * Update crates/vm/src/vm/vm_new.rs --------- Co-authored-by: Jeong, YunWon <69878+youknowone@users.noreply.github.com> --- crates/vm/src/exceptions.rs | 15 ++++---- crates/vm/src/stdlib/_io.rs | 60 +++++++++++++++++++------------- crates/vm/src/stdlib/_thread.rs | 2 +- crates/vm/src/stdlib/builtins.rs | 2 +- crates/vm/src/stdlib/sys.rs | 3 +- crates/vm/src/vm/mod.rs | 2 +- crates/vm/src/vm/vm_new.rs | 49 +++++++++++++++++++------- 7 files changed, 82 insertions(+), 51 deletions(-) diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index ffe5a6f0a41..9c42df966ea 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -1389,14 +1389,8 @@ impl OSErrorBuilder { vec![strerror.to_pyobject(vm)] }; - let payload = PyOSError::py_new(&exc_type, args.clone().into(), vm) - .expect("new_os_error usage error"); - let os_error = payload - .into_ref_with_type_lazy_dict(vm, exc_type) - .expect("new_os_error usage error"); - PyOSError::slot_init(os_error.as_object().to_owned(), args.into(), vm) - .expect("new_os_error usage error"); - os_error + vm.new_payload_exception::(exc_type, args.into()) + .expect("new_os_error usage error") } } @@ -2148,7 +2142,10 @@ pub(super) mod types { .downcast_ref::() .and_then(|errno| errno.try_to_primitive::(vm).ok()) .and_then(|errno| super::errno_to_exc_type(errno, vm)) - .and_then(|typ| vm.invoke_exception(typ, args_vec).ok()) + .and_then(|typ| { + vm.new_payload_exception::(typ.to_owned(), args_vec.into()) + .ok() + }) { return error.to_pyresult(vm); } diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index 43a869fbecf..ab1be4297ec 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -20,7 +20,8 @@ cfg_select! { } use crate::{ - AsObject, PyObject, PyObjectRef, PyResult, TryFromObject, VirtualMachine, builtins::PyModule, + AsObject, PyObject, PyObjectRef, PyResult, TryFromObject, VirtualMachine, + builtins::{PyModule, PyOSError}, }; pub use _io::{OpenArgs, io_open as open}; use rustpython_host_env::io as host_io; @@ -943,14 +944,17 @@ mod _io { Some(n) => n, None => { // BlockingIOError(errno, msg, characters_written=0) - return Err(vm.invoke_exception( - vm.ctx.exceptions.blocking_io_error, - vec![ - vm.new_pyobj(EAGAIN), - vm.new_pyobj("write could not complete without blocking"), - vm.new_pyobj(0), - ], - )?); + return Err(vm + .new_payload_exception::( + vm.ctx.exceptions.blocking_io_error.to_owned(), + vec![ + vm.new_pyobj(EAGAIN), + vm.new_pyobj("write could not complete without blocking"), + vm.new_pyobj(0), + ] + .into(), + )? + .upcast()); } }; self.write_pos += n as Offset; @@ -1154,14 +1158,17 @@ mod _io { self.buffer[self.write_end as usize..][..avail].copy_from_slice(&buf[..avail]); self.write_end += avail as Offset; self.pos += avail as Offset; - return Err(vm.invoke_exception( - vm.ctx.exceptions.blocking_io_error, - vec![ - vm.new_pyobj(EAGAIN), - vm.new_pyobj("write could not complete without blocking"), - vm.new_pyobj(avail), - ], - )?); + return Err(vm + .new_payload_exception::( + vm.ctx.exceptions.blocking_io_error.to_owned(), + vec![ + vm.new_pyobj(EAGAIN), + vm.new_pyobj("write could not complete without blocking"), + vm.new_pyobj(avail), + ] + .into(), + )? + .upcast()); } Err(e) => return Err(e), } @@ -1200,14 +1207,17 @@ mod _io { self.write_end = buffer_size; // BlockingIOError(errno, msg, characters_written) let chars_written = written + buffer_len; - return Err(vm.invoke_exception( - vm.ctx.exceptions.blocking_io_error, - vec![ - vm.new_pyobj(EAGAIN), - vm.new_pyobj("write could not complete without blocking"), - vm.new_pyobj(chars_written), - ], - )?); + return Err(vm + .new_payload_exception::( + vm.ctx.exceptions.blocking_io_error.to_owned(), + vec![ + vm.new_pyobj(EAGAIN), + vm.new_pyobj("write could not complete without blocking"), + vm.new_pyobj(chars_written), + ] + .into(), + )? + .upcast()); } None => break, } diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index 0a80285dbe3..70304e63980 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -635,7 +635,7 @@ pub(crate) mod _thread { #[pyfunction] fn exit(vm: &VirtualMachine) -> PyResult { - Err(vm.invoke_exception(vm.ctx.exceptions.system_exit, vec![])?) + Err(vm.new_system_exit(vec![].into())) } thread_local!(static SENTINELS: RefCell>> = const { RefCell::new(Vec::new()) }); diff --git a/crates/vm/src/stdlib/builtins.rs b/crates/vm/src/stdlib/builtins.rs index 7817d6ecb97..35f404f0f3b 100644 --- a/crates/vm/src/stdlib/builtins.rs +++ b/crates/vm/src/stdlib/builtins.rs @@ -1041,7 +1041,7 @@ mod builtins { #[pyfunction] pub(super) fn exit(exit_code_arg: OptionalArg, vm: &VirtualMachine) -> PyResult { let code = exit_code_arg.unwrap_or_else(|| vm.ctx.new_int(0).into()); - Err(vm.invoke_exception(vm.ctx.exceptions.system_exit, vec![code])?) + Err(vm.new_system_exit(vec![code].into())) } #[derive(Debug, Default, FromArgs)] diff --git a/crates/vm/src/stdlib/sys.rs b/crates/vm/src/stdlib/sys.rs index 71aeccf34da..c7cc2fd298a 100644 --- a/crates/vm/src/stdlib/sys.rs +++ b/crates/vm/src/stdlib/sys.rs @@ -776,8 +776,7 @@ pub mod sys { } else { vec![status] }; - let exc = vm.invoke_exception(vm.ctx.exceptions.system_exit, args)?; - Err(exc) + Err(vm.new_system_exit(args.into())) } #[pyfunction] diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index e0a086c10db..6009f421e12 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -2737,7 +2737,7 @@ impl VirtualMachine { if self.state.finalizing.load(Ordering::Acquire) && !self.is_main_thread() { // once finalization starts, // non-main Python threads should stop running bytecode. - return Err(self.invoke_exception(self.ctx.exceptions.system_exit, vec![])?); + return Err(self.new_system_exit(vec![].into())); } // Suspend this thread if stop-the-world is in progress diff --git a/crates/vm/src/vm/vm_new.rs b/crates/vm/src/vm/vm_new.rs index 4df3c639182..6110a3d5b1a 100644 --- a/crates/vm/src/vm/vm_new.rs +++ b/crates/vm/src/vm/vm_new.rs @@ -12,17 +12,18 @@ use rustpython_compiler::{CompileError, ParseError}; use crate::{ AsObject, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, builtins::{ - PyBaseException, PyBaseExceptionRef, PyBytesRef, PyDictRef, PyModule, PyOSError, PyStrRef, - PyType, PyTypeRef, + PyBaseException, PyBaseExceptionRef, PyBytesRef, PyDictRef, PyModule, PyOSError, + PyStopIteration, PyStrRef, PySystemExit, PyType, PyTypeRef, builtin_func::PyNativeFunction, descriptor::PyMethodDescriptor, tuple::{IntoPyTuple, PyTupleRef}, }, convert::{ToPyException, ToPyObject}, exceptions::OSErrorBuilder, - function::{IntoPyNativeFn, PyMethodFlags}, + function::{FuncArgs, IntoPyNativeFn, PyMethodFlags}, scope::Scope, set_attrs, + types::{Constructor, Initializer}, vm::VirtualMachine, }; @@ -353,6 +354,26 @@ impl VirtualMachine { .expect("vm.new_exception() called with an invalid exception type") } + /// Construct a built-in exception type that carries a payload, directly + /// (`py_new` + `slot_init`), without routing through `PyType::call`. + /// Only valid for a built-in `T` whose exact type is known at compile time. + pub fn new_payload_exception(&self, cls: PyTypeRef, args: FuncArgs) -> PyResult> + where + T: Constructor + Initializer, + { + debug_assert_eq!( + cls.slots.basicsize, + size_of::(), + "vm.new_payload_exception::<{}>() called with mismatched type '{}'", + core::any::type_name::(), + cls.name() + ); + let payload = T::py_new(&cls, args.clone(), self)?; + let exc = payload.into_ref_with_type_lazy_dict(self, cls)?; + T::slot_init(exc.as_object().to_owned(), args, self)?; + Ok(exc) + } + pub fn new_os_error(&self, msg: impl ToPyObject) -> PyRef { self.new_os_subtype_error(self.ctx.exceptions.os_error.to_owned(), None, msg) .upcast() @@ -905,16 +926,20 @@ impl VirtualMachine { exc } - pub fn new_stop_iteration(&self, value: Option) -> PyBaseExceptionRef { - let stop_iteration_error = self.ctx.exceptions.stop_iteration; - let args = if let Some(value) = value { - vec![value] - } else { - Vec::new() - }; - let exc = self.invoke_exception(stop_iteration_error, args); + pub fn new_system_exit(&self, args: FuncArgs) -> PyBaseExceptionRef { + self.new_payload_exception::(self.ctx.exceptions.system_exit.to_owned(), args) + .expect("SystemExit construction from internal args is infallible") + .upcast() + } - exc.expect("StopIteration is a BaseException Subclass.") + pub fn new_stop_iteration(&self, value: Option) -> PyBaseExceptionRef { + let args: FuncArgs = value.map(|v| vec![v]).unwrap_or_default().into(); + self.new_payload_exception::( + self.ctx.exceptions.stop_iteration.to_owned(), + args, + ) + .expect("StopIteration construction from internal args is infallible") + .upcast() } fn new_downcast_error( From 365434b5f120440d32d7b218cbc38fa4eb95cea5 Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:45:16 +0900 Subject: [PATCH 266/351] Add JIT support for returning None (#8479) --- crates/jit/src/instructions.rs | 39 ++++++++++++++++++---------------- crates/jit/src/lib.rs | 32 ++++++++++++++-------------- crates/jit/tests/misc_tests.rs | 19 ++++++++--------- 3 files changed, 46 insertions(+), 44 deletions(-) diff --git a/crates/jit/src/instructions.rs b/crates/jit/src/instructions.rs index 9b4656da5ad..67cf07f6e7f 100644 --- a/crates/jit/src/instructions.rs +++ b/crates/jit/src/instructions.rs @@ -39,6 +39,7 @@ impl JitValue { JitType::Int => Self::Int(val), JitType::Float => Self::Float(val), JitType::Bool => Self::Bool(val), + JitType::None => unreachable!("None cannot be used as an argument type"), } } @@ -47,7 +48,8 @@ impl JitValue { Self::Int(_) => Some(JitType::Int), Self::Float(_) => Some(JitType::Float), Self::Bool(_) => Some(JitType::Bool), - Self::None | Self::Null | Self::Tuple(_) | Self::FuncRef(_) => None, + Self::None => Some(JitType::None), + Self::Null | Self::Tuple(_) | Self::FuncRef(_) => None, } } @@ -112,8 +114,9 @@ impl<'a, 'b> FunctionCompiler<'a, 'b> { #[expect(clippy::mut_mut, reason = "This seems like a false positive")] let builder = &mut self.builder; let ty = val.to_jit_type().ok_or(JitCompileError::NotSupported)?; + let cranelift_ty = ty.to_cranelift().ok_or(JitCompileError::NotSupported)?; let local = self.variables[idx].get_or_insert_with(|| { - let var = builder.declare_var(ty.to_cranelift()); + let var = builder.declare_var(cranelift_ty); Local { var, ty: ty.clone(), @@ -328,27 +331,27 @@ impl<'a, 'b> FunctionCompiler<'a, 'b> { } fn return_value(&mut self, val: JitValue) -> Result<(), JitCompileError> { - if let Some(ref ty) = self.sig.ret { - // If the signature has a return type, enforce it - if val.to_jit_type().as_ref() != Some(ty) { + let val_type = val.to_jit_type().ok_or(JitCompileError::NotSupported)?; + if let Some(ref ret_type) = self.sig.ret { + if ret_type != &val_type { return Err(JitCompileError::NotSupported); } } else { - // First time we see a return, define it in the signature - let ty = val.to_jit_type().ok_or(JitCompileError::NotSupported)?; - self.sig.ret = Some(ty.clone()); - self.builder - .func - .signature - .returns - .push(AbiParam::new(ty.to_cranelift())); + self.sig.ret = Some(val_type.clone()); + if let Some(val_type) = val_type.to_cranelift() { + self.builder + .func + .signature + .returns + .push(AbiParam::new(val_type)); + } } - // If this is e.g. an Int, Float, or Bool we have a Cranelift `Value`. - // If we have JitValue::None or .Tuple(...) but can't handle that, error out (or handle differently). - let cr_val = val.into_value().ok_or(JitCompileError::NotSupported)?; - - self.builder.ins().return_(&[cr_val]); + if let Some(cr_val) = val.into_value() { + self.builder.ins().return_(&[cr_val]); + } else { + self.builder.ins().return_(&[]); + } Ok(()) } diff --git a/crates/jit/src/lib.rs b/crates/jit/src/lib.rs index dbaa4a3eb26..0c700e93cf8 100644 --- a/crates/jit/src/lib.rs +++ b/crates/jit/src/lib.rs @@ -61,19 +61,12 @@ impl Jit { ret: Option, ) -> Result<(FuncId, JitSig), JitCompileError> { for arg in args { - self.ctx - .func - .signature - .params - .push(AbiParam::new(arg.to_cranelift())); + let arg = arg.to_cranelift().ok_or(JitCompileError::NotSupported)?; + self.ctx.func.signature.params.push(AbiParam::new(arg)); } - if ret.is_some() { - self.ctx - .func - .signature - .returns - .push(AbiParam::new(ret.clone().unwrap().to_cranelift())); + if let Some(ret) = ret.as_ref().and_then(JitType::to_cranelift) { + self.ctx.func.signature.returns.push(AbiParam::new(ret)); } let id = self.module.declare_function( @@ -167,7 +160,10 @@ impl CompiledCode { libffi::middle::CodePtr::from_ptr(self.code as *const _), cif_args, ); - self.sig.ret.as_ref().map(|ty| value.to_typed(ty)) + match self.sig.ret.as_ref() { + Some(JitType::None) | None => None, + Some(ty) => Some(value.to_typed(ty)), + } } } } @@ -193,14 +189,16 @@ pub enum JitType { Int, Float, Bool, + None, } impl JitType { - fn to_cranelift(&self) -> types::Type { + fn to_cranelift(&self) -> Option { match self { - Self::Int => types::I64, - Self::Float => types::F64, - Self::Bool => types::I8, + Self::Int => Some(types::I64), + Self::Float => Some(types::F64), + Self::Bool => Some(types::I8), + Self::None => None, } } @@ -209,6 +207,7 @@ impl JitType { Self::Int => libffi::middle::Type::i64(), Self::Float => libffi::middle::Type::f64(), Self::Bool => libffi::middle::Type::u8(), + Self::None => libffi::middle::Type::void(), } } } @@ -306,6 +305,7 @@ impl UnTypedAbiValue { JitType::Int => AbiValue::Int(self.int), JitType::Float => AbiValue::Float(self.float), JitType::Bool => AbiValue::Bool(self.boolean != 0), + JitType::None => unreachable!("None has no ABI value"), } } } diff --git a/crates/jit/tests/misc_tests.rs b/crates/jit/tests/misc_tests.rs index b73100ad6ec..5404df0a769 100644 --- a/crates/jit/tests/misc_tests.rs +++ b/crates/jit/tests/misc_tests.rs @@ -2,16 +2,15 @@ mod tests { use rustpython_jit::{AbiValue, JitArgumentError}; - // TODO currently broken - // #[test] - // fn test_no_return_value() { - // let func = jit_function! { func() => r##" - // def func(): - // pass - // "## }; - // - // assert_eq!(func(), Ok(())); - // } + #[test] + fn no_return_value() { + let func = jit_function! { func() => r##" + def func(): + pass + "## }; + + assert_eq!(func(), Ok(())); + } #[test] fn invoke() { From 98d060d69d2a8b3b4745cc51cec72c820e055eff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:25:19 +0900 Subject: [PATCH 267/351] build(deps): bump cargo-bins/cargo-binstall from 1.21.0 to 1.21.1 (#8482) Bumps [cargo-bins/cargo-binstall](https://github.com/cargo-bins/cargo-binstall) from 1.21.0 to 1.21.1. - [Release notes](https://github.com/cargo-bins/cargo-binstall/releases) - [Changelog](https://github.com/cargo-bins/cargo-binstall/blob/main/release-plz.toml) - [Commits](https://github.com/cargo-bins/cargo-binstall/compare/ead08b90bd7b2e6d81963fb9cf0b7239f66d5db4...e00d2c94cc0067b77737821097a62d91c0301baa) --- updated-dependencies: - dependency-name: cargo-bins/cargo-binstall dependency-version: 1.21.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ae82353c284..3a295f7427d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -526,7 +526,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - uses: cargo-bins/cargo-binstall@ead08b90bd7b2e6d81963fb9cf0b7239f66d5db4 # v1.21.0 + - uses: cargo-bins/cargo-binstall@e00d2c94cc0067b77737821097a62d91c0301baa # v1.21.1 - name: cargo shear run: | From 81187db09bcf5e85c25f5b712f239bb82f5a93ae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:25:28 +0900 Subject: [PATCH 268/351] build(deps): bump j178/prek-action from 2.0.6 to 3.0.0 (#8483) Bumps [j178/prek-action](https://github.com/j178/prek-action) from 2.0.6 to 3.0.0. - [Release notes](https://github.com/j178/prek-action/releases) - [Commits](https://github.com/j178/prek-action/compare/5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16...4e14d07f9231acabce116ccfca13b13dd9755ece) --- updated-dependencies: - dependency-name: j178/prek-action dependency-version: 3.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3a295f7427d..4222579f621 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -567,7 +567,7 @@ jobs: - name: install prek id: prek - uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6 + uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0 with: cache: false show-verbose-logs: false From 0d8260b88528cf43d86b8d97f112e47fd3f4e574 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:25:40 +0900 Subject: [PATCH 269/351] build(deps): bump jiff in the jiff group across 1 directory (#8484) Bumps the jiff group with 1 update in the / directory: [jiff](https://github.com/BurntSushi/jiff). Updates `jiff` from 0.2.31 to 0.2.35 - [Release notes](https://github.com/BurntSushi/jiff/releases) - [Changelog](https://github.com/BurntSushi/jiff/blob/master/CHANGELOG.md) - [Commits](https://github.com/BurntSushi/jiff/compare/jiff-static-0.2.31...jiff-static-0.2.35) --- updated-dependencies: - dependency-name: jiff dependency-version: 0.2.35 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: jiff ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3acba335146..b8762619a97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1768,11 +1768,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.31" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ "defmt", + "jiff-core", "jiff-static", "jiff-tzdb-platform", "js-sys", @@ -1784,12 +1785,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + [[package]] name = "jiff-static" -version = "0.2.31" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" dependencies = [ + "jiff-core", "proc-macro2", "quote", "syn 2.0.119", From c30748c961e369fa6a18907cc046b1aee328bca8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:25:50 +0900 Subject: [PATCH 270/351] build(deps): bump pyo3 in the pyo3 group across 1 directory (#8485) Bumps the pyo3 group with 1 update in the / directory: [pyo3](https://github.com/pyo3/pyo3). Updates `pyo3` from 0.29.0 to 0.29.2 - [Release notes](https://github.com/pyo3/pyo3/releases) - [Changelog](https://github.com/PyO3/pyo3/blob/main/CHANGELOG.md) - [Commits](https://github.com/pyo3/pyo3/compare/v0.29.0...v0.29.2) --- updated-dependencies: - dependency-name: pyo3 dependency-version: 0.29.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: pyo3 ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b8762619a97..4af185c048b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2790,9 +2790,9 @@ dependencies = [ [[package]] name = "pyo3" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c" +checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" dependencies = [ "libc", "once_cell", @@ -2804,18 +2804,18 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078" +checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9" dependencies = [ "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b" +checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327" dependencies = [ "libc", "pyo3-build-config", @@ -2823,9 +2823,9 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771" +checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -2835,9 +2835,9 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362" +checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952" dependencies = [ "heck", "proc-macro2", From 6ff1c26e6382f17b78d3818275f776c21c4d10b5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:25:59 +0900 Subject: [PATCH 271/351] build(deps): bump libz-rs-sys from 0.6.6 to 0.6.7 (#8486) Bumps [libz-rs-sys](https://github.com/trifectatechfoundation/zlib-rs) from 0.6.6 to 0.6.7. - [Release notes](https://github.com/trifectatechfoundation/zlib-rs/releases) - [Changelog](https://github.com/trifectatechfoundation/zlib-rs/blob/main/docs/release.md) - [Commits](https://github.com/trifectatechfoundation/zlib-rs/compare/v0.6.6...v0.6.7) --- updated-dependencies: - dependency-name: libz-rs-sys dependency-version: 0.6.7 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4af185c048b..26015d39933 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2033,9 +2033,9 @@ dependencies = [ [[package]] name = "libz-rs-sys" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50474818739ccab820cd57bca432d6b02d090b47f9e85501d963cd05851f82eb" +checksum = "03dcace986b149f29509af6ca70e6182bccce916b644424ecf484faa8ddc899a" dependencies = [ "zlib-rs", ] @@ -5081,9 +5081,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" From 68fa89ac9e52be7b71425075f0c5ec23595502d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:26:10 +0900 Subject: [PATCH 272/351] build(deps): bump github/gh-aw/actions/setup from 0.83.4 to 0.84.3 (#8487) Bumps [github/gh-aw/actions/setup](https://github.com/github/gh-aw) from 0.83.4 to 0.84.3. - [Release notes](https://github.com/github/gh-aw/releases) - [Changelog](https://github.com/github/gh-aw/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/gh-aw/compare/bbb8042878459948333b15b66f27113f4b5c1b9a...53258938b59e0797fefeed05ec0c681514b2a827) --- updated-dependencies: - dependency-name: github/gh-aw/actions/setup dependency-version: 0.84.3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/upgrade-pylib.lock.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/upgrade-pylib.lock.yml b/.github/workflows/upgrade-pylib.lock.yml index 4f2944408f2..3ac44585943 100644 --- a/.github/workflows/upgrade-pylib.lock.yml +++ b/.github/workflows/upgrade-pylib.lock.yml @@ -58,7 +58,7 @@ jobs: comment_repo: "" steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 + uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 with: destination: /opt/gh-aw/actions - name: Check workflow file timestamps @@ -99,7 +99,7 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 + uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 with: destination: /opt/gh-aw/actions - name: Checkout repository @@ -806,7 +806,7 @@ jobs: total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 + uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 with: destination: /opt/gh-aw/actions - name: Download agent output artifact @@ -927,7 +927,7 @@ jobs: success: ${{ steps.parse_results.outputs.success }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 + uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 with: destination: /opt/gh-aw/actions - name: Download agent artifacts @@ -1039,7 +1039,7 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@bbb8042878459948333b15b66f27113f4b5c1b9a # v0.83.4 + uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 with: destination: /opt/gh-aw/actions - name: Download agent output artifact From b25b14fffb4c7f96e8c3261bfa684ad0a31a7e42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:26:22 +0900 Subject: [PATCH 273/351] build(deps): bump https://github.com/astral-sh/ruff-pre-commit (#8488) Bumps [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit) from v0.16.0 to 0.16.1. - [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases) - [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.16.0...v0.16.1) --- updated-dependencies: - dependency-name: https://github.com/astral-sh/ruff-pre-commit dependency-version: 0.16.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index db9869bb3a5..9ffc4b8d4fd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: priority: 0 - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.0 + rev: v0.16.1 hooks: - id: ruff-format priority: 0 From 05a9873a99d152069bb0418fb7f85a5140d7e8fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:37:49 +0900 Subject: [PATCH 274/351] build(deps): bump zizmorcore/zizmor-action from 0.6.1 to 0.6.2 (#8481) Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.6.1 to 0.6.2. - [Release notes](https://github.com/zizmorcore/zizmor-action/releases) - [Commits](https://github.com/zizmorcore/zizmor-action/compare/6fc4b006235f201fdab3722e17240ab420d580e5...3dc1ecc9bcb9e94e9b2c709687979e1298497054) --- updated-dependencies: - dependency-name: zizmorcore/zizmor-action dependency-version: 0.6.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4222579f621..d832398ebf6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -557,7 +557,7 @@ jobs: uses: reviewdog/action-actionlint@50842263c20a7c46bd0065b9e624d3c569db061e # v1.73.0 - name: zizmor - uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 + uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 - name: restore prek cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 From e8431490e72bbb864f85c9f806035b4e165b5885 Mon Sep 17 00:00:00 2001 From: William Goode <95141298+william-goode@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:16:10 -0400 Subject: [PATCH 275/351] Fix map no iterables (#8478) * map: raise TypeError when called with no iterables Assisted-by: Claude Code:claude-fable-5 * add test for map with no iterables Assisted-by: Claude Code:claude-fable-5 * removed expectedFailures * removed test for map constructed with no iterables - covered by existing in test_itertools --- Lib/test/test_itertools.py | 1 - crates/vm/src/builtins/map.rs | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_itertools.py b/Lib/test/test_itertools.py index e865c9bf059..585f6611ade 100644 --- a/Lib/test/test_itertools.py +++ b/Lib/test/test_itertools.py @@ -1136,7 +1136,6 @@ def test_repeat_with_negative_times(self): self.assertEqual(repr(repeat('a', times=-1)), "repeat('a', 0)") self.assertEqual(repr(repeat('a', times=-2)), "repeat('a', 0)") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_map(self): self.assertEqual(list(map(operator.pow, range(3), range(1,7))), [0**1, 1**2, 2**3]) diff --git a/crates/vm/src/builtins/map.rs b/crates/vm/src/builtins/map.rs index 4dda9caf211..cb8db23e640 100644 --- a/crates/vm/src/builtins/map.rs +++ b/crates/vm/src/builtins/map.rs @@ -37,9 +37,12 @@ impl Constructor for PyMap { fn py_new( _cls: &Py, (mapper, iterators, args): Self::Args, - _vm: &VirtualMachine, + vm: &VirtualMachine, ) -> PyResult { let iterators = iterators.into_vec(); + if iterators.is_empty() { + return Err(vm.new_type_error("map() must have at least two arguments.")); + } let strict = Radium::new(args.strict.unwrap_or(false)); Ok(Self { mapper, From 81df1ff12f5cf660d6189b3c0dcf375beb0ab1bd Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:07:02 +0900 Subject: [PATCH 276/351] Replace deleted README demo script reference (#8492) * Initial plan * Fix stale README example Assisted-by: Copilot: GPT-5.6 Co-authored-by: youknowone <69878+youknowone@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: youknowone <69878+youknowone@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 37b577d0f0e..cb086687d8f 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ needed to prevent stack overflow on Windows): ```bash $ cd RustPython -$ cargo run --release demo_closures.py +$ cargo run --release -- -c 'print("Hello, RustPython!")' Hello, RustPython! ``` From 7612e68357e4d7b8c386eb7785d000f5fcfe6f8d Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Wed, 12 Aug 2026 16:20:37 +0900 Subject: [PATCH 277/351] Reuse stored hashes when building a set from a set/frozenset/dict (#8491) * Reuse stored hashes when building a set from a set/frozenset/dict set and frozenset recomputed __hash__ for every element even when the source object already stored a hash per entry. CPython's set_update_internal branches on PyAnySet_Check / PyDict_CheckExact and feeds set_add_entry the hash read from the source table; RustPython always iterated generically. Split the hash computation out of the Dict entry points so callers can supply a hash they already hold, add keys_with_hashes() to hand out (key, hash) pairs, and take the fast path in the set constructors and in the set operations whose argument is a set/frozenset/exact dict. ArgIterable::as_object() exposes the pre-__iter__ object so the set operations can dispatch on the source type without changing any of their signatures. Closes #8489. dict.fromkeys() is the dict-target counterpart and is tracked in #8490, so test_do_not_rehash_dict_keys keeps its expectedFailure marker until that lands too. Co-Authored-By: Claude Opus 5 (1M context) * Rename the hash-carrying variants to *_known_hash `_with_hash` said nothing about which direction the hash travels, and the same suffix was already used both ways in this file: keys_with_hashes() hands hashes out, while insert_with_hash() takes one in. The pre-existing insert_with_hint()/get_with_hint() pair has the same problem. Follow CPython's "KnownHash" variants (_PyDict_SetItem_KnownHash, _PyDict_Contains_KnownHash, _PyDict_DelItem_KnownHash) instead, so the suffix marks the argument direction and matches the name a reader familiar with CPython already expects. keys_with_hashes() keeps `with` because it really does return the hashes. Co-Authored-By: Claude Opus 5 (1M context) * Narrow visibility of the new helpers and trim their docs from_object() and ArgIterable::as_object() had no caller outside their own file and crate respectively, so drop them to private and pub(crate). The dict_inner helpers stay pub(crate) because builtins::set calls them. Also shorten the doc comments to match the density of the surrounding code; only insert_known_hash keeps a real note, since a wrong hash there corrupts the table silently. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- crates/vm/src/builtins/set.rs | 121 ++++++++++++++++++++++++----- crates/vm/src/dict_inner.rs | 84 +++++++++++++++++++- crates/vm/src/function/protocol.rs | 5 ++ 3 files changed, 190 insertions(+), 20 deletions(-) diff --git a/crates/vm/src/builtins/set.rs b/crates/vm/src/builtins/set.rs index cd724abc5c1..62bdb0f0da5 100644 --- a/crates/vm/src/builtins/set.rs +++ b/crates/vm/src/builtins/set.rs @@ -195,6 +195,26 @@ impl PySetInner { Ok(set) } + /// Build a set from an arbitrary object, reusing stored hashes when the + /// source is a set/frozenset/dict. + fn from_object(iterable: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let set = Self::default(); + set.update_internal(iterable, vm)?; + Ok(set) + } + + /// Elements of `obj` with their stored hashes, or `None` if `obj` keeps + /// none and must be iterated generically. Mirrors the `PyAnySet_Check` / + /// `PyDict_CheckExact` fast paths in CPython's `set_update_internal`. + fn cached_hashes(obj: &PyObject, vm: &VirtualMachine) -> Option> { + if let Some(set) = extract_set(obj) { + Some(set.content.keys_with_hashes()) + } else { + obj.downcast_ref_if_exact::(vm) + .map(|dict| dict._as_dict_inner().keys_with_hashes()) + } + } + fn fold_op( &self, others: impl core::iter::Iterator, @@ -228,6 +248,17 @@ impl PySetInner { Self::wrap_unhashable_error(result, needle, vm) } + /// [`Self::contains`] with a known hash. Such a needle came out of a + /// set/dict, so it is hashable and needs no frozenset retry. + fn contains_known_hash( + &self, + needle: &PyObject, + hash: PyHash, + vm: &VirtualMachine, + ) -> PyResult { + self.content.contains_known_hash(vm, needle, hash) + } + fn compare(&self, other: &Self, op: PyComparisonOp, vm: &VirtualMachine) -> PyResult { if op == PyComparisonOp::Ne { return self.compare(other, PyComparisonOp::Eq, vm).map(|eq| !eq); @@ -251,6 +282,12 @@ impl PySetInner { pub(super) fn union(&self, other: ArgIterable, vm: &VirtualMachine) -> PyResult { let set = self.clone(); + if let Some(elements) = Self::cached_hashes(other.as_object(), vm) { + for (item, hash) in elements { + set.add_known_hash(item, hash, vm)?; + } + return Ok(set); + } for item in other.iter(vm)? { set.add(item?, vm)?; } @@ -260,6 +297,14 @@ impl PySetInner { pub(super) fn intersection(&self, other: ArgIterable, vm: &VirtualMachine) -> PyResult { let set = Self::default(); + if let Some(elements) = Self::cached_hashes(other.as_object(), vm) { + for (obj, hash) in elements { + if self.contains_known_hash(&obj, hash, vm)? { + set.add_known_hash(obj, hash, vm)?; + } + } + return Ok(set); + } for item in other.iter(vm)? { let obj = item?; if self.contains(&obj, vm)? { @@ -271,6 +316,12 @@ impl PySetInner { pub(super) fn difference(&self, other: ArgIterable, vm: &VirtualMachine) -> PyResult { let set = self.copy(); + if let Some(elements) = Self::cached_hashes(other.as_object(), vm) { + for (item, hash) in elements { + set.content.delete_if_exists_known_hash(vm, &*item, hash)?; + } + return Ok(set); + } for item in other.iter(vm)? { set.content.delete_if_exists(vm, &*item?)?; } @@ -284,6 +335,16 @@ impl PySetInner { ) -> PyResult { let new_inner = self.clone(); + if let Some(elements) = Self::cached_hashes(other.as_object(), vm) { + // the source is already duplicate-free + for (item, hash) in elements { + new_inner + .content + .delete_or_insert_known_hash(vm, &item, hash, ())?; + } + return Ok(new_inner); + } + // We want to remove duplicates in other let other_set = Self::from_iter(other.iter(vm)?, vm)?; @@ -333,6 +394,12 @@ impl PySetInner { Self::wrap_unhashable_error(result, &item, vm) } + /// [`Self::add`] with a known hash. + fn add_known_hash(&self, item: PyObjectRef, hash: PyHash, vm: &VirtualMachine) -> PyResult<()> { + let result = self.content.insert_known_hash(vm, &*item, hash, ()); + Self::wrap_unhashable_error(result, &item, vm) + } + fn remove(&self, item: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { let result = self.retry_op_with_frozenset(&item, vm, |item, vm| self.content.delete(vm, item)); @@ -393,15 +460,15 @@ impl PySetInner { } fn merge_set(&self, any_set: AnySet, vm: &VirtualMachine) -> PyResult<()> { - for item in any_set.as_inner().elements() { - self.add(item, vm)?; + for (item, hash) in any_set.as_inner().content.keys_with_hashes() { + self.add_known_hash(item, hash, vm)?; } Ok(()) } fn merge_dict(&self, dict: PyDictRef, vm: &VirtualMachine) -> PyResult<()> { - for (key, _value) in dict { - self.add(key, vm)?; + for (key, hash) in dict._as_dict_inner().keys_with_hashes() { + self.add_known_hash(key, hash, vm)?; } Ok(()) } @@ -413,8 +480,8 @@ impl PySetInner { ) -> PyResult<()> { let temp_inner = self.fold_op(others, Self::intersection, vm)?; self.clear(); - for obj in temp_inner.elements() { - self.add(obj, vm)?; + for (obj, hash) in temp_inner.content.keys_with_hashes() { + self.add_known_hash(obj, hash, vm)?; } Ok(()) } @@ -425,6 +492,12 @@ impl PySetInner { vm: &VirtualMachine, ) -> PyResult<()> { for iterable in others { + if let Some(elements) = Self::cached_hashes(iterable.as_object(), vm) { + for (item, hash) in elements { + self.content.delete_if_exists_known_hash(vm, &*item, hash)?; + } + continue; + } let items = iterable.iter(vm)?.collect::, _>>()?; for item in items { self.content.delete_if_exists(vm, &*item)?; @@ -439,6 +512,14 @@ impl PySetInner { vm: &VirtualMachine, ) -> PyResult<()> { for iterable in others { + if let Some(elements) = Self::cached_hashes(iterable.as_object(), vm) { + // the source is already duplicate-free + for (item, hash) in elements { + self.content + .delete_or_insert_known_hash(vm, &item, hash, ())?; + } + continue; + } // We want to remove duplicates in iterable let iterable_set = Self::from_iter(iterable.iter(vm)?, vm)?; for item in iterable_set.elements() { @@ -955,7 +1036,7 @@ impl Representable for PySet { } impl Constructor for PyFrozenSet { - type Args = Vec; + type Args = OptionalArg; fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { let is_exact_frozenset = cls.is(vm.ctx.types.frozenset_type); @@ -988,11 +1069,11 @@ impl Constructor for PyFrozenSet { return Ok(input.clone()); } - iterable.into_option() + iterable } else { match &args.args[..] { - [] => None, - [iterable] => Some(iterable.clone()), + [] => OptionalArg::Missing, + [iterable] => OptionalArg::Present(iterable.clone()), slice => { return Err(vm.new_type_error(format!( "frozenset expected at most 1 argument, got {}", @@ -1002,23 +1083,25 @@ impl Constructor for PyFrozenSet { } }; - let elements = if let Some(iterable) = iterable_opt { - iterable.try_to_value(vm)? - } else { - vec![] - }; + let payload = Self::py_new(&cls, iterable_opt, vm)?; // Return empty frozenset singleton - if is_exact_frozenset && elements.is_empty() { + if is_exact_frozenset && payload.inner.len() == 0 { return Ok(vm.ctx.empty_frozenset.clone().into()); } - let payload = Self::py_new(&cls, elements, vm)?; payload.into_ref_with_type(vm, cls).map(Into::into) } - fn py_new(_cls: &Py, elements: Self::Args, vm: &VirtualMachine) -> PyResult { - Self::from_iter(vm, elements) + fn py_new(_cls: &Py, iterable: Self::Args, vm: &VirtualMachine) -> PyResult { + let inner = match iterable { + OptionalArg::Present(iterable) => PySetInner::from_object(iterable, vm)?, + OptionalArg::Missing => PySetInner::default(), + }; + Ok(Self { + inner, + ..Default::default() + }) } } diff --git a/crates/vm/src/dict_inner.rs b/crates/vm/src/dict_inner.rs index 3e75e6f27a6..9dda6194a0c 100644 --- a/crates/vm/src/dict_inner.rs +++ b/crates/vm/src/dict_inner.rs @@ -463,6 +463,24 @@ impl Dict { K: DictKey + ?Sized, { let hash = key.key_hash(vm)?; + self.insert_known_hash(vm, key, hash, value) + } + + /// Store a key whose hash the caller already knows. + /// + /// `hash` must equal `key.key_hash(vm)`; a wrong one lands the entry in a + /// bucket no lookup probes, silently losing the key. Only pass a hash from + /// [`Self::keys_with_hashes`] on a container holding this same key. + pub(crate) fn insert_known_hash( + &self, + vm: &VirtualMachine, + key: &K, + hash: HashValue, + value: T, + ) -> PyResult<()> + where + K: DictKey + ?Sized, + { let _removed = loop { let (entry_index, index_index) = self.lookup(vm, key, hash, None)?; let mut inner = self.write(); @@ -512,7 +530,18 @@ impl Dict { key: &K, ) -> PyResult { let key_hash = key.key_hash(vm)?; - let (entry, _) = self.lookup(vm, key, key_hash, None)?; + self.contains_known_hash(vm, key, key_hash) + } + + /// [`Self::contains`] with a known hash. Same contract as + /// [`Self::insert_known_hash`]. + pub(crate) fn contains_known_hash( + &self, + vm: &VirtualMachine, + key: &K, + hash: HashValue, + ) -> PyResult { + let (entry, _) = self.lookup(vm, key, hash, None)?; Ok(entry.index().is_some()) } @@ -697,6 +726,21 @@ impl Dict { self.remove_if_exists(vm, key).map(|opt| opt.is_some()) } + /// [`Self::delete_if_exists`] with a known hash. Same contract as + /// [`Self::insert_known_hash`]. + pub(crate) fn delete_if_exists_known_hash( + &self, + vm: &VirtualMachine, + key: &K, + hash: HashValue, + ) -> PyResult + where + K: DictKey + ?Sized, + { + self.remove_if_known_hash(vm, key, hash, |_| Ok(true)) + .map(|opt| opt.is_some()) + } + pub(crate) fn delete_if(&self, vm: &VirtualMachine, key: &K, pred: F) -> PyResult where K: DictKey + ?Sized, @@ -725,6 +769,22 @@ impl Dict { F: Fn(&T) -> PyResult, { let hash = key.key_hash(vm)?; + self.remove_if_known_hash(vm, key, hash, pred) + } + + /// [`Self::remove_if`] with a known hash. Same contract as + /// [`Self::insert_known_hash`]. + fn remove_if_known_hash( + &self, + vm: &VirtualMachine, + key: &K, + hash: HashValue, + pred: F, + ) -> PyResult> + where + K: DictKey + ?Sized, + F: Fn(&T) -> PyResult, + { let removed = loop { let lookup = self.lookup(vm, key, hash, None)?; match self.pop_inner_if(lookup, &pred)? { @@ -742,6 +802,18 @@ impl Dict { value: T, ) -> PyResult<()> { let hash = key.key_hash(vm)?; + self.delete_or_insert_known_hash(vm, key, hash, value) + } + + /// [`Self::delete_or_insert`] with a known hash. Same contract as + /// [`Self::insert_known_hash`]. + pub(crate) fn delete_or_insert_known_hash( + &self, + vm: &VirtualMachine, + key: &PyObject, + hash: HashValue, + value: T, + ) -> PyResult<()> { let _removed = loop { let lookup = self.lookup(vm, key, hash, None)?; let (entry, index_index) = lookup; @@ -888,6 +960,16 @@ impl Dict { .collect() } + /// All keys paired with the hash stored in their entry, for feeding + /// [`Self::insert_known_hash`] without re-calling `__hash__`. + pub(crate) fn keys_with_hashes(&self) -> Vec<(PyObjectRef, HashValue)> { + self.read() + .entries + .iter() + .filter_map(|v| v.as_ref().map(|v| (v.key.clone(), v.hash))) + .collect() + } + pub(crate) fn values(&self) -> Vec { self.read() .entries diff --git a/crates/vm/src/function/protocol.rs b/crates/vm/src/function/protocol.rs index 25ef62b458d..d503fabaca8 100644 --- a/crates/vm/src/function/protocol.rs +++ b/crates/vm/src/function/protocol.rs @@ -86,6 +86,11 @@ unsafe impl Traverse for ArgIterable { } impl ArgIterable { + #[must_use] + pub(crate) fn as_object(&self) -> &PyObject { + &self.iterable + } + /// Returns an iterator over this sequence of objects. /// /// This operation may fail if an exception is raised while invoking the From 643039de9403ab44b922c38b837c052fb5c8f013 Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:22:05 +0900 Subject: [PATCH 278/351] Fix __qualname__ of compiler-generated __annotate__ functions (#8498) Assisted-by: Codex:5.6-sol --- Lib/test/test_type_annotations.py | 1 - crates/codegen/src/compile.rs | 64 ++++++++++++++++++++++--------- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/Lib/test/test_type_annotations.py b/Lib/test/test_type_annotations.py index 3f056f2b753..c98b99e98e9 100644 --- a/Lib/test/test_type_annotations.py +++ b/Lib/test/test_type_annotations.py @@ -843,7 +843,6 @@ def test_complex_comprehension_inlining_exec(self): lamb = list(genexp)[0] self.assertEqual(lamb(), 42) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: '__annotate__' != 'f.__annotate__' def test_annotate_qualname(self): code = """ def f() -> None: diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index ba2d41f12b3..b16540762fd 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -2175,7 +2175,7 @@ impl<'warnings> Compiler<'warnings> { /// On success, returns the saved CompileContext to pass to exit_annotation_scope. fn enter_annotation_scope( &mut self, - _func_name: &str, + func_name: &str, loc: TextRange, ) -> CompileResult> { if !self.push_annotation_symbol_table() { @@ -2200,6 +2200,12 @@ impl<'warnings> Compiler<'warnings> { lineno.to_u32(), )?; + // enter_scope() qualified the scope by the enclosing scope only; redo it + // now that the annotated function is known. Only signature annotations + // get this treatment - deferred class and module annotations are + // compiled inside the scope they belong to and are already qualified. + self.set_annotation_qualname(func_name); + // Keep the internal ".format" name; exit_annotation_scope() // renames it to "format" on the final code object. self.current_code_info() @@ -2605,11 +2611,24 @@ impl<'warnings> Compiler<'warnings> { /// Set the qualified name for the current code object // = compiler_set_qualname fn set_qualname(&mut self) -> String { - let qualname = self.make_qualname(); + self.set_qualname_for_function(None) + } + + /// Set the qualname of an annotation scope, qualified by the function whose + /// signature it annotates. CPython records that name on the annotation + /// block's symbol table entry (`ste_function_name`) and folds it into the + /// qualname, so `f`'s annotation scope is named `f.__annotate__`. + fn set_annotation_qualname(&mut self, function_name: &str) { + self.set_qualname_for_function(Some(function_name)); + } + + fn set_qualname_for_function(&mut self, function_name: Option<&str>) -> String { + let qualname = self.make_qualname(function_name); self.current_code_info().metadata.qualname = Some(qualname.clone()); qualname } - fn make_qualname(&mut self) -> String { + + fn make_qualname(&mut self, function_name: Option<&str>) -> String { let stack_size = self.code_stack.len(); assert!(stack_size >= 1); @@ -2693,10 +2712,10 @@ impl<'warnings> Compiler<'warnings> { } } - // Build the qualified name - if force_global { + // Build the prefix the current name is qualified by, if any + let base = if force_global { // For global symbols, qualname is just the name - current_obj_name + None } else { // Check parent scope type let parent_obj_name = &parent.metadata.name; @@ -2709,23 +2728,32 @@ impl<'warnings> Compiler<'warnings> { ) ); + // Use parent's qualname if available, otherwise use parent_obj_name + let parent_qualname = parent.metadata.qualname.as_ref().unwrap_or(parent_obj_name); + if is_function_parent { // For functions, append . to parent qualname - // Use parent's qualname if available, otherwise use parent_obj_name - let parent_qualname = parent.metadata.qualname.as_ref().unwrap_or(parent_obj_name); - format!("{parent_qualname}..{current_obj_name}") + Some(format!("{parent_qualname}.")) + } else if parent_qualname == "" { + // Module level, nothing to qualify by + None } else { // For classes and other scopes, use parent's qualname directly - // Use parent's qualname if available, otherwise use parent_obj_name - let parent_qualname = parent.metadata.qualname.as_ref().unwrap_or(parent_obj_name); - if parent_qualname == "" { - // Module level, just use the name - current_obj_name - } else { - // Concatenate parent qualname with current name - format!("{parent_qualname}.{current_obj_name}") - } + Some(parent_qualname.clone()) } + }; + + // An annotation scope is compiled in the scope enclosing the function it + // annotates, so the function itself is missing from the prefix above. + let base = match (base, function_name) { + (Some(base), Some(function_name)) => Some(format!("{base}.{function_name}")), + (None, Some(function_name)) => Some(function_name.to_owned()), + (base, None) => base, + }; + + match base { + Some(base) => format!("{base}.{current_obj_name}"), + None => current_obj_name, } } From 9de06ccdb34d35b564e8dcc8e60d22f7fe01d0f1 Mon Sep 17 00:00:00 2001 From: Jeaeun Kim <109906379+kyokuping@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:24:44 +0900 Subject: [PATCH 279/351] jit: preserve return types for recursive calls (#8499) Assisted-by: Codex:5.6-sol --- crates/jit/src/instructions.rs | 18 ++++++++++++++++-- crates/jit/tests/bool_tests.rs | 14 ++++++++++++++ crates/jit/tests/float_tests.rs | 14 ++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/jit/src/instructions.rs b/crates/jit/src/instructions.rs index 67cf07f6e7f..a3c4ca800c4 100644 --- a/crates/jit/src/instructions.rs +++ b/crates/jit/src/instructions.rs @@ -548,8 +548,22 @@ impl<'a, 'b> FunctionCompiler<'a, 'b> { match self.stack.pop().ok_or(JitCompileError::BadBytecode)? { JitValue::FuncRef(reference) => { let call = self.builder.ins().call(reference, &args); - let returns = self.builder.inst_results(call); - self.stack.push(JitValue::Int(returns[0])); + // The only callable reachable here is this function itself, + // so the result carries the declared return type - it is not + // always an Int. A function whose return type is still + // unknown has no return slot in the signature it was + // declared with, and there is nothing to type the result as. + let ret = match *self.builder.inst_results(call) { + [] => None, + [val] => Some(val), + _ => return Err(JitCompileError::NotSupported), + }; + let val = match (self.sig.ret.clone(), ret) { + (Some(JitType::None), None) => JitValue::None, + (Some(ty), Some(val)) => JitValue::from_type_and_value(ty, val), + _ => return Err(JitCompileError::NotSupported), + }; + self.stack.push(val); Ok(()) } diff --git a/crates/jit/tests/bool_tests.rs b/crates/jit/tests/bool_tests.rs index 8a5f4ea9db3..1874ee4d55d 100644 --- a/crates/jit/tests/bool_tests.rs +++ b/crates/jit/tests/bool_tests.rs @@ -202,4 +202,18 @@ mod tests { assert_eq!(lte(false, 1), Ok(1)); assert_eq!(lte(true, 0), Ok(0)); } + + #[test] + fn recursive_bool() { + let recursive_bool = jit_function! { recursive_bool(n: i64) -> bool => r##" + def recursive_bool(n: int) -> bool: + if n == 0: + return True + return not recursive_bool(n - 1) + "## }; + + assert_eq!(recursive_bool(0), Ok(true)); + assert_eq!(recursive_bool(1), Ok(false)); + assert_eq!(recursive_bool(4), Ok(true)); + } } diff --git a/crates/jit/tests/float_tests.rs b/crates/jit/tests/float_tests.rs index b9bbb3ea63c..f667b1e764a 100644 --- a/crates/jit/tests/float_tests.rs +++ b/crates/jit/tests/float_tests.rs @@ -379,4 +379,18 @@ mod tests { assert_eq!(float_lte(f64::NAN, f64::NAN), Ok(false)); assert_eq!(float_lte(f64::INFINITY, f64::NEG_INFINITY), Ok(false)); } + + #[test] + fn recursive_float() { + let recursive_float = jit_function! { recursive_float(n: i64) -> f64 => r##" + def recursive_float(n: int) -> float: + if n == 0: + return 1.0 + return recursive_float(n - 1) / 2.0 + "## }; + + assert_eq!(recursive_float(0), Ok(1.0)); + assert_eq!(recursive_float(1), Ok(0.5)); + assert_eq!(recursive_float(4), Ok(0.0625)); + } } From d64cc2cff5ee611862ca9d2a27ccbd9a6f740e5d Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:28:09 +0900 Subject: [PATCH 280/351] Fix marshal recursive reference loading (#8501) * Fix marshal recursive reference loading Create reference-tracked containers before reading their children so recursive list, dict, set, and tuple graphs can be unmarshaled. Preserve interned string markers through the runtime bag and add an initialization-only tuple construction path. Assisted-by: Codex:gpt-5 * Preserve marshal container insertion errors Keep Python exceptions raised while constructing unmarshaled sets, frozensets, and dictionaries instead of collapsing them into ValueError. This makes abnormal recursive hash-container streams report TypeError like CPython and removes the remaining test_marshal expected failure. Assisted-by: Codex:gpt-5 * Enable full abnormal marshal reference loop test * Test direct marshal tuple reference loop Assisted-by: Codex:gpt-5 --- Lib/test/test_marshal.py | 21 ++-- crates/compiler-core/src/marshal.rs | 168 ++++++++++++++++++++++++---- crates/vm/src/builtins/tuple.rs | 101 ++++++++++++++--- crates/vm/src/stdlib/marshal.rs | 165 +++++++++++++++++++++------ 4 files changed, 366 insertions(+), 89 deletions(-) diff --git a/Lib/test/test_marshal.py b/Lib/test/test_marshal.py index ad4c6095abf..1f04a7f697e 100644 --- a/Lib/test/test_marshal.py +++ b/Lib/test/test_marshal.py @@ -319,7 +319,6 @@ def test_recursion_limit(self): last.append([0]) self.assertRaises(ValueError, marshal.dumps, head) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: bad marshal data def test_reference_loop_list(self): a = [] a.append(a) @@ -331,7 +330,6 @@ def test_reference_loop_list(self): self.assertIsInstance(b, list) self.assertIs(b[0], b) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: bad marshal data def test_reference_loop_dict(self): a = {} a[None] = a @@ -343,7 +341,6 @@ def test_reference_loop_dict(self): self.assertIsInstance(b, dict) self.assertIs(b[None], b) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: bad marshal data def test_reference_loop_tuple(self): a = ([],) a[0].append(a) @@ -387,21 +384,18 @@ def test_reference_loop_slice(self): for v in range(marshal.version + 1): self.assertRaises(ValueError, marshal.dumps, a, v) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: bad marshal data def test_loads_reference_loop_list(self): data = b'\xdb\x01\x00\x00\x00r\x00\x00\x00\x00' # [] a = marshal.loads(data) self.assertIsInstance(a, list) self.assertIs(a[0], a) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: bad marshal data def test_loads_reference_loop_dict(self): data = b'\xfbNr\x00\x00\x00\x000' # {None: } a = marshal.loads(data) self.assertIsInstance(a, dict) self.assertIs(a[None], a) - @unittest.expectedFailure # TODO: RUSTPYTHON; ValueError: bad marshal data def test_loads_abnormal_reference_loops(self): # Indirect self-references of tuples. data = b'\xa8\x01\x00\x00\x00[\x01\x00\x00\x00r\x00\x00\x00\x00' # ([],) @@ -416,13 +410,13 @@ def test_loads_abnormal_reference_loops(self): self.assertIsInstance(a[0], dict) self.assertIs(a[0][None], a) - # Direct self-reference which cannot be created in Python. - # This creates a reference loop which cannot be collected. - if False: - data = b'\xa8\x01\x00\x00\x00r\x00\x00\x00\x00' # (,) - a = marshal.loads(data) - self.assertIsInstance(a, tuple) - self.assertIs(a[0], a) + # Direct self-reference which cannot be created in Python. CPython + # leaves this disabled because its reference counting cannot collect + # the resulting cycle; RustPython's tracing collector can. + data = b'\xa8\x01\x00\x00\x00r\x00\x00\x00\x00' # (,) + a = marshal.loads(data) + self.assertIsInstance(a, tuple) + self.assertIs(a[0], a) # Direct self-references which cannot be created in Python # because of unhashability. @@ -748,7 +742,6 @@ class InterningTestCase(unittest.TestCase, HelperMixin): strobj = "this is an interned string" strobj = sys.intern(strobj) - @unittest.expectedFailure # TODO: RUSTPYTHON def testIntern(self): s = marshal.loads(marshal.dumps(self.strobj)) self.assertEqual(s, self.strobj) diff --git a/crates/compiler-core/src/marshal.rs b/crates/compiler-core/src/marshal.rs index a2a23054e4b..dd5d4f2cddb 100644 --- a/crates/compiler-core/src/marshal.rs +++ b/crates/compiler-core/src/marshal.rs @@ -516,7 +516,7 @@ fn read_const_value( let code = deserialize_code_inner(rdr, bag, depth - 1, refs)?; bag.make_code(code) } else { - deserialize_value_typed(rdr, bag, depth, refs, typ)? + deserialize_value_typed(rdr, bag, depth, refs, typ, slot)? }; if let Some(idx) = slot { refs[idx] = Some(value.clone()); @@ -540,6 +540,10 @@ pub trait MarshalBag: Copy { fn make_str(&self, value: &Wtf8) -> Self::Value; + fn make_interned_str(&self, value: &Wtf8) -> Self::Value { + self.make_str(value) + } + fn make_bytes(&self, value: &[u8]) -> Self::Value; fn make_int(&self, value: BigInt) -> Self::Value; @@ -564,6 +568,51 @@ pub trait MarshalBag: Copy { it: impl Iterator, ) -> Result; + /// Install partially-built containers in the marshal reference table + /// before reading their children, as CPython's `r_object()` does. + /// Runtime bags can opt in; constant bags retain collect-then-construct. + fn make_tuple_placeholder(&self, _len: usize) -> Option { + None + } + + fn set_tuple_item( + &self, + _tuple: &Self::Value, + _index: usize, + _value: Self::Value, + ) -> Result<()> { + Err(MarshalError::BadType) + } + + fn make_list_placeholder(&self, _len: usize) -> Option { + None + } + + fn set_list_item(&self, _list: &Self::Value, _index: usize, _value: Self::Value) -> Result<()> { + Err(MarshalError::BadType) + } + + fn make_set_placeholder(&self) -> Option { + None + } + + fn insert_set_item(&self, _set: &Self::Value, _value: Self::Value) -> Result<()> { + Err(MarshalError::BadType) + } + + fn make_dict_placeholder(&self) -> Option { + None + } + + fn insert_dict_item( + &self, + _dict: &Self::Value, + _key: Self::Value, + _value: Self::Value, + ) -> Result<()> { + Err(MarshalError::BadType) + } + fn make_slice( &self, _start: Self::Value, @@ -755,7 +804,7 @@ fn deserialize_value_after_header( let code = deserialize_code_inner(rdr, bag.constant_bag(), depth - 1, &mut inner_refs)?; bag.make_code(code) } else { - deserialize_value_typed(rdr, bag, depth, refs, typ)? + deserialize_value_typed(rdr, bag, depth, refs, typ, slot)? }; if let Some(idx) = slot { @@ -770,6 +819,7 @@ fn deserialize_value_typed( depth: usize, refs: &mut Vec>, typ: Type, + slot: Option, ) -> Result { if depth == 0 { return Err(MarshalError::InvalidBytecode); @@ -806,21 +856,42 @@ fn deserialize_value_typed( let value = Complex64 { re, im }; bag.make_complex(value) } - Type::Ascii | Type::AsciiInterned | Type::Unicode | Type::Interned => { + Type::Ascii | Type::Unicode => { let len = rdr.read_u32()?; let value = rdr.read_wtf8(len)?; bag.make_str(value) } - Type::ShortAscii | Type::ShortAsciiInterned => { + Type::AsciiInterned | Type::Interned => { + let len = rdr.read_u32()?; + let value = rdr.read_wtf8(len)?; + bag.make_interned_str(value) + } + Type::ShortAscii => { let len = rdr.read_u8()? as u32; let value = rdr.read_wtf8(len)?; bag.make_str(value) } + Type::ShortAsciiInterned => { + let len = rdr.read_u8()? as u32; + let value = rdr.read_wtf8(len)?; + bag.make_interned_str(value) + } Type::SmallTuple => { let len = rdr.read_u8()? as usize; let d = depth - 1; - let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); - itertools::process_results(it, |it| bag.make_tuple(it))? + if let Some(index) = slot + && let Some(tuple) = bag.make_tuple_placeholder(len) + { + refs[index] = Some(tuple.clone()); + for item_index in 0..len { + let item = deserialize_value_depth(rdr, bag, d, refs)?; + bag.set_tuple_item(&tuple, item_index, item)?; + } + tuple + } else { + let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); + itertools::process_results(it, |it| bag.make_tuple(it))? + } } Type::Null => { return Err(MarshalError::BadType); @@ -830,22 +901,55 @@ fn deserialize_value_typed( return Err(MarshalError::BadType); } Type::Tuple => { - let len = rdr.read_u32()?; + let len = rdr.read_u32()? as usize; let d = depth - 1; - let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); - itertools::process_results(it, |it| bag.make_tuple(it))? + if let Some(index) = slot + && let Some(tuple) = bag.make_tuple_placeholder(len) + { + refs[index] = Some(tuple.clone()); + for item_index in 0..len { + let item = deserialize_value_depth(rdr, bag, d, refs)?; + bag.set_tuple_item(&tuple, item_index, item)?; + } + tuple + } else { + let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); + itertools::process_results(it, |it| bag.make_tuple(it))? + } } Type::List => { - let len = rdr.read_u32()?; + let len = rdr.read_u32()? as usize; let d = depth - 1; - let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); - itertools::process_results(it, |it| bag.make_list(it))?? + if let Some(index) = slot + && let Some(list) = bag.make_list_placeholder(len) + { + refs[index] = Some(list.clone()); + for item_index in 0..len { + let item = deserialize_value_depth(rdr, bag, d, refs)?; + bag.set_list_item(&list, item_index, item)?; + } + list + } else { + let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); + itertools::process_results(it, |it| bag.make_list(it))?? + } } Type::Set => { - let len = rdr.read_u32()?; + let len = rdr.read_u32()? as usize; let d = depth - 1; - let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); - itertools::process_results(it, |it| bag.make_set(it))?? + if let Some(index) = slot + && let Some(set) = bag.make_set_placeholder() + { + refs[index] = Some(set.clone()); + for _ in 0..len { + let item = deserialize_value_depth(rdr, bag, d, refs)?; + bag.insert_set_item(&set, item)?; + } + set + } else { + let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); + itertools::process_results(it, |it| bag.make_set(it))?? + } } Type::FrozenSet => { let len = rdr.read_u32()?; @@ -855,17 +959,33 @@ fn deserialize_value_typed( } Type::Dict => { let d = depth - 1; - let mut pairs = Vec::new(); - loop { - let raw = rdr.read_u8()?; - if raw & !FLAG_REF == b'0' { - break; + if let Some(index) = slot + && let Some(dict) = bag.make_dict_placeholder() + { + refs[index] = Some(dict.clone()); + loop { + let raw = rdr.read_u8()?; + if raw & !FLAG_REF == b'0' { + break; + } + let key = deserialize_value_after_header(rdr, bag, d, refs, raw)?; + let value = deserialize_value_depth(rdr, bag, d, refs)?; + bag.insert_dict_item(&dict, key, value)?; + } + dict + } else { + let mut pairs = Vec::new(); + loop { + let raw = rdr.read_u8()?; + if raw & !FLAG_REF == b'0' { + break; + } + let key = deserialize_value_after_header(rdr, bag, d, refs, raw)?; + let value = deserialize_value_depth(rdr, bag, d, refs)?; + pairs.push((key, value)); } - let k = deserialize_value_after_header(rdr, bag, d, refs, raw)?; - let v = deserialize_value_depth(rdr, bag, d, refs)?; - pairs.push((k, v)); + bag.make_dict(pairs.into_iter())? } - bag.make_dict(pairs.into_iter())? } Type::Bytes => { // After marshaling, byte arrays are converted into bytes. diff --git a/crates/vm/src/builtins/tuple.rs b/crates/vm/src/builtins/tuple.rs index d48639b2c11..06fa2519205 100644 --- a/crates/vm/src/builtins/tuple.rs +++ b/crates/vm/src/builtins/tuple.rs @@ -23,12 +23,60 @@ use crate::{ vm::VirtualMachine, }; use alloc::fmt; -use core::cell::Cell; +use core::cell::{Cell, UnsafeCell}; use core::ptr::NonNull; #[pyclass(module = false, name = "tuple", traverse = "manual")] pub struct PyTuple { - elements: Box<[R]>, + elements: TupleElements, +} + +/// Tuple storage is immutable after publication, but marshal must publish a +/// tuple in its reference table before recursively reading its children. +/// This mirrors CPython's `PyTuple_New` followed by `PyTuple_SET_ITEM`. +struct TupleElements(UnsafeCell>); + +unsafe impl Send for TupleElements {} +unsafe impl Sync for TupleElements {} + +impl TupleElements { + const fn new(elements: Box<[R]>) -> Self { + Self(UnsafeCell::new(elements)) + } + + fn as_slice(&self) -> &[R] { + // SAFETY: initialization writes happen only while the tuple is owned by + // the synchronous marshal decoder; afterwards the storage is immutable. + unsafe { &*self.0.get() } + } + + fn get_mut(&mut self) -> &mut Box<[R]> { + self.0.get_mut() + } + + /// # Safety + /// The tuple must still be in its private initialization phase, and each + /// placeholder index must be replaced at most once before it is observable. + unsafe fn set_initializing(&self, index: usize, value: R) { + unsafe { (*self.0.get())[index] = value }; + } +} + +impl core::ops::Deref for TupleElements { + type Target = [R]; + + fn deref(&self) -> &Self::Target { + self.as_slice() + } +} + +impl<'a, R> IntoIterator for &'a TupleElements { + type Item = &'a R; + type IntoIter = core::slice::Iter<'a, R>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } } impl fmt::Debug for PyTuple { @@ -42,11 +90,11 @@ impl fmt::Debug for PyTuple { // Note: Only impl for PyTuple (the default) unsafe impl Traverse for PyTuple { fn traverse(&self, traverse_fn: &mut TraverseFn<'_>) { - self.elements.traverse(traverse_fn); + self.elements.as_slice().traverse(traverse_fn); } fn clear(&mut self, out: &mut Vec) { - let elements = core::mem::take(&mut self.elements); + let elements = core::mem::take(self.elements.get_mut()); out.extend(elements.into_vec()); } } @@ -206,7 +254,7 @@ impl Constructor for PyTuple { fn py_new(_cls: &Py, elements: Self::Args, _vm: &VirtualMachine) -> PyResult { Ok(Self { - elements: elements.into_boxed_slice(), + elements: TupleElements::new(elements.into_boxed_slice()), }) } } @@ -245,19 +293,19 @@ impl<'a, R> core::iter::IntoIterator for &'a Py> { impl PyTuple { #[must_use] - pub const fn as_slice(&self) -> &[R] { + pub fn as_slice(&self) -> &[R] { &self.elements } #[inline] #[must_use] - pub const fn len(&self) -> usize { + pub fn len(&self) -> usize { self.elements.len() } #[inline] #[must_use] - pub const fn is_empty(&self) -> bool { + pub fn is_empty(&self) -> bool { self.elements.is_empty() } @@ -274,7 +322,13 @@ impl PyTuple { ctx.empty_tuple.clone() } else { let elements = elements.into_boxed_slice(); - PyRef::new_ref(Self { elements }, ctx.types.tuple_type.to_owned(), None) + PyRef::new_ref( + Self { + elements: TupleElements::new(elements), + }, + ctx.types.tuple_type.to_owned(), + None, + ) } } @@ -283,7 +337,20 @@ impl PyTuple { /// Calling this function implies trying micro optimization for non-zero-sized tuple. #[must_use] pub const fn new_unchecked(elements: Box<[PyObjectRef]>) -> Self { - Self { elements } + Self { + elements: TupleElements::new(elements), + } + } + + pub(crate) fn new_marshal_placeholder(len: usize, ctx: &Context) -> PyRef { + Self::new_ref(vec![ctx.none(); len], ctx) + } + + /// # Safety + /// This tuple must be a marshal placeholder which has not escaped the + /// decoder, and `index` must not have been replaced previously. + pub(crate) unsafe fn set_marshal_item(&self, index: usize, value: PyObjectRef) { + unsafe { self.elements.set_initializing(index, value) }; } fn repeat(zelf: PyRef, value: isize, vm: &VirtualMachine) -> PyResult> { @@ -298,7 +365,10 @@ impl PyTuple { } else { let v = zelf.elements.mul(vm, value)?; let elements = v.into_boxed_slice(); - Self { elements }.into_ref(&vm.ctx) + Self { + elements: TupleElements::new(elements), + } + .into_ref(&vm.ctx) }) } @@ -341,7 +411,10 @@ impl PyTuple { .chain(other.as_slice()) .cloned() .collect::>(); - Self { elements }.into_ref(&vm.ctx) + Self { + elements: TupleElements::new(elements), + } + .into_ref(&vm.ctx) } }); PyArithmeticValue::from_option(added.ok()) @@ -360,7 +433,7 @@ impl PyTuple { #[inline] #[must_use] - pub const fn __len__(&self) -> usize { + pub fn __len__(&self) -> usize { self.elements.len() } @@ -425,7 +498,7 @@ impl PyTuple { let tup_arg = if zelf.class().is(vm.ctx.types.tuple_type) { zelf } else { - Self::new_ref(zelf.elements.clone().into_vec(), &vm.ctx) + Self::new_ref(zelf.elements.as_slice().to_vec(), &vm.ctx) }; (tup_arg,) } diff --git a/crates/vm/src/stdlib/marshal.rs b/crates/vm/src/stdlib/marshal.rs index ace3aff58f2..a4665d0f5b6 100644 --- a/crates/vm/src/stdlib/marshal.rs +++ b/crates/vm/src/stdlib/marshal.rs @@ -9,14 +9,16 @@ mod decl { use crate::{ PyObjectRef, PyResult, TryFromObject, VirtualMachine, builtins::{ - PyBool, PyByteArray, PyBytes, PyCode, PyComplex, PyDict, PyEllipsis, PyFloat, - PyFrozenSet, PyInt, PyList, PyNone, PySet, PyStopIteration, PyStr, PyTuple, + PyBaseExceptionRef, PyBool, PyByteArray, PyBytes, PyCode, PyComplex, PyDict, + PyEllipsis, PyFloat, PyFrozenSet, PyInt, PyList, PyNone, PySet, PyStopIteration, PyStr, + PyTuple, }, convert::ToPyObject, function::{ArgBytesLike, OptionalArg}, object::{AsObject, PyPayload}, protocol::PyBuffer, }; + use core::cell::RefCell; use malachite_bigint::BigInt; use num_traits::Zero; use rustpython_compiler_core::marshal::{self, DumpableValue}; @@ -386,79 +388,165 @@ mod decl { } #[derive(Copy, Clone)] - struct PyMarshalBag<'a>(&'a VirtualMachine); + struct PyMarshalBag<'a> { + vm: &'a VirtualMachine, + pending_error: &'a RefCell>, + } + + impl<'a> PyMarshalBag<'a> { + fn new( + vm: &'a VirtualMachine, + pending_error: &'a RefCell>, + ) -> Self { + Self { vm, pending_error } + } + + fn remember_python_error(&self, error: PyBaseExceptionRef) -> marshal::MarshalError { + let mut pending = self.pending_error.borrow_mut(); + if pending.is_none() { + *pending = Some(error); + } + marshal::MarshalError::BadType + } + } impl<'a> marshal::MarshalBag for PyMarshalBag<'a> { type Value = PyObjectRef; type ConstantBag = PyVmBag<'a>; fn make_bool(&self, value: bool) -> Self::Value { - self.0.ctx.new_bool(value).into() + self.vm.ctx.new_bool(value).into() } fn make_none(&self) -> Self::Value { - self.0.ctx.none() + self.vm.ctx.none() } fn make_ellipsis(&self) -> Self::Value { - self.0.ctx.ellipsis.clone().into() + self.vm.ctx.ellipsis.clone().into() } fn make_float(&self, value: f64) -> Self::Value { - self.0.ctx.new_float(value).into() + self.vm.ctx.new_float(value).into() } fn make_complex(&self, value: num_complex::Complex64) -> Self::Value { - self.0.ctx.new_complex(value).into() + self.vm.ctx.new_complex(value).into() } fn make_str(&self, value: &Wtf8) -> Self::Value { - self.0.ctx.new_str(value).into() + self.vm.ctx.new_str(value).into() + } + fn make_interned_str(&self, value: &Wtf8) -> Self::Value { + self.vm.ctx.intern_str(value).to_owned().into() } fn make_bytes(&self, value: &[u8]) -> Self::Value { - self.0.ctx.new_bytes(value.to_vec()).into() + self.vm.ctx.new_bytes(value.to_vec()).into() } fn make_int(&self, value: BigInt) -> Self::Value { - self.0.ctx.new_int(value).into() + self.vm.ctx.new_int(value).into() } fn make_tuple(&self, elements: impl Iterator) -> Self::Value { - self.0.ctx.new_tuple(elements.collect()).into() + self.vm.ctx.new_tuple(elements.collect()).into() + } + fn make_tuple_placeholder(&self, len: usize) -> Option { + Some(PyTuple::new_marshal_placeholder(len, &self.vm.ctx).into()) + } + fn set_tuple_item( + &self, + tuple: &Self::Value, + index: usize, + value: Self::Value, + ) -> Result<(), marshal::MarshalError> { + let tuple = tuple + .downcast_ref::() + .ok_or(marshal::MarshalError::BadType)?; + // SAFETY: compiler-core calls this only on a fresh placeholder, + // once per index, before returning it to Python code. + unsafe { tuple.set_marshal_item(index, value) }; + Ok(()) } fn make_code(&self, code: CodeObject) -> Self::Value { - crate::builtins::PyCode::new_ref_with_bag(self.0, code).into() + crate::builtins::PyCode::new_ref_with_bag(self.vm, code).into() } fn make_stop_iter(&self) -> Result { - Ok(self.0.ctx.exceptions.stop_iteration.to_owned().into()) + Ok(self.vm.ctx.exceptions.stop_iteration.to_owned().into()) } fn make_list( &self, it: impl Iterator, ) -> Result { - Ok(self.0.ctx.new_list(it.collect()).into()) + Ok(self.vm.ctx.new_list(it.collect()).into()) + } + fn make_list_placeholder(&self, len: usize) -> Option { + Some(self.vm.ctx.new_list(vec![self.vm.ctx.none(); len]).into()) + } + fn set_list_item( + &self, + list: &Self::Value, + index: usize, + value: Self::Value, + ) -> Result<(), marshal::MarshalError> { + let list = list + .downcast_ref::() + .ok_or(marshal::MarshalError::BadType)?; + list.borrow_vec_mut()[index] = value; + Ok(()) } fn make_set( &self, it: impl Iterator, ) -> Result { - let set = PySet::default().into_ref(&self.0.ctx); + let set = PySet::default().into_ref(&self.vm.ctx); for elem in it { - set.add(elem, self.0).unwrap() + set.add(elem, self.vm) + .map_err(|error| self.remember_python_error(error))?; } Ok(set.into()) } + fn make_set_placeholder(&self) -> Option { + Some(PySet::default().into_ref(&self.vm.ctx).into()) + } + fn insert_set_item( + &self, + set: &Self::Value, + value: Self::Value, + ) -> Result<(), marshal::MarshalError> { + let set = set + .downcast_ref::() + .ok_or(marshal::MarshalError::BadType)?; + set.add(value, self.vm) + .map_err(|error| self.remember_python_error(error)) + } fn make_frozenset( &self, it: impl Iterator, ) -> Result { - Ok(PyFrozenSet::from_iter(self.0, it) - .unwrap() - .to_pyobject(self.0)) + PyFrozenSet::from_iter(self.vm, it) + .map(|set| set.to_pyobject(self.vm)) + .map_err(|error| self.remember_python_error(error)) } fn make_dict( &self, it: impl Iterator, ) -> Result { - let dict = self.0.ctx.new_dict(); + let dict = self.vm.ctx.new_dict(); for (k, v) in it { - dict.set_item(&*k, v, self.0).unwrap() + dict.set_item(&*k, v, self.vm) + .map_err(|error| self.remember_python_error(error))?; } Ok(dict.into()) } + fn make_dict_placeholder(&self) -> Option { + Some(self.vm.ctx.new_dict().into()) + } + fn insert_dict_item( + &self, + dict: &Self::Value, + key: Self::Value, + value: Self::Value, + ) -> Result<(), marshal::MarshalError> { + let dict = dict + .downcast_ref::() + .ok_or(marshal::MarshalError::BadType)?; + dict.set_item(&*key, value, self.vm) + .map_err(|error| self.remember_python_error(error)) + } fn make_slice( &self, start: Self::Value, @@ -466,7 +554,7 @@ mod decl { step: Self::Value, ) -> Result { use crate::builtins::PySlice; - let vm = self.0; + let vm = self.vm; Ok(PySlice { start: if vm.is_none(&start) { None @@ -480,7 +568,21 @@ mod decl { .into()) } fn constant_bag(self) -> Self::ConstantBag { - PyVmBag(self.0) + PyVmBag(self.vm) + } + } + + fn deserialize_value( + rdr: &mut impl marshal::Read, + vm: &VirtualMachine, + ) -> PyResult { + let pending_error = RefCell::new(None); + match marshal::deserialize_value(rdr, PyMarshalBag::new(vm, &pending_error)) { + Ok(value) => Ok(value), + Err(error) => Err(pending_error.into_inner().unwrap_or_else(|| match error { + marshal::MarshalError::Eof => vm.new_eof_error("marshal data too short"), + _ => vm.new_value_error("bad marshal data"), + })), } } @@ -502,11 +604,7 @@ mod decl { vm.new_buffer_error("Buffer provided to marshal.loads() is not contiguous") })?; - let result = - marshal::deserialize_value(&mut &buf[..], PyMarshalBag(vm)).map_err(|e| match e { - marshal::MarshalError::Eof => vm.new_eof_error("marshal data too short"), - _ => vm.new_value_error("bad marshal data"), - })?; + let result = deserialize_value(&mut &buf[..], vm)?; if !allow_code { check_no_code(&result, vm)?; } @@ -534,14 +632,7 @@ mod decl { let mut rdr: &[u8] = &buf; let len_before = rdr.len(); - let result = - marshal::deserialize_value(&mut rdr, PyMarshalBag(vm)).map_err(|e| match e { - marshal::MarshalError::Eof => vm.new_exception_msg( - vm.ctx.exceptions.eof_error.to_owned(), - "marshal data too short".into(), - ), - _ => vm.new_value_error("bad marshal data"), - })?; + let result = deserialize_value(&mut rdr, vm)?; let consumed = len_before - rdr.len(); // Seek file to just after the consumed bytes From 24bd3b33f9c6d1a3d32ab297457f7a1b73984263 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:55:23 +0900 Subject: [PATCH 281/351] ssl: pass test_ssl with the rustls backend (#8502) * Fix rustls test_ssl compatibility Assisted-by: OpenAI Codex:GPT-5 * Keep urllib3 compatible SSL version prefix Assisted-by: OpenAI Codex:GPT-5 --- crates/stdlib/src/ssl.rs | 16 ++++--- crates/stdlib/src/ssl/cert.rs | 40 +++++++++++------ crates/vm/src/stdlib/_thread.rs | 76 ++++++++++++++++++++++++++++++++- 3 files changed, 109 insertions(+), 23 deletions(-) diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 04b905d544e..18d171a8583 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -323,15 +323,17 @@ mod _ssl { #[pyattr] const ALERT_DESCRIPTION_NO_APPLICATION_PROTOCOL: i32 = 120; - // Version info - reporting as OpenSSL 3.3.0 for compatibility + // `ssl.py` still requires OpenSSL-shaped numeric compatibility fields even + // for non-OpenSSL TLS providers. Keep them in the supported 3.x ABI range, + // but report the actual rustls/AWS-LC backend in the human-readable string. #[pyattr] - const OPENSSL_VERSION_NUMBER: i32 = 0x30300000; // OpenSSL 3.3.0 (808452096) + const OPENSSL_VERSION_NUMBER: i32 = 0x30300000; #[pyattr] - const OPENSSL_VERSION: &str = "OpenSSL 3.3.0 (rustls/0.23)"; + const OPENSSL_VERSION: &str = "OpenSSL 3.3.0-compatible (AWS-LC/rustls 0.23)"; #[pyattr] - const OPENSSL_VERSION_INFO: (i32, i32, i32, i32, i32) = (3, 3, 0, 0, 15); // 3.3.0 release + const OPENSSL_VERSION_INFO: (i32, i32, i32, i32, i32) = (3, 3, 0, 0, 15); #[pyattr] - const _OPENSSL_API_VERSION: (i32, i32, i32, i32, i32) = (3, 3, 0, 0, 15); // 3.3.0 release + const _OPENSSL_API_VERSION: (i32, i32, i32, i32, i32) = (3, 3, 0, 0, 15); // Default cipher list for rustls - using modern secure ciphers #[pyattr] @@ -2816,8 +2818,8 @@ mod _ssl { super::compat::SslError::create_ssl_error_with_reason( vm, Some("SSL"), - "CALLBACK_FAILED", - "[SSL: CALLBACK_FAILED] callback failed", + "PARSE_TLSEXT", + "[SSL: PARSE_TLSEXT] SNI callback owner is no longer available", ) })?; let server_name_py: PyObjectRef = match sni_name { diff --git a/crates/stdlib/src/ssl/cert.rs b/crates/stdlib/src/ssl/cert.rs index f12f4307239..47d11f730b2 100644 --- a/crates/stdlib/src/ssl/cert.rs +++ b/crates/stdlib/src/ssl/cert.rs @@ -287,9 +287,11 @@ pub(super) fn is_ca_certificate(cert_der: &[u8]) -> bool { return ext.value.ca; } - // No Basic Constraints extension -> NOT a CA certificate - // (matches OpenSSL X509_check_ca() behavior) - false + // X509_check_ca() also retains OpenSSL's legacy trust-anchor rule: a + // self-issued X.509v1 certificate has no extensions at all, but is still + // classified as a CA. CPython's test CA at capath/4e1295a3.0 exercises + // precisely this case. + cert.version().0 == 0 && cert.subject() == cert.issuer() } /// Convert an X509Name to Python nested tuple format for SSL certificate dicts @@ -867,26 +869,36 @@ impl ServerCertVerifier for NoVerifier { fn verify_tls12_signature( &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, ) -> Result { - // Accept all signatures without verification - Ok(HandshakeSignatureValid::assertion()) + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &CryptoExt::get_provider().signature_verification_algorithms, + ) } fn verify_tls13_signature( &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, ) -> Result { - // Accept all signatures without verification - Ok(HandshakeSignatureValid::assertion()) + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &CryptoExt::get_provider().signature_verification_algorithms, + ) } fn supported_verify_schemes(&self) -> Vec { - ALL_SIGNATURE_SCHEMES.to_vec() + CryptoExt::get_provider() + .signature_verification_algorithms + .supported_schemes() } } diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index 70304e63980..377c68dca74 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -605,14 +605,35 @@ pub(crate) mod _thread { vm.state.thread_count.fetch_sub(1); } + /// Default stack size for Python threads in **debug builds only**, where + /// Rust stack frames are substantially larger than in release. Rust's + /// `std::thread::Builder` otherwise defaults to 2 MB, which is too small + /// for the call chains the Python stdlib runs on helper threads in debug + /// (e.g. the SSL test server, see #7941). Release builds keep the prior + /// behavior — leave the stack size unset and let Rust's std default apply + /// — to avoid oversized virtual stack mappings when many threads spawn. + #[cfg(debug_assertions)] + const DEFAULT_THREAD_STACK_SIZE: usize = 8 * 1024 * 1024; + + /// Configure a `thread::Builder` with the stack size to use for a new + /// Python thread. Uses the value set via `threading.stack_size(N)` when + /// the user has provided one (non-zero). Otherwise, debug builds fall + /// back to [`DEFAULT_THREAD_STACK_SIZE`] and release builds leave the + /// builder unmodified (Rust's std default applies). fn apply_thread_stack_size( thread_builder: thread::Builder, vm: &VirtualMachine, ) -> thread::Builder { let configured = vm.state.stacksize.load(); if configured != 0 { - thread_builder.stack_size(configured) - } else { + return thread_builder.stack_size(configured); + } + #[cfg(debug_assertions)] + { + thread_builder.stack_size(DEFAULT_THREAD_STACK_SIZE) + } + #[cfg(not(debug_assertions))] + { thread_builder } } @@ -1996,4 +2017,55 @@ pub(crate) mod _thread { Ok(handle_clone) } + + #[cfg(test)] + mod tests { + #[cfg(all(debug_assertions, any(target_os = "linux", target_os = "macos")))] + use super::*; + #[cfg(all(debug_assertions, any(target_os = "linux", target_os = "macos")))] + use crate::Interpreter; + + /// Regression test for #7941: a Python thread started without an + /// explicit `threading.stack_size()` must not run on Rust's 2 MiB + /// std default in debug builds, where the call chains the stdlib + /// runs on helper threads (e.g. the SSL test server) overflowed it. + #[test] + #[cfg(all(debug_assertions, any(target_os = "linux", target_os = "macos")))] + fn default_python_thread_stack_size_debug() { + Interpreter::without_stdlib(Default::default()).enter(|vm| { + assert_eq!(vm.state.stacksize.load(), 0); + let builder = apply_thread_stack_size(thread::Builder::new(), vm); + let stack_size = builder + .spawn(current_thread_stack_size) + .expect("failed to spawn thread") + .join() + .expect("thread panicked"); + assert!( + stack_size >= DEFAULT_THREAD_STACK_SIZE, + "Python thread stack size is {stack_size} bytes, expected at least {DEFAULT_THREAD_STACK_SIZE}" + ); + }); + } + + #[cfg(all(debug_assertions, target_os = "linux"))] + fn current_thread_stack_size() -> usize { + use libc::{ + pthread_attr_destroy, pthread_attr_getstacksize, pthread_attr_t, + pthread_getattr_np, pthread_self, + }; + let mut attr: pthread_attr_t = unsafe { core::mem::zeroed() }; + unsafe { + assert_eq!(pthread_getattr_np(pthread_self(), &mut attr), 0); + let mut size = 0; + assert_eq!(pthread_attr_getstacksize(&attr, &mut size), 0); + pthread_attr_destroy(&mut attr); + size + } + } + + #[cfg(all(debug_assertions, target_os = "macos"))] + fn current_thread_stack_size() -> usize { + unsafe { libc::pthread_get_stacksize_np(libc::pthread_self()) } + } + } } From e02e215353494c52c3a95a6030b567794c0071d5 Mon Sep 17 00:00:00 2001 From: Lee Dogeon Date: Thu, 13 Aug 2026 04:29:13 +0900 Subject: [PATCH 282/351] ci: automate OSCCA pull request triage (#8505) Assisted-by: Codex:gpt-5.6-sol --- .github/workflows/oscca-pr.yml | 70 ++++++++++++++++++++++++++++++++++ .github/zizmor.yml | 5 +++ 2 files changed, 75 insertions(+) create mode 100644 .github/workflows/oscca-pr.yml diff --git a/.github/workflows/oscca-pr.yml b/.github/workflows/oscca-pr.yml new file mode 100644 index 00000000000..f96c18fd3c3 --- /dev/null +++ b/.github/workflows/oscca-pr.yml @@ -0,0 +1,70 @@ +name: Manage OSCCA pull requests + +on: + pull_request_target: + types: [opened] + +permissions: {} + +jobs: + label-and-assign: + name: Label and assign OSCCA pull request + runs-on: ubuntu-slim + timeout-minutes: 5 + permissions: + issues: write + steps: + - name: Label and assign pull request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const osccaUsers = new Set( + [ + "2jiyong", + "chestnut1717", + "devyubin", + "fregataa", + "hyoinandout", + "HyoJongPark", + "JaceJung-dev", + "jinmay", + "jiwahn", + "kangdora", + "kim-jaedeok", + "kyokuping", + "leehanjeong", + "lms0806", + "lsahn-gh", + "moreal", + "name-of-okja", + "rlaisqls", + "seungje0612", + "shAn-kor", + "sigmaith", + "teddygood", + "widehyo1", + "YangSiJun528", + "zzarbttoo", + ].map((login) => login.toLowerCase()), + ); + const pullRequest = context.payload.pull_request; + const author = pullRequest.user.login; + + if (!osccaUsers.has(author.toLowerCase())) { + core.info(`${author} is not an OSCCA participant; skipping.`); + return; + } + + const issue = { + ...context.repo, + issue_number: pullRequest.number, + }; + + await github.rest.issues.addLabels({ + ...issue, + labels: ["z-ca-2026"], + }); + await github.rest.issues.addAssignees({ + ...issue, + assignees: [author], + }); diff --git a/.github/zizmor.yml b/.github/zizmor.yml index 33ac61c6489..02ceb805c2c 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,4 +1,9 @@ rules: + dangerous-triggers: + ignore: + # pull_request_target is needed to label and assign PRs from forks with issues: write. + # The workflow does not check out or execute pull request code. + - oscca-pr.yml:3 excessive-permissions: ignore: # pull_request_target is needed to post PR comments with pull-requests: write. From 3a98ef746a3c050fafd59f0331c922c9bbd5a685 Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:30:53 -0400 Subject: [PATCH 283/351] Allow and document clippy::drain_collect (#8500) RustPython's tail call machinery pre-allocates and reuses a vector. The code drains the vector into a new vector which is stored elsewhere. Clippy warns that this pattern causes a spurious location. Clippy is usually right that this pattern is suspect, but in this case the initial vector is reused so we want to keep the initial location. --- crates/vm/src/stdlib/_codecs.rs | 11 +++++------ crates/vm/src/vm/mod.rs | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/crates/vm/src/stdlib/_codecs.rs b/crates/vm/src/stdlib/_codecs.rs index 69d9e0e4fde..497d62fcc81 100644 --- a/crates/vm/src/stdlib/_codecs.rs +++ b/crates/vm/src/stdlib/_codecs.rs @@ -791,19 +791,18 @@ mod _codecs_windows { // Convert code point to UTF-16 let mut wchars = [0u16; 2]; - let wchar_len; let is_surrogate = (0xD800..=0xDFFF).contains(&ch); - if is_surrogate { - wchar_len = 0; // Can't encode surrogates normally + let wchar_len = if is_surrogate { + 0 // Can't encode surrogates normally } else if ch < 0x10000 { wchars[0] = ch as u16; - wchar_len = 1; + 1 } else { wchars[0] = ((ch - 0x10000) >> 10) as u16 + 0xD800; wchars[1] = ((ch - 0x10000) & 0x3FF) as u16 + 0xDC00; - wchar_len = 2; - } + 2 + }; if !is_surrogate { let mut buf = [0u8; 8]; diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 6009f421e12..7c7d017c1fd 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -1467,6 +1467,10 @@ impl VirtualMachine { let initial_ptr = self.take_pending_tailcall(); // Drain the refs that keep the initial callee's raw pointers alive. + #[allow( + clippy::drain_collect, + reason = "`pending_tailcall_refs`'s allocation is intentionally reused" + )] let initial_refs = unsafe { &mut *self.pending_tailcall_refs.get() } .drain(..) .collect(); @@ -1498,6 +1502,10 @@ impl VirtualMachine { let result = crate::frame::run_iframe(callee, self); match result { Ok(ExecutionResult::TailCall) => { + #[allow( + clippy::drain_collect, + reason = "`pending_tailcall_refs`'s allocation is intentionally reused" + )] let refs = unsafe { &mut *self.pending_tailcall_refs.get() } .drain(..) .collect(); @@ -1548,6 +1556,10 @@ impl VirtualMachine { let result = crate::frame::run_iframe(caller_iframe, self); match result { Ok(ExecutionResult::TailCall) => { + #[allow( + clippy::drain_collect, + reason = "`pending_tailcall_refs`'s allocation is intentionally reused" + )] let refs = unsafe { &mut *self.pending_tailcall_refs.get() } .drain(..) .collect(); @@ -1609,6 +1621,10 @@ impl VirtualMachine { let result = crate::frame::run_iframe(caller_iframe, self); match result { Ok(ExecutionResult::TailCall) => { + #[allow( + clippy::drain_collect, + reason = "`pending_tailcall_refs`'s allocation is intentionally reused" + )] let refs = unsafe { &mut *self.pending_tailcall_refs.get() } .drain(..) .collect(); From db5de5e5e238f8b0e03b2cff17f8603bbf45ef84 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Thu, 13 Aug 2026 04:31:53 +0900 Subject: [PATCH 284/351] Reuse stored hashes in dict.fromkeys() (#8503) * Reuse stored hashes in dict.fromkeys() Closes #8490. Co-Authored-By: Claude Opus 5 (1M context) * Make fromkeys_known_hashes a PyDict associated fn --------- Co-authored-by: Claude Opus 5 (1M context) --- Lib/test/test_set.py | 1 - crates/vm/src/builtins/dict.rs | 30 ++++++++++++++++++++++++++---- crates/vm/src/builtins/set.rs | 17 +++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_set.py b/Lib/test/test_set.py index 4d062b42ded..42f11c9eb28 100644 --- a/Lib/test/test_set.py +++ b/Lib/test/test_set.py @@ -330,7 +330,6 @@ def test_cyclical_repr(self): name = repr(s).partition('(')[0] # strip class name self.assertEqual(repr(s), '%s({%s(...)})' % (name, name)) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_do_not_rehash_dict_keys(self): n = 10 d = dict.fromkeys(map(HashCountingInt, range(n))) diff --git a/crates/vm/src/builtins/dict.rs b/crates/vm/src/builtins/dict.rs index 5db071e1d8f..fbc23a0dde7 100644 --- a/crates/vm/src/builtins/dict.rs +++ b/crates/vm/src/builtins/dict.rs @@ -1,6 +1,6 @@ use super::{ IterStatus, PositionIterInternal, PyBaseExceptionRef, PyGenericAlias, PyMappingProxy, PySet, - PyStr, PyStrRef, PyTupleRef, PyType, PyTypeRef, set::PySetInner, + PyStr, PyStrRef, PyTupleRef, PyType, PyTypeRef, set, set::PySetInner, }; use crate::common::lock::LazyLock; use crate::object::{Traverse, TraverseFn}; @@ -9,7 +9,7 @@ use crate::{ TryFromObject, atomic_func, builtins::{PyList, PyTuple, iter::builtins_iter, type_::PyAttributes}, class::{PyClassDef, PyClassImpl}, - common::ascii, + common::{ascii, hash::PyHash}, dict_inner::{self, DictKey}, function::{ArgIterable, FuncArgs, KwArgs, OptionalArg, PyArithmeticValue, PyComparisonValue}, iter::PyExactSizeIterator, @@ -354,6 +354,20 @@ impl PyDict { ) -> PyResult> { self.entries.get(vm, key) } + + /// Keys of `obj` with their stored hashes, or `None` if it must be iterated + /// generically. Only exact dicts and sets qualify, as in CPython's + /// `_PyDict_FromKeys`: a subclass may override `__iter__`. + fn fromkeys_known_hashes( + obj: &PyObject, + vm: &VirtualMachine, + ) -> Option> { + if let Some(dict) = obj.downcast_ref_if_exact::(vm) { + Some(dict.entries.keys_with_hashes()) + } else { + set::exact_set_keys_with_hashes(obj, vm) + } + } } // Python dict methods: @@ -384,8 +398,16 @@ impl PyDict { let d = PyType::call(&class, ().into(), vm)?; match d.downcast_exact::(vm) { Ok(pydict) => { - for key in iterable.iter(vm)? { - pydict.__setitem__(key?, value.clone(), vm)?; + if let Some(keys) = Self::fromkeys_known_hashes(iterable.as_object(), vm) { + for (key, hash) in keys { + pydict + .entries + .insert_known_hash(vm, &*key, hash, value.clone())?; + } + } else { + for key in iterable.iter(vm)? { + pydict.__setitem__(key?, value.clone(), vm)?; + } } Ok(pydict.into_pyref().into()) } diff --git a/crates/vm/src/builtins/set.rs b/crates/vm/src/builtins/set.rs index 62bdb0f0da5..6961040c792 100644 --- a/crates/vm/src/builtins/set.rs +++ b/crates/vm/src/builtins/set.rs @@ -605,6 +605,23 @@ fn extract_set(obj: &PyObject) -> Option<&PySetInner> { }) } +/// Elements of `obj` with their stored hashes, or `None` unless `obj` is exactly +/// a `set` or `frozenset` — `PyAnySet_CheckExact`, where [`extract_set`] is the +/// subclass-inclusive `PyAnySet_Check`. +pub(super) fn exact_set_keys_with_hashes( + obj: &PyObject, + vm: &VirtualMachine, +) -> Option> { + let inner = obj + .downcast_ref_if_exact::(vm) + .map(|set| &set.inner) + .or_else(|| { + obj.downcast_ref_if_exact::(vm) + .map(|frozen| &frozen.inner) + })?; + Some(inner.content.keys_with_hashes()) +} + fn reduce_set(zelf: &PyObject, vm: &VirtualMachine) -> (PyTypeRef, PyTupleRef, Option) { ( zelf.class().to_owned(), From 670152068cc8624374b2e20c0ede0a10787e3270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B4=91=ED=9A=A8?= <87641474+widehyo1@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:32:35 +0900 Subject: [PATCH 285/351] itertools: defer dropwhile predicate validation (#8504) Store the `dropwhile` predicate as a Python object and call it while advancing the iterator. This defers callable validation until the predicate is first needed, matching CPython for empty input while preserving exception propagation during iteration. Remove the now-passing `test_dropwhile` expected-failure marker. Assisted-by: Codex:gpt-5.6-sol --- Lib/test/test_itertools.py | 1 - crates/vm/src/stdlib/itertools.rs | 9 ++++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_itertools.py b/Lib/test/test_itertools.py index 585f6611ade..b91e3735d94 100644 --- a/Lib/test/test_itertools.py +++ b/Lib/test/test_itertools.py @@ -1260,7 +1260,6 @@ def test_takewhile(self): self.assertEqual(list(t), [1, 1, 1]) self.assertRaises(StopIteration, next, t) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_dropwhile(self): data = [1, 3, 5, 20, 2, 4, 6, 8] self.assertEqual(list(dropwhile(underten, data)), [20, 2, 4, 6, 8]) diff --git a/crates/vm/src/stdlib/itertools.rs b/crates/vm/src/stdlib/itertools.rs index 041620298e7..30a4d8773be 100644 --- a/crates/vm/src/stdlib/itertools.rs +++ b/crates/vm/src/stdlib/itertools.rs @@ -10,7 +10,7 @@ mod decl { rc::PyRc, }, convert::ToPyObject, - function::{ArgCallable, FuncArgs, OptionalArg, OptionalOption, PosArgs}, + function::{FuncArgs, OptionalArg, OptionalOption, PosArgs}, protocol::{PyIter, PyIterReturn, PyNumber}, raise_if_stop, stdlib::sys, @@ -477,7 +477,7 @@ mod decl { #[pyclass(name = "dropwhile")] #[derive(Debug, PyPayload)] struct PyItertoolsDropwhile { - predicate: ArgCallable, + predicate: PyObjectRef, iterable: PyIter, start_flag: AtomicCell, } @@ -485,7 +485,7 @@ mod decl { #[derive(FromArgs)] struct DropwhileNewArgs { #[pyarg(positional)] - predicate: ArgCallable, + predicate: PyObjectRef, #[pyarg(positional)] iterable: PyIter, } @@ -522,8 +522,7 @@ mod decl { if !zelf.start_flag.load() { loop { let obj = raise_if_stop!(iterable.next(vm)?); - let pred = predicate.clone(); - let pred_value = pred.invoke((obj.clone(),), vm)?; + let pred_value = predicate.call((obj.clone(),), vm)?; if !pred_value.try_to_bool(vm)? { zelf.start_flag.store(true); return Ok(PyIterReturn::Return(obj)); From 212c0d0b154b45565b7f27fcb116abc6299608ca Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:24:50 +0900 Subject: [PATCH 286/351] Fix future annotation block alignment (#8506) --- crates/codegen/src/compile.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index b16540762fd..1ca7afb8e1e 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -5083,6 +5083,14 @@ impl<'warnings> Compiler<'warnings> { func_range: TextRange, ) -> CompileResult { if !self.next_function_annotation_symbol_table_uses_annotations() { + // CPython creates a hidden AnnotationBlock for every function + // signature under `from __future__ import annotations`, including + // an unannotated one. It still belongs to this function: consume + // it so the next function sees its own block rather than remaining + // pinned to this unused entry. + if self.push_annotation_symbol_table() { + self.pop_annotation_symbol_table(); + } return Ok(false); } @@ -31247,6 +31255,25 @@ def f(x: T): pass ); } + #[test] + fn future_unannotated_function_does_not_hide_next_annotation_block() { + let code = compile_exec( + "\ +from __future__ import annotations +def plain(x): pass +def annotated(x: int): pass +", + ); + let annotate = find_direct_child_code(&code, "__annotate__") + .expect("second function must retain its annotation closure"); + assert!( + annotate.constants.iter().any( + |constant| matches!(constant, ConstantData::Str { value } if value.as_str() == Ok("int")) + ), + "annotation closure must belong to the annotated function" + ); + } + #[test] fn deferred_annotation_format_name_does_not_capture_helper_parameter() { let code = compile_exec( From 525ba8cf0a595627afb7822d2130ba11de488569 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:16:13 +0900 Subject: [PATCH 287/351] codegen: preserve symbol tables across copied finally bodies (#8507) Assisted-by: OpenAI Codex:gpt-5 --- crates/codegen/src/compile.rs | 6 +++ extra_tests/snippets/syntax_try.py | 82 ++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index 1ca7afb8e1e..afe868dab6c 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -2406,7 +2406,13 @@ impl<'warnings> Compiler<'warnings> { } if let FBlockDatum::FinallyBody(ref body) = info.fb_datum { + // This is an extra copy of the finally body, emitted for the + // path that leaves the try block early. The try statement + // emits its own copies afterwards, so rewind the symbol table + // cursors and leave the nested scopes for those copies. + let symbol_table_cursors = self.current_symbol_table_cursors(); self.compile_statements(body)?; + self.set_symbol_table_cursors(symbol_table_cursors); } if preserve_tos { diff --git a/extra_tests/snippets/syntax_try.py b/extra_tests/snippets/syntax_try.py index 1f46caae3e7..5610cb23e6a 100644 --- a/extra_tests/snippets/syntax_try.py +++ b/extra_tests/snippets/syntax_try.py @@ -285,3 +285,85 @@ def y(): try: pass """) + + +# leaving the try block early emits an extra copy of the finally body, which +# must not consume the symbol tables of the nested scopes it contains +def return_from_try(): + log = [] + try: + return "returned" + finally: + log.append((lambda x: x * 2)(3)) + log.append({t for t in [1, 2]}) + log.append([t for t in [3]]) + log.append({k: k for k in [4]}) + + def nested(): + return 5 + + class Nested: + value = 6 + + assert log == [6, {1, 2}, [3], {4: 4}], log + assert nested() == 5 + assert Nested.value == 6 + + +assert return_from_try() == "returned" + + +def break_and_continue_from_try(): + seen = [] + for i in range(4): + try: + if i == 1: + continue + if i == 3: + break + seen.append(i) + finally: + seen.append({t for t in [i]}) + return seen + + +assert break_and_continue_from_try() == [0, {0}, {1}, 2, {2}, {3}] + + +def return_from_try_runs_finally_once(): + log = [] + + def inner(): + try: + return "value" + finally: + log.append(sorted({t for t in "ab"})) + + assert inner() == "value" + return log + + +assert return_from_try_runs_finally_once() == [["a", "b"]] + + +def generator_return_from_try(): + log = [] + + def gen(): + try: + return (yield "yielded") + finally: + log.append([t for t in "z"]) + + g = gen() + assert g.send(None) == "yielded" + try: + g.send("sent") + except StopIteration as stop: + assert stop.value == "sent", stop.value + else: + assert False, "generator did not stop" + return log + + +assert generator_return_from_try() == [["z"]] From 901d8e186e5b88259d12a7d855135c01a46a0c55 Mon Sep 17 00:00:00 2001 From: Jeongseop Lim Date: Thu, 13 Aug 2026 20:27:24 +0900 Subject: [PATCH 288/351] Normalize round() result to an exact int (#8511) int.__round__ returned the receiver unchanged on the no-rounding path, so round() on an int subclass handed back an instance of that subclass: round(True) was True, not 1. CPython routes both no-op paths (ndigits is None, and ndigits >= 0) through long_long(), which reuses the reference only when the value is an exact int and otherwise copies it into a fresh one. Return zelf.__int__(vm).into_pyref(), which goes through into_exact_or() and is the same normalization the neighbouring __trunc__, __floor__ and __ceil__ already perform. Assisted-by: Claude Code:claude-opus-5 --- crates/vm/src/builtins/int.rs | 4 +++- extra_tests/snippets/builtin_round.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/vm/src/builtins/int.rs b/crates/vm/src/builtins/int.rs index 278a9cecbb1..c12bf2c721c 100644 --- a/crates/vm/src/builtins/int.rs +++ b/crates/vm/src/builtins/int.rs @@ -487,7 +487,9 @@ impl PyInt { return vm.ctx.new_int(rounded); } } - zelf + // No rounding to do, but an int subclass must still be normalized to an + // exact int, the way CPython's long_long() does. + zelf.__int__(vm).into_pyref() } #[pymethod] diff --git a/extra_tests/snippets/builtin_round.py b/extra_tests/snippets/builtin_round.py index e94d9754204..83725420a40 100644 --- a/extra_tests/snippets/builtin_round.py +++ b/extra_tests/snippets/builtin_round.py @@ -93,3 +93,21 @@ def __round__(self, ndigits=None): assert round(1.0, 1000) == 1.0 assert round(1.0, -1000) == 0.0 assert round(1.7976931348623157e308, 0) == 1.7976931348623157e308 + + +# round() normalizes an int subclass to an exact int, like CPython's long_long(). +assert round(True) == 1 +assert type(round(True)) is int +assert type(round(True, 0)) is int +assert type(round(False)) is int + + +class MyInt(int): + pass + + +assert round(MyInt(5)) == 5 +assert type(round(MyInt(5))) is int +assert type(round(MyInt(5), 2)) is int +# A negative ndigits already produced a fresh exact int. +assert type(round(MyInt(15), -1)) is int From 0b150ca569f14b8161221cb71a6319c94ba79d45 Mon Sep 17 00:00:00 2001 From: Jeongseop Lim Date: Thu, 13 Aug 2026 21:51:56 +0900 Subject: [PATCH 289/351] Reject a fifth argument to property() (#8510) PropertyArgs declared a fifth positional-or-keyword field, name, so the derived arity was 0..=5 and property(None, None, None, None, None) built a property object with the fifth argument stored in the __name__ slot. CPython's property.__init__ is Argument Clinic generated with maxpos = 4 and the keyword list {fget, fset, fdel, doc}, so it rejects both that call and property(name='x'). Drop the field. Nothing passed it: clone_property_with always supplied None and copies the name separately, and the name slot is still filled by __set_name__ and the __name__ setter. Assisted-by: Claude Code:claude-opus-5 --- crates/vm/src/builtins/property.rs | 6 +----- extra_tests/snippets/builtin_property.py | 7 +++++++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/vm/src/builtins/property.rs b/crates/vm/src/builtins/property.rs index cff5a8a60d0..65ae48222fa 100644 --- a/crates/vm/src/builtins/property.rs +++ b/crates/vm/src/builtins/property.rs @@ -1,7 +1,7 @@ /*! Python `property` descriptor class. */ -use super::{PyStrRef, PyType}; +use super::PyType; use crate::common::lock::PyRwLock; use crate::function::{IntoFuncArgs, PosArgs}; use crate::{ @@ -41,8 +41,6 @@ pub struct PropertyArgs { fdel: Option, #[pyarg(any, default)] doc: Option, - #[pyarg(any, default)] - name: Option, } impl GetDescriptor for PyProperty { @@ -221,7 +219,6 @@ impl PyProperty { fset: new_setter.or_else(|| zelf.fset()), fdel: new_deleter.or_else(|| zelf.fdel()), doc, - name: None, }; // Create new property using py_new and init @@ -401,7 +398,6 @@ impl Initializer for PyProperty { *zelf.getter.write() = args.fget; *zelf.setter.write() = args.fset; *zelf.deleter.write() = args.fdel; - *zelf.name.write() = args.name.map(|a| a.as_object().to_owned()); zelf.getter_doc.store(getter_doc, Ordering::Relaxed); Ok(()) diff --git a/extra_tests/snippets/builtin_property.py b/extra_tests/snippets/builtin_property.py index de64e526228..397d41fb075 100644 --- a/extra_tests/snippets/builtin_property.py +++ b/extra_tests/snippets/builtin_property.py @@ -85,3 +85,10 @@ def foo(self): p2 = property("a", doc="pdoc") # assert p2.__doc__ == 'pdoc' + + +# property() takes at most four arguments, and `name` is not one of them: +# the name slot is filled by __set_name__ and the __name__ setter instead. +assert_raises(TypeError, property, None, None, None, None, None) +assert_raises(TypeError, property, "a", "b", "c", "d", "e") +assert_raises(TypeError, property, name="x") From c9a62449f02e89b694b384a5c54bf4c92add782e Mon Sep 17 00:00:00 2001 From: Jeongseop Lim Date: Thu, 13 Aug 2026 23:32:19 +0900 Subject: [PATCH 290/351] Reject extra positional arguments to `float()` (#8509) * Reject extra positional arguments to float() PyFloat::slot_new's exact-float fast path tested args.args.first(), which succeeds for any number of positional arguments, so float(1.5, True) returned the first one and never reached args.bind(vm). CPython's clinic generated float_new runs _PyArg_CheckPositional("float", nargs, 0, 1) before it fetches the first argument. Add args.args.len() == 1 to the condition, as PyInt::slot_new, PyStr::slot_new and PyComplex::slot_new already do; the extra argument then falls through to args.bind(vm) and raises TypeError. Assisted-by: Claude Code:claude-opus-5 * Bind float() arguments before the fast path The exact-float fast path repeated float()'s argument count inline, which duplicates what FromArgs::arity already knows. Bind first and match on the resulting OptionalArg instead, so the positional and keyword rules stay in one place and the bound value is reused by py_new. Assisted-by: Claude Code:claude-opus-5 --- crates/vm/src/builtins/float.rs | 8 +++++--- extra_tests/snippets/builtin_float.py | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/vm/src/builtins/float.rs b/crates/vm/src/builtins/float.rs index 1c861b14fc6..0b739694623 100644 --- a/crates/vm/src/builtins/float.rs +++ b/crates/vm/src/builtins/float.rs @@ -176,16 +176,18 @@ impl Constructor for PyFloat { type Args = OptionalArg; fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { + // Bind before the fast path so FromArgs::arity decides how many arguments + // are acceptable, rather than a count repeated here. + let arg: Self::Args = args.bind(vm)?; + // Optimization: return exact float as-is if cls.is(vm.ctx.types.float_type) - && args.kwargs.is_empty() - && let Some(first) = args.args.first() + && let OptionalArg::Present(first) = &arg && first.class().is(vm.ctx.types.float_type) { return Ok(first.clone()); } - let arg: Self::Args = args.bind(vm)?; let payload = Self::py_new(&cls, arg, vm)?; payload.into_ref_with_type(vm, cls).map(Into::into) } diff --git a/extra_tests/snippets/builtin_float.py b/extra_tests/snippets/builtin_float.py index 1417c5ae174..c459c2d0da6 100644 --- a/extra_tests/snippets/builtin_float.py +++ b/extra_tests/snippets/builtin_float.py @@ -561,3 +561,12 @@ def _check_msg(call, exc_type, expected_msg): assert repr(1.5) == "1.5" assert repr(0.1) == "0.1" assert repr(100.0) == "100.0" + + +# float() takes at most one positional argument; the exact-float fast path +# must not let extra ones through. +assert_raises(TypeError, float, 1.5, True) +assert_raises(TypeError, float, 1.5, 2, 3) +assert_raises(TypeError, float, "1.5", 2) +assert float(1.5) == 1.5 +assert float() == 0.0 From 2ed082a51ef5628ff716671a063cc3c36e55c043 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:57:10 +0900 Subject: [PATCH 291/351] Fix 33 reproduced fuzzing and static-review defects (#8514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * mmap: guard move() against dest/src past the mapping size An offset larger than the mapping made `size - dest` underflow, producing an out-of-range slice index panic. Check the high side first, as write() does. Assisted-by: Claude * exceptions: keep ImportError name/path/name_from out of an empty instance dict ImportError.__reduce__ unwrapped get_arg(0), which is None when the exception was constructed with no positional argument, aborting on pickle.dumps(ImportError()). Fall back to the full args tuple there, and expose name/path/name_from as class attributes defaulting to None so a bare ImportError() reduces to (cls, ()). Assisted-by: Claude * asyncio: stop unwrapping the _current_tasks downcast in current_task() _asyncio._current_tasks is a reassignable module attribute; the three sibling functions already degrade gracefully when it is not a dict, current_task() did not. Assisted-by: Claude * asyncio: raise TypeError when FutureIter.throw()'s exception class returns a non-exception exc_type.call() goes through a Python-controlled __new__, so its result can be any object; the downcast was unwrapped. Report it as a TypeError instead. Assisted-by: Claude * sequence: use a fallible reservation for sequence repetition The guard only rejects a repeat whose per-element size crosses MAX_MEMORY_SIZE, so (1,) * (10**12) still reached Vec::with_capacity and aborted in the allocator. Reserve fallibly and surface a MemoryError. Assisted-by: Claude * collections: reject an oversized deque repetition with MemoryError deque * sys.maxsize reached the allocator and aborted with a capacity overflow. Apply the MAX_MEMORY_SIZE guard the other sequences already carry. Assisted-by: Claude * itertools: raise OverflowError for an out-of-range r argument combinations/combinations_with_replacement/permutations narrowed r with to_usize().unwrap(), so r=2**64 panicked instead of raising. Assisted-by: Claude * math: stream the generic sumprod path instead of collecting both iterables The big-int path collected each argument into a Vec before multiplying, so an unbounded iterable exhausted memory. Advance both iterators in lockstep and accumulate, reporting a length mismatch when only one is exhausted. Assisted-by: Claude * _imp: raise TypeError for a second positional argument to find_frozen withdata is keyword-only and unimplemented; passing it positionally hit an unimplemented!() and aborted. Assisted-by: Claude * _typing: check _idfunc arity before indexing args _typing._idfunc() with no argument indexed args[0] out of bounds. Assisted-by: Claude * exceptions: require a sequence for the ExceptionGroup excs argument The argument was collected before being validated, so an unbounded iterable such as itertools.count() exhausted memory. Reject a non-sequence up front. Assisted-by: Claude * mmap: treat an inverted find/rfind range as empty find(b"x", 5, 2) built a slice whose start exceeded its end and panicked. Assisted-by: Claude * collections: give deque and defaultdict a GC traverse Neither type opted into traversal, so a reference cycle through a deque or a defaultdict was never collected. Assisted-by: Claude * itertools: opt the iterator types into GC traverse cycle and its siblings hold Python references but declared no traverse, so a cycle built through one of them leaked. Assisted-by: Claude * _ctypes: mask an out-of-range int instead of panicking c_char_p(2**64) and pointer item assignment called .expect() on the narrowing conversion. Wrap the value to the target width, which is what the C implementation stores. Assisted-by: Claude * hashlib: import _hashlib when a hash module is loaded The hash object type is a static type owned by _hashlib; calling _md5.md5() without _hashlib imported hit an uninitialized static type and panicked. Assisted-by: Claude * _csv: fall back to the built-in dialect defaults when none is registered The dialect table is empty until csv.py registers 'excel', so _csv.reader([]) unwrapped a missing entry and panicked. Assisted-by: Claude * builtins: reject surrogates in compile()/eval() source instead of panicking expect_str() panics on a str containing surrogates; convert with try_as_utf8 so eval(chr(0xd800)) raises. Assisted-by: Claude * _suggestions: require a list for _generate_suggestions candidates The argument was collected before validation, so an unbounded iterable exhausted memory. Assisted-by: Claude * lzma: size the filter chain before consuming it filters= was collected into a Vec before the length check, so an unbounded iterable exhausted memory. Take the length through the sequence protocol first. Assisted-by: Claude * classmethod: opt into GC traverse staticmethod already declares traverse; classmethod did not, so a cycle through the wrapped callable leaked. Assisted-by: Claude * posix: reject unbounded iterables in posix_spawn and setgroups argv, setsigdef, setsigmask and setgroups bound an ArgIterable and collected it before validating, so an infinite generator exhausted memory. Require a list/tuple for argv, validate signals while streaming, and take setgroups through the sequence protocol. Assisted-by: Claude * _ctypes: size Array slice assignment and _argtypes_ before collecting Both eagerly collected their argument, so an unbounded iterable exhausted memory before the length check ran. Assisted-by: Claude * sys: propagate the breakpointhook warning failure warn() was unwrapped, so an unimportable $PYTHONBREAKPOINT under -W error panicked instead of raising. Assisted-by: Claude * structseq: require the sequence argument when constructing a struct sequence A no-argument construction produced an empty backing tuple, and reading any named field then indexed out of bounds. Assisted-by: Claude * Guard the hash slot dispatch against unbounded recursion `PyObject::hash` invoked the type's hash slot directly, so an element-wise `__hash__` following a deeply nested object graph recursed one native frame per level and overflowed the stack. Wrap the dispatch in `with_recursion`, matching the repr and rich-compare dispatches in the same file, so `hash(x)` on a nested tuple/GenericAlias/slice raises `RecursionError`. Assisted-by: Claude * genericalias: guard the __parameters__ walk against unbounded recursion `make_parameters_from_slice` recursed into every list/tuple argument with nothing counting the frames, so `list[L]` for a self-referential or deeply nested `L` overflowed the native stack at subscript time. Wrap the descent in `with_recursion`, which makes the walk fallible; `PyGenericAlias::new`, `from_args` and `make_parameters` now return `PyResult` and every caller propagates. Assisted-by: Claude * Fix the type confusion in PyAtomicRef's Debug impl `PyAtomicRef` stores a pointer to a `Py`, as `Deref`, `load_raw`, `swap` and `Drop` all read it, but `Debug` cast it to a bare `T` and formatted the object header as payload bytes. For `PyFunction`, whose `code: PyAtomicRef` has a pointer-chasing `Debug`, that dereferenced header words and segfaulted. Cast to `PyObject` instead, which is what `Drop` already does and which also covers the `PyAtomicRef` and `PyAtomicRef>` instantiations that have no `Py`. `_asyncio._enter_task` reached this through `{:?}` in its "Cannot enter into task" message; format the two tasks with their Python repr, which is what the message is meant to show. Assisted-by: Claude * _sre: disallow instantiating Match `Match` inherited `object.__new__`, so `M.__new__(M)` produced an instance whose `regs`/`string`/`pattern` were never filled in by a match run; the mapping subscript path read them and dereferenced garbage. The type has no public constructor, so mark it `DISALLOW_INSTANTIATION`, which makes `M.__new__(M)` raise `TypeError: cannot create 're.Match' instances`. Assisted-by: Claude * utils: return the empty repr instead of asserting a non-empty collection `collection_repr` took the first element with an `.expect()` justified by the caller's preceding non-empty check. Another thread clearing the collection between that check and the iteration made the iterator yield nothing and panicked the worker. Fall back to the caller-supplied empty form, which is the text those callers already produce for an empty collection. Assisted-by: Claude * itertools: advance cycle's index atomically `cycle.__next__` did `fetch_add(1)` and then reset the index to 0 in a separate store, so two threads replaying the saved items could both read an index past the end of `saved` and panic on the slice access. Do the advance and the wrap in one `fetch_update`. Assisted-by: Claude * _ctypes: reject a float argument to a foreign call without argtypes `conv_param`, the conversion used when `argtypes` is not set, converted its argument with `try_int`, which goes through `__int__` and so accepts a float. `libc.strlen(1.5)` therefore passed 1 where the callee expects a `char *` and the callee dereferenced it. Match `ConvParam`, which does a `PyLong_Check` and converts nothing: take the branch only for an `int` (or a subclass, so `True` still converts), and let a float fall through to "Don't know how to convert parameter". The branch below it converted a float to a C double, but `try_int` claimed every float before it could run, so it was dead; `CArgValue::Double` existed only for that branch and both go. Typed doubles are unaffected — they travel as `CArgValue::Typed` with code 'd'. Assisted-by: Claude * Report the iterator itself from PyIter's traverse `Traverse for PyIter` delegated to the inherent `PyObject::traverse` of the object it wraps, so it reported that iterator's referents instead of the iterator. The iterator's own reference to those referents was then never subtracted during the collector's reference-subtraction pass, the referents kept a non-zero gc_refs, and every object reachable from them was classified as a root. Any cycle running through a type with a `PyIter` field therefore survived collection: `map`, `filter`, `zip`, `enumerate`, `reversed` and the `itertools` iterators all leaked, while the same cycle through a `list`, `tuple` or `list_iterator` collected. Report the wrapped object, as the `PyObjectRef`, `PyRef` and `PyStackRef` impls do. `itertools.tee` still leaks: its shared buffer is a `PyRc` rather than a Python object, so the collector cannot see through it. Assisted-by: Claude * Remove the obsolete expectedFailure on test_code_module.test_unicode_error Compiling a source string containing a lone surrogate now raises UnicodeEncodeError, so the test passes. Assisted-by: Claude * itertools: reserve the combination indices fallibly `combinations` and `combinations_with_replacement` built their index vector with an infallible allocation, so an `r` that passes the ssize_t check but does not fit in memory aborted the process instead of raising MemoryError. Assisted-by: Claude * Apply the struct sequence constructor's dict argument `structseq(sequence, dict)` discarded its second argument, so the hidden fields past `n_sequence_fields` — `tm_zone`, `st_atime` and friends — were always None when constructed directly or restored from a `(sequence, dict)` pickle, and a non-dict second argument was accepted silently. Take the dict, require it to be a dict, and fill the hidden slots the sequence did not cover from it. A key that names a field the sequence already supplied, or no field at all, is now a "got duplicate or unexpected field name(s)" TypeError instead of being dropped. Both arguments are bindable by name, as `sequence` and `dict`. `os.stat_result` and `os.statvfs_result` did not accept a second argument at all; they and `time.struct_time` now share the parsing. Assisted-by: Claude * _imp: report the argument count in find_frozen's arity error Assisted-by: Claude * Add regression tests for the reproduced crashers One case per catalog entry, each asserting the behavior the fix produces: recursion guards, the memory-unsafety sites, the overflow and unbounded allocation guards, the eager-collection rejections, the unwrap sites, and the cycles the collector now breaks. Every expected value was checked against CPython 3.14. Assisted-by: Claude * Tolerate a changed-size RuntimeError in the set repr stress test The mutator and reader threads race on purpose; a "changed size during iteration" RuntimeError is a valid outcome of that race and should not fail the test. The panic it guards against is not. Assisted-by: Claude * Move the crash regression tests into per-module snippets crash_regressions.py collected every reproduced crasher in one file. Split it into the snippet for the module each case exercises, and add stdlib_gc.py, stdlib_asyncio.py, stdlib_lzma.py, stdlib_threading_set_repr.py and stdlib_threading_itertools_cycle.py for the cases with no existing home. The suite runs every snippet under the host CPython too, so the checks only RustPython raises are guarded by sys.implementation.name: the hash and __parameters__ recursion depth, and the deque repeat overflow. The float ctypes argument accepts either TypeError or ctypes.ArgumentError. Assisted-by: Claude --- Lib/test/test_code_module.py | 1 - Lib/test/test_structseq.py | 6 - crates/capi/src/genericaliasobject.rs | 2 +- crates/stdlib/src/_asyncio.rs | 54 +++++--- crates/stdlib/src/_queue.rs | 2 +- crates/stdlib/src/array.rs | 2 +- crates/stdlib/src/blake2.rs | 9 +- crates/stdlib/src/contextvars.rs | 4 +- crates/stdlib/src/csv.rs | 15 ++- crates/stdlib/src/lzma.rs | 35 ++--- crates/stdlib/src/math.rs | 27 ++-- crates/stdlib/src/md5.rs | 9 +- crates/stdlib/src/mmap.rs | 7 +- crates/stdlib/src/sha1.rs | 9 +- crates/stdlib/src/sha3.rs | 9 +- crates/stdlib/src/suggestions.rs | 20 ++- crates/vm/src/builtins/asyncgenerator.rs | 6 +- crates/vm/src/builtins/bytearray.rs | 6 +- crates/vm/src/builtins/bytes.rs | 6 +- crates/vm/src/builtins/classmethod.rs | 8 +- crates/vm/src/builtins/coroutine.rs | 6 +- crates/vm/src/builtins/dict.rs | 6 +- crates/vm/src/builtins/enumerate.rs | 6 +- crates/vm/src/builtins/generator.rs | 6 +- crates/vm/src/builtins/genericalias.rs | 28 ++-- crates/vm/src/builtins/interpolation.rs | 6 +- crates/vm/src/builtins/list.rs | 6 +- crates/vm/src/builtins/mappingproxy.rs | 6 +- crates/vm/src/builtins/memory.rs | 6 +- crates/vm/src/builtins/range.rs | 6 +- crates/vm/src/builtins/set.rs | 15 ++- crates/vm/src/builtins/slice.rs | 6 +- crates/vm/src/builtins/staticmethod.rs | 6 +- crates/vm/src/builtins/template.rs | 6 +- crates/vm/src/builtins/tuple.rs | 8 +- crates/vm/src/builtins/union.rs | 2 +- crates/vm/src/builtins/weakref.rs | 6 +- crates/vm/src/exception_group.rs | 19 +-- crates/vm/src/exceptions.rs | 31 +++-- crates/vm/src/object/ext.rs | 7 +- crates/vm/src/protocol/iter.rs | 6 +- crates/vm/src/protocol/object.rs | 8 +- crates/vm/src/sequence.rs | 7 +- crates/vm/src/stdlib/_ast/pyast.rs | 8 +- crates/vm/src/stdlib/_collections.rs | 54 +++++++- crates/vm/src/stdlib/_ctypes.rs | 1 - crates/vm/src/stdlib/_ctypes/array.rs | 16 ++- crates/vm/src/stdlib/_ctypes/base.rs | 5 +- crates/vm/src/stdlib/_ctypes/function.rs | 59 ++++----- crates/vm/src/stdlib/_ctypes/pointer.rs | 11 +- crates/vm/src/stdlib/_ctypes/simple.rs | 46 ++++--- crates/vm/src/stdlib/_functools.rs | 2 +- crates/vm/src/stdlib/_imp.rs | 14 +- crates/vm/src/stdlib/_sre.rs | 6 +- crates/vm/src/stdlib/_typing.rs | 15 ++- crates/vm/src/stdlib/builtins.rs | 7 +- crates/vm/src/stdlib/itertools.rs | 125 ++++++++++++------ crates/vm/src/stdlib/os.rs | 23 +++- crates/vm/src/stdlib/posix.rs | 84 ++++++------ crates/vm/src/stdlib/sys.rs | 3 +- crates/vm/src/stdlib/time.rs | 10 +- crates/vm/src/types/mod.rs | 4 +- crates/vm/src/types/structseq.rs | 68 +++++++++- crates/vm/src/utils.rs | 8 +- extra_tests/snippets/builtin_compile.py | 5 + extra_tests/snippets/builtin_eval.py | 7 + extra_tests/snippets/builtin_exceptions.py | 17 +++ extra_tests/snippets/builtin_exec.py | 7 + extra_tests/snippets/builtin_hash.py | 18 +++ extra_tests/snippets/builtin_list.py | 7 + extra_tests/snippets/builtin_tuple.py | 7 + .../snippets/forbidden_instantiation.py | 7 + extra_tests/snippets/stdlib_asyncio.py | 52 ++++++++ .../snippets/stdlib_collections_deque.py | 8 ++ extra_tests/snippets/stdlib_csv.py | 6 + extra_tests/snippets/stdlib_ctypes.py | 23 ++++ extra_tests/snippets/stdlib_ctypes_calls.py | 16 ++- extra_tests/snippets/stdlib_gc.py | 66 +++++++++ extra_tests/snippets/stdlib_hashlib.py | 8 ++ extra_tests/snippets/stdlib_imp.py | 7 + extra_tests/snippets/stdlib_itertools.py | 16 +++ extra_tests/snippets/stdlib_lzma.py | 22 +++ extra_tests/snippets/stdlib_math.py | 7 + extra_tests/snippets/stdlib_mmap.py | 13 ++ extra_tests/snippets/stdlib_os.py | 17 +++ extra_tests/snippets/stdlib_pwd.py | 4 + extra_tests/snippets/stdlib_sys.py | 16 +++ .../stdlib_threading_itertools_cycle.py | 26 ++++ .../snippets/stdlib_threading_set_repr.py | 44 ++++++ extra_tests/snippets/stdlib_time.py | 13 ++ extra_tests/snippets/stdlib_traceback.py | 11 ++ extra_tests/snippets/stdlib_types.py | 24 ++++ extra_tests/snippets/stdlib_typing.py | 10 ++ 93 files changed, 1157 insertions(+), 342 deletions(-) create mode 100644 extra_tests/snippets/stdlib_asyncio.py create mode 100644 extra_tests/snippets/stdlib_gc.py create mode 100644 extra_tests/snippets/stdlib_lzma.py create mode 100644 extra_tests/snippets/stdlib_threading_itertools_cycle.py create mode 100644 extra_tests/snippets/stdlib_threading_set_repr.py diff --git a/Lib/test/test_code_module.py b/Lib/test/test_code_module.py index 39d85d46274..fb519878cd8 100644 --- a/Lib/test/test_code_module.py +++ b/Lib/test/test_code_module.py @@ -128,7 +128,6 @@ def test_indentation_error(self): self.assertIsNone(self.sysmod.last_value.__traceback__) self.assertIs(self.sysmod.last_exc, self.sysmod.last_value) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: 'UnicodeDecodeError: invalid utf-8 sequence of 1 bytes from index 1\n\nnow exiti [truncated]... doesn't start with 'UnicodeEncodeError: ' def test_unicode_error(self): self.infunc.side_effect = ["'\ud800'", EOFError('Finished')] self.console.interact() diff --git a/Lib/test/test_structseq.py b/Lib/test/test_structseq.py index 8ef6dd2fee8..d4014a784da 100644 --- a/Lib/test/test_structseq.py +++ b/Lib/test/test_structseq.py @@ -87,7 +87,6 @@ def test_fields(self): self.assertEqual(t.n_unnamed_fields, 0) self.assertEqual(t.n_fields, time._STRUCT_TM_ITEMS) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: Unexpected keyword argument dict def test_constructor(self): t = time.struct_time @@ -111,7 +110,6 @@ def test_constructor(self): s = "123456789" self.assertEqual("".join(t(s)), s) - @unittest.expectedFailure # TODO: RUSTPYTHON; Wrong error message def test_constructor_with_duplicate_fields(self): t = time.struct_time @@ -125,7 +123,6 @@ def test_constructor_with_duplicate_fields(self): with self.assertRaisesRegex(TypeError, error_message): t("1234567890", dict={"error": 0, "tm_zone": "some zone", "tm_mon": 1}) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: expected at most 1 arguments, got 2 def test_constructor_with_duplicate_unnamed_fields(self): assert os.stat_result.n_unnamed_fields > 0 n_visible_fields = os.stat_result.n_sequence_fields @@ -142,7 +139,6 @@ def test_constructor_with_duplicate_unnamed_fields(self): re.escape("got duplicate or unexpected field name(s)")): os.stat_result((*range(n_visible_fields), -1.0), {'st_atime': -1.0}) - @unittest.expectedFailure # TODO: RUSTPYTHON; Wrong error message def test_constructor_with_unknown_fields(self): t = time.struct_time @@ -185,7 +181,6 @@ def test_pickling(self): self.assertEqual(t2.tm_year, t.tm_year) self.assertEqual(t2.tm_zone, t.tm_zone) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: expected at most 1 arguments, got 2 def test_pickling_with_unnamed_fields(self): assert os.stat_result.n_unnamed_fields > 0 @@ -220,7 +215,6 @@ def test_copying(self): self.assertIsNot(t3[0], t[0]) self.assertIsNot(t3.tm_year, t.tm_year) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: expected at most 1 arguments, got 2 def test_copying_with_unnamed_fields(self): assert os.stat_result.n_unnamed_fields > 0 diff --git a/crates/capi/src/genericaliasobject.rs b/crates/capi/src/genericaliasobject.rs index bcd31308679..1ab443e13ad 100644 --- a/crates/capi/src/genericaliasobject.rs +++ b/crates/capi/src/genericaliasobject.rs @@ -10,6 +10,6 @@ pub unsafe extern "C" fn Py_GenericAlias( with_vm(|vm| { let origin = unsafe { &*origin }.to_owned(); let args = unsafe { &*args }.to_owned(); - PyGenericAlias::from_args(origin, args, vm).into_pyobject(vm) + PyGenericAlias::from_args(origin, args, vm).map(|alias| alias.into_pyobject(vm)) }) } diff --git a/crates/stdlib/src/_asyncio.rs b/crates/stdlib/src/_asyncio.rs index 3146e39b77d..b311db4a315 100644 --- a/crates/stdlib/src/_asyncio.rs +++ b/crates/stdlib/src/_asyncio.rs @@ -12,8 +12,8 @@ pub(crate) mod _asyncio { vm::{ AsObject, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, builtins::{ - PyBaseException, PyBaseExceptionRef, PyDict, PyDictRef, PyGenericAlias, PyList, - PyListRef, PyModule, PySet, PyTuple, PyType, PyTypeRef, + PyBaseException, PyBaseExceptionRef, PyDict, PyGenericAlias, PyList, PyListRef, + PyModule, PySet, PyTuple, PyType, PyTypeRef, }, extend_module, function::{FuncArgs, KwArgs, OptionalArg, OptionalOption, PySetterValue}, @@ -779,7 +779,7 @@ pub(crate) mod _asyncio { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } @@ -1036,7 +1036,7 @@ pub(crate) mod _asyncio { ))); } - let exc = if exc_type.fast_isinstance(vm.ctx.types.type_type) { + let exc: PyBaseExceptionRef = if exc_type.fast_isinstance(vm.ctx.types.type_type) { // exc_type is a class let exc_class: PyTypeRef = exc_type.clone().downcast().unwrap(); // Must be a subclass of BaseException @@ -1047,12 +1047,23 @@ pub(crate) mod _asyncio { } let val = exc_val.unwrap_or_none(vm); - if vm.is_none(&val) { + let exc = if vm.is_none(&val) { exc_type.call((), vm)? } else if val.fast_isinstance(&exc_class) { val } else { exc_type.call((val,), vm)? + }; + match exc.downcast() { + Ok(exc) => exc, + Err(obj) => { + let exc_class_repr = exc_class.as_object().repr(vm)?; + vm.new_type_error(format!( + "calling {} should have returned an instance of BaseException, not {}", + exc_class_repr.as_wtf8(), + obj.class() + )) + } } } else if exc_type.fast_isinstance(vm.ctx.exceptions.base_exception_type) { // exc_type is an exception instance @@ -1063,7 +1074,7 @@ pub(crate) mod _asyncio { vm.new_type_error("instance exception may not have a separate value") ); } - exc_type + exc_type.downcast().unwrap() } else { // exc_type is neither a class nor an exception instance return Err(vm.new_type_error(format!( @@ -1075,10 +1086,11 @@ pub(crate) mod _asyncio { if let OptionalArg::Present(tb) = exc_tb && !vm.is_none(&tb) { - exc.set_attr(vm.ctx.intern_str("__traceback__"), tb, vm)?; + exc.as_object() + .set_attr(vm.ctx.intern_str("__traceback__"), tb, vm)?; } - Err(exc.downcast().unwrap()) + Err(exc) } #[pymethod] @@ -1840,7 +1852,7 @@ pub(crate) mod _asyncio { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } @@ -2405,7 +2417,9 @@ pub(crate) mod _asyncio { // Slow path: look up in the module-level dict for cross-thread queries let current_tasks = get_current_tasks_dict(vm)?; - let dict: PyDictRef = current_tasks.downcast().unwrap(); + let Ok(dict) = current_tasks.downcast::() else { + return Ok(vm.ctx.none()); + }; match dict.get_item(&*loop_obj, vm) { Ok(task) => Ok(task), @@ -2485,15 +2499,17 @@ pub(crate) mod _asyncio { #[pyfunction] fn _enter_task(loop_: PyObjectRef, task: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { // Per-thread check, matching CPython's ts->asyncio_running_task - { - let running_task = vm.asyncio_running_task.borrow(); - if running_task.is_some() { - return Err(vm.new_runtime_error(format!( - "Cannot enter into task {:?} while another task {:?} is being executed.", - task, - running_task.as_ref().unwrap() - ))); - } + let running_task = vm.asyncio_running_task.borrow().clone(); + if let Some(running_task) = running_task { + let task_repr = task.repr(vm)?; + let running_task_repr = running_task.repr(vm)?; + return Err(vm.new_runtime_error(wtf8_concat!( + "Cannot enter into task ", + task_repr.as_wtf8(), + " while another task ", + running_task_repr.as_wtf8(), + " is being executed." + ))); } *vm.asyncio_running_task.borrow_mut() = Some(task.clone()); diff --git a/crates/stdlib/src/_queue.rs b/crates/stdlib/src/_queue.rs index 6b150e4c68b..1c8a4b0b21b 100644 --- a/crates/stdlib/src/_queue.rs +++ b/crates/stdlib/src/_queue.rs @@ -282,7 +282,7 @@ mod _queue { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/stdlib/src/array.rs b/crates/stdlib/src/array.rs index f2a16d72356..094e690665f 100644 --- a/crates/stdlib/src/array.rs +++ b/crates/stdlib/src/array.rs @@ -1234,7 +1234,7 @@ pub mod array { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/stdlib/src/blake2.rs b/crates/stdlib/src/blake2.rs index 382aec826b1..83504435674 100644 --- a/crates/stdlib/src/blake2.rs +++ b/crates/stdlib/src/blake2.rs @@ -5,7 +5,7 @@ pub(crate) use _blake2::module_def; #[pymodule] mod _blake2 { use crate::hashlib::_hashlib::{BlakeHashArgs, local_blake2b, local_blake2s}; - use crate::vm::{PyPayload, PyResult, VirtualMachine}; + use crate::vm::{Py, PyPayload, PyResult, VirtualMachine, builtins::PyModule}; #[pyattr(name = "_GIL_MINSIZE")] const GIL_MINSIZE: u16 = 2048; @@ -43,4 +43,11 @@ mod _blake2 { fn blake2s(args: BlakeHashArgs, vm: &VirtualMachine) -> PyResult { Ok(local_blake2s(args, vm)?.into_pyobject(vm)) } + + #[expect(clippy::unnecessary_wraps, reason = "Needs to comply with a signature")] + pub(crate) fn module_exec(vm: &VirtualMachine, module: &Py) -> PyResult<()> { + let _ = vm.import("_hashlib", 0); + __module_exec(vm, module); + Ok(()) + } } diff --git a/crates/stdlib/src/contextvars.rs b/crates/stdlib/src/contextvars.rs index 0a6e0f12314..19fbcb8412f 100644 --- a/crates/stdlib/src/contextvars.rs +++ b/crates/stdlib/src/contextvars.rs @@ -462,7 +462,7 @@ mod _contextvars { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } @@ -562,7 +562,7 @@ mod _contextvars { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } diff --git a/crates/stdlib/src/csv.rs b/crates/stdlib/src/csv.rs index 4271d9af62c..3471f7a28d8 100644 --- a/crates/stdlib/src/csv.rs +++ b/crates/stdlib/src/csv.rs @@ -779,11 +779,16 @@ mod _csv { // TODO: Maybe need to update the obj from HashMap } DialectItem::Obj(o) => Ok(self.update_py_dialect(o.clone())), - DialectItem::None => { - let g = GLOBAL_HASHMAP.lock(); - let res = g.get("excel").unwrap().clone(); - Ok(self.update_py_dialect(res)) - } + DialectItem::None => Ok(self.update_py_dialect(PyDialect { + delimiter: b',', + quotechar: Some(b'"'), + escapechar: None, + doublequote: true, + skipinitialspace: false, + lineterminator: "\r\n".to_owned(), + quoting: QuoteStyle::Minimal, + strict: false, + })), } } diff --git a/crates/stdlib/src/lzma.rs b/crates/stdlib/src/lzma.rs index 0b699baddbb..6e8a913abaa 100644 --- a/crates/stdlib/src/lzma.rs +++ b/crates/stdlib/src/lzma.rs @@ -337,40 +337,43 @@ mod _lzma { } fn parse_filter_chain_spec( - filter_specs: Vec, + filter_specs: PyObjectRef, vm: &VirtualMachine, ) -> PyResult { const LZMA_FILTERS_MAX: usize = 4; - if filter_specs.len() > LZMA_FILTERS_MAX { + let filter_specs_len = filter_specs.length(vm)?; + if filter_specs_len > LZMA_FILTERS_MAX { return Err(new_lzma_error( format!("Too many filters - liblzma supports a maximum of {LZMA_FILTERS_MAX}"), vm, )); } + let filter_specs = filter_specs.try_sequence(vm)?; let mut filters = Filters::new(); - for spec in &filter_specs { - let filter_id = get_dict_opt_u64(spec, "id", vm)? + for i in 0..filter_specs_len { + let spec = filter_specs.get_item(i as isize, vm)?; + let filter_id = get_dict_opt_u64(&spec, "id", vm)? .ok_or_else(|| vm.new_value_error("Filter specifier must have an \"id\" entry"))?; match filter_id { FILTER_LZMA1 => { - let opts = parse_filter_spec_lzma(spec, vm)?; + let opts = parse_filter_spec_lzma(&spec, vm)?; filters.lzma1(&opts); } FILTER_LZMA2 => { - let opts = parse_filter_spec_lzma(spec, vm)?; + let opts = parse_filter_spec_lzma(&spec, vm)?; filters.lzma2(&opts); } FILTER_DELTA => { - let dist = parse_filter_spec_delta(spec, vm)?; + let dist = parse_filter_spec_delta(&spec, vm)?; filters .delta_properties(&[(dist - 1) as u8]) .map_err(|e| catch_lzma_error(e, vm))?; } FILTER_X86 | FILTER_POWERPC | FILTER_IA64 | FILTER_ARM | FILTER_ARMTHUMB | FILTER_SPARC => { - let start_offset = parse_filter_spec_bcj(spec, vm)?; + let start_offset = parse_filter_spec_bcj(&spec, vm)?; add_bcj_filter(&mut filters, filter_id, start_offset) .map_err(|e| catch_lzma_error(e, vm))?; } @@ -570,7 +573,7 @@ mod _lzma { #[pyarg(any, optional)] memlimit: Option, #[pyarg(any, optional)] - filters: Option>, + filters: Option, } impl Constructor for LZMADecompressor { @@ -735,7 +738,7 @@ mod _lzma { fn init_xz( check: i32, preset: u32, - filters: Option>, + filters: Option, vm: &VirtualMachine, ) -> PyResult { let real_check = @@ -751,10 +754,11 @@ mod _lzma { fn init_alone( preset: u32, - filter_specs: Option>, + filter_specs: Option, vm: &VirtualMachine, ) -> PyResult { - if let Some(_filter_specs) = filter_specs { + if let Some(filter_specs) = filter_specs { + filter_specs.length(vm)?; // TODO: validate single LZMA1 filter and use its options let options = LzmaOptions::new_preset(preset).map_err(|_| { new_lzma_error(format!("Invalid compression preset: {preset}"), vm) @@ -768,10 +772,7 @@ mod _lzma { } } - fn init_raw( - filter_specs: Option>, - vm: &VirtualMachine, - ) -> PyResult { + fn init_raw(filter_specs: Option, vm: &VirtualMachine) -> PyResult { let filter_specs = filter_specs .ok_or_else(|| vm.new_value_error("Must specify filters for FORMAT_RAW"))?; let filters = parse_filter_chain_spec(filter_specs, vm)?; @@ -788,7 +789,7 @@ mod _lzma { #[pyarg(any, optional)] preset: Option, #[pyarg(any, optional)] - filters: Option>, + filters: Option, } impl Constructor for LZMACompressor { diff --git a/crates/stdlib/src/math.rs b/crates/stdlib/src/math.rs index 92c2a66e93e..3fe1ffd3e63 100644 --- a/crates/stdlib/src/math.rs +++ b/crates/stdlib/src/math.rs @@ -727,25 +727,20 @@ mod math { } // Generic Python path - let (p_i, q_i) = (p_i.unwrap(), q_i.unwrap()); - - // Collect current + remaining elements - let p_remaining: Result, _> = - core::iter::once(Ok(p_i)).chain(p_iter).collect(); - let q_remaining: Result, _> = - core::iter::once(Ok(q_i)).chain(q_iter).collect(); - let (p_vec, q_vec) = (p_remaining?, q_remaining?); - - if p_vec.len() != q_vec.len() { - return Err(vm.new_value_error("Inputs are not the same length")); - } - + let (mut p_i, mut q_i) = (p_i.unwrap(), q_i.unwrap()); let mut total = obj_total.unwrap_or_else(|| vm.ctx.new_int(0).into()); - for (p_item, q_item) in p_vec.into_iter().zip(q_vec) { - let prod = vm._mul(&p_item, &q_item)?; + loop { + let prod = vm._mul(&p_i, &q_i)?; total = vm._add(&total, &prod)?; + + let next_p = p_iter.next().transpose()?; + let next_q = q_iter.next().transpose()?; + match (next_p, next_q) { + (Some(next_p), Some(next_q)) => (p_i, q_i) = (next_p, next_q), + (None, None) => return Ok(total), + _ => return Err(vm.new_value_error("Inputs are not the same length")), + } } - return Ok(total); } Ok(obj_total.unwrap_or_else(|| vm.ctx.new_int(0).into())) diff --git a/crates/stdlib/src/md5.rs b/crates/stdlib/src/md5.rs index 2ff6cd24ff7..0339bf8ace7 100644 --- a/crates/stdlib/src/md5.rs +++ b/crates/stdlib/src/md5.rs @@ -3,10 +3,17 @@ pub(crate) use _md5::module_def; #[pymodule] mod _md5 { use crate::hashlib::_hashlib::{HashArgs, local_md5}; - use crate::vm::{PyPayload, PyResult, VirtualMachine}; + use crate::vm::{Py, PyPayload, PyResult, VirtualMachine, builtins::PyModule}; #[pyfunction] fn md5(args: HashArgs, vm: &VirtualMachine) -> PyResult { Ok(local_md5(args, vm)?.into_pyobject(vm)) } + + #[expect(clippy::unnecessary_wraps, reason = "Needs to comply with a signature")] + pub(crate) fn module_exec(vm: &VirtualMachine, module: &Py) -> PyResult<()> { + let _ = vm.import("_hashlib", 0); + __module_exec(vm, module); + Ok(()) + } } diff --git a/crates/stdlib/src/mmap.rs b/crates/stdlib/src/mmap.rs index 312d35a4ed4..91d4058a706 100644 --- a/crates/stdlib/src/mmap.rs +++ b/crates/stdlib/src/mmap.rs @@ -777,7 +777,10 @@ mod mmap { let start = options .start .map_or_else(|| self.pos(), |start| start.saturated_at(size)); - let end = options.end.map_or(size, |end| end.saturated_at(size)); + let end = options + .end + .map_or(size, |end| end.saturated_at(size)) + .max(start); (start, end) } @@ -886,7 +889,7 @@ mod mmap { let dest = dest.try_to_primitive(vm).ok()?; let src = src.try_to_primitive(vm).ok()?; let cnt = cnt.try_to_primitive(vm).ok()?; - if size - dest < cnt || size - src < cnt { + if dest > size || src > size || size - dest < cnt || size - src < cnt { return None; } Some((dest, src, cnt)) diff --git a/crates/stdlib/src/sha1.rs b/crates/stdlib/src/sha1.rs index 3e3d4928c79..71495435e56 100644 --- a/crates/stdlib/src/sha1.rs +++ b/crates/stdlib/src/sha1.rs @@ -3,10 +3,17 @@ pub(crate) use _sha1::module_def; #[pymodule] mod _sha1 { use crate::hashlib::_hashlib::{HashArgs, local_sha1}; - use crate::vm::{PyPayload, PyResult, VirtualMachine}; + use crate::vm::{Py, PyPayload, PyResult, VirtualMachine, builtins::PyModule}; #[pyfunction] fn sha1(args: HashArgs, vm: &VirtualMachine) -> PyResult { Ok(local_sha1(args, vm)?.into_pyobject(vm)) } + + #[expect(clippy::unnecessary_wraps, reason = "Needs to comply with a signature")] + pub(crate) fn module_exec(vm: &VirtualMachine, module: &Py) -> PyResult<()> { + let _ = vm.import("_hashlib", 0); + __module_exec(vm, module); + Ok(()) + } } diff --git a/crates/stdlib/src/sha3.rs b/crates/stdlib/src/sha3.rs index 0eb2dfa84d5..642ed838a4d 100644 --- a/crates/stdlib/src/sha3.rs +++ b/crates/stdlib/src/sha3.rs @@ -6,7 +6,7 @@ mod _sha3 { HashArgs, local_sha3_224, local_sha3_256, local_sha3_384, local_sha3_512, local_shake_128, local_shake_256, }; - use crate::vm::{PyPayload, PyResult, VirtualMachine}; + use crate::vm::{Py, PyPayload, PyResult, VirtualMachine, builtins::PyModule}; #[pyfunction] fn sha3_224(args: HashArgs, vm: &VirtualMachine) -> PyResult { @@ -37,4 +37,11 @@ mod _sha3 { fn shake_256(args: HashArgs, vm: &VirtualMachine) -> PyResult { Ok(local_shake_256(args, vm)?.into_pyobject(vm)) } + + #[expect(clippy::unnecessary_wraps, reason = "Needs to comply with a signature")] + pub(crate) fn module_exec(vm: &VirtualMachine, module: &Py) -> PyResult<()> { + let _ = vm.import("_hashlib", 0); + __module_exec(vm, module); + Ok(()) + } } diff --git a/crates/stdlib/src/suggestions.rs b/crates/stdlib/src/suggestions.rs index e0667dfb553..bfde00d2bb9 100644 --- a/crates/stdlib/src/suggestions.rs +++ b/crates/stdlib/src/suggestions.rs @@ -2,19 +2,25 @@ pub(crate) use _suggestions::module_def; #[pymodule] mod _suggestions { - use rustpython_vm::VirtualMachine; + use rustpython_vm::{PyResult, VirtualMachine, builtins::PyList}; use crate::vm::PyObjectRef; #[pyfunction] fn _generate_suggestions( - candidates: Vec, + candidates: PyObjectRef, name: PyObjectRef, vm: &VirtualMachine, - ) -> PyObjectRef { - match crate::vm::suggestion::calculate_suggestions(candidates.iter(), &name) { - Some(suggestion) => suggestion.into(), - None => vm.ctx.none(), - } + ) -> PyResult { + let candidates = candidates + .downcast::() + .map_err(|_| vm.new_type_error("candidates must be a list"))?; + let candidates = candidates.borrow_vec(); + Ok( + match crate::vm::suggestion::calculate_suggestions(candidates.iter(), &name) { + Some(suggestion) => suggestion.into(), + None => vm.ctx.none(), + }, + ) } } diff --git a/crates/vm/src/builtins/asyncgenerator.rs b/crates/vm/src/builtins/asyncgenerator.rs index b53e59d58c1..7ea43f389c6 100644 --- a/crates/vm/src/builtins/asyncgenerator.rs +++ b/crates/vm/src/builtins/asyncgenerator.rs @@ -144,7 +144,11 @@ impl PyAsyncGen { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/bytearray.rs b/crates/vm/src/builtins/bytearray.rs index a649fe9d8d5..793b269d100 100644 --- a/crates/vm/src/builtins/bytearray.rs +++ b/crates/vm/src/builtins/bytearray.rs @@ -554,7 +554,11 @@ impl PyByteArray { // TODO: Uncomment when Python adds __class_getitem__ to bytearray // #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/bytes.rs b/crates/vm/src/builtins/bytes.rs index d4c30a7e94d..bb514b84ce1 100644 --- a/crates/vm/src/builtins/bytes.rs +++ b/crates/vm/src/builtins/bytes.rs @@ -544,7 +544,11 @@ impl PyBytes { // TODO: Uncomment when Python adds __class_getitem__ to bytes // #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/classmethod.rs b/crates/vm/src/builtins/classmethod.rs index eb0e15ece01..26dcd251251 100644 --- a/crates/vm/src/builtins/classmethod.rs +++ b/crates/vm/src/builtins/classmethod.rs @@ -27,7 +27,7 @@ use crate::{ /// /// Class methods are different than C++ or Java static methods. /// If you want those, see the staticmethod builtin. -#[pyclass(module = false, name = "classmethod")] +#[pyclass(module = false, name = "classmethod", traverse)] #[derive(Debug)] pub struct PyClassMethod { callable: PyMutex, @@ -187,7 +187,11 @@ impl PyClassMethod { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/coroutine.rs b/crates/vm/src/builtins/coroutine.rs index d472f1a0bfa..0fc50fb1356 100644 --- a/crates/vm/src/builtins/coroutine.rs +++ b/crates/vm/src/builtins/coroutine.rs @@ -103,7 +103,11 @@ impl PyCoroutine { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/dict.rs b/crates/vm/src/builtins/dict.rs index fbc23a0dde7..1a380d74d02 100644 --- a/crates/vm/src/builtins/dict.rs +++ b/crates/vm/src/builtins/dict.rs @@ -535,7 +535,11 @@ impl PyDict { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/enumerate.rs b/crates/vm/src/builtins/enumerate.rs index 96073ba7667..95e144dad21 100644 --- a/crates/vm/src/builtins/enumerate.rs +++ b/crates/vm/src/builtins/enumerate.rs @@ -57,7 +57,11 @@ impl Constructor for PyEnumerate { #[pyclass(with(Py, IterNext, Iterable, Constructor), flags(BASETYPE))] impl PyEnumerate { #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/generator.rs b/crates/vm/src/builtins/generator.rs index 52db3c9522a..b06a3a45ea7 100644 --- a/crates/vm/src/builtins/generator.rs +++ b/crates/vm/src/builtins/generator.rs @@ -99,7 +99,11 @@ impl PyGenerator { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/genericalias.rs b/crates/vm/src/builtins/genericalias.rs index b6f6012fd43..8004bd535be 100644 --- a/crates/vm/src/builtins/genericalias.rs +++ b/crates/vm/src/builtins/genericalias.rs @@ -68,7 +68,7 @@ impl Constructor for PyGenericAlias { } else { PyTuple::new_ref(vec![arguments], &vm.ctx) }; - Ok(Self::new(origin, args, false, vm)) + Self::new(origin, args, false, vm) } } @@ -92,14 +92,14 @@ impl PyGenericAlias { args: PyTupleRef, starred: bool, vm: &VirtualMachine, - ) -> Self { - let parameters = make_parameters(&args, vm); - Self { + ) -> PyResult { + let parameters = make_parameters(&args, vm)?; + Ok(Self { origin: origin.into(), args, parameters, starred, - } + }) } /// Create a GenericAlias from an origin and PyObjectRef arguments (helper for compatibility) @@ -107,7 +107,7 @@ impl PyGenericAlias { origin: impl Into, args: PyObjectRef, vm: &VirtualMachine, - ) -> Self { + ) -> PyResult { let args = if let Ok(tuple) = args.try_to_ref::(vm) { tuple.to_owned() } else { @@ -228,7 +228,7 @@ impl PyGenericAlias { vm, )?; - Ok(Self::new(zelf.origin.clone(), new_args, false, vm).into_pyobject(vm)) + Ok(Self::new(zelf.origin.clone(), new_args, false, vm)?.into_pyobject(vm)) } #[pymethod] @@ -247,7 +247,7 @@ impl PyGenericAlias { if zelf.starred { // (next, (iter(GenericAlias(origin, args)),)) let next_fn = vm.builtins.get_attr("next", vm)?; - let non_starred = Self::new(zelf.origin.clone(), zelf.args.clone(), false, vm); + let non_starred = Self::new(zelf.origin.clone(), zelf.args.clone(), false, vm)?; let iter_obj = PyGenericAliasIterator { obj: crate::common::lock::PyMutex::new(Some(non_starred.into_pyobject(vm))), } @@ -292,11 +292,11 @@ impl PyGenericAlias { } } -pub(crate) fn make_parameters(args: &Py, vm: &VirtualMachine) -> PyTupleRef { +pub(crate) fn make_parameters(args: &Py, vm: &VirtualMachine) -> PyResult { make_parameters_from_slice(args.as_slice(), vm) } -fn make_parameters_from_slice(args: &[PyObjectRef], vm: &VirtualMachine) -> PyTupleRef { +fn make_parameters_from_slice(args: &[PyObjectRef], vm: &VirtualMachine) -> PyResult { let mut parameters: Vec = Vec::with_capacity(args.len()); for arg in args { @@ -326,7 +326,9 @@ fn make_parameters_from_slice(args: &[PyObjectRef], vm: &VirtualMachine) -> PyTu let list = arg.downcast_ref::().unwrap(); list.borrow_vec().to_vec() }; - let sub = make_parameters_from_slice(&items, vm); + let sub = vm.with_recursion("while computing __parameters__", || { + make_parameters_from_slice(&items, vm) + })?; for sub_param in sub.iter() { if tuple_index(¶meters, sub_param).is_none() { parameters.push(sub_param.clone()); @@ -335,7 +337,7 @@ fn make_parameters_from_slice(args: &[PyObjectRef], vm: &VirtualMachine) -> PyTu } } - PyTuple::new_ref(parameters, &vm.ctx) + Ok(PyTuple::new_ref(parameters, &vm.ctx)) } #[inline] @@ -716,7 +718,7 @@ impl crate::types::IterNext for PyGenericAliasIterator { let alias = obj .downcast_ref::() .ok_or_else(|| vm.new_type_error("generic_alias_iterator expected GenericAlias"))?; - let starred = PyGenericAlias::new(alias.origin.clone(), alias.args.clone(), true, vm); + let starred = PyGenericAlias::new(alias.origin.clone(), alias.args.clone(), true, vm)?; Ok(PyIterReturn::Return(starred.into_pyobject(vm))) } } diff --git a/crates/vm/src/builtins/interpolation.rs b/crates/vm/src/builtins/interpolation.rs index 0ae1b33120b..5d5f3774640 100644 --- a/crates/vm/src/builtins/interpolation.rs +++ b/crates/vm/src/builtins/interpolation.rs @@ -144,7 +144,11 @@ impl PyInterpolation { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } diff --git a/crates/vm/src/builtins/list.rs b/crates/vm/src/builtins/list.rs index c2059e28806..fe674a45821 100644 --- a/crates/vm/src/builtins/list.rs +++ b/crates/vm/src/builtins/list.rs @@ -421,7 +421,11 @@ impl PyList { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/mappingproxy.rs b/crates/vm/src/builtins/mappingproxy.rs index c8b891f7972..dd8c689facb 100644 --- a/crates/vm/src/builtins/mappingproxy.rs +++ b/crates/vm/src/builtins/mappingproxy.rs @@ -177,7 +177,11 @@ impl PyMappingProxy { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } diff --git a/crates/vm/src/builtins/memory.rs b/crates/vm/src/builtins/memory.rs index ee5a071287b..9f8312a0704 100644 --- a/crates/vm/src/builtins/memory.rs +++ b/crates/vm/src/builtins/memory.rs @@ -554,7 +554,11 @@ impl Py { )] impl PyMemoryView { #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } diff --git a/crates/vm/src/builtins/range.rs b/crates/vm/src/builtins/range.rs index 415d34fdb05..5962f90e521 100644 --- a/crates/vm/src/builtins/range.rs +++ b/crates/vm/src/builtins/range.rs @@ -364,7 +364,11 @@ impl PyRange { // TODO: Uncomment when Python adds __class_getitem__ to range // #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/set.rs b/crates/vm/src/builtins/set.rs index 6961040c792..860f86f4319 100644 --- a/crates/vm/src/builtins/set.rs +++ b/crates/vm/src/builtins/set.rs @@ -386,7 +386,8 @@ impl PySetInner { } fn repr(&self, class_name: Option<&str>, vm: &VirtualMachine) -> PyResult { - collection_repr(class_name, "{", "}", self.elements().iter(), vm) + let empty = format!("{}()", class_name.unwrap_or("set")); + collection_repr(class_name, "{", "}", &empty, self.elements().iter(), vm) } fn add(&self, item: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { @@ -881,7 +882,11 @@ impl PySet { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } @@ -1286,7 +1291,11 @@ impl PyFrozenSet { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/slice.rs b/crates/vm/src/builtins/slice.rs index 3c5f13b382d..026b976b65e 100644 --- a/crates/vm/src/builtins/slice.rs +++ b/crates/vm/src/builtins/slice.rs @@ -260,7 +260,11 @@ impl PySlice { // TODO: Uncomment when Python adds __class_getitem__ to slice // #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/staticmethod.rs b/crates/vm/src/builtins/staticmethod.rs index addfe8a4e2b..8ae31b67b5c 100644 --- a/crates/vm/src/builtins/staticmethod.rs +++ b/crates/vm/src/builtins/staticmethod.rs @@ -163,7 +163,11 @@ impl PyStaticMethod { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/builtins/template.rs b/crates/vm/src/builtins/template.rs index 30812b4f171..94c4d653df3 100644 --- a/crates/vm/src/builtins/template.rs +++ b/crates/vm/src/builtins/template.rs @@ -186,7 +186,11 @@ impl PyTemplate { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } diff --git a/crates/vm/src/builtins/tuple.rs b/crates/vm/src/builtins/tuple.rs index 06fa2519205..7af176840b7 100644 --- a/crates/vm/src/builtins/tuple.rs +++ b/crates/vm/src/builtins/tuple.rs @@ -504,7 +504,11 @@ impl PyTuple { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } @@ -609,7 +613,7 @@ impl Representable for PyTuple { let s = if zelf.len() == 1 { wtf8_concat!("(", zelf.elements[0].repr(vm)?.as_wtf8(), ",)") } else { - collection_repr(None, "(", ")", zelf.elements.iter(), vm)? + collection_repr(None, "(", ")", "()", zelf.elements.iter(), vm)? }; vm.ctx.new_str(s) } else { diff --git a/crates/vm/src/builtins/union.rs b/crates/vm/src/builtins/union.rs index cb6dd0d6559..c1be5c8ec9a 100644 --- a/crates/vm/src/builtins/union.rs +++ b/crates/vm/src/builtins/union.rs @@ -234,7 +234,7 @@ pub(crate) fn or_op(zelf: PyObjectRef, other: PyObjectRef, vm: &VirtualMachine) } fn make_parameters(args: &Py, vm: &VirtualMachine) -> PyResult { - let parameters = genericalias::make_parameters(args, vm); + let parameters = genericalias::make_parameters(args, vm)?; let result = dedup_and_flatten_args(¶meters, vm)?; Ok(result.args) } diff --git a/crates/vm/src/builtins/weakref.rs b/crates/vm/src/builtins/weakref.rs index 9e88ffaa2e6..e0f012f169c 100644 --- a/crates/vm/src/builtins/weakref.rs +++ b/crates/vm/src/builtins/weakref.rs @@ -92,7 +92,11 @@ impl PyWeak { } #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/exception_group.rs b/crates/vm/src/exception_group.rs index c6d18cc6594..11c13912b76 100644 --- a/crates/vm/src/exception_group.rs +++ b/crates/vm/src/exception_group.rs @@ -60,7 +60,7 @@ pub(super) mod types { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } @@ -255,20 +255,11 @@ pub(super) mod types { ))); } - // Validate exceptions is a sequence (not set or None) + // Validate exceptions is a sequence let exceptions_arg = &args[1]; - - // Check for set/frozenset (not a sequence - unordered) - if exceptions_arg.fast_isinstance(vm.ctx.types.set_type) - || exceptions_arg.fast_isinstance(vm.ctx.types.frozenset_type) - { - return Err(vm.new_type_error("second argument (exceptions) must be a sequence")); - } - - // Check for None - if exceptions_arg.is(&vm.ctx.none) { - return Err(vm.new_type_error("second argument (exceptions) must be a sequence")); - } + exceptions_arg.try_sequence(vm).map_err(|_| { + vm.new_type_error("second argument (exceptions) must be a sequence") + })?; let exceptions: Vec = exceptions_arg.try_to_value(vm).map_err(|_| { vm.new_type_error("second argument (exceptions) must be a sequence") diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index 9c42df966ea..0a1c2cb75ee 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -988,6 +988,9 @@ impl ExceptionZoo { extend_exception!(PyImportError, ctx, excs.import_error, { "msg" => ctx.new_readonly_getset("msg", excs.import_error, make_arg_getter(0)), + "name" => ctx.none(), + "path" => ctx.none(), + "name_from" => ctx.none(), }); extend_exception!(PyModuleNotFoundError, ctx, excs.module_not_found_error); @@ -1908,10 +1911,11 @@ pub(super) mod types { #[pymethod] fn __reduce__(exc: PyBaseExceptionRef, vm: &VirtualMachine) -> PyTupleRef { let obj = exc.as_object().to_owned(); - let mut result: Vec = vec![ - obj.class().to_owned().into(), - vm.new_tuple((exc.get_arg(0).unwrap(),)).into(), - ]; + let args: PyObjectRef = match exc.get_arg(0) { + Some(arg) => vm.new_tuple((arg,)).into(), + None => exc.args().into(), + }; + let mut result: Vec = vec![obj.class().to_owned().into(), args]; if let Some(dict) = obj.dict().filter(|x| !x.is_empty()) { result.push(dict.into()); @@ -1938,10 +1942,21 @@ pub(super) mod types { ))); } - let dict = crate::builtins::object::object_get_dict(zelf.clone(), vm)?; - dict.set_item("name", vm.unwrap_or_none(name), vm)?; - dict.set_item("path", vm.unwrap_or_none(path), vm)?; - dict.set_item("name_from", vm.unwrap_or_none(name_from), vm)?; + if let Some(name) = name { + zelf.set_attr("name", name, vm)?; + } else if let Some(dict) = zelf.dict() { + dict.del_item("name", vm).ok(); + } + if let Some(path) = path { + zelf.set_attr("path", path, vm)?; + } else if let Some(dict) = zelf.dict() { + dict.del_item("path", vm).ok(); + } + if let Some(name_from) = name_from { + zelf.set_attr("name_from", name_from, vm)?; + } else if let Some(dict) = zelf.dict() { + dict.del_item("name_from", vm).ok(); + } PyBaseException::slot_init(zelf, args, vm) } diff --git a/crates/vm/src/object/ext.rs b/crates/vm/src/object/ext.rs index 69ee0e3c510..186fa8e8a84 100644 --- a/crates/vm/src/object/ext.rs +++ b/crates/vm/src/object/ext.rs @@ -269,13 +269,16 @@ cfg_select! { _ => {} } -impl fmt::Debug for PyAtomicRef { +impl fmt::Debug for PyAtomicRef { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "PyAtomicRef(")?; + // The stored pointer is a `Py` — the full object, header included — + // as `Deref`, `load_raw` and `swap` all read it. Formatting it as a + // bare payload would skip the header and print misaligned bytes. unsafe { self.inner .load(Ordering::Relaxed) - .cast::() + .cast::() .as_ref() .fmt(f) }?; diff --git a/crates/vm/src/protocol/iter.rs b/crates/vm/src/protocol/iter.rs index 2f51287b181..1aa0bcd5b13 100644 --- a/crates/vm/src/protocol/iter.rs +++ b/crates/vm/src/protocol/iter.rs @@ -16,7 +16,11 @@ where unsafe impl> Traverse for PyIter { fn traverse(&self, tracer_fn: &mut TraverseFn<'_>) { - self.0.borrow().traverse(tracer_fn); + // Report the iterator itself, not its referents: an owner holding a + // `PyIter` owns the iterator object, and reporting what the iterator + // points at instead leaves the iterator's own reference unaccounted + // for, so a cycle running through it is never collected. + tracer_fn(self.0.borrow()); } } diff --git a/crates/vm/src/protocol/object.rs b/crates/vm/src/protocol/object.rs index 37007422404..4974fca9343 100644 --- a/crates/vm/src/protocol/object.rs +++ b/crates/vm/src/protocol/object.rs @@ -7,7 +7,7 @@ use crate::{ PyType, PyTypeRef, PyUtf8Str, int::check_int_to_str_digits, pystr::AsPyStr, }, common::{hash::PyHash, str::to_ascii}, - convert::{ToPyObject, ToPyResult}, + convert::ToPyObject, dict_inner::DictKey, function::{Either, FuncArgs, PyArithmeticValue, PySetterValue}, object::PyPayload, @@ -694,7 +694,7 @@ impl PyObject { pub fn hash(&self, vm: &VirtualMachine) -> PyResult { if let Some(hash) = self.class().slots.hash.load() { - return hash(self, vm); + return vm.with_recursion("while hashing", || hash(self, vm)); } Err(vm.new_type_error(format!("unhashable type: '{}'", self.class().name()))) @@ -741,8 +741,8 @@ impl PyObject { } else { if self.class().fast_issubclass(vm.ctx.types.type_type) { if self.is(vm.ctx.types.type_type) { - return PyGenericAlias::from_args(self.class().to_owned(), needle, vm) - .to_pyresult(vm); + let alias = PyGenericAlias::from_args(self.class().to_owned(), needle, vm)?; + return Ok(alias.to_pyobject(vm)); } if let Some(class_getitem) = diff --git a/crates/vm/src/sequence.rs b/crates/vm/src/sequence.rs index 4e6ed97f21c..1e126d087ea 100644 --- a/crates/vm/src/sequence.rs +++ b/crates/vm/src/sequence.rs @@ -104,7 +104,12 @@ where return Err(vm.new_memory_error("")); } - let mut v = Vec::with_capacity(n * self.as_ref().len()); + let total = n + .checked_mul(self.as_ref().len()) + .ok_or_else(|| vm.new_memory_error(""))?; + let mut v = Vec::new(); + v.try_reserve_exact(total) + .map_err(|_| vm.new_memory_error(""))?; for _ in 0..n { v.extend_from_slice(self.as_ref()); } diff --git a/crates/vm/src/stdlib/_ast/pyast.rs b/crates/vm/src/stdlib/_ast/pyast.rs index eb97eec8024..ebce1a788d2 100644 --- a/crates/vm/src/stdlib/_ast/pyast.rs +++ b/crates/vm/src/stdlib/_ast/pyast.rs @@ -1718,12 +1718,16 @@ fn populate_field_types(vm: &VirtualMachine, module: &Py) { FieldType::ListOf(name) => { let elem = resolve_node(name); let args = PyTuple::new_ref(vec![elem], &vm.ctx); - PyGenericAlias::new(list_type.clone(), args, false, vm).to_pyobject(vm) + PyGenericAlias::new(list_type.clone(), args, false, vm) + .expect("static field types are not nested, so no recursion is possible") + .to_pyobject(vm) } FieldType::ListOfBuiltin(name) => { let elem = resolve_builtin(name); let args = PyTuple::new_ref(vec![elem], &vm.ctx); - PyGenericAlias::new(list_type.clone(), args, false, vm).to_pyobject(vm) + PyGenericAlias::new(list_type.clone(), args, false, vm) + .expect("static field types are not nested, so no recursion is possible") + .to_pyobject(vm) } FieldType::Optional(name) => { let base = resolve_node(name); diff --git a/crates/vm/src/stdlib/_collections.rs b/crates/vm/src/stdlib/_collections.rs index c7cce5c735a..b48c0e670ac 100644 --- a/crates/vm/src/stdlib/_collections.rs +++ b/crates/vm/src/stdlib/_collections.rs @@ -13,6 +13,7 @@ mod _collections { convert::ToPyObject, function::{FuncArgs, KwArgs, OptionalArg, PyComparisonValue}, iter::PyExactSizeIterator, + object::{Traverse, TraverseFn}, protocol::{PyIterReturn, PyMappingMethods, PyNumberMethods, PySequenceMethods}, recursion::ReprGuard, sequence::{MutObjectSequenceOp, OptionalRangeArgs}, @@ -22,13 +23,19 @@ mod _collections { Initializer, IterNext, Iterable, PyComparisonOp, Representable, SelfIter, }, utils::collection_repr, + vm::MAX_MEMORY_SIZE, }; use alloc::collections::VecDeque; - use core::cmp::max; + use core::{cmp::max, mem::size_of}; use crossbeam_utils::atomic::AtomicCell; #[pyattr] - #[pyclass(module = "collections", name = "deque", unhashable = true)] + #[pyclass( + module = "collections", + name = "deque", + unhashable = true, + traverse = "manual" + )] #[derive(Debug, Default, PyPayload)] struct PyDeque { deque: PyRwLock>, @@ -36,6 +43,21 @@ mod _collections { state: AtomicCell, // incremented whenever the indices move } + // SAFETY: Traverse visits each owned Python reference at most once. + unsafe impl Traverse for PyDeque { + fn traverse(&self, tracer_fn: &mut TraverseFn<'_>) { + if let Some(deque) = self.deque.try_read_recursive() { + for obj in deque.iter() { + obj.traverse(tracer_fn); + } + } + } + + fn clear(&mut self, out: &mut Vec) { + out.extend(self.deque.get_mut().drain(..)); + } + } + type PyDequeRef = PyRef; #[derive(FromArgs)] @@ -318,6 +340,10 @@ mod _collections { let deque = self.borrow_deque(); let n = vm.check_repeat_or_overflow_error(deque.len(), n)?; let mul_len = n * deque.len(); + let result_len = self.maxlen.map_or(mul_len, |maxlen| mul_len.min(maxlen)); + if n > 1 && result_len.saturating_mul(size_of::()) >= MAX_MEMORY_SIZE { + return Err(vm.new_memory_error("")); + } let iter = deque.iter().cycle().take(mul_len); let skipped = self .maxlen @@ -400,7 +426,7 @@ mod _collections { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } @@ -576,9 +602,10 @@ mod _collections { let closing_part = zelf .maxlen .map_or_else(|| "]".to_owned(), |maxlen| format!("], maxlen={maxlen}")); + let empty = format!("{class_name}([{closing_part})"); if zelf.__len__() == 0 { - return Ok(vm.ctx.new_str(format!("{class_name}([{closing_part})"))); + return Ok(vm.ctx.new_str(empty)); } if let Some(_guard) = ReprGuard::enter(vm, zelf.as_object()) { @@ -586,6 +613,7 @@ mod _collections { Some(&class_name), "[", &closing_part, + &empty, deque.iter(), vm, )?)) @@ -753,7 +781,8 @@ mod _collections { module = "collections", name = "defaultdict", base = PyDict, - unhashable = true + unhashable = true, + traverse = "manual" )] #[derive(Debug, Default)] struct PyDefaultDict { @@ -761,6 +790,21 @@ mod _collections { default_factory: PyRwLock>, } + // SAFETY: Traverse visits each owned Python reference at most once. + unsafe impl Traverse for PyDefaultDict { + fn traverse(&self, tracer_fn: &mut TraverseFn<'_>) { + self.dict.traverse(tracer_fn); + self.default_factory.traverse(tracer_fn); + } + + fn clear(&mut self, out: &mut Vec) { + Traverse::clear(&mut self.dict, out); + if let Some(factory) = self.default_factory.get_mut().take() { + out.push(factory); + } + } + } + #[pyclass( with(AsMapping, AsNumber, Constructor, Initializer, Representable), flags(BASETYPE, MAPPING, HAS_DICT) diff --git a/crates/vm/src/stdlib/_ctypes.rs b/crates/vm/src/stdlib/_ctypes.rs index adf047ec750..e4857d0ee06 100644 --- a/crates/vm/src/stdlib/_ctypes.rs +++ b/crates/vm/src/stdlib/_ctypes.rs @@ -141,7 +141,6 @@ pub(crate) mod _ctypes { ffi_value_from_type_code(code.encode_utf8(&mut buf), bytes) } super::CArgValue::Int(v) => FfiValue::I32(*v), - super::CArgValue::Double(v) => FfiValue::F64(*v), super::CArgValue::Pointer(v) => FfiValue::Pointer(*v), // 'V' aggregates format via the object-address default arm below. super::CArgValue::Aggregate { .. } => FfiValue::Pointer(0), diff --git a/crates/vm/src/stdlib/_ctypes/array.rs b/crates/vm/src/stdlib/_ctypes/array.rs index a99fabc812d..c65f9748caf 100644 --- a/crates/vm/src/stdlib/_ctypes/array.rs +++ b/crates/vm/src/stdlib/_ctypes/array.rs @@ -511,7 +511,11 @@ impl AsMapping for PyCArray { )] impl PyCArray { #[pyclassmethod] - fn __class_getitem__(cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine) -> PyGenericAlias { + fn __class_getitem__( + cls: PyTypeRef, + args: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } @@ -990,13 +994,19 @@ impl PyCArray { let (range, step, slice_len) = sat_slice.adjust_indices(length); // other_len = PySequence_Length(value); - let items: Vec = vm.extract_elements_with(&value, Ok)?; - let other_len = items.len(); + // Size the operand before consuming it so an unbounded iterable is + // rejected without being materialized. + let other_len = value + .sequence_unchecked() + .length(vm) + .map_err(|_| vm.new_value_error("Can only assign sequence of same size"))?; if other_len != slice_len { return Err(vm.new_value_error("Can only assign sequence of same size")); } + let items: Vec = vm.extract_elements_with(&value, Ok)?; + // Use SaturatedSliceIter for correct index iteration (handles negative step) let iter = SaturatedSliceIter::from_adjust_indices(range, step, slice_len); diff --git a/crates/vm/src/stdlib/_ctypes/base.rs b/crates/vm/src/stdlib/_ctypes/base.rs index 1cc84750cb5..e86fdbc7a42 100644 --- a/crates/vm/src/stdlib/_ctypes/base.rs +++ b/crates/vm/src/stdlib/_ctypes/base.rs @@ -1939,7 +1939,7 @@ fn struct_union_paramfunc(obj: &PyObject, stg_info: &StgInfo, _vm: &VirtualMachi /// A foreign-call argument in a form the unified `call` entry point accepts: a /// simple-typed scalar (its ctypes code plus a native-endian bytes snapshot), -/// an untyped int/float, or an address. Any object whose memory an address +/// an untyped int, or an address. Any object whose memory an address /// refers to is kept alive by the enclosing `Argument`/`CArgObject`, not here. #[derive(Debug, Clone)] pub enum CArgValue { @@ -1947,8 +1947,6 @@ pub enum CArgValue { Typed { code: char, bytes: Vec }, /// Untyped Python int (ConvParam default: C int). Int(i32), - /// Untyped Python float (ConvParam default: C double). - Double(f64), /// Address-valued argument (pointer decay, byref, buffer copies, NULL = 0). Pointer(usize), /// By-value aggregate: its call layout plus a snapshot of its bytes. @@ -1985,7 +1983,6 @@ impl CArgValue { buffer: bytes, }, Self::Int(value) => CallArg::Int(*value), - Self::Double(value) => CallArg::Double(*value), Self::Pointer(value) => CallArg::Pointer(*value), Self::Aggregate { layout, bytes } => CallArg::Aggregate { layout, diff --git a/crates/vm/src/stdlib/_ctypes/function.rs b/crates/vm/src/stdlib/_ctypes/function.rs index 676ee5be8eb..90b41a4e66a 100644 --- a/crates/vm/src/stdlib/_ctypes/function.rs +++ b/crates/vm/src/stdlib/_ctypes/function.rs @@ -9,7 +9,7 @@ use super::{ }; use crate::{ AsObject, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, - builtins::{PyBytes, PyDict, PyStr, PyTuple, PyType, PyTypeRef}, + builtins::{PyBytes, PyDict, PyInt, PyStr, PyTuple, PyType, PyTypeRef}, class::StaticType, function::FuncArgs, protocol::{BufferDescriptor, PyBuffer, PyNumberMethods}, @@ -171,7 +171,10 @@ fn conv_param(value: &PyObject, vm: &VirtualMachine) -> PyResult { } // 10. Python int -> i32 (default integer type) - if let Ok(int_val) = value.try_int(vm) { + // PyLong_Check: only an int (or a subclass) converts. Going through + // `__int__` would accept a float and pass its truncated value where the + // callee expects a pointer. + if let Some(int_val) = value.downcast_ref::() { let val = int_val.as_bigint().to_i32().unwrap_or(0); return Ok(Argument { keep: None, @@ -179,15 +182,7 @@ fn conv_param(value: &PyObject, vm: &VirtualMachine) -> PyResult { }); } - // 11. Python float -> f64 - if let Ok(float_val) = value.try_float(vm) { - return Ok(Argument { - keep: None, - value: CArgValue::Double(float_val.to_f64()), - }); - } - - // 12. Check _as_parameter_ attribute + // 11. Check _as_parameter_ attribute if let Ok(as_param) = value.get_attr("_as_parameter_", vm) { return conv_param(&as_param, vm); } @@ -939,6 +934,23 @@ struct CallInfo { ret: RetSpec, } +fn extract_arg_types(argtypes: &PyObject, vm: &VirtualMachine) -> PyResult> { + let error = || vm.new_type_error("_argtypes_ must be a sequence of types"); + let sequence = argtypes.try_sequence(vm).map_err(|_| error())?; + let length = sequence.length(vm).map_err(|_| error())?; + let mut types = Vec::new(); + types + .try_reserve(length) + .map_err(|_| vm.new_memory_error(""))?; + + for index in 0..length { + let item = sequence.get_item(index as isize, vm).map_err(|_| error())?; + types.push(item.downcast::().map_err(|_| error())?); + } + + Ok(types) +} + /// Determine how to retrieve the return value from restype, reproducing the /// prior `ffi_return_type` + `is_pointer_return` dispatch. fn compute_ret_spec( @@ -1007,13 +1019,7 @@ fn extract_call_info(zelf: &Py, vm: &VirtualMachine) -> PyResult> = if let Some(argtypes_obj) = zelf.argtypes.read().as_ref() { if !vm.is_none(argtypes_obj) { - Some( - argtypes_obj - .try_to_value::>(vm)? - .into_iter() - .filter_map(|obj| obj.downcast::().ok()) - .collect(), - ) + Some(extract_arg_types(argtypes_obj, vm)?) } else { None // argtypes is None -> use ConvParam } @@ -1023,13 +1029,7 @@ fn extract_call_info(zelf: &Py, vm: &VirtualMachine) -> PyResult>(vm)? - .into_iter() - .filter_map(|obj| obj.downcast::().ok()) - .collect(), - ) + Some(extract_arg_types(&class_argtypes, vm)?) } else { None // No argtypes -> use ConvParam }; @@ -1944,14 +1944,7 @@ impl PyCThunk { vm: &VirtualMachine, ) -> PyResult { let arg_type_vec: Vec = match arg_types { - Some(args) if !vm.is_none(&args) => args - .try_to_value::>(vm)? - .into_iter() - .map(|item| { - item.downcast::() - .map_err(|_| vm.new_type_error("_argtypes_ must be a sequence of types")) - }) - .collect::>>()?, + Some(args) if !vm.is_none(&args) => extract_arg_types(&args, vm)?, _ => Vec::new(), }; diff --git a/crates/vm/src/stdlib/_ctypes/pointer.rs b/crates/vm/src/stdlib/_ctypes/pointer.rs index f522e6dfb7e..a401fde6fc0 100644 --- a/crates/vm/src/stdlib/_ctypes/pointer.rs +++ b/crates/vm/src/stdlib/_ctypes/pointer.rs @@ -668,7 +668,7 @@ impl PyCPointer { let ptr_val = if vm.is_none(value) { 0usize } else if let Ok(int_val) = value.try_index(vm) { - int_val.as_bigint().to_usize().unwrap_or(0) + super::simple::bigint_to_i128_wrapping(int_val.as_bigint()) as usize } else { return Err(vm.new_type_error("bytes/string or integer address expected")); }; @@ -684,12 +684,13 @@ impl PyCPointer { // Use write_unaligned for safety on strict-alignment architectures if let Ok(int_val) = value.try_int(vm) { let i = int_val.as_bigint(); + let wrapped = super::simple::bigint_to_i128_wrapping(i); let bytes; let write_value = match size { - 1 => AddressWriteValue::U8(i.to_u8().expect("int too large")), - 2 => AddressWriteValue::I16(i.to_i16().expect("int too large")), - 4 => AddressWriteValue::I32(i.to_i32().expect("int too large")), - 8 => AddressWriteValue::I64(i.to_i64().expect("int too large")), + 1 => AddressWriteValue::U8(wrapped as u8), + 2 => AddressWriteValue::I16(wrapped as i16), + 4 => AddressWriteValue::I32(wrapped as i32), + 8 => AddressWriteValue::I64(wrapped as i64), _ => { bytes = i.to_signed_bytes_le(); AddressWriteValue::Bytes(&bytes) diff --git a/crates/vm/src/stdlib/_ctypes/simple.rs b/crates/vm/src/stdlib/_ctypes/simple.rs index c947e56010a..5577fb8d25d 100644 --- a/crates/vm/src/stdlib/_ctypes/simple.rs +++ b/crates/vm/src/stdlib/_ctypes/simple.rs @@ -72,6 +72,17 @@ fn new_simple_type( Ok(PyCSimple(PyCData::from_bytes(zeroed_bytes(size), None))) } +pub(super) fn bigint_to_i128_wrapping(value: &malachite_bigint::BigInt) -> i128 { + let bytes = value.to_signed_bytes_le(); + let fill = bytes + .last() + .map_or(0, |byte| if *byte & 0x80 == 0 { 0 } else { u8::MAX }); + let mut wrapped = [fill; 16]; + let len = bytes.len().min(wrapped.len()); + wrapped[..len].copy_from_slice(&bytes[..len]); + i128::from_le_bytes(wrapped) +} + fn set_primitive(_type_: &str, value: &PyObject, vm: &VirtualMachine) -> PyResult { match _type_ { "c" => { @@ -756,7 +767,7 @@ fn value_to_bytes_endian( "b" => { // c_byte - signed char (1 byte) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -764,7 +775,7 @@ fn value_to_bytes_endian( "B" => { // c_ubyte - unsigned char (1 byte) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -772,7 +783,7 @@ fn value_to_bytes_endian( "h" => { // c_short (2 bytes) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -780,7 +791,7 @@ fn value_to_bytes_endian( "H" => { // c_ushort (2 bytes) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -788,7 +799,7 @@ fn value_to_bytes_endian( "i" => { // c_int (4 bytes) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -796,7 +807,7 @@ fn value_to_bytes_endian( "I" => { // c_uint (4 bytes) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -804,7 +815,7 @@ fn value_to_bytes_endian( "l" => { // c_long (platform dependent) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -812,7 +823,7 @@ fn value_to_bytes_endian( "L" => { // c_ulong (platform dependent) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -820,7 +831,7 @@ fn value_to_bytes_endian( "q" => { // c_longlong (8 bytes) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -828,7 +839,7 @@ fn value_to_bytes_endian( "Q" => { // c_ulonglong (8 bytes) if let Ok(int_val) = value.try_index(vm) { - SimpleStorageValue::Signed(int_val.as_bigint().to_i128().expect("int too large")) + SimpleStorageValue::Signed(bigint_to_i128_wrapping(int_val.as_bigint())) } else { SimpleStorageValue::Zero } @@ -889,10 +900,7 @@ fn value_to_bytes_endian( "P" => { // c_void_p - pointer type (platform pointer size) if let Ok(int_val) = value.try_index(vm) { - let v = int_val - .as_bigint() - .to_usize() - .expect("int too large for pointer"); + let v = bigint_to_i128_wrapping(int_val.as_bigint()) as usize; SimpleStorageValue::Pointer(v) } else { SimpleStorageValue::Zero @@ -902,10 +910,7 @@ fn value_to_bytes_endian( // c_char_p - pointer to char (stores pointer value from int) // PyBytes case is handled in slot_new/set_value with make_z_buffer() if let Ok(int_val) = value.try_index(vm) { - let v = int_val - .as_bigint() - .to_usize() - .expect("int too large for pointer"); + let v = bigint_to_i128_wrapping(int_val.as_bigint()) as usize; SimpleStorageValue::Pointer(v) } else { SimpleStorageValue::Zero @@ -915,10 +920,7 @@ fn value_to_bytes_endian( // c_wchar_p - pointer to wchar_t (stores pointer value from int) // PyStr case is handled in slot_new/set_value with make_wchar_buffer() if let Ok(int_val) = value.try_index(vm) { - let v = int_val - .as_bigint() - .to_usize() - .expect("int too large for pointer"); + let v = bigint_to_i128_wrapping(int_val.as_bigint()) as usize; SimpleStorageValue::Pointer(v) } else { SimpleStorageValue::Zero diff --git a/crates/vm/src/stdlib/_functools.rs b/crates/vm/src/stdlib/_functools.rs index 94b9565e79d..9b49e564562 100644 --- a/crates/vm/src/stdlib/_functools.rs +++ b/crates/vm/src/stdlib/_functools.rs @@ -302,7 +302,7 @@ mod _functools { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/stdlib/_imp.rs b/crates/vm/src/stdlib/_imp.rs index 322eaedd7d0..fa979fcadbb 100644 --- a/crates/vm/src/stdlib/_imp.rs +++ b/crates/vm/src/stdlib/_imp.rs @@ -179,7 +179,7 @@ mod _imp { PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, builtins::{PyBytesRef, PyCode, PyMemoryView, PyModule, PyStrRef, PyUtf8StrRef}, convert::TryFromBorrowedObject, - function::OptionalArg, + function::{FuncArgs, OptionalArg}, import, version, }; @@ -320,14 +320,16 @@ mod _imp { #[allow(clippy::type_complexity)] #[pyfunction] fn find_frozen( - name: PyUtf8StrRef, - withdata: OptionalArg, + args: FuncArgs, vm: &VirtualMachine, ) -> PyResult>, bool, Option)>> { - if withdata.into_option().is_some() { - // this is keyword-only argument in CPython - unimplemented!(); + if args.args.len() > 1 { + return Err(vm.new_type_error(format!( + "find_frozen() takes exactly 1 positional argument ({} given)", + args.args.len() + ))); } + let (name,): (PyUtf8StrRef,) = args.bind(vm)?; let name_str = name.as_str(); let info = match super::find_frozen(name_str, vm) { diff --git a/crates/vm/src/stdlib/_sre.rs b/crates/vm/src/stdlib/_sre.rs index 03382549b47..18b4ffde818 100644 --- a/crates/vm/src/stdlib/_sre.rs +++ b/crates/vm/src/stdlib/_sre.rs @@ -498,7 +498,7 @@ mod _sre { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } @@ -597,7 +597,7 @@ mod _sre { regs: Vec<(isize, isize)>, } - #[pyclass(with(AsMapping, Representable))] + #[pyclass(with(AsMapping, Representable), flags(DISALLOW_INSTANTIATION))] impl Match { pub(crate) fn new(state: &mut State, pattern: PyRef, string: PyObjectRef) -> Self { let string_position = state.cursor.position; @@ -844,7 +844,7 @@ mod _sre { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } diff --git a/crates/vm/src/stdlib/_typing.rs b/crates/vm/src/stdlib/_typing.rs index 0214c3cc544..0b19d8e3c32 100644 --- a/crates/vm/src/stdlib/_typing.rs +++ b/crates/vm/src/stdlib/_typing.rs @@ -39,8 +39,17 @@ pub(crate) mod decl { }; #[pyfunction] - pub(crate) fn _idfunc(args: FuncArgs, _vm: &VirtualMachine) -> PyObjectRef { - args.args[0].clone() + pub(crate) fn _idfunc(args: FuncArgs, vm: &VirtualMachine) -> PyResult { + if !args.kwargs.is_empty() { + return Err(vm.new_type_error("_typing._idfunc() takes no keyword arguments")); + } + if args.args.len() != 1 { + return Err(vm.new_type_error(format!( + "_typing._idfunc() takes exactly one argument ({} given)", + args.args.len() + ))); + } + Ok(args.args[0].clone()) } #[pyfunction(name = "override")] @@ -288,7 +297,7 @@ pub(crate) mod decl { PyTuple::new_ref(vec![args], &vm.ctx) }; let origin: PyObjectRef = zelf.as_object().to_owned(); - Ok(PyGenericAlias::new(origin, args_tuple, false, vm).into_pyobject(vm)) + Ok(PyGenericAlias::new(origin, args_tuple, false, vm)?.into_pyobject(vm)) } #[pymethod] diff --git a/crates/vm/src/stdlib/builtins.rs b/crates/vm/src/stdlib/builtins.rs index 35f404f0f3b..95feea65620 100644 --- a/crates/vm/src/stdlib/builtins.rs +++ b/crates/vm/src/stdlib/builtins.rs @@ -341,6 +341,7 @@ mod builtins { }; match &source { ArgStrOrBytesLike::Str(source) => { + let source = source.try_as_utf8(vm)?.as_str(); if source.as_bytes().contains(&0) { return Err(vm.new_exception_msg( vm.ctx.exceptions.syntax_error.to_owned(), @@ -548,13 +549,14 @@ mod builtins { Either::A(either) => { let source = match &either { ArgStrOrBytesLike::Str(source) => { + let source = source.try_as_utf8(vm)?.as_str(); if source.as_bytes().contains(&0) { return Err(vm.new_exception_msg( vm.ctx.exceptions.syntax_error.to_owned(), "source code string cannot contain null bytes".into(), )); } - let source = source.expect_str().trim_start_matches([' ', '\t']); + let source = source.trim_start_matches([' ', '\t']); audit_compile_source(vm, source.as_bytes(), "")?; source.to_owned() } @@ -597,6 +599,7 @@ mod builtins { } let source = match &either { ArgStrOrBytesLike::Str(source) => { + let source = source.try_as_utf8(vm)?.as_str(); if source.as_bytes().contains(&0) { return Err(vm.new_exception_msg( vm.ctx.exceptions.syntax_error.to_owned(), @@ -604,7 +607,7 @@ mod builtins { )); } audit_compile_source(vm, source.as_bytes(), "")?; - source.expect_str().to_owned() + source.to_owned() } ArgStrOrBytesLike::Buf(source) => { let source: &[u8] = &source.borrow_buf(); diff --git a/crates/vm/src/stdlib/itertools.rs b/crates/vm/src/stdlib/itertools.rs index 30a4d8773be..e633404e803 100644 --- a/crates/vm/src/stdlib/itertools.rs +++ b/crates/vm/src/stdlib/itertools.rs @@ -26,7 +26,7 @@ mod decl { use num_traits::{Signed, ToPrimitive}; #[pyattr] - #[pyclass(name = "chain")] + #[pyclass(name = "chain", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsChain { source: PyRwLock>, @@ -64,7 +64,7 @@ mod decl { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } } @@ -119,7 +119,7 @@ mod decl { } #[pyattr] - #[pyclass(name = "compress")] + #[pyclass(name = "compress", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsCompress { data: PyIter, @@ -166,7 +166,7 @@ mod decl { } #[pyattr] - #[pyclass(name = "count")] + #[pyclass(name = "count", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsCount { cur: PyRwLock, @@ -237,11 +237,12 @@ mod decl { } #[pyattr] - #[pyclass(name = "cycle")] + #[pyclass(name = "cycle", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsCycle { iter: PyIter, saved: PyRwLock>, + #[pytraverse(skip)] index: AtomicCell, } @@ -273,11 +274,15 @@ mod decl { return Ok(PyIterReturn::StopIteration(None)); } - let last_index = zelf.index.fetch_add(1); - - if last_index >= saved.len() - 1 { - zelf.index.store(0); - } + // Advance and wrap in a single atomic step. A separate + // fetch_add followed by a reset lets a second thread observe + // an index past the end of `saved`. + let last_index = match zelf.index.fetch_update(|index| { + let next = index + 1; + Some(if next < saved.len() { next } else { 0 }) + }) { + Ok(index) | Err(index) => index, + }; saved[last_index].clone() }; @@ -287,10 +292,11 @@ mod decl { } #[pyattr] - #[pyclass(name = "repeat")] + #[pyclass(name = "repeat", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsRepeat { object: PyObjectRef, + #[pytraverse(skip)] times: Option>, } @@ -365,7 +371,7 @@ mod decl { } #[pyattr] - #[pyclass(name = "starmap")] + #[pyclass(name = "starmap", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsStarmap { function: PyObjectRef, @@ -412,11 +418,12 @@ mod decl { } #[pyattr] - #[pyclass(name = "takewhile")] + #[pyclass(name = "takewhile", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsTakewhile { predicate: PyObjectRef, iterable: PyIter, + #[pytraverse(skip)] stop_flag: AtomicCell, } @@ -474,11 +481,12 @@ mod decl { } #[pyattr] - #[pyclass(name = "dropwhile")] + #[pyclass(name = "dropwhile", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsDropwhile { predicate: PyObjectRef, iterable: PyIter, + #[pytraverse(skip)] start_flag: AtomicCell, } @@ -533,11 +541,13 @@ mod decl { } } - #[derive(Default)] + #[derive(Default, Traverse)] struct GroupByState { current_value: Option, current_key: Option, + #[pytraverse(skip)] next_group: bool, + #[pytraverse(skip)] grouper: Option>, } @@ -561,7 +571,7 @@ mod decl { } #[pyattr] - #[pyclass(name = "groupby")] + #[pyclass(name = "groupby", traverse)] #[derive(PyPayload)] struct PyItertoolsGroupBy { iterable: PyIter, @@ -661,7 +671,7 @@ mod decl { } #[pyattr] - #[pyclass(name = "_grouper")] + #[pyclass(name = "_grouper", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsGrouper { groupby: PyRef, @@ -703,13 +713,17 @@ mod decl { } #[pyattr] - #[pyclass(name = "islice")] + #[pyclass(name = "islice", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsIslice { iterable: PyIter, + #[pytraverse(skip)] cur: AtomicCell, + #[pytraverse(skip)] next: AtomicCell, + #[pytraverse(skip)] stop: Option, + #[pytraverse(skip)] step: usize, } @@ -828,7 +842,7 @@ mod decl { } #[pyattr] - #[pyclass(name = "filterfalse")] + #[pyclass(name = "filterfalse", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsFilterFalse { predicate: PyObjectRef, @@ -887,7 +901,7 @@ mod decl { } #[pyattr] - #[pyclass(name = "accumulate")] + #[pyclass(name = "accumulate", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsAccumulate { iterable: PyIter, @@ -1053,7 +1067,7 @@ mod decl { #[pymethod] fn __copy__(&self) -> Self { Self { - tee_data: PyRc::clone(&self.tee_data), + tee_data: self.tee_data.clone(), index: AtomicCell::new(self.index.load()), } } @@ -1068,12 +1082,15 @@ mod decl { } #[pyattr] - #[pyclass(name = "product")] + #[pyclass(name = "product", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsProduct { pools: Vec>, + #[pytraverse(skip)] idxs: PyRwLock>, + #[pytraverse(skip)] cur: AtomicCell, + #[pytraverse(skip)] stop: AtomicCell, } @@ -1169,13 +1186,16 @@ mod decl { } #[pyattr] - #[pyclass(name = "combinations")] + #[pyclass(name = "combinations", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsCombinations { pool: Vec, + #[pytraverse(skip)] indices: PyRwLock>, result: PyRwLock>>, + #[pytraverse(skip)] r: AtomicCell, + #[pytraverse(skip)] exhausted: AtomicCell, } @@ -1201,13 +1221,21 @@ mod decl { if r.is_negative() { return Err(vm.new_value_error("r must be non-negative")); } - let r = r.to_usize().unwrap(); + let r = r.to_isize().ok_or_else(|| { + vm.new_overflow_error("Python int too large to convert to C ssize_t") + })? as usize; let n = pool.len(); + let mut indices = Vec::new(); + indices + .try_reserve_exact(r) + .map_err(|_| vm.new_memory_error(""))?; + indices.extend(0..r); + Ok(Self { pool, - indices: PyRwLock::new((0..r).collect()), + indices: PyRwLock::new(indices), result: PyRwLock::new(None), r: AtomicCell::new(r), exhausted: AtomicCell::new(r > n), @@ -1280,12 +1308,15 @@ mod decl { } #[pyattr] - #[pyclass(name = "combinations_with_replacement")] + #[pyclass(name = "combinations_with_replacement", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsCombinationsWithReplacement { pool: Vec, + #[pytraverse(skip)] indices: PyRwLock>, + #[pytraverse(skip)] r: AtomicCell, + #[pytraverse(skip)] exhausted: AtomicCell, } @@ -1302,13 +1333,21 @@ mod decl { if r.is_negative() { return Err(vm.new_value_error("r must be non-negative")); } - let r = r.to_usize().unwrap(); + let r = r.to_isize().ok_or_else(|| { + vm.new_overflow_error("Python int too large to convert to C ssize_t") + })? as usize; let n = pool.len(); + let mut indices = Vec::new(); + indices + .try_reserve_exact(r) + .map_err(|_| vm.new_memory_error(""))?; + indices.resize(r, 0); + Ok(Self { pool, - indices: PyRwLock::new(vec![0; r]), + indices: PyRwLock::new(indices), r: AtomicCell::new(r), exhausted: AtomicCell::new(n == 0 && r > 0), }) @@ -1366,15 +1405,20 @@ mod decl { } #[pyattr] - #[pyclass(name = "permutations")] + #[pyclass(name = "permutations", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsPermutations { - pool: Vec, // Collected input iterable - indices: PyRwLock>, // One index per element in pool - cycles: PyRwLock>, // One rollover counter per element in the result + pool: Vec, // Collected input iterable + #[pytraverse(skip)] + indices: PyRwLock>, // One index per element in pool + #[pytraverse(skip)] + cycles: PyRwLock>, // One rollover counter per element in the result + #[pytraverse(skip)] result: PyRwLock>>, // Indexes of the most recently returned result - r: AtomicCell, // Size of result tuple - exhausted: AtomicCell, // Set when the iterator is exhausted + #[pytraverse(skip)] + r: AtomicCell, // Size of result tuple + #[pytraverse(skip)] + exhausted: AtomicCell, // Set when the iterator is exhausted } #[derive(FromArgs)] @@ -1408,7 +1452,9 @@ mod decl { if val.is_negative() { return Err(vm.new_value_error("r must be non-negative")); } - val.to_usize().unwrap() + val.to_isize().ok_or_else(|| { + vm.new_overflow_error("Python int too large to convert to C ssize_t") + })? as usize } None => n, }; @@ -1524,7 +1570,7 @@ mod decl { } #[pyattr] - #[pyclass(name = "zip_longest")] + #[pyclass(name = "zip_longest", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsZipLongest { iterators: Vec, @@ -1562,7 +1608,7 @@ mod decl { } #[pyattr] - #[pyclass(name = "pairwise")] + #[pyclass(name = "pairwise", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsPairwise { iterator: PyIter, @@ -1611,12 +1657,15 @@ mod decl { } #[pyattr] - #[pyclass(name = "batched")] + #[pyclass(name = "batched", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsBatched { + #[pytraverse(skip)] exhausted: AtomicCell, iterable: PyIter, + #[pytraverse(skip)] n: AtomicCell, + #[pytraverse(skip)] strict: AtomicCell, } diff --git a/crates/vm/src/stdlib/os.rs b/crates/vm/src/stdlib/os.rs index a934c6d812f..9156c9fc0bf 100644 --- a/crates/vm/src/stdlib/os.rs +++ b/crates/vm/src/stdlib/os.rs @@ -206,7 +206,10 @@ pub(super) mod _os { ospath::{OsPath, OsPathOrFd, OutputMode, PathConverter}, protocol::PyIterReturn, recursion::ReprGuard, - types::{Destructor, IterNext, Iterable, PyStructSequence, Representable, SelfIter}, + types::{ + Destructor, IterNext, Iterable, PyStructSequence, PyStructSequenceData, Representable, + SelfIter, + }, vm::VirtualMachine, }; #[cfg(not(windows))] @@ -883,7 +886,7 @@ pub(super) mod _os { cls: PyTypeRef, args: PyObjectRef, vm: &VirtualMachine, - ) -> PyGenericAlias { + ) -> PyResult { PyGenericAlias::from_args(cls, args, vm) } @@ -1314,8 +1317,12 @@ pub(super) mod _os { impl PyStatResult { #[pyslot] fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { - let seq: PyObjectRef = args.bind(vm)?; - let result = crate::types::struct_sequence_new(cls.clone(), seq, vm)?; + let result = crate::types::struct_sequence_new( + cls.clone(), + args.bind(vm)?, + StatResultData::OPTIONAL_FIELD_NAMES, + vm, + )?; let tuple = result.downcast_ref::().unwrap(); let mut items: Vec = tuple.to_vec(); @@ -1964,8 +1971,12 @@ pub(super) mod _os { impl PyStatvfsResult { #[pyslot] fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { - let seq: PyObjectRef = args.bind(vm)?; - crate::types::struct_sequence_new(cls, seq, vm) + crate::types::struct_sequence_new( + cls, + args.bind(vm)?, + StatvfsResultData::OPTIONAL_FIELD_NAMES, + vm, + ) } } diff --git a/crates/vm/src/stdlib/posix.rs b/crates/vm/src/stdlib/posix.rs index c16da1ee703..6b91950e907 100644 --- a/crates/vm/src/stdlib/posix.rs +++ b/crates/vm/src/stdlib/posix.rs @@ -1333,14 +1333,13 @@ pub mod module { // cfg from nix #[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "redox")))] #[pyfunction] - fn setgroups( - group_ids: crate::function::ArgIterable, - vm: &VirtualMachine, - ) -> PyResult<()> { - let gids = group_ids - .iter(vm)? - .map(|gid| gid.map(|gid| gid.0)) - .collect::, _>>()?; + fn setgroups(group_ids: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { + group_ids + .try_sequence(vm) + .map_err(|_| vm.new_type_error("setgroups argument must be a sequence"))?; + let gids = vm.extract_elements_with(&group_ids, |gid| { + RawGid::try_from_object(vm, gid).map(|gid| gid.0) + })?; rustpython_host_env::posix::setgroups_raw(&gids).map_err(|err| err.into_pyexception(vm)) } @@ -1400,7 +1399,7 @@ pub mod module { #[pyarg(positional)] path: OsPath, #[pyarg(positional)] - args: crate::function::ArgIterable, + args: PyObjectRef, #[pyarg(positional)] env: Option, #[pyarg(named, default)] @@ -1439,6 +1438,19 @@ pub mod module { .into_cstring(vm) .map_err(|_| vm.new_value_error("path should not have nul bytes"))?; + let function_name = if spawnp { + "posix_spawnp" + } else { + "posix_spawn" + }; + if !self.args.fast_isinstance(vm.ctx.types.list_type) + && !self.args.fast_isinstance(vm.ctx.types.tuple_type) + { + return Err( + vm.new_type_error(format!("{function_name}: argv must be a tuple or list")) + ); + } + let mut file_actions = Vec::new(); if let Some(it) = self.file_actions { for action in it.iter(vm)? { @@ -1478,20 +1490,21 @@ pub mod module { } } - let setsigdef = self - .setsigdef - .map(|sigs| { - let sigs = sigs.iter(vm)?.collect::>>()?; - for &sig in &sigs { - if !rustpython_host_env::posix::validate_posix_spawn_signal(sig) { - return Err( - vm.new_value_error(format!("signal number {sig} out of range")) - ); - } + let collect_signals = |sigs: crate::function::ArgIterable| { + let mut collected = Vec::new(); + for sig in sigs.iter(vm)? { + let sig = sig?; + if !rustpython_host_env::posix::validate_posix_spawn_signal(sig) { + return Err(vm.new_value_error(format!("signal number {sig} out of range"))); } - Ok(sigs) - }) - .transpose()?; + if !collected.contains(&sig) { + collected.push(sig); + } + } + Ok(collected) + }; + + let setsigdef = self.setsigdef.map(&collect_signals).transpose()?; if let Some(_scheduler) = self.scheduler { // TODO: Implement scheduler parameter handling @@ -1507,29 +1520,12 @@ pub mod module { )); } - let setsigmask = self - .setsigmask - .map(|sigs| { - let sigs = sigs.iter(vm)?.collect::>>()?; - for &sig in &sigs { - if !rustpython_host_env::posix::validate_posix_spawn_signal(sig) { - return Err( - vm.new_value_error(format!("signal number {sig} out of range")) - ); - } - } - Ok(sigs) - }) - .transpose()?; + let setsigmask = self.setsigmask.map(collect_signals).transpose()?; - let args: Vec = self - .args - .iter(vm)? - .map(|res| { - CString::new(res?.into_bytes()) - .map_err(|_| vm.new_value_error("path should not have nul bytes")) - }) - .collect::>()?; + let args = vm.extract_elements_with(&self.args, |arg| { + CString::new(OsPath::try_from_object(vm, arg)?.into_bytes()) + .map_err(|_| vm.new_value_error("path should not have nul bytes")) + })?; let env = if let Some(env_dict) = self.env { envp_from_dict(env_dict, vm)? } else { diff --git a/crates/vm/src/stdlib/sys.rs b/crates/vm/src/stdlib/sys.rs index c7cc2fd298a..66257806e22 100644 --- a/crates/vm/src/stdlib/sys.rs +++ b/crates/vm/src/stdlib/sys.rs @@ -888,8 +888,7 @@ pub mod sys { format!("Ignoring unimportable $PYTHONBREAKPOINT: \"{env_var}\"",), 0, vm, - ) - .unwrap(); + )?; Ok(vm.ctx.none()) }; diff --git a/crates/vm/src/stdlib/time.rs b/crates/vm/src/stdlib/time.rs index 3d777c24b89..a5daa9cd2ff 100644 --- a/crates/vm/src/stdlib/time.rs +++ b/crates/vm/src/stdlib/time.rs @@ -18,7 +18,7 @@ mod decl { AsObject, Py, PyObjectRef, PyResult, VirtualMachine, builtins::{PyStrRef, PyTypeRef}, function::{Either, FuncArgs, OptionalArg}, - types::{PyStructSequence, struct_sequence_new}, + types::{PyStructSequence, PyStructSequenceData, struct_sequence_new}, }; #[cfg(any(unix, windows))] use crate::{ @@ -811,8 +811,12 @@ mod decl { impl PyStructTime { #[pyslot] fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { - let (seq, _dict): (PyObjectRef, OptionalArg) = args.bind(vm)?; - struct_sequence_new(cls, seq, vm) + struct_sequence_new( + cls, + args.bind(vm)?, + StructTimeData::OPTIONAL_FIELD_NAMES, + vm, + ) } } diff --git a/crates/vm/src/types/mod.rs b/crates/vm/src/types/mod.rs index b17a737545f..11c3a4dc51e 100644 --- a/crates/vm/src/types/mod.rs +++ b/crates/vm/src/types/mod.rs @@ -5,5 +5,7 @@ mod zoo; pub use slot::*; pub use slot_defs::{SLOT_DEFS, SlotAccessor, SlotDef}; -pub use structseq::{PyStructSequence, PyStructSequenceData, struct_sequence_new}; +pub use structseq::{ + PyStructSequence, PyStructSequenceData, StructSequenceNewArgs, struct_sequence_new, +}; pub(crate) use zoo::TypeZoo; diff --git a/crates/vm/src/types/structseq.rs b/crates/vm/src/types/structseq.rs index 703cc79c193..7f8099e7efb 100644 --- a/crates/vm/src/types/structseq.rs +++ b/crates/vm/src/types/structseq.rs @@ -2,9 +2,11 @@ use crate::common::lock::LazyLock; use crate::common::wtf8::Wtf8; use crate::{ AsObject, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, atomic_func, - builtins::{PyBaseExceptionRef, PyStr, PyStrRef, PyTuple, PyTupleRef, PyType, PyTypeRef}, + builtins::{ + PyBaseExceptionRef, PyDict, PyStr, PyStrRef, PyTuple, PyTupleRef, PyType, PyTypeRef, + }, class::{PyClassImpl, StaticType}, - function::{Either, FuncArgs, PyComparisonValue, PyMethodDef, PyMethodFlags}, + function::{Either, FuncArgs, OptionalArg, PyComparisonValue, PyMethodDef, PyMethodFlags}, iter::PyExactSizeIterator, protocol::{PyMappingMethods, PySequenceMethods}, sliceable::{SequenceIndex, SliceableSequenceOp}, @@ -21,12 +23,35 @@ const DEFAULT_STRUCTSEQ_REDUCE: PyMethodDef = PyMethodDef::new_const( None, ); +/// The arguments every struct sequence constructor takes. +#[derive(FromArgs)] +pub struct StructSequenceNewArgs { + #[pyarg(any)] + pub sequence: PyObjectRef, + #[pyarg(any, optional)] + pub dict: OptionalArg, +} + /// Create a new struct sequence instance from a sequence. /// +/// `dict` supplies the hidden fields — the ones past `n_sequence_fields`, named +/// by `hidden_field_names` in order — that the sequence itself did not cover. It +/// may not name a field the sequence already supplied, nor one that does not +/// exist. +/// /// The class must have `n_sequence_fields` and `n_fields` attributes set /// (done automatically by `PyStructSequence::extend_pyclass`). -pub fn struct_sequence_new(cls: PyTypeRef, seq: PyObjectRef, vm: &VirtualMachine) -> PyResult { +pub fn struct_sequence_new( + cls: PyTypeRef, + args: StructSequenceNewArgs, + hidden_field_names: &[&str], + vm: &VirtualMachine, +) -> PyResult { // = structseq_new + let StructSequenceNewArgs { + sequence: seq, + dict, + } = args; #[cold] fn length_error( @@ -60,6 +85,16 @@ pub fn struct_sequence_new(cls: PyTypeRef, seq: PyObjectRef, vm: &VirtualMachine .ok_or_else(|| vm.new_type_error("missing n_fields attribute"))? .try_into_value(vm)?; + let dict = match dict { + OptionalArg::Missing => None, + OptionalArg::Present(dict) => Some(dict.downcast::().map_err(|_| { + vm.new_type_error(format!( + "{}() takes a dict as second arg, if any", + cls.slot_name() + )) + })?), + }; + let seq: Vec = seq.try_into_value(vm)?; let len = seq.len(); @@ -67,10 +102,30 @@ pub fn struct_sequence_new(cls: PyTypeRef, seq: PyObjectRef, vm: &VirtualMachine return Err(length_error(&cls.slot_name(), min_len, max_len, len, vm)); } - // Copy items and pad with None + // Copy items and pad the hidden fields the sequence did not cover with None. let mut items = seq; items.resize_with(max_len, || vm.ctx.none()); + // Fill those padded slots from `dict`. Every key has to land in one of them: + // a key naming a field the sequence already supplied, or no field at all, + // would otherwise be silently dropped. + if let Some(dict) = dict.filter(|dict| !dict.is_empty()) { + let mut found = 0; + let names = hidden_field_names.get(len - min_len..).unwrap_or(&[]); + for (item, name) in items[len..].iter_mut().zip(names) { + if let Some(value) = dict.get_item_opt(*name, vm)? { + *item = value; + found += 1; + } + } + if found != dict.__len__() { + return Err(vm.new_type_error(format!( + "{}() got duplicate or unexpected field name(s)", + cls.slot_name() + ))); + } + } + PyTuple::new_unchecked(items.into_boxed_slice()) .into_ref_with_type(vm, cls) .map(Into::into) @@ -193,6 +248,11 @@ pub trait PyStructSequence: StaticType + PyClassImpl + Sized + 'static { /// The Data struct that provides field definitions. type Data: PyStructSequenceData; + #[pyslot] + fn slot_new(cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { + struct_sequence_new(cls, args.bind(vm)?, Self::Data::OPTIONAL_FIELD_NAMES, vm) + } + /// Convert a Data struct into a PyStructSequence instance. fn from_data(data: Self::Data, vm: &VirtualMachine) -> PyTupleRef { let tuple = diff --git a/crates/vm/src/utils.rs b/crates/vm/src/utils.rs index 80402480cfd..8a28a32f663 100644 --- a/crates/vm/src/utils.rs +++ b/crates/vm/src/utils.rs @@ -33,6 +33,7 @@ pub(crate) fn collection_repr<'a, I>( class_name: Option<&str>, prefix: &str, suffix: &str, + empty: &str, iter: I, vm: &VirtualMachine, ) -> PyResult @@ -47,10 +48,9 @@ where repr.push_str(prefix); { let mut parts_iter = iter.map(|o| o.repr(vm)); - let first = parts_iter - .next() - .transpose()? - .expect("this is not called for empty collection"); + let Some(first) = parts_iter.next().transpose()? else { + return Ok(Wtf8Buf::from(empty)); + }; repr.push_wtf8(first.as_wtf8()); for part in parts_iter { repr.push_str(", "); diff --git a/extra_tests/snippets/builtin_compile.py b/extra_tests/snippets/builtin_compile.py index 49295bf26d2..73247e50df1 100644 --- a/extra_tests/snippets/builtin_compile.py +++ b/extra_tests/snippets/builtin_compile.py @@ -145,3 +145,8 @@ def _check_flags_error(flags): assert exc.args[0] == "incomplete input", repr(exc) else: raise AssertionError("expected _IncompleteInputError") + +# The source is encoded before it is parsed, so a lone surrogate has to be +# reported rather than assumed away. +with assert_raises(UnicodeEncodeError): + compile(chr(0xD800), "", "eval") diff --git a/extra_tests/snippets/builtin_eval.py b/extra_tests/snippets/builtin_eval.py index 2f2405c8d9e..1648a1a271d 100644 --- a/extra_tests/snippets/builtin_eval.py +++ b/extra_tests/snippets/builtin_eval.py @@ -1,3 +1,5 @@ +from testutils import assert_raises + assert 3 == eval("1+2") code = compile("5+3", "x.py", "eval") @@ -75,3 +77,8 @@ def make_closure(): assert False, "eval with code containing free variables should fail" except NameError as e: pass + +# The source is encoded before it is parsed, so a lone surrogate has to be +# reported rather than assumed away. +with assert_raises(UnicodeEncodeError): + eval(chr(0xD800)) diff --git a/extra_tests/snippets/builtin_exceptions.py b/extra_tests/snippets/builtin_exceptions.py index 8879e130bc2..080294a3c8a 100644 --- a/extra_tests/snippets/builtin_exceptions.py +++ b/extra_tests/snippets/builtin_exceptions.py @@ -1,4 +1,5 @@ import builtins +import itertools import pickle import platform import sys @@ -393,3 +394,19 @@ class SubError(MyError): assert err.exceptions[0].args == ("x",) else: assert False, "except* handler did not run" + +# The exceptions argument is a sequence, so an arbitrary iterable must be +# rejected rather than drained. +try: + ExceptionGroup("m", itertools.count()) +except TypeError: + pass +else: + assert False, "ExceptionGroup accepted an unbounded iterable" + +# ImportError.__reduce__ has to cope with the exception carrying no args. +assert pickle.loads(pickle.dumps(ImportError())).args == () +restored = pickle.loads(pickle.dumps(ImportError("m", name="n", path="p"))) +assert restored.args == ("m",) +assert restored.name == "n" +assert restored.path == "p" diff --git a/extra_tests/snippets/builtin_exec.py b/extra_tests/snippets/builtin_exec.py index 2eae90e91c5..cfb88c15dc1 100644 --- a/extra_tests/snippets/builtin_exec.py +++ b/extra_tests/snippets/builtin_exec.py @@ -1,3 +1,5 @@ +from testutils import assert_raises + exec("def square(x):\n return x * x\n") assert 16 == square(4) # noqa: F821 @@ -71,3 +73,8 @@ def f(): f() + +# The source is encoded before it is parsed, so a lone surrogate has to be +# reported rather than assumed away. +with assert_raises(UnicodeEncodeError): + exec(chr(0xD800)) diff --git a/extra_tests/snippets/builtin_hash.py b/extra_tests/snippets/builtin_hash.py index 9b2c8388790..b3128cecc5a 100644 --- a/extra_tests/snippets/builtin_hash.py +++ b/extra_tests/snippets/builtin_hash.py @@ -1,3 +1,5 @@ +import sys + from testutils import assert_raises @@ -28,3 +30,19 @@ def __hash__(self): with assert_raises(TypeError): hash([]) + +# Hashing a deeply nested tuple must not run off the native stack: the hash +# slot dispatch is what recurses, so that is where the depth is checked. + +if sys.implementation.name == "rustpython": + # CPython, which also runs this snippet, survives this depth unguarded. + deep_tuple = () + for _ in range(sys.getrecursionlimit() * 2): + deep_tuple = (deep_tuple,) + with assert_raises(RecursionError): + hash(deep_tuple) + # a dict key and a set member are hashed on insertion, same dispatch + with assert_raises(RecursionError): + {deep_tuple: 1} + with assert_raises(RecursionError): + {deep_tuple} diff --git a/extra_tests/snippets/builtin_list.py b/extra_tests/snippets/builtin_list.py index d62cae03b50..44492092bad 100644 --- a/extra_tests/snippets/builtin_list.py +++ b/extra_tests/snippets/builtin_list.py @@ -1,3 +1,5 @@ +import sys + from testutils import assert_raises x = [1, 2, 3] @@ -923,3 +925,8 @@ def __eq__(self, other): list1 = rewrite_list_eq([poc()]) list1.remove(list1) assert list1 == [] + +# The repeat count is multiplied by the element size; a count that overflows +# that product must raise instead of wrapping into a short allocation. +with assert_raises(MemoryError): + [1] * sys.maxsize diff --git a/extra_tests/snippets/builtin_tuple.py b/extra_tests/snippets/builtin_tuple.py index fc2f8d5bb75..a679d2a99a8 100644 --- a/extra_tests/snippets/builtin_tuple.py +++ b/extra_tests/snippets/builtin_tuple.py @@ -1,3 +1,5 @@ +import sys + from testutils import assert_raises assert (1, 2) == (1, 2) @@ -93,3 +95,8 @@ def __eq__(self, x): assert (float("inf"), float("inf")) >= (float("inf"), float("inf")) assert not (float("inf"), float("inf")) < (float("inf"), float("inf")) assert not (float("inf"), float("inf")) > (float("inf"), float("inf")) + +# The repeat count is multiplied by the element size; a count that overflows +# that product must raise instead of wrapping into a short allocation. +with assert_raises(MemoryError): + (1,) * sys.maxsize diff --git a/extra_tests/snippets/forbidden_instantiation.py b/extra_tests/snippets/forbidden_instantiation.py index 50b6f58f07f..50a0e2cf635 100644 --- a/extra_tests/snippets/forbidden_instantiation.py +++ b/extra_tests/snippets/forbidden_instantiation.py @@ -1,3 +1,4 @@ +import re from types import ( AsyncGeneratorType, BuiltinFunctionType, @@ -62,3 +63,9 @@ def check_forbidden_instantiation(typ, reverse=False): for typ in internal_types: with assert_raises(TypeError): typ() + +# a match object carries state that only the matcher can fill in +with assert_raises(TypeError): + re.Match() +with assert_raises(TypeError): + re.Match.__new__(re.Match) diff --git a/extra_tests/snippets/stdlib_asyncio.py b/extra_tests/snippets/stdlib_asyncio.py new file mode 100644 index 00000000000..7f03aeb436b --- /dev/null +++ b/extra_tests/snippets/stdlib_asyncio.py @@ -0,0 +1,52 @@ +"""The private _asyncio accessors, reached directly instead of through a loop. + +CPython's _asyncio rejects every call below with "loop ... is not the running +loop" before it gets anywhere, and does not expose _current_tasks at all, so +these only run where they are reachable. +""" + +import sys + +from testutils import assert_raises + +if sys.implementation.name != "rustpython": + sys.exit(0) + +import _asyncio + + +def _task(): + pass + + +# The "already entered" message formats both tasks; a plain function used to be +# formatted as the wrong type there. +_asyncio._enter_task(0, _task) +with assert_raises(RuntimeError) as cm: + _asyncio._enter_task(0, _task) +assert "Cannot enter into task" in str(cm.exception), cm.exception +assert " str: # print(get_win_folder_via_ctypes("CSIDL_DOWNLOADS")) +# A value wider than the C type is masked down to it instead of failing an +# unchecked conversion. +assert ctypes.c_char_p(2**64).value is None +assert ctypes.c_int(2**64 + 7).value == 7 +buf = (ctypes.c_int * 1)() +int_ptr = ctypes.cast(buf, ctypes.POINTER(ctypes.c_int)) +int_ptr[0] = 2**64 + 5 +assert int_ptr[0] == 5 + +# A slice assignment is length-checked against the slice, so the right-hand +# side must not be drained first. +array3 = (ctypes.c_int * 3)() +try: + array3[0:3] = itertools.count() +except ValueError: + pass +else: + assert False, "slice assignment accepted an unbounded iterable" +array3[0:3] = [7, 8, 9] +assert list(array3) == [7, 8, 9] + print("done") diff --git a/extra_tests/snippets/stdlib_ctypes_calls.py b/extra_tests/snippets/stdlib_ctypes_calls.py index 1de29931429..cc4e8020511 100644 --- a/extra_tests/snippets/stdlib_ctypes_calls.py +++ b/extra_tests/snippets/stdlib_ctypes_calls.py @@ -1,6 +1,7 @@ # Exercises the migrated _ctypes foreign-call path (routed through the unified # host_env `call` entry point): scalar int/double arguments and returns, -# pointer (c_char_p / c_void_p) returns, and a use_errno round-trip. +# pointer (c_char_p / c_void_p) returns, a use_errno round-trip, and the +# argument conversion an untyped call performs. # # Prints "OK" and exits 0; any failed assertion aborts. Output is identical # under CPython and RustPython on the same platform. @@ -61,4 +62,17 @@ libc.strtol(b"9" * 40, None, 10) assert get_errno() == errno.ERANGE, (get_errno(), errno.ERANGE) +# 7. A float has no implicit conversion to an integer argument: converting it +# would pass a truncated value where the callee expects an int or a pointer. +libc.abs.argtypes = None +for bad in (1.5, 0.0, 1e300): + try: + libc.abs(bad) + except (TypeError, ctypes.ArgumentError): + pass + else: + assert False, f"{bad!r} was accepted as an integer argument" +assert libc.abs(-3) == 3 +assert libc.abs(True) == 1 + print("OK") diff --git a/extra_tests/snippets/stdlib_gc.py b/extra_tests/snippets/stdlib_gc.py new file mode 100644 index 00000000000..134b1b9f458 --- /dev/null +++ b/extra_tests/snippets/stdlib_gc.py @@ -0,0 +1,66 @@ +"""The cycle collector has to walk the internal fields of containers and +iterators. + +Every type below is built into the cycle + + node -> node.__dict__ -> wrapper -> container -> node + +so the only path back to `node` runs through a field of the wrapper. A type +that reports nothing while being traversed, or reports the objects it iterates +instead of the iterator it holds, leaves its own reference unaccounted for: the +cycle is then classified as reachable and `node` is never freed. +""" + +import gc +import itertools +import weakref +from collections import defaultdict, deque + + +class Node: + pass + + +def collects(wrap): + """Report whether the collector breaks the cycle built around wrap().""" + + def build(): + container = [] + node = Node() + container.append(node) + node.held = wrap(container) + return weakref.ref(node) + + gc.collect() + ref = build() + gc.collect() + return ref() is None + + +# containers keeping their items in a field of their own +assert collects(deque) +assert collects(lambda c: defaultdict(int, {"k": c})) +assert collects(lambda c: classmethod(lambda cls: c)) + +# iterators: the wrapper holds an iterator, and that iterator holds the +# container +assert collects(iter) +assert collects(lambda c: map(str, c)) +assert collects(lambda c: filter(None, c)) +assert collects(lambda c: zip(c)) +assert collects(enumerate) +assert collects(reversed) +assert collects(itertools.chain) +assert collects(itertools.cycle) +assert collects(lambda c: itertools.islice(c, 5)) +assert collects(itertools.groupby) +assert collects(itertools.accumulate) +assert collects(lambda c: itertools.starmap(str, c)) +assert collects(lambda c: itertools.takewhile(bool, c)) +assert collects(lambda c: itertools.dropwhile(bool, c)) +assert collects(lambda c: itertools.filterfalse(None, c)) +assert collects(lambda c: itertools.compress(c, [1])) +assert collects(lambda c: itertools.product(c)) +assert collects(lambda c: itertools.combinations(c, 1)) + +print("ok") diff --git a/extra_tests/snippets/stdlib_hashlib.py b/extra_tests/snippets/stdlib_hashlib.py index c5feb709e17..a463941b29a 100644 --- a/extra_tests/snippets/stdlib_hashlib.py +++ b/extra_tests/snippets/stdlib_hashlib.py @@ -1,3 +1,5 @@ +import _md5 +import _sha1 import hashlib # print(hashlib.md5) @@ -48,3 +50,9 @@ assert ( h.hexdigest() == "25738bfe4cc104131e1b45bece4dfd4e7e1d6f0dffda1211e996e9d5d3b66e81" ) + +# The single-algorithm modules set up their own types rather than relying on +# hashlib having done it. + +assert _md5.md5(b"").hexdigest() == "d41d8cd98f00b204e9800998ecf8427e" +assert _sha1.sha1(b"").hexdigest() == "da39a3ee5e6b4b0d3255bfef95601890afd80709" diff --git a/extra_tests/snippets/stdlib_imp.py b/extra_tests/snippets/stdlib_imp.py index 835b50d6171..64f1a0ad67e 100644 --- a/extra_tests/snippets/stdlib_imp.py +++ b/extra_tests/snippets/stdlib_imp.py @@ -1,6 +1,8 @@ import _imp import time as import_time +from testutils import assert_raises + assert _imp.is_builtin("time") == True assert _imp.is_builtin("os") == False assert _imp.is_builtin("not existing module") == False @@ -29,3 +31,8 @@ def __init__(self, name): hello = _imp.init_frozen("__hello__") assert hello.initialized == True + +# withdata is keyword-only +with assert_raises(TypeError): + _imp.find_frozen("x", True) +assert _imp.find_frozen("_this_module_does_not_exist_") is None diff --git a/extra_tests/snippets/stdlib_itertools.py b/extra_tests/snippets/stdlib_itertools.py index ce7a494713a..029d0d4229a 100644 --- a/extra_tests/snippets/stdlib_itertools.py +++ b/extra_tests/snippets/stdlib_itertools.py @@ -524,3 +524,19 @@ def __iter__(self): assert next(it) == (2, None) with assert_raises(StopIteration): next(it) + +# r is an arbitrary Python int: one too large for an index must raise +# OverflowError, and a representable one that cannot be allocated must raise +# MemoryError. +for factory in ( + itertools.combinations, + itertools.combinations_with_replacement, + itertools.permutations, +): + with assert_raises(OverflowError): + factory(range(5), 2**64) + +with assert_raises(MemoryError): + itertools.combinations(range(5), 2**44) +with assert_raises(MemoryError): + itertools.combinations_with_replacement(range(5), 2**44) diff --git a/extra_tests/snippets/stdlib_lzma.py b/extra_tests/snippets/stdlib_lzma.py new file mode 100644 index 00000000000..5ebce3c7fb1 --- /dev/null +++ b/extra_tests/snippets/stdlib_lzma.py @@ -0,0 +1,22 @@ +import itertools +import lzma + +from testutils import assert_raises + +# A raw-format compressor needs the filter chain's length before it can build +# it, so a filter argument that is not a sequence has to be rejected instead of +# being drained. +with assert_raises(TypeError): + lzma.LZMACompressor( + format=lzma.FORMAT_RAW, + filters=({"id": lzma.FILTER_LZMA2} for _ in itertools.count()), + ) + +compressor = lzma.LZMACompressor( + format=lzma.FORMAT_RAW, filters=[{"id": lzma.FILTER_LZMA2}] +) +compressed = compressor.compress(b"data") + compressor.flush() +decompressor = lzma.LZMADecompressor( + format=lzma.FORMAT_RAW, filters=[{"id": lzma.FILTER_LZMA2}] +) +assert decompressor.decompress(compressed) == b"data" diff --git a/extra_tests/snippets/stdlib_math.py b/extra_tests/snippets/stdlib_math.py index a6bb0099c05..bc8673797c0 100644 --- a/extra_tests/snippets/stdlib_math.py +++ b/extra_tests/snippets/stdlib_math.py @@ -1,3 +1,4 @@ +import itertools import math from testutils import assert_raises, skip_if_unsupported @@ -311,3 +312,9 @@ def assertAllNotClose(examples, *args, **kwargs): assert math.fmod(0.0, NINF) == 0.0 assert math.gamma(1) == 1.0 + +# sumprod compares the two lengths as it goes; it must not drain either +# argument first. +assert_raises(ValueError, lambda: math.sumprod(itertools.count(), [1, 2, 3])) +assert_raises(ValueError, lambda: math.sumprod([1, 2, 3], itertools.count())) +assert math.sumprod(iter([1, 2, 3]), iter([4, 5, 6])) == 32 diff --git a/extra_tests/snippets/stdlib_mmap.py b/extra_tests/snippets/stdlib_mmap.py index 3a2b139a333..2dee29e6bab 100644 --- a/extra_tests/snippets/stdlib_mmap.py +++ b/extra_tests/snippets/stdlib_mmap.py @@ -1,6 +1,19 @@ import mmap +from testutils import assert_raises + mapped = mmap.mmap(-1, 1) assert mapped.seekable() mapped.close() assert mapped.seekable() + +mapped = mmap.mmap(-1, 10) +# an inverted range finds nothing rather than being subtracted into a huge one +assert mapped.find(b"x", 5, 2) == -1 +assert mapped.rfind(b"x", 5, 2) == -1 +# both offsets are bounds-checked before anything is copied +with assert_raises(ValueError): + mapped.move(20, 0, 1) +with assert_raises(ValueError): + mapped.move(0, 20, 1) +mapped.close() diff --git a/extra_tests/snippets/stdlib_os.py b/extra_tests/snippets/stdlib_os.py index d00924e10f2..a1f40ef4c45 100644 --- a/extra_tests/snippets/stdlib_os.py +++ b/extra_tests/snippets/stdlib_os.py @@ -1,3 +1,4 @@ +import itertools import os import stat import sys @@ -528,3 +529,19 @@ def __exit__(self, exc_type, exc_val, exc_tb): assert os.access("nonexistent_file_12345", os.W_OK) is False assert os.access("README.md", os.F_OK) is True assert os.access("README.md", os.R_OK) is True + +# argv and the group list are sequences; an arbitrary iterable must be rejected +# rather than drained. +if hasattr(os, "posix_spawn"): + with assert_raises(TypeError): + os.posix_spawn("/bin/true", map(str, itertools.count()), os.environ) +if hasattr(os, "setgroups"): + with assert_raises(TypeError): + os.setgroups(itertools.count()) + +# The optional second argument fills the fields past the visible ones, and the +# getters must not index past what __new__ stored. +assert os.stat_result(tuple(range(10))).st_atime == 7 +assert os.stat_result(tuple(range(10)), {"st_atime": 1.5}).st_atime == 1.5 +with assert_raises(TypeError): + os.stat_result(tuple(range(10)), ["st_atime"]) diff --git a/extra_tests/snippets/stdlib_pwd.py b/extra_tests/snippets/stdlib_pwd.py index c3aeb7c8703..6229f631c91 100644 --- a/extra_tests/snippets/stdlib_pwd.py +++ b/extra_tests/snippets/stdlib_pwd.py @@ -12,3 +12,7 @@ fake_name = "fake_user" while pwd.getpwnam(fake_name): fake_name += "1" + +# The field getters must not index a struct sequence that __new__ never filled. +with assert_raises(TypeError): + pwd.struct_passwd() diff --git a/extra_tests/snippets/stdlib_sys.py b/extra_tests/snippets/stdlib_sys.py index 155fc905a73..9dba301fb01 100644 --- a/extra_tests/snippets/stdlib_sys.py +++ b/extra_tests/snippets/stdlib_sys.py @@ -1,6 +1,7 @@ import os import subprocess import sys +import warnings from testutils import assert_raises @@ -158,3 +159,18 @@ def test_getframemodulename(): test_getframemodulename.__module__ = "awesome_module" assert test_getframemodulename() == "awesome_module" + +# An unimportable $PYTHONBREAKPOINT warns, and the hook has to survive that +# warning being turned into an exception. +saved_breakpoint_env = os.environ.get("PYTHONBREAKPOINT") +os.environ["PYTHONBREAKPOINT"] = "nonexistent_xyz.foo" +try: + with warnings.catch_warnings(): + warnings.simplefilter("error") + with assert_raises(RuntimeWarning): + sys.breakpointhook() +finally: + if saved_breakpoint_env is None: + del os.environ["PYTHONBREAKPOINT"] + else: + os.environ["PYTHONBREAKPOINT"] = saved_breakpoint_env diff --git a/extra_tests/snippets/stdlib_threading_itertools_cycle.py b/extra_tests/snippets/stdlib_threading_itertools_cycle.py new file mode 100644 index 00000000000..b50a31b2443 --- /dev/null +++ b/extra_tests/snippets/stdlib_threading_itertools_cycle.py @@ -0,0 +1,26 @@ +"""Stress itertools.cycle from several threads at once. + +cycle() advances its index and wraps it back to zero when it reaches the end of +the saved items. Doing that in two separate steps lets another thread observe +the index past the end and read out of bounds, so the update has to be a single +atomic step. +""" + +import itertools +import threading + +shared_cycle = itertools.cycle([1, 2, 3]) + + +def spin(): + for _ in range(20000): + next(shared_cycle) + + +threads = [threading.Thread(target=spin) for _ in range(4)] +for t in threads: + t.start() +for t in threads: + t.join() + +print("ok") diff --git a/extra_tests/snippets/stdlib_threading_set_repr.py b/extra_tests/snippets/stdlib_threading_set_repr.py new file mode 100644 index 00000000000..e2ce2d94357 --- /dev/null +++ b/extra_tests/snippets/stdlib_threading_set_repr.py @@ -0,0 +1,44 @@ +"""Stress set repr against concurrent mutation. + +repr() checks that the set is non-empty and then reads its first element. The +two steps are separate, so another thread can empty the set in between; the +read has to cope with that rather than trusting the earlier check. + +Threads that observe a mutation mid-iteration raise RuntimeError, which is a +legitimate outcome here; a regression shows up as a crash instead. +""" + +import threading + +shared_set = {1, 2, 3, 4, 5} +stop = False + + +def mutate(): + while not stop: + try: + shared_set.clear() + shared_set.update({1, 2, 3}) + except RuntimeError: # changed size during iteration + pass + + +def read(): + for _ in range(20000): + try: + repr(shared_set) + except RuntimeError: # changed size during iteration + pass + + +mutators = [threading.Thread(target=mutate) for _ in range(2)] +readers = [threading.Thread(target=read) for _ in range(2)] +for t in mutators + readers: + t.start() +for t in readers: + t.join() +stop = True +for t in mutators: + t.join() + +print("ok") diff --git a/extra_tests/snippets/stdlib_time.py b/extra_tests/snippets/stdlib_time.py index 68ceab89521..b74d5bbc638 100644 --- a/extra_tests/snippets/stdlib_time.py +++ b/extra_tests/snippets/stdlib_time.py @@ -82,3 +82,16 @@ assert monotonic_elapsed >= 0.01 assert perf_elapsed >= 0.01 + +# The optional second argument fills the fields that are not part of the +# sequence. +fields = (2024, 1, 2, 3, 4, 5, 6, 7, 0) +assert time.struct_time(fields).tm_zone is None +assert time.struct_time(fields, {"tm_zone": "UTC"}).tm_zone == "UTC" +assert time.struct_time(fields, {"tm_gmtoff": 60}).tm_gmtoff == 60 +try: + time.struct_time(fields, ["tm_zone", "UTC"]) +except TypeError: + pass +else: + assert False, "struct_time accepted a non-dict second argument" diff --git a/extra_tests/snippets/stdlib_traceback.py b/extra_tests/snippets/stdlib_traceback.py index c2cc5773dbc..b1b11a75503 100644 --- a/extra_tests/snippets/stdlib_traceback.py +++ b/extra_tests/snippets/stdlib_traceback.py @@ -1,5 +1,9 @@ +import itertools import traceback +import _suggestions +from testutils import assert_raises + try: 1 / 0 except ZeroDivisionError as ex: @@ -25,3 +29,10 @@ except ZeroDivisionError as ex2: tb = traceback.extract_tb(ex2.__traceback__) assert len(tb) == 1 + +# The candidate list backing "Did you mean" suggestions is a list; an arbitrary +# iterable must be rejected rather than drained. + +with assert_raises(TypeError): + _suggestions._generate_suggestions(itertools.count(), "x") +assert _suggestions._generate_suggestions(["value"], "valu") == "value" diff --git a/extra_tests/snippets/stdlib_types.py b/extra_tests/snippets/stdlib_types.py index cdecf12dd2b..335069811a8 100644 --- a/extra_tests/snippets/stdlib_types.py +++ b/extra_tests/snippets/stdlib_types.py @@ -1,5 +1,6 @@ import _ast import platform +import sys import types from testutils import assert_raises @@ -34,3 +35,26 @@ def _run_missing_type_params_regression(): _run_missing_type_params_regression() + +if sys.implementation.name == "rustpython": + # __parameters__ is computed when the alias is built, and the walk descends + # into every list and tuple argument, so a self-referential or deeply + # nested argument must be caught. CPython, which also runs this snippet, + # does not walk into plain lists at all. + self_referential = [] + self_referential.append(self_referential) + with assert_raises(RecursionError): + list[self_referential] + + nested = [0] + for _ in range(sys.getrecursionlimit() * 2): + nested = [nested] + with assert_raises(RecursionError): + list[nested] + + # hashing an alias walks the same shape + deep_alias = int + for _ in range(sys.getrecursionlimit() * 2): + deep_alias = list[deep_alias] + with assert_raises(RecursionError): + hash(deep_alias) diff --git a/extra_tests/snippets/stdlib_typing.py b/extra_tests/snippets/stdlib_typing.py index 07348945842..98d368c02cd 100644 --- a/extra_tests/snippets/stdlib_typing.py +++ b/extra_tests/snippets/stdlib_typing.py @@ -1,6 +1,9 @@ from collections.abc import Awaitable, Callable from typing import TypeVar +import _typing +from testutils import assert_raises + T = TypeVar("T") @@ -35,3 +38,10 @@ def __init__( def method(self, value: Union[int, float]) -> Union[str, bytes]: return str(value) + + +# _idfunc takes exactly one argument, checked before the argument is read. + +assert _typing._idfunc(1) == 1 +with assert_raises(TypeError): + _typing._idfunc() From fd7d107c75f2225c5f2a99e1c97383fed02149ea Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:30:51 +0900 Subject: [PATCH 292/351] marshal: round-trip code constants through the runtime bag (#8516) * Keep abnormal marshal loop test aligned with CPython Assisted-by: OpenAI Codex:gpt-5.5 * marshal: retain runtime code constants while loading Decode code fields through the runtime MarshalBag so co_consts values that do not fit the compiler constant enum remain available to VM code wrappers while the compiler table receives shape placeholders. Assisted-by: OpenAI Codex:GPT-5.4 * marshal: write and read co_consts through the runtime bag serialize_code gains a serialize_code_with variant that writes each co_consts entry through a caller-supplied writer; serialize_code keeps the BorrowedConstant writer as its default. The VM writer passes its own write_object_depth, so a code constant that Literal holds but BorrowedConstant cannot describe reaches the stream instead of panicking in borrow_obj_constant, and a constant shared with the enclosing object takes an entry in the writer's reference table. PyMarshalBag implements constant_ref_from_value, bytes_from_value, str_from_value and tuple_elements_from_value, which deserialize_code_value_inner requires to decode code fields through the runtime bag. Assisted-by: Claude --- Lib/test/test_marshal.py | 14 +- crates/compiler-core/src/marshal.rs | 224 +++++++++++++++++++++++-- crates/vm/src/stdlib/marshal.rs | 30 +++- extra_tests/snippets/stdlib_marshal.py | 21 +++ 4 files changed, 267 insertions(+), 22 deletions(-) diff --git a/Lib/test/test_marshal.py b/Lib/test/test_marshal.py index 1f04a7f697e..7d2bedf77ab 100644 --- a/Lib/test/test_marshal.py +++ b/Lib/test/test_marshal.py @@ -410,13 +410,13 @@ def test_loads_abnormal_reference_loops(self): self.assertIsInstance(a[0], dict) self.assertIs(a[0][None], a) - # Direct self-reference which cannot be created in Python. CPython - # leaves this disabled because its reference counting cannot collect - # the resulting cycle; RustPython's tracing collector can. - data = b'\xa8\x01\x00\x00\x00r\x00\x00\x00\x00' # (,) - a = marshal.loads(data) - self.assertIsInstance(a, tuple) - self.assertIs(a[0], a) + # Direct self-reference which cannot be created in Python. + # This creates a reference loop which cannot be collected. + if False: + data = b'\xa8\x01\x00\x00\x00r\x00\x00\x00\x00' # (,) + a = marshal.loads(data) + self.assertIsInstance(a, tuple) + self.assertIs(a[0], a) # Direct self-references which cannot be created in Python # because of unhashability. diff --git a/crates/compiler-core/src/marshal.rs b/crates/compiler-core/src/marshal.rs index dd5d4f2cddb..46e0047941c 100644 --- a/crates/compiler-core/src/marshal.rs +++ b/crates/compiler-core/src/marshal.rs @@ -555,6 +555,18 @@ pub trait MarshalBag: Copy { code: CodeObject<::Constant>, ) -> Self::Value; + /// Construct a runtime code object while retaining the exact values read + /// from ``co_consts``. Compiler bags ignore this second channel; runtime + /// bags use it for marshalable values (lists, dicts, sets, recursive + /// containers) that their compiler constant representation cannot hold. + fn make_code_with_constants( + &self, + code: CodeObject<::Constant>, + _constants: Vec, + ) -> Self::Value { + self.make_code(code) + } + fn make_stop_iter(&self) -> Result; fn make_list(&self, it: impl Iterator) -> Result; @@ -630,6 +642,30 @@ pub trait MarshalBag: Copy { ) -> Option<::Constant> { None } + + /// Convert a runtime constant to the compiler-side shape stored in + /// ``CodeObject``. Runtime implementations may return a semantically + /// unused placeholder when the exact value is carried by + /// `make_code_with_constants` instead. + fn code_constant_from_value( + &self, + value: &Self::Value, + ) -> Result<::Constant> { + self.constant_ref_from_value(value) + .ok_or(MarshalError::BadType) + } + + fn bytes_from_value(&self, _value: &Self::Value) -> Option> { + None + } + + fn str_from_value(&self, _value: &Self::Value) -> Option { + None + } + + fn tuple_elements_from_value(&self, _value: &Self::Value) -> Option> { + None + } } impl MarshalBag for Bag { @@ -731,6 +767,27 @@ impl MarshalBag for Bag { ) -> Option<::Constant> { Some(value.clone()) } + + fn bytes_from_value(&self, value: &Self::Value) -> Option> { + match value.borrow_constant() { + BorrowedConstant::Bytes { value } => Some(value.to_vec()), + _ => None, + } + } + + fn str_from_value(&self, value: &Self::Value) -> Option { + match value.borrow_constant() { + BorrowedConstant::Str { value } => Some(value.to_string_lossy().into_owned()), + _ => None, + } + } + + fn tuple_elements_from_value(&self, value: &Self::Value) -> Option> { + match value.borrow_constant() { + BorrowedConstant::Tuple { elements } => Some(elements.to_vec()), + _ => None, + } + } } pub const MAX_MARSHAL_STACK_DEPTH: usize = 2000; @@ -789,20 +846,8 @@ fn deserialize_value_after_header( }; let typ = Type::try_from(type_code)?; - // CPython's r_object() uses one global ref table: TYPE_CODE reserves its - // slot before reading code fields, and those fields may use later TYPE_REF - // indexes. Keep the same indexes even when Bag::Value and Constant differ. let value = if matches!(typ, Type::Code) { - let mut inner_refs: Vec::Constant>> = refs - .iter() - .map(|value| { - value - .as_ref() - .and_then(|value| bag.constant_ref_from_value(value)) - }) - .collect(); - let code = deserialize_code_inner(rdr, bag.constant_bag(), depth - 1, &mut inner_refs)?; - bag.make_code(code) + deserialize_code_value_inner(rdr, bag, depth - 1, refs)? } else { deserialize_value_typed(rdr, bag, depth, refs, typ, slot)? }; @@ -813,6 +858,137 @@ fn deserialize_value_after_header( Ok(value) } +/// Decode a code object through the runtime bag. CPython's marshal reader +/// keeps one reference table for the code fields and `co_consts`; using +/// `Bag::Value` here preserves that index space and lets runtime-only +/// constants survive alongside the compiler representation. +fn deserialize_code_value_inner( + rdr: &mut R, + bag: Bag, + depth: usize, + refs: &mut Vec>, +) -> Result { + if depth == 0 { + return Err(MarshalError::InvalidBytecode); + } + let arg_count = rdr.read_u32()?; + let posonlyarg_count = rdr.read_u32()?; + let kwonlyarg_count = rdr.read_u32()?; + let max_stackdepth = rdr.read_u32()?; + let flags = CodeFlags::from_bits_truncate(rdr.read_u32()?); + let child_depth = depth - 1; + + let code_value = deserialize_value_depth(rdr, bag, child_depth, refs)?; + let code_bytes = bag + .bytes_from_value(&code_value) + .ok_or(MarshalError::BadType)?; + + let consts_value = deserialize_value_depth(rdr, bag, child_depth, refs)?; + let constant_values = bag + .tuple_elements_from_value(&consts_value) + .ok_or(MarshalError::BadType)?; + let constants = constant_values + .iter() + .map(|value| bag.code_constant_from_value(value)) + .collect::>>()? + .into_iter() + .collect(); + + let read_strings = + |rdr: &mut R, refs: &mut Vec>| -> Result> { + let tuple = deserialize_value_depth(rdr, bag, child_depth, refs)?; + bag.tuple_elements_from_value(&tuple) + .ok_or(MarshalError::BadType)? + .iter() + .map(|value| bag.str_from_value(value).ok_or(MarshalError::BadType)) + .collect() + }; + let names_raw = read_strings(rdr, refs)?; + let localsplusnames = read_strings(rdr, refs)?; + + let kinds_value = deserialize_value_depth(rdr, bag, child_depth, refs)?; + let localspluskinds = bag + .bytes_from_value(&kinds_value) + .ok_or(MarshalError::BadType)?; + + let read_string = + |rdr: &mut R, refs: &mut Vec>| -> Result { + let value = deserialize_value_depth(rdr, bag, child_depth, refs)?; + bag.str_from_value(&value).ok_or(MarshalError::BadType) + }; + let source_path_raw = read_string(rdr, refs)?; + let obj_name_raw = read_string(rdr, refs)?; + let qualname_raw = read_string(rdr, refs)?; + + let first_line_raw = rdr.read_u32()? as i32; + let first_line_number = if first_line_raw > 0 { + OneIndexed::new(first_line_raw as usize) + } else { + None + }; + let linetable_value = deserialize_value_depth(rdr, bag, child_depth, refs)?; + let linetable = bag + .bytes_from_value(&linetable_value) + .ok_or(MarshalError::BadType)? + .into_boxed_slice(); + let exceptiontable_value = deserialize_value_depth(rdr, bag, child_depth, refs)?; + let exceptiontable = bag + .bytes_from_value(&exceptiontable_value) + .ok_or(MarshalError::BadType)? + .into_boxed_slice(); + + let lp = split_localplus( + &localsplusnames + .iter() + .map(|s| s.as_str()) + .collect::>(), + &localspluskinds, + arg_count, + kwonlyarg_count, + flags, + )?; + let instructions = CodeUnits::try_from(code_bytes.as_slice())?; + let locations = linetable_to_locations(&linetable, first_line_raw, instructions.len()); + let constant_bag = bag.constant_bag(); + let code = CodeObject { + instructions, + locations, + flags, + posonlyarg_count, + arg_count, + kwonlyarg_count, + source_path: constant_bag.make_name(&source_path_raw), + first_line_number, + max_stackdepth, + obj_name: constant_bag.make_name(&obj_name_raw), + qualname: constant_bag.make_name(&qualname_raw), + constants, + names: names_raw + .iter() + .map(|name| constant_bag.make_name(name)) + .collect(), + varnames: lp + .varnames + .iter() + .map(|name| constant_bag.make_name(name)) + .collect(), + cellvars: lp + .cellvars + .iter() + .map(|name| constant_bag.make_name(name)) + .collect(), + freevars: lp + .freevars + .iter() + .map(|name| constant_bag.make_name(name)) + .collect(), + localspluskinds: localspluskinds.into_boxed_slice(), + linetable, + exceptiontable, + }; + Ok(bag.make_code_with_constants(code, constant_values)) +} + fn deserialize_value_typed( rdr: &mut R, bag: Bag, @@ -1222,6 +1398,25 @@ pub fn serialize_value( /// Split varnames/cellvars/freevars are reassembled into /// co_localsplusnames/co_localspluskinds. pub fn serialize_code(buf: &mut W, code: &CodeObject) { + serialize_code_with(buf, code, |buf, constant| { + serialize_value(buf, constant.borrow_constant().into()).unwrap_or_else(|x| match x {}); + Ok::<(), core::convert::Infallible>(()) + }) + .unwrap_or_else(|x| match x {}) +} + +/// Serialize a code object, writing each `co_consts` entry through +/// `write_constant`. +/// +/// A runtime caller passes its own object writer so that values its constant +/// representation carries but `BorrowedConstant` cannot describe — lists, +/// dicts, sets — reach the stream, and so a constant shared with the enclosing +/// object keeps its entry in that writer's reference table. +pub fn serialize_code_with( + buf: &mut W, + code: &CodeObject, + mut write_constant: impl FnMut(&mut W, &C) -> core::result::Result<(), E>, +) -> core::result::Result<(), E> { // 1–5: scalar fields buf.write_u32(code.arg_count); buf.write_u32(code.posonlyarg_count); @@ -1238,7 +1433,7 @@ pub fn serialize_code(buf: &mut W, code: &CodeObject) buf.write_u8(Type::Tuple as u8); write_len(buf, code.constants.len()); for constant in &*code.constants { - serialize_value(buf, constant.borrow_constant().into()).unwrap_or_else(|x| match x {}) + write_constant(buf, constant)?; } // 8: co_names (tuple of strings) @@ -1281,6 +1476,7 @@ pub fn serialize_code(buf: &mut W, code: &CodeObject) // 16: co_exceptiontable buf.write_u8(Type::Bytes as u8); write_vec(buf, &code.exceptiontable); + Ok(()) } fn write_marshal_str(buf: &mut W, s: &str) { diff --git a/crates/vm/src/stdlib/marshal.rs b/crates/vm/src/stdlib/marshal.rs index a4665d0f5b6..3c2ab581748 100644 --- a/crates/vm/src/stdlib/marshal.rs +++ b/crates/vm/src/stdlib/marshal.rs @@ -324,7 +324,14 @@ mod decl { } } else if let Some(co) = obj.downcast_ref::() { buf.write_u8(b'c'); - marshal::serialize_code(buf, &co.code); + // `Literal` holds the exact object a constant was built from, so + // route `co_consts` back through the object writer: it reaches the + // values `BorrowedConstant` cannot describe and shares the one + // reference table the reader indexes against. + marshal::serialize_code_with(buf, &co.code, |buf, constant| { + let constant = PyObjectRef::from(constant.clone()); + write_object_depth(buf, &constant, refs, version, vm, depth - 1) + })?; } else if let Some(sl) = obj.downcast_ref::() { if version < 5 { return Err(vm.new_value_error("unmarshallable object")); @@ -570,6 +577,27 @@ mod decl { fn constant_bag(self) -> Self::ConstantBag { PyVmBag(self.vm) } + /// `Literal` wraps any object, so a decoded `co_consts` entry is + /// already its own compiler-side constant — no placeholder is needed + /// and `make_code_with_constants` keeps the default. + fn constant_ref_from_value(&self, value: &Self::Value) -> Option { + Some(Literal::from(value.clone())) + } + fn bytes_from_value(&self, value: &Self::Value) -> Option> { + value + .downcast_ref::() + .map(|bytes| bytes.as_bytes().to_vec()) + } + fn str_from_value(&self, value: &Self::Value) -> Option { + value + .downcast_ref::() + .map(|str| str.to_string_lossy().into_owned()) + } + fn tuple_elements_from_value(&self, value: &Self::Value) -> Option> { + value + .downcast_ref::() + .map(|tuple| tuple.as_slice().to_vec()) + } } fn deserialize_value( diff --git a/extra_tests/snippets/stdlib_marshal.py b/extra_tests/snippets/stdlib_marshal.py index db843ff65d5..8881d3e0a7b 100644 --- a/extra_tests/snippets/stdlib_marshal.py +++ b/extra_tests/snippets/stdlib_marshal.py @@ -74,6 +74,27 @@ def test_roundtrip(self): assert eval(loaded) == eval(orig) + def test_roundtrip_non_constant_co_consts(self): + # `code.replace` accepts any marshalable object, including values the + # compiler constant representation cannot describe. + orig = compile("1 + 1", "", "eval").replace( + co_consts=([1, 2], {"a": 3}, {4, 5}, 6) + ) + + loaded = marshal.loads(marshal.dumps(orig)) + + self.assertEqual(loaded.co_consts, ([1, 2], {"a": 3}, {4, 5}, 6)) + + def test_roundtrip_shared_co_const(self): + # A constant shared with the enclosing object is written once and both + # readers resolve the same reference. + shared = ["shared"] + orig = compile("1 + 1", "", "eval").replace(co_consts=(shared,)) + + loaded_code, loaded_shared = marshal.loads(marshal.dumps((orig, shared))) + + self.assertIs(loaded_code.co_consts[0], loaded_shared) + if __name__ == "__main__": unittest.main() From 2690c16fdceedf1c248a64a360de90e3ffac3dfc Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:58:42 +0900 Subject: [PATCH 293/351] marshal: refuse a back-reference to an object still being written (#8519) `w_ref` marks a code or slice entry incomplete until `w_complete`, because the reader rebuilds both from their fields and a `TYPE_REF` issued while those fields are still on the wire names an object that does not exist yet. `WriterRefTable` carries that marker and `write_object_depth` raises `cannot marshal recursion objects` instead of emitting the reference. `test_reference_loop_code`, `test_unmarshallable` and `test_reference_loop_slice` lose their RustPython markers; `test_marshal` is 75 run, 16 skipped. Assisted-by: Claude --- Lib/test/test_marshal.py | 3 -- crates/vm/src/stdlib/marshal.rs | 68 ++++++++++++++++++++++++--------- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/Lib/test/test_marshal.py b/Lib/test/test_marshal.py index 7d2bedf77ab..4e5311cd0a2 100644 --- a/Lib/test/test_marshal.py +++ b/Lib/test/test_marshal.py @@ -353,7 +353,6 @@ def test_reference_loop_tuple(self): self.assertIsInstance(b[0], list) self.assertIs(b[0][0], b) - @unittest.skip("TODO: RUSTPYTHON; unexpected payload for constant python value") def test_reference_loop_code(self): def f(): return 1234.5 @@ -367,7 +366,6 @@ def f(): for v in range(marshal.version + 1): self.assertRaises(ValueError, marshal.dumps, code, v) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: ValueError not raised by dumps def test_reference_loop_slice(self): a = slice([], None) a.start.append(a) @@ -541,7 +539,6 @@ def test_deterministic_sets(self): _, dump_1, _ = assert_python_ok(*args, PYTHONHASHSEED="1") self.assertEqual(dump_0, dump_1) - @unittest.skip("TODO: RUSTPYTHON; unexpected payload for constant python value") def test_unmarshallable(self): # Check no crash after encountering unmarshallable objects. # See https://github.com/python/cpython/issues/106287. diff --git a/crates/vm/src/stdlib/marshal.rs b/crates/vm/src/stdlib/marshal.rs index 3c2ab581748..38891200b05 100644 --- a/crates/vm/src/stdlib/marshal.rs +++ b/crates/vm/src/stdlib/marshal.rs @@ -131,8 +131,15 @@ mod decl { Ok(PyBytes::from(buf)) } + struct WriterRefEntry { + idx: u32, + /// Set between `reserve` and `complete` for the object kinds whose + /// immutable representation cannot be rebuilt from a back-reference. + incomplete: bool, + } + struct WriterRefTable { - map: std::collections::HashMap, + map: std::collections::HashMap, next_idx: u32, } @@ -143,23 +150,35 @@ mod decl { next_idx: 0, } } - fn try_ref(&mut self, buf: &mut Vec, obj: &PyObjectRef) -> bool { + /// `w_ref`: write a back-reference to an object already in the table. + /// Reaching an entry that is still being written is a recursion the + /// reader could not rebuild, so it is an error rather than a `TYPE_REF`. + fn try_ref(&mut self, buf: &mut Vec, obj: &PyObjectRef) -> Result { use marshal::Write; - let id = obj.get_id(); - if let Some(&idx) = self.map.get(&id) { - buf.write_u8(b'r'); - buf.write_u32(idx); - true - } else { - false + let Some(entry) = self.map.get(&obj.get_id()) else { + return Ok(false); + }; + if entry.incomplete { + return Err(()); } + buf.write_u8(b'r'); + buf.write_u32(entry.idx); + Ok(true) } - fn reserve(&mut self, obj: &PyObjectRef) -> u32 { + fn reserve(&mut self, obj: &PyObjectRef, incomplete: bool) -> u32 { let idx = self.next_idx; - self.map.insert(obj.get_id(), idx); + self.map + .insert(obj.get_id(), WriterRefEntry { idx, incomplete }); self.next_idx += 1; idx } + /// `w_complete`: the object's contents are on the stream, so a later + /// occurrence may reference it. + fn complete(&mut self, obj: &PyObjectRef) { + if let Some(entry) = self.map.get_mut(&obj.get_id()) { + entry.incomplete = false; + } + } } fn write_object( @@ -199,16 +218,28 @@ mod decl { || obj.downcast_ref::().is_some(); // FLAG_REF: check if already written, otherwise reserve slot - if !is_singleton - && let Some(rt) = refs.as_mut() - && rt.try_ref(buf, obj) - { - return Ok(()); + if !is_singleton && let Some(rt) = refs.as_mut() { + match rt.try_ref(buf, obj) { + Ok(true) => return Ok(()), + Ok(false) => {} + Err(()) => { + return Err(vm.new_value_error(format!( + "cannot marshal recursion {} objects", + obj.class().name() + ))); + } + } } let type_pos = buf.len(); let use_ref = refs.is_some() && !is_singleton; + // A code or slice entry stays incomplete until its contents are + // written: the reader rebuilds both from their fields, so a + // back-reference issued while those fields are still being emitted + // would name an object that does not exist yet. + let requires_completion = obj.downcast_ref::().is_some() + || obj.downcast_ref::().is_some(); if use_ref { - refs.as_mut().unwrap().reserve(obj); + refs.as_mut().unwrap().reserve(obj, requires_completion); } if vm.is_none(obj) { @@ -366,6 +397,9 @@ mod decl { if use_ref { buf[type_pos] |= marshal::FLAG_REF; + if requires_completion { + refs.as_mut().unwrap().complete(obj); + } } Ok(()) } From d0baa1c5c1937c5dfed13a983c76fe6323d36572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=9A=A8=EC=A2=85?= Date: Fri, 14 Aug 2026 13:00:57 +0900 Subject: [PATCH 294/351] Preserve lone surrogates across WASM conversions (#8508) Assisted-by: Codex:GPT-5 --- crates/vm/src/py_serde.rs | 5 +- crates/wasm/src/convert.rs | 94 ++++++++++++++++++++++++++++--------- crates/wasm/src/vm_class.rs | 15 +++--- 3 files changed, 86 insertions(+), 28 deletions(-) diff --git a/crates/vm/src/py_serde.rs b/crates/vm/src/py_serde.rs index 50ea4422b16..0e8b70781cd 100644 --- a/crates/vm/src/py_serde.rs +++ b/crates/vm/src/py_serde.rs @@ -63,7 +63,10 @@ impl serde::Serialize for PyObjectSerializer<'_> { seq.end() }; if let Some(s) = self.pyobject.downcast_ref::() { - serializer.serialize_str(s.as_ref()) + serializer.serialize_str( + s.to_str() + .ok_or_else(|| serde::ser::Error::custom("str contains surrogates"))?, + ) } else if self.pyobject.fast_isinstance(self.vm.ctx.types.float_type) { serializer.serialize_f64(float::get_value(self.pyobject)) } else if self.pyobject.fast_isinstance(self.vm.ctx.types.bool_type) { diff --git a/crates/wasm/src/convert.rs b/crates/wasm/src/convert.rs index 3e07b27d4a0..9349c727942 100644 --- a/crates/wasm/src/convert.rs +++ b/crates/wasm/src/convert.rs @@ -2,10 +2,13 @@ use crate::js_module; use crate::vm_class::{WASMVirtualMachine, stored_vm_from_wasm}; -use js_sys::{Array, ArrayBuffer, Object, Promise, Reflect, SyntaxError, Uint8Array}; +use js_sys::{ + Array, ArrayBuffer, JsString, Map, Object, Promise, Reflect, SyntaxError, Uint8Array, +}; +use rustpython_common::wtf8::{Wtf8, Wtf8Buf}; use rustpython_vm::{ AsObject, Py, PyObjectRef, PyPayload, PyResult, TryFromBorrowedObject, VirtualMachine, - builtins::{PyBaseException, PyBaseExceptionRef}, + builtins::{PyBaseException, PyBaseExceptionRef, PyDict, PyList, PyStr, PyTuple}, compiler::{CompileError, ParseError, parser::LexicalErrorType, parser::ParseErrorType}, exceptions, function::{ArgBytesLike, FuncArgs}, @@ -13,6 +16,26 @@ use rustpython_vm::{ }; use wasm_bindgen::{JsCast, closure::Closure, prelude::*}; +pub(crate) fn js_string_to_wtf8(value: &JsString) -> Wtf8Buf { + Wtf8Buf::from_wide(&value.iter().collect::>()) +} + +fn wtf8_to_js_string(value: &Wtf8) -> JsString { + const CHUNK_SIZE: usize = 8192; + + if let Ok(value) = value.as_str() { + return value.into(); + } + + value + .encode_wide() + .collect::>() + .chunks(CHUNK_SIZE) + .map(JsString::from_char_code) + .collect::() + .join("") +} + #[wasm_bindgen(inline_js = r" export class PyError extends Error { constructor(info) { @@ -119,12 +142,9 @@ pub fn py_to_js(vm: &VirtualMachine, py_obj: PyObjectRef) -> JsValue { if let Some(ref kwargs) = kwargs { for pair in object_entries(kwargs) { let (key, val) = pair?; - py_func_args.kwargs.insert( - // JS strings coming in are UTF-16; go through Rust `String` - // (kwargs keys are now WTF-8, so convert String -> Wtf8Buf). - String::from(js_sys::JsString::from(key)).into(), - js_to_py(vm, val), - ); + py_func_args + .kwargs + .insert(js_string_to_wtf8(&key.into()), js_to_py(vm, val)); } } let result = py_obj.call(py_func_args, vm); @@ -151,17 +171,44 @@ pub fn py_to_js(vm: &VirtualMachine, py_obj: PyObjectRef) -> JsValue { } if let Ok(bytes) = ArgBytesLike::try_from_borrowed_object(vm, &py_obj) { - bytes.with_ref(|bytes| unsafe { + return bytes.with_ref(|bytes| unsafe { // `Uint8Array::view` is an `unsafe fn` because it provides // a direct view into the WASM linear memory; if you were to allocate // something with Rust that view would probably become invalid. It's safe // because we then copy the array using `Uint8Array::slice`. let view = Uint8Array::view(bytes); view.slice(0, bytes.len() as u32).into() - }) + }); + } + py_serde_to_js(vm, &py_obj).unwrap_or(JsValue::UNDEFINED) +} + +fn py_serde_to_js( + vm: &VirtualMachine, + py_obj: &PyObjectRef, +) -> Result { + if let Some(value) = py_obj.downcast_ref::() { + Ok(wtf8_to_js_string(value.as_wtf8()).into()) + } else if let Some(value) = py_obj.downcast_ref::() { + let array = Array::new(); + for item in value.borrow_vec().iter() { + array.push(&py_serde_to_js(vm, item)?); + } + Ok(array.into()) + } else if let Some(value) = py_obj.downcast_ref::() { + let array = Array::new(); + for item in value { + array.push(&py_serde_to_js(vm, item)?); + } + Ok(array.into()) + } else if let Some(value) = py_obj.downcast_ref::() { + let map = Map::new(); + for (key, value) in value { + map.set(&py_serde_to_js(vm, &key)?, &py_serde_to_js(vm, &value)?); + } + Ok(map.into()) } else { - py_serde::serialize(vm, &py_obj, &serde_wasm_bindgen::Serializer::new()) - .unwrap_or(JsValue::UNDEFINED) + py_serde::serialize(vm, py_obj, &serde_wasm_bindgen::Serializer::new()) } } @@ -199,6 +246,15 @@ pub fn js_to_py(vm: &VirtualMachine, js_val: JsValue) -> PyObjectRef { .map(|val| js_to_py(vm, val.expect("Iteration over array failed"))) .collect(); vm.ctx.new_list(elems).into() + } else if let Some(map) = js_val.dyn_ref::() { + let dict = vm.ctx.new_dict(); + for entry in map.entries() { + let entry = Array::from(&entry.expect("Iteration over map failed")); + let key = js_to_py(vm, entry.get(0)); + dict.set_item(&*key, js_to_py(vm, entry.get(1)), vm) + .unwrap(); + } + dict.into() } else if ArrayBuffer::is_view(&js_val) || js_val.is_instance_of::() { // unchecked_ref because if it's not an ArrayBuffer it could either be a TypedArray // or a DataView, but they all have a `buffer` property @@ -216,12 +272,8 @@ pub fn js_to_py(vm: &VirtualMachine, js_val: JsValue) -> PyObjectRef { for pair in object_entries(&Object::from(js_val)) { let (key, val) = pair.expect("iteration over object to not fail"); let py_val = js_to_py(vm, val); - dict.set_item( - String::from(js_sys::JsString::from(key)).as_str(), - py_val, - vm, - ) - .unwrap(); + dict.set_item(&*js_string_to_wtf8(&key.into()), py_val, vm) + .unwrap(); } dict.into() } @@ -232,9 +284,7 @@ pub fn js_to_py(vm: &VirtualMachine, js_val: JsValue) -> PyObjectRef { move |args: FuncArgs, vm: &VirtualMachine| -> PyResult { let this = Object::new(); for (k, v) in args.kwargs { - // WTF-8 -> JS string: lone surrogates in the key become U+FFFD - // (wasm-bindgen only accepts Rust `String`); acceptable at this boundary. - Reflect::set(&this, &k.to_string().into(), &py_to_js(vm, v)) + Reflect::set(&this, &wtf8_to_js_string(&k).into(), &py_to_js(vm, v)) .expect("property to be settable"); } let js_args = args @@ -253,6 +303,8 @@ pub fn js_to_py(vm: &VirtualMachine, js_val: JsValue) -> PyObjectRef { } else if js_val.is_undefined() { // Because `JSON.stringify(undefined)` returns undefined vm.ctx.none() + } else if js_val.is_string() { + vm.ctx.new_str(js_string_to_wtf8(&js_val.into())).into() } else { py_serde::deserialize(vm, serde_wasm_bindgen::Deserializer::from(js_val)) .unwrap_or_else(|_| vm.ctx.none()) diff --git a/crates/wasm/src/vm_class.rs b/crates/wasm/src/vm_class.rs index 5e09af0ee95..80f3ece1358 100644 --- a/crates/wasm/src/vm_class.rs +++ b/crates/wasm/src/vm_class.rs @@ -325,9 +325,12 @@ impl WASMVirtualMachine { if let Some(imports) = imports { for entry in convert::object_entries(&imports) { let (key, value) = entry?; - let key: String = Object::from(key).to_string().into(); attrs - .set_item(key.as_str(), convert::js_to_py(vm, value), vm) + .set_item( + &*convert::js_string_to_wtf8(&key.into()), + convert::js_to_py(vm, value), + vm, + ) .into_js(vm)?; } } @@ -356,10 +359,10 @@ impl WASMVirtualMachine { let py_module = vm.new_module(&name, vm.ctx.new_dict(), None); for entry in convert::object_entries(&module) { let (key, value) = entry?; - let key = Object::from(key).to_string(); - extend_module!(vm, &py_module, { - String::from(key) => convert::js_to_py(vm, value), - }); + let key = vm.ctx.new_str(convert::js_string_to_wtf8(&key.into())); + py_module + .set_attr(&key, convert::js_to_py(vm, value), vm) + .into_js(vm)?; } let sys_modules = vm.sys_module.get_attr("modules", vm).into_js(vm)?; From b109723242e8a98556f2b0d09f6b002278ef64a4 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:05:30 +0900 Subject: [PATCH 295/351] Fix the itertools.tee leak and race, the contextvars and generator races, _asyncio's InvalidStateError lookup, and _imp's frozen data (#8518) * _imp: read get_frozen_object's data as a whole marshal value The data argument went through deserialize_code(), which reads a code body without the type byte in front of it, so nothing marshal.dumps() produces was accepted. Read it with marshal.loads() and require a code object back. Assisted-by: Claude * _imp: bind find_frozen's arguments with FromArgs The arity check was done by hand on a FuncArgs. Take the arguments through a FromArgs struct instead, which makes withdata keyword-only, and fill in the data it asks for: the frozen encoding is not marshal, so the code is re-serialized into what get_frozen_object() reads back. Assisted-by: Claude * itertools: give tee's shared buffer a Python type The buffer was a PyRc, which is not a Python object, so the collector could not walk into it and any cycle running through a tee was uncollectable. Make it the _tee_dataobject type with a traverse, held by PyRef, and split the rest along the same lines: tee() is a function, _tee is the iterator type it builds, and _tee takes a single iterable rather than returning a tuple from __new__. _tee is weak-referenceable, tee() rejects a negative n with ValueError and reserves its tuple fallibly. test_itertools.test_tee passes now; its expectedFailure marker is removed. Assisted-by: Claude * contextvars: hold the shared context state under locks The variable map was a RefCell, the enter flag, the context index, the token's used flag and the variable hash were Cells, and each carried an unsafe impl Sync. A Context or ContextVar shared between threads overlapped their borrows and panicked. The map and the per-variable cache are now PyMutex, the flags and the index are atomics, entering a context is a compare_exchange, and the three unsafe impl Sync are gone. The cache also stopped being read through AtomicCell::as_ptr, which raced a concurrent store on a value holding a PyObjectRef. Values displaced from the map or the cache are dropped after the lock is released: __del__ can come straight back into the same context, and the locks are not reentrant. Assisted-by: Claude * generator: read the frame state under the running claim send(), send_none(), throw() and close() read `closed` and `frame.lasti()` before `running` was compare_exchanged, so the frame they went on to resume could be one another thread had already advanced. A resume that decided from `lasti() == 0` that the generator had not started pushes no value onto the value stack, and the code after the yield pops one, which underflows the stack. The compare_exchange now hands back a guard, taken before those reads and released after maybe_close(), so the generator is retired while it is still claimed. Assisted-by: Claude * itertools: claim a tee's position and its buffer as one step `_tee::next` read `index` and moved it on afterwards, and `_tee_dataobject::get_item` released `running` before the value it fetched from the source was cached. Two callers on one `_tee` then read the same index, hand out the same value twice and advance past a value that was never cached, which leaves `index` past `values.len()` and indexes the buffer out of bounds. Two callers at the same index on separate tees each fetch a value from the source, and one of the two is dropped without reaching a caller. Both claims now cover the read and the update. Assisted-by: Claude * _asyncio: require InvalidStateError to be a type new_invalid_state_error() called whatever `asyncio.exceptions.InvalidStateError` names and unwrapped the downcast of the result, so a future asked for a result it does not have panicked once that attribute was rebound to something that is not an exception: asyncio.InvalidStateError = lambda *args: 42 _asyncio.Future(loop=object()).result() The type is looked up the way get_cancelled_error_type() looks its own up, and raised with new_exception_msg; a lookup that does not produce an exception type falls back to RuntimeError, as the other arms already did. Assisted-by: Claude --- Lib/test/test_itertools.py | 1 - crates/stdlib/src/_asyncio.rs | 22 +-- crates/stdlib/src/contextvars.rs | 149 +++++++++--------- crates/vm/src/coroutine.rs | 111 ++++++++----- crates/vm/src/stdlib/_imp.rs | 51 +++--- crates/vm/src/stdlib/itertools.rs | 122 ++++++++------ extra_tests/snippets/stdlib_asyncio.py | 23 +++ extra_tests/snippets/stdlib_gc.py | 2 + extra_tests/snippets/stdlib_imp.py | 6 + .../snippets/stdlib_threading_contextvars.py | 72 +++++++++ .../snippets/stdlib_threading_generator.py | 95 +++++++++++ .../stdlib_threading_itertools_tee.py | 57 +++++++ 12 files changed, 521 insertions(+), 190 deletions(-) create mode 100644 extra_tests/snippets/stdlib_threading_contextvars.py create mode 100644 extra_tests/snippets/stdlib_threading_generator.py create mode 100644 extra_tests/snippets/stdlib_threading_itertools_tee.py diff --git a/Lib/test/test_itertools.py b/Lib/test/test_itertools.py index b91e3735d94..c1695690b72 100644 --- a/Lib/test/test_itertools.py +++ b/Lib/test/test_itertools.py @@ -1270,7 +1270,6 @@ def test_dropwhile(self): self.assertRaises(TypeError, next, dropwhile(10, [(4,5)])) self.assertRaises(ValueError, next, dropwhile(errfunc, [(4,5)])) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_tee(self): n = 200 diff --git a/crates/stdlib/src/_asyncio.rs b/crates/stdlib/src/_asyncio.rs index b311db4a315..9ad75fb8d69 100644 --- a/crates/stdlib/src/_asyncio.rs +++ b/crates/stdlib/src/_asyncio.rs @@ -2745,16 +2745,20 @@ pub(crate) mod _asyncio { } } + fn get_invalid_state_error_type(vm: &VirtualMachine) -> PyResult { + let module = vm.import("asyncio.exceptions", 0)?; + let exc_type = vm + .get_attribute_opt(module, vm.ctx.intern_str("InvalidStateError"))? + .ok_or_else(|| vm.new_attribute_error("InvalidStateError not found"))?; + exc_type + .downcast() + .map_err(|_| vm.new_type_error("InvalidStateError is not a type")) + } + fn new_invalid_state_error(vm: &VirtualMachine, msg: &str) -> PyBaseExceptionRef { - match vm.import("asyncio.exceptions", 0) { - Ok(module) => { - match vm.get_attribute_opt(module, vm.ctx.intern_str("InvalidStateError")) { - Ok(Some(exc_type)) => match exc_type.call((msg,), vm) { - Ok(exc) => exc.downcast().unwrap(), - Err(_) => vm.new_runtime_error(msg.to_string()), - }, - _ => vm.new_runtime_error(msg.to_string()), - } + match get_invalid_state_error_type(vm) { + Ok(invalid_state_error) => { + vm.new_exception_msg(invalid_state_error, msg.to_string().into()) } Err(_) => vm.new_runtime_error(msg.to_string()), } diff --git a/crates/stdlib/src/contextvars.rs b/crates/stdlib/src/contextvars.rs index 19fbcb8412f..e3823f6ac59 100644 --- a/crates/stdlib/src/contextvars.rs +++ b/crates/stdlib/src/contextvars.rs @@ -15,16 +15,16 @@ mod _contextvars { AsObject, Py, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, atomic_func, builtins::{PyGenericAlias, PyList, PyStrRef, PyType, PyTypeRef}, class::StaticType, - common::{hash::PyHash, lock::LazyLock, wtf8::Wtf8Buf}, + common::{ + hash::PyHash, + lock::{LazyLock, PyMutex}, + wtf8::Wtf8Buf, + }, function::{ArgCallable, FuncArgs, OptionalArg}, protocol::{PyMappingMethods, PySequenceMethods}, types::{AsMapping, AsSequence, Constructor, Hashable, Iterable, Representable}, }; - use core::{ - cell::{Cell, RefCell, UnsafeCell}, - sync::atomic::Ordering, - }; - use crossbeam_utils::atomic::AtomicCell; + use core::sync::atomic::{AtomicBool, AtomicI64, AtomicUsize, Ordering}; use indexmap::IndexMap; // TODO: Real hamt implementation @@ -33,7 +33,7 @@ mod _contextvars { #[pyclass(no_attr, name = "Hamt", module = "contextvars")] #[derive(Debug, PyPayload)] pub(crate) struct HamtObject { - hamt: RefCell, + hamt: PyMutex, } #[pyclass] @@ -42,23 +42,19 @@ mod _contextvars { impl Default for HamtObject { fn default() -> Self { Self { - hamt: RefCell::new(Hamt::default()), + hamt: PyMutex::new(Hamt::default()), } } } - unsafe impl Sync for HamtObject {} - #[derive(Debug)] struct ContextInner { - idx: Cell, + idx: AtomicUsize, vars: PyRef, // PyObject *ctx_weakreflist; - entered: Cell, + entered: AtomicBool, } - unsafe impl Sync for ContextInner {} - #[pyattr] #[pyclass(name = "Context")] #[derive(Debug, PyPayload)] @@ -71,23 +67,30 @@ mod _contextvars { fn empty(vm: &VirtualMachine) -> Self { Self { inner: ContextInner { - idx: Cell::new(usize::MAX), + idx: AtomicUsize::new(usize::MAX), vars: HamtObject::default().into_ref(&vm.ctx), - entered: Cell::new(false), + entered: AtomicBool::new(false), }, } } - fn borrow_vars(&self) -> impl core::ops::Deref + '_ { - self.inner.vars.hamt.borrow() + fn borrow_vars(&self) -> impl core::ops::DerefMut + '_ { + self.inner.vars.hamt.lock() } fn borrow_vars_mut(&self) -> impl core::ops::DerefMut + '_ { - self.inner.vars.hamt.borrow_mut() + self.inner.vars.hamt.lock() } fn enter(zelf: &Py, vm: &VirtualMachine) -> PyResult<()> { - if zelf.inner.entered.get() { + // A context is entered by one thread at a time, so the check and the + // claim have to be a single step. + if zelf + .inner + .entered + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { return Err(vm.new_runtime_error(format!( "cannot enter context: {} is already entered", zelf.as_object().repr(vm)? @@ -95,16 +98,15 @@ mod _contextvars { } super::CONTEXTS.with_borrow_mut(|ctxs| { - zelf.inner.idx.set(ctxs.len()); + zelf.inner.idx.store(ctxs.len(), Ordering::Relaxed); ctxs.push(zelf.to_owned()); }); - zelf.inner.entered.set(true); Ok(()) } fn exit(zelf: &Py, vm: &VirtualMachine) -> PyResult<()> { - if !zelf.inner.entered.get() { + if !zelf.inner.entered.load(Ordering::Acquire) { return Err(vm.new_runtime_error(format!( "cannot exit context: {} is not entered", zelf.as_object().repr(vm)? @@ -120,7 +122,7 @@ mod _contextvars { ) }) })?; - zelf.inner.entered.set(false); + zelf.inner.entered.store(false, Ordering::Release); Ok(()) } @@ -131,8 +133,8 @@ mod _contextvars { ctx.clone() } else { let ctx = Self::empty(vm); - ctx.inner.idx.set(0); - ctx.inner.entered.set(true); + ctx.inner.idx.store(0, Ordering::Relaxed); + ctx.inner.entered.store(true, Ordering::Release); let ctx = ctx.into_ref(&vm.ctx); ctxs.push(ctx); ctxs[0].clone() @@ -170,13 +172,13 @@ mod _contextvars { fn copy(&self, vm: &VirtualMachine) -> Self { // Deep copy the vars - clone the underlying Hamt data, not just the PyRef let vars_copy = HamtObject { - hamt: RefCell::new(self.inner.vars.hamt.borrow().clone()), + hamt: PyMutex::new(self.inner.vars.hamt.lock().clone()), }; Self { inner: ContextInner { - idx: Cell::new(usize::MAX), + idx: AtomicUsize::new(usize::MAX), vars: vars_copy.into_ref(&vm.ctx), - entered: Cell::new(false), + entered: AtomicBool::new(false), }, } } @@ -186,11 +188,8 @@ mod _contextvars { var: PyRef, vm: &VirtualMachine, ) -> PyResult { - let vars = self.borrow_vars(); - let item = vars - .get(&*var) - .ok_or_else(|| vm.new_key_error(var.into()))?; - Ok(item.to_owned()) + let item = self.borrow_vars().get(&*var).map(|item| item.to_owned()); + item.ok_or_else(|| vm.new_key_error(var.into())) } fn __len__(&self) -> usize { @@ -290,11 +289,11 @@ mod _contextvars { name: String, default: Option, #[pytraverse(skip)] - cached: AtomicCell>, + cached: PyMutex>, #[pytraverse(skip)] - cached_id: core::sync::atomic::AtomicUsize, // cached_tsid in CPython + cached_id: AtomicUsize, // cached_tsid in CPython #[pytraverse(skip)] - hash: UnsafeCell, + hash: AtomicI64, } impl core::fmt::Debug for ContextVar { @@ -303,8 +302,6 @@ mod _contextvars { } } - unsafe impl Sync for ContextVar {} - impl PartialEq for ContextVar { fn eq(&self, other: &Self) -> bool { core::ptr::eq(self, other) @@ -320,12 +317,15 @@ mod _contextvars { impl ContextVar { fn delete(zelf: &Py, vm: &VirtualMachine) -> PyResult<()> { - zelf.cached.store(None); + let cached = zelf.cached.lock().take(); + drop(cached); let ctx = PyContext::current(vm); - let mut vars = ctx.borrow_vars_mut(); - if vars.swap_remove(zelf).is_none() { + let removed = ctx.borrow_vars_mut().swap_remove(zelf); + let existed = removed.is_some(); + drop(removed); + if !existed { // TODO: // PyErr_SetObject(PyExc_LookupError, (PyObject *)var); return Err(vm.new_lookup_error(zelf.as_object().repr(vm)?.as_wtf8().to_owned())); @@ -338,16 +338,17 @@ mod _contextvars { fn set_inner(zelf: &Py, value: PyObjectRef, vm: &VirtualMachine) { let ctx = PyContext::current(vm); - let mut vars = ctx.borrow_vars_mut(); - vars.insert(zelf.to_owned(), value.clone()); + let replaced = ctx.borrow_vars_mut().insert(zelf.to_owned(), value.clone()); + drop(replaced); zelf.cached_id.store(ctx.get_id(), Ordering::SeqCst); let cache = ContextVarCache { object: value, - idx: ctx.inner.idx.get(), + idx: ctx.inner.idx.load(Ordering::Relaxed), }; - zelf.cached.store(Some(cache)); + let replaced = zelf.cached.lock().replace(cache); + drop(replaced); } fn generate_hash(zelf: &Py, vm: &VirtualMachine) -> PyHash { @@ -370,28 +371,32 @@ mod _contextvars { default: OptionalArg, vm: &VirtualMachine, ) -> PyResult> { - let found = super::CONTEXTS.with_borrow(|ctxs| { - let ctx = ctxs.last()?; - let cached_ptr = zelf.cached.as_ptr(); - debug_assert!(!cached_ptr.is_null()); - if let Some(cached) = unsafe { &*cached_ptr } + // The replaced cache entry comes back out so that dropping it, which + // can run a __del__ that calls back in, happens with no lock held. + let (found, replaced) = super::CONTEXTS.with_borrow(|ctxs| { + let Some(ctx) = ctxs.last() else { + return (None, None); + }; + let mut cached = zelf.cached.lock(); + if let Some(cached) = &*cached && zelf.cached_id.load(Ordering::SeqCst) == ctx.get_id() && cached.idx + 1 == ctxs.len() { - return Some(cached.object.clone()); + return (Some(cached.object.clone()), None); } - let vars = ctx.borrow_vars(); - let obj = vars.get(zelf)?; + let Some(obj) = ctx.borrow_vars().get(zelf).map(|obj| obj.to_owned()) else { + return (None, None); + }; zelf.cached_id.store(ctx.get_id(), Ordering::SeqCst); - // TODO: ensure cached is not changed - let _removed = zelf.cached.swap(Some(ContextVarCache { + let replaced = cached.replace(ContextVarCache { object: obj.clone(), idx: ctxs.len() - 1, - })); + }); - Some(obj.clone()) + (Some(obj), replaced) }); + drop(replaced); let value = if let Some(value) = found { value @@ -425,7 +430,7 @@ mod _contextvars { #[pymethod] fn reset(zelf: &Py, token: PyRef, vm: &VirtualMachine) -> PyResult<()> { - if token.used.get() { + if token.used.load(Ordering::Acquire) { return Err(vm.new_runtime_error(format!( "{} has already been used once", token.as_object().repr(vm)? @@ -447,7 +452,7 @@ mod _contextvars { ))); } - token.used.set(true); + token.used.store(true, Ordering::Release); if let Some(old_value) = &token.old_value { Self::set_inner(zelf, old_value.clone(), vm); @@ -484,15 +489,13 @@ mod _contextvars { name: args.name.to_string(), default: args.default.into_option(), cached_id: 0.into(), - cached: AtomicCell::new(None), - hash: UnsafeCell::new(0), + cached: PyMutex::new(None), + hash: AtomicI64::new(0), }; let py_var = var.into_ref_with_type(vm, cls)?; - unsafe { - // SAFETY: py_var is not exposed to python memory model yet - *py_var.hash.get() = Self::generate_hash(&py_var, vm) - }; + let hash = Self::generate_hash(&py_var, vm); + py_var.hash.store(hash, Ordering::Relaxed); Ok(py_var.into()) } @@ -504,14 +507,14 @@ mod _contextvars { impl core::hash::Hash for ContextVar { #[inline] fn hash(&self, state: &mut H) { - unsafe { *self.hash.get() }.hash(state) + self.hash.load(Ordering::Relaxed).hash(state) } } impl Hashable for ContextVar { #[inline] fn hash(zelf: &Py, _vm: &VirtualMachine) -> PyResult { - Ok(unsafe { *zelf.hash.get() }) + Ok(zelf.hash.load(Ordering::Relaxed)) } } @@ -537,11 +540,9 @@ mod _contextvars { ctx: PyRef, // tok_ctx in CPython var: PyRef, // tok_var in CPython old_value: Option, // tok_oldval in CPython - used: Cell, + used: AtomicBool, } - unsafe impl Sync for ContextToken {} - #[pyclass(with(Constructor, Representable))] impl ContextToken { #[pygetset] @@ -598,7 +599,11 @@ mod _contextvars { impl Representable for ContextToken { #[inline] fn repr_wtf8(zelf: &Py, vm: &VirtualMachine) -> PyResult { - let used = if zelf.used.get() { " used" } else { "" }; + let used = if zelf.used.load(Ordering::Acquire) { + " used" + } else { + "" + }; let var = Representable::repr_wtf8(&zelf.var, vm)?; let ptr = zelf.as_object().get_id() as *const u8; let mut result = Wtf8Buf::from(format!("( &self, - jen: &PyObject, + _claim: &RunningGuard<'_>, vm: &VirtualMachine, func: F, - ) -> (PyResult, bool) + ) -> PyResult where F: FnOnce(&Py) -> PyResult, { - if self.running.compare_exchange(false, true).is_err() { - return ( - Err(vm.new_value_error(format!("{} already executing", gen_name(jen, vm)))), - false, - ); - } - - // SAFETY: running.compare_exchange guarantees exclusive access + // SAFETY: the claim guarantees exclusive access let gen_exc = unsafe { self.exception.swap(None) }; let exception_ptr = &self.exception as *const PyAtomicRef>; - let result = vm.resume_gen_frame(&self.frame, gen_exc, |f| { + vm.resume_gen_frame(&self.frame, gen_exc, |f| { let result = func(f); - // SAFETY: exclusive access guaranteed by running flag + // SAFETY: exclusive access guaranteed by the claim let _old = unsafe { (*exception_ptr).swap(vm.current_exception()) }; result - }); - - self.running.store(false); - (result, true) + }) } fn finalize_send_result( &self, result: PyResult, - entered_frame: bool, jen: &PyObject, vm: &VirtualMachine, ) -> PyResult { - self.maybe_close(&result, entered_frame); match result { Ok(exec_res) => Ok(exec_res.into_iter_return(vm)), Err(e) => { @@ -177,16 +191,20 @@ impl Coro { if self.closed.load() { return Ok(PyIterReturn::StopIteration(None)); } - if self.running.load() { - return Err(vm.new_value_error(format!("{} already executing", gen_name(jen, vm)))); + let claim = self.claim(jen, vm)?; + // The generator can have run to its end in the meantime. + if self.closed.load() { + return Ok(PyIterReturn::StopIteration(None)); } let value = if self.frame.lasti() > 0 { Some(vm.ctx.none()) } else { None }; - let (result, entered_frame) = self.run_with_context(jen, vm, |f| f.resume(value, vm)); - self.finalize_send_result(result, entered_frame, jen, vm) + let result = self.run_claimed(&claim, vm, |f| f.resume(value, vm)); + self.maybe_close(&result, &claim); + drop(claim); + self.finalize_send_result(result, jen, vm) } pub fn send( @@ -198,8 +216,10 @@ impl Coro { if self.closed.load() { return Ok(PyIterReturn::StopIteration(None)); } - if self.running.load() { - return Err(vm.new_value_error(format!("{} already executing", gen_name(jen, vm)))); + let claim = self.claim(jen, vm)?; + // The generator can have run to its end in the meantime. + if self.closed.load() { + return Ok(PyIterReturn::StopIteration(None)); } let value = if self.frame.lasti() > 0 { Some(value) @@ -211,8 +231,10 @@ impl Coro { } else { None }; - let (result, entered_frame) = self.run_with_context(jen, vm, |f| f.resume(value, vm)); - self.finalize_send_result(result, entered_frame, jen, vm) + let result = self.run_claimed(&claim, vm, |f| f.resume(value, vm)); + self.maybe_close(&result, &claim); + drop(claim); + self.finalize_send_result(result, jen, vm) } pub fn throw( @@ -237,13 +259,25 @@ impl Coro { // Validate exception type before entering generator context. // Invalid types propagate to caller without closing the generator. crate::exceptions::ExceptionCtor::try_from_object(vm, exc_type.clone())?; - let (result, entered_frame) = - self.run_with_context(jen, vm, |f| f.gen_throw(vm, exc_type, exc_val, exc_tb)); - self.maybe_close(&result, entered_frame); + let claim = self.claim(jen, vm)?; + // The generator can have run to its end in the meantime. Normalizing + // runs the exception's constructor, so let the claim go first. + if self.closed.load() { + drop(claim); + return Err(vm.normalize_exception(exc_type, exc_val, exc_tb)?); + } + let result = self.run_claimed(&claim, vm, |f| f.gen_throw(vm, exc_type, exc_val, exc_tb)); + self.maybe_close(&result, &claim); + drop(claim); Ok(result?.into_iter_return(vm)) } pub fn close(&self, jen: &PyObject, vm: &VirtualMachine) -> PyResult { + if self.closed.load() { + return Ok(vm.ctx.none()); + } + let claim = self.claim(jen, vm)?; + // The generator can have run to its end in the meantime. if self.closed.load() { return Ok(vm.ctx.none()); } @@ -252,7 +286,7 @@ impl Coro { self.closed.store(true); return Ok(vm.ctx.none()); } - let (result, entered_frame) = self.run_with_context(jen, vm, |f| { + let result = self.run_claimed(&claim, vm, |f| { f.gen_throw( vm, vm.ctx.exceptions.generator_exit.to_owned().into(), @@ -260,16 +294,11 @@ impl Coro { vm.ctx.none(), ) }); - if !entered_frame { - return match result { - Err(err) => Err(err), - Ok(_) => unreachable!("run_with_context preflight returned without an error"), - }; - } self.closed.store(true); // Release frame locals and stack to free references held by the // closed generator, matching gen_send_ex2 with close_on_completion. self.clear_frame_locals_on_close(); + drop(claim); match result { Ok(ExecutionResult::Yield(_)) => { Err(vm.new_runtime_error(format!("{} ignored GeneratorExit", gen_name(jen, vm)))) diff --git a/crates/vm/src/stdlib/_imp.rs b/crates/vm/src/stdlib/_imp.rs index fa979fcadbb..50f0b0be8ab 100644 --- a/crates/vm/src/stdlib/_imp.rs +++ b/crates/vm/src/stdlib/_imp.rs @@ -179,7 +179,7 @@ mod _imp { PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, builtins::{PyBytesRef, PyCode, PyMemoryView, PyModule, PyStrRef, PyUtf8StrRef}, convert::TryFromBorrowedObject, - function::{FuncArgs, OptionalArg}, + function::OptionalArg, import, version, }; @@ -264,21 +264,20 @@ mod _imp { if let OptionalArg::Present(data) = data && !vm.is_none(&data) { - let buf = crate::protocol::PyBuffer::try_from_borrowed_object(vm, &data)?; - let contiguous = buf.as_contiguous().ok_or_else(|| { - vm.new_buffer_error("get_frozen_object() requires a contiguous buffer") - })?; let invalid_err = || { vm.new_import_error( format!("Frozen object named '{}' is invalid", name.as_str()), name.clone().into_wtf8(), ) }; - let bag = crate::builtins::code::PyVmBag(vm); - let code = - rustpython_compiler_core::marshal::deserialize_code(&mut &contiguous[..], bag) - .map_err(|_| invalid_err())?; - return Ok(PyCode::new_ref_with_bag(vm, code)); + // A non-buffer is a TypeError, not invalid frozen data. + crate::protocol::PyBuffer::try_from_borrowed_object(vm, &data)?; + // The data is a marshalled code object: a whole marshal value, which + // deserialize_code() does not read — it takes the code body alone, + // without the type byte the writer puts in front of it. + let loads = vm.import("marshal", 0)?.get_attr("loads", vm)?; + let code = loads.call((data,), vm).map_err(|_| invalid_err())?; + return code.downcast::().map_err(|_| invalid_err()); } import::make_frozen(vm, name.as_str()) } @@ -317,19 +316,21 @@ mod _imp { .collect() } + #[derive(FromArgs)] + struct FindFrozenArgs { + #[pyarg(positional)] + name: PyUtf8StrRef, + #[pyarg(named, default = false)] + withdata: bool, + } + #[allow(clippy::type_complexity)] #[pyfunction] fn find_frozen( - args: FuncArgs, + args: FindFrozenArgs, vm: &VirtualMachine, ) -> PyResult>, bool, Option)>> { - if args.args.len() > 1 { - return Err(vm.new_type_error(format!( - "find_frozen() takes exactly 1 positional argument ({} given)", - args.args.len() - ))); - } - let (name,): (PyUtf8StrRef,) = args.bind(vm)?; + let FindFrozenArgs { name, withdata } = args; let name_str = name.as_str(); let info = match super::find_frozen(name_str, vm) { @@ -340,6 +341,18 @@ mod _imp { Err(e) => return Err(e.to_pyexception(name_str, vm)), }; + // The data is what get_frozen_object() takes back, i.e. marshalled code. + // Frozen modules are stored in their own encoding, so it has to be + // re-serialized rather than handed out as a view of the stored bytes. + let data = if withdata { + let code = PyCode::new_ref_from_frozen(vm, info.code); + let dumps = vm.import("marshal", 0)?.get_attr("dumps", vm)?; + let bytes = dumps.call((code,), vm)?; + Some(PyMemoryView::from_object(&bytes, vm)?.into_ref(&vm.ctx)) + } else { + None + }; + // When origname is empty (e.g. __hello_only__), return None. // Otherwise return the resolved alias name. let origname_str = super::resolve_frozen_alias(name_str); @@ -348,7 +361,7 @@ mod _imp { } else { Some(vm.ctx.new_utf8_str(origname_str).into()) }; - Ok(Some((None, info.package, origname))) + Ok(Some((data, info.package, origname))) } #[pyfunction] diff --git a/crates/vm/src/stdlib/itertools.rs b/crates/vm/src/stdlib/itertools.rs index e633404e803..6eb268d94c1 100644 --- a/crates/vm/src/stdlib/itertools.rs +++ b/crates/vm/src/stdlib/itertools.rs @@ -4,11 +4,10 @@ pub(crate) use decl::module_def; mod decl { use crate::{ AsObject, Py, PyObjectRef, PyPayload, PyRef, PyResult, PyWeakRef, VirtualMachine, - builtins::{PyGenericAlias, PyInt, PyIntRef, PyList, PyTuple, PyType, PyTypeRef, int}, - common::{ - lock::{PyMutex, PyRwLock, PyRwLockWriteGuard}, - rc::PyRc, + builtins::{ + PyGenericAlias, PyInt, PyIntRef, PyList, PyTuple, PyTupleRef, PyType, PyTypeRef, int, }, + common::lock::{PyMutex, PyRwLock, PyRwLockWriteGuard}, convert::ToPyObject, function::{FuncArgs, OptionalArg, OptionalOption, PosArgs}, protocol::{PyIter, PyIterReturn, PyNumber}, @@ -962,20 +961,25 @@ mod decl { } } - #[derive(Debug)] + #[pyattr] + #[pyclass(name = "_tee_dataobject", traverse)] + #[derive(Debug, PyPayload)] struct PyItertoolsTeeData { iterable: PyIter, values: PyMutex>, + #[pytraverse(skip)] running: AtomicBool, } + #[pyclass(flags(DISALLOW_INSTANTIATION))] impl PyItertoolsTeeData { - fn new(iterable: PyIter, _vm: &VirtualMachine) -> PyRc { - PyRc::new(Self { + fn new(iterable: PyIter, vm: &VirtualMachine) -> PyRef { + Self { iterable, values: PyMutex::new(vec![]), running: AtomicBool::new(false), - }) + } + .into_ref(&vm.ctx) } fn get_item(&self, vm: &VirtualMachine, index: usize) -> PyResult { @@ -988,13 +992,15 @@ mod decl { return Ok(PyIterReturn::Return(values[index].clone())); } } - // Prevent concurrent/reentrant calls to iterable.next() + // Prevent concurrent/reentrant calls to iterable.next(). The claim + // covers caching the value as well: released any earlier, a second + // tee at the same index fetches a value of its own and one of the + // two is dropped without ever reaching a caller. if self.running.swap(true, Ordering::Acquire) { return Err(vm.new_runtime_error("cannot re-enter the tee iterator")); } - let result = self.iterable.next(vm); - self.running.store(false, Ordering::Release); - let obj = raise_if_stop!(result?); + scopeguard::defer! { self.running.store(false, Ordering::Release) } + let obj = raise_if_stop!(self.iterable.next(vm)?); let Some(mut values) = self.values.try_lock() else { return Err(vm.new_runtime_error("cannot re-enter the tee iterator")); }; @@ -1006,59 +1012,44 @@ mod decl { } #[pyattr] - #[pyclass(name = "tee")] + #[pyclass(name = "_tee", traverse)] #[derive(Debug, PyPayload)] struct PyItertoolsTee { - tee_data: PyRc, + tee_data: PyRef, + #[pytraverse(skip)] index: AtomicCell, - } - - #[derive(FromArgs)] - struct TeeNewArgs { - #[pyarg(positional)] - iterable: PyIter, - #[pyarg(positional, optional)] - n: OptionalArg, + #[pytraverse(skip)] + advancing: AtomicBool, } impl Constructor for PyItertoolsTee { - type Args = TeeNewArgs; - - // TODO: make tee() a function, rename this class to itertools._tee and make - // teedata a python class - fn slot_new(_cls: PyTypeRef, args: FuncArgs, vm: &VirtualMachine) -> PyResult { - let TeeNewArgs { iterable, n } = args.bind(vm)?; - let n = n.unwrap_or(2); - - let copyable = if iterable.class().has_attr(identifier!(vm, __copy__)) { - vm.call_special_method(iterable.as_object(), identifier!(vm, __copy__), ())? - } else { - Self::from_iter(iterable, vm)? - }; + type Args = PyIter; - let mut tee_vec: Vec = Vec::with_capacity(n); - for _ in 0..n { - tee_vec.push(vm.call_special_method(©able, identifier!(vm, __copy__), ())?); + fn py_new(_cls: &Py, iterator: Self::Args, vm: &VirtualMachine) -> PyResult { + // An iterator that is already a tee shares its buffer rather than + // getting one of its own. + if let Some(tee) = iterator.as_object().downcast_ref::() { + return Ok(tee.__copy__()); } - - Ok(PyTuple::new_ref(tee_vec, &vm.ctx).into()) - } - - fn py_new(_cls: &Py, _args: Self::Args, _vm: &VirtualMachine) -> PyResult { - unimplemented!("use slot_new") + Ok(Self { + tee_data: PyItertoolsTeeData::new(iterator, vm), + index: AtomicCell::new(0), + advancing: AtomicBool::new(false), + }) } } - #[pyclass(with(IterNext, Iterable, Constructor))] + #[pyclass(with(IterNext, Iterable, Constructor), flags(HAS_WEAKREF))] impl PyItertoolsTee { fn from_iter(iterator: PyIter, vm: &VirtualMachine) -> PyResult { let class = Self::class(&vm.ctx); - if iterator.class().is(Self::class(&vm.ctx)) { + if iterator.class().is(class) { return vm.call_special_method(&iterator, identifier!(vm, __copy__), ()); } Ok(Self { tee_data: PyItertoolsTeeData::new(iterator, vm), index: AtomicCell::new(0), + advancing: AtomicBool::new(false), } .into_ref_with_type(vm, class.to_owned())? .into()) @@ -1069,14 +1060,49 @@ mod decl { Self { tee_data: self.tee_data.clone(), index: AtomicCell::new(self.index.load()), + advancing: AtomicBool::new(false), } } } + + #[pyfunction] + fn tee(iterable: PyIter, n: OptionalArg, vm: &VirtualMachine) -> PyResult { + let n = n.unwrap_or(2); + if n < 0 { + return Err(vm.new_value_error("n must be >= 0")); + } + let n = n as usize; + + // Only an iterator that cannot copy itself needs a tee to buffer it. + let copyable = if iterable.class().has_attr(identifier!(vm, __copy__)) { + iterable.into() + } else { + PyItertoolsTee::from_iter(iterable, vm)? + }; + + let mut tee_vec: Vec = Vec::new(); + tee_vec + .try_reserve_exact(n) + .map_err(|_| vm.new_memory_error(""))?; + for _ in 0..n { + tee_vec.push(vm.call_special_method(©able, identifier!(vm, __copy__), ())?); + } + + Ok(PyTuple::new_ref(tee_vec, &vm.ctx)) + } impl SelfIter for PyItertoolsTee {} impl IterNext for PyItertoolsTee { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { - let value = raise_if_stop!(zelf.tee_data.get_item(vm, zelf.index.load())?); - zelf.index.fetch_add(1); + // Reading the index and moving it on is one step: two callers that + // read the same index hand out the same value twice and leave the + // buffer to be filled out of order. + if zelf.advancing.swap(true, Ordering::Acquire) { + return Err(vm.new_runtime_error("cannot re-enter the tee iterator")); + } + scopeguard::defer! { zelf.advancing.store(false, Ordering::Release) } + let index = zelf.index.load(); + let value = raise_if_stop!(zelf.tee_data.get_item(vm, index)?); + zelf.index.store(index + 1); Ok(PyIterReturn::Return(value)) } } diff --git a/extra_tests/snippets/stdlib_asyncio.py b/extra_tests/snippets/stdlib_asyncio.py index 7f03aeb436b..d54f84564a3 100644 --- a/extra_tests/snippets/stdlib_asyncio.py +++ b/extra_tests/snippets/stdlib_asyncio.py @@ -49,4 +49,27 @@ def __new__(cls, *args): with assert_raises(TypeError): future.__await__().throw(BadException) +# InvalidStateError is looked up in asyncio.exceptions every time a pending +# future is asked for its result, so what is found there need not be an +# exception type at all. +import asyncio # noqa: E402 +import asyncio.exceptions # noqa: E402 + +pending = _asyncio.Future(loop=object()) +with assert_raises(asyncio.exceptions.InvalidStateError): + pending.result() + +saved_invalid_state_error = asyncio.exceptions.InvalidStateError +try: + for replacement in (lambda *args: 42, None): + asyncio.InvalidStateError = replacement + asyncio.exceptions.InvalidStateError = replacement + with assert_raises(RuntimeError): + pending.result() + with assert_raises(RuntimeError): + pending.exception() +finally: + asyncio.InvalidStateError = saved_invalid_state_error + asyncio.exceptions.InvalidStateError = saved_invalid_state_error + print("ok") diff --git a/extra_tests/snippets/stdlib_gc.py b/extra_tests/snippets/stdlib_gc.py index 134b1b9f458..6c3169beedb 100644 --- a/extra_tests/snippets/stdlib_gc.py +++ b/extra_tests/snippets/stdlib_gc.py @@ -62,5 +62,7 @@ def build(): assert collects(lambda c: itertools.compress(c, [1])) assert collects(lambda c: itertools.product(c)) assert collects(lambda c: itertools.combinations(c, 1)) +# tee holds its buffer through a second object, which has to be walked too +assert collects(lambda c: itertools.tee(c)[0]) print("ok") diff --git a/extra_tests/snippets/stdlib_imp.py b/extra_tests/snippets/stdlib_imp.py index 64f1a0ad67e..9fd5f8a36fa 100644 --- a/extra_tests/snippets/stdlib_imp.py +++ b/extra_tests/snippets/stdlib_imp.py @@ -36,3 +36,9 @@ def __init__(self, name): with assert_raises(TypeError): _imp.find_frozen("x", True) assert _imp.find_frozen("_this_module_does_not_exist_") is None + +# and it hands back the marshalled code that get_frozen_object() takes +data, ispkg, origname = _imp.find_frozen("__hello__", withdata=True) +assert ispkg is False +assert origname == "__hello__" +assert _imp.get_frozen_object("__hello__", data).co_name == "" diff --git a/extra_tests/snippets/stdlib_threading_contextvars.py b/extra_tests/snippets/stdlib_threading_contextvars.py new file mode 100644 index 00000000000..7947ef74fd4 --- /dev/null +++ b/extra_tests/snippets/stdlib_threading_contextvars.py @@ -0,0 +1,72 @@ +"""Stress contextvars from several threads at once. + +A Context holds the variable map, and both the map and the per-variable cache +are shared between every thread that touches the Context. Reading and writing +them has to be done under a lock rather than a cell borrow. + +Dropping a value that a set() or reset() displaced can run a __del__ that comes +straight back into the same Context, so the displaced value has to be released +after the lock is, not while it is held. +""" + +import contextvars +import threading + +ROUNDS = 2000 + +var = contextvars.ContextVar("v", default=0) +shared = contextvars.Context() +errors = [] + + +class Reentrant: + """__del__ runs while the variable that held this value is being replaced.""" + + def __del__(self): + try: + var.get() + except Exception: # a different context, or no value: not what is tested + pass + + +def churn(): + try: + for i in range(ROUNDS): + token = var.set(Reentrant()) + var.get() + var.reset(token) + var.set(i) + var.get() + contextvars.copy_context() + except Exception as exc: # noqa: BLE001 + errors.append(exc) + + +def run_in_shared(): + for i in range(ROUNDS): + try: + shared.run(var.set, i) + except RuntimeError: + # the Context is already entered by another thread + pass + + +threads = [threading.Thread(target=churn) for _ in range(4)] +threads += [threading.Thread(target=run_in_shared) for _ in range(4)] +for t in threads: + t.start() +for t in threads: + t.join() + +assert not errors, errors + +# the map itself still behaves +ctx = contextvars.copy_context() +ctx.run(var.set, 42) +assert ctx[var] == 42 +assert var in ctx +assert list(ctx) == [var] +assert ctx.get(var) == 42 +assert len(ctx) == 1 + +print("ok") diff --git a/extra_tests/snippets/stdlib_threading_generator.py b/extra_tests/snippets/stdlib_threading_generator.py new file mode 100644 index 00000000000..e606ef11cec --- /dev/null +++ b/extra_tests/snippets/stdlib_threading_generator.py @@ -0,0 +1,95 @@ +"""Resume one generator from several threads at once. + +A generator is resumed by one thread at a time, and whether the sent value is +pushed onto the frame's value stack depends on whether the generator has +already started. Deciding that before the generator is claimed reads a frame +another thread can advance in the meantime, and resuming it then leaves the +stack short of what the code after the yield expects. + +Every yielded value still has to reach exactly one caller: threads that lose +the race get a ValueError instead of a value. +""" + +import threading + +WORKERS = 4 +ROUNDS = 400 + + +def counter(): + yield 1 + yield 2 + yield 3 + + +gens = [counter() for _ in range(ROUNDS)] +received = [[] for _ in range(ROUNDS)] +start = threading.Barrier(WORKERS) +errors = [] + + +def worker(): + try: + for index, gen in enumerate(gens): + start.wait() + for _ in range(3): + try: + received[index].append(next(gen)) + except StopIteration: + break + except ValueError: + # another thread is running this generator + pass + except Exception as exc: # noqa: BLE001 + errors.append(exc) + # the other workers are waiting at the barrier for this one + start.abort() + + +threads = [threading.Thread(target=worker) for _ in range(WORKERS)] +for t in threads: + t.start() +for t in threads: + t.join() + +assert not errors, errors +for got in received: + # no value handed out twice, and none skipped + assert sorted(got) == list(range(1, len(got) + 1)), got + + +# a generator that is closed while it is being resumed stays consistent +def loop(): + while True: + yield 1 + + +shared = loop() +closed = threading.Barrier(2) + + +def resumer(): + closed.wait() + for _ in range(ROUNDS): + try: + next(shared) + except (StopIteration, ValueError): + pass + + +def closer(): + closed.wait() + try: + shared.close() + except ValueError: + # the generator was running + pass + + +pair = [threading.Thread(target=resumer), threading.Thread(target=closer)] +for t in pair: + t.start() +for t in pair: + t.join() + +print("ok") diff --git a/extra_tests/snippets/stdlib_threading_itertools_tee.py b/extra_tests/snippets/stdlib_threading_itertools_tee.py new file mode 100644 index 00000000000..e13e20731f8 --- /dev/null +++ b/extra_tests/snippets/stdlib_threading_itertools_tee.py @@ -0,0 +1,57 @@ +"""Advance the iterators of one tee() from several threads at once. + +Every tee iterator reads its position, asks the shared buffer for that item and +then moves the position on. Reading and moving it on has to be one step, and +the buffer has to stay claimed until the value it fetched from the source is +cached: otherwise two callers work on the same index, a fetched value is +dropped, and the buffer is left to be filled out of order. + +A caller that loses the race gets a RuntimeError, never a value another caller +has already been handed. +""" + +import itertools +import threading + +ROUNDS = 200 +WORKERS = 4 + +errors = [] + + +def drain(iterator, out): + for _ in range(ROUNDS): + try: + out.append(next(iterator)) + except StopIteration: + break + except RuntimeError: + # another thread is advancing this tee + pass + except Exception as exc: # noqa: BLE001 + errors.append(exc) + break + + +for _ in range(10): + first, second = itertools.tee(iter(range(ROUNDS * WORKERS))) + taken = [[] for _ in range(WORKERS)] + threads = [ + threading.Thread(target=drain, args=(first if i % 2 else second, taken[i])) + for i in range(WORKERS) + ] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, errors + for got in taken: + # one iterator hands out ascending values, each of them once + assert got == sorted(set(got)), got + for side in (taken[1], taken[3]), (taken[0], taken[2]): + # the two threads sharing an iterator split its values between them + shared = side[0] + side[1] + assert len(shared) == len(set(shared)), shared + +print("ok") From d04318efacffbca1a18f361e961470e94c593b8d Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:07:02 +0900 Subject: [PATCH 296/351] _sre: drive an all-ASCII str subject over its bytes (#8520) * sre_engine: document that StrDrive positions are character indices StrDrive carried no documentation, so nothing recorded that a cursor's position is a character index rather than a byte offset. Every implementation satisfies it -- skip(n) advances position by exactly n in all three -- and the engine depends on it: _count bounds a repeat with position + max_count and reports the repeat length as a difference of positions, ASSERT compares position against a lookbehind width, and search_info recovers a match start as position - (len - 1). A drive over a variable-width encoding that stored byte offsets would leave those type-correct and wrong, and would index a lookbehind out of bounds. Write the invariant down on StringCursor and on each trait method. Also walk the group's own cursor in GROUPREF instead of counting to the group's width, so the loop bound is the thing being stepped. Generated machine code is unchanged. Assisted-by: Claude * _sre: drive an all-ASCII str subject over its bytes with_sre_str handed every str subject to the &Wtf8 drive, which answers count() by counting every code point and create_cursor(n) by stepping over the first n. Both run once per Request, so a scan that restarts at successive positions walked the subject again on every call: finditer over an ASCII subject of n tokens was quadratic in n. PyStr already records whether it is ASCII -- StrKind is decided when the string is built -- and for ASCII a character index is a byte index, so the &[u8] drive's cursor arithmetic already applies: count() is the byte length and create_cursor() is a pointer offset. Add AsciiStr, which delegates every StrDrive method to that impl and differs only in slice(), which reslices the span and returns str rather than bytes. Matching is unaffected: StrDrive carries no unicode semantics, because the engine keys every unicode decision on the compiled pattern's opcode rather than on the subject type. Also bind the subject once in with_sre_str, so callers passing a temporary (`&x.clone()`) build it once rather than per arm. finditer over an ASCII subject, n tokens, this machine: n before after 5000 41.60ms 1.37ms 10000 217.30ms 2.68ms 20000 1206.28ms 5.17ms 40000 5016.65ms 10.31ms Per-doubling x5.22/x5.55/x4.16 becomes x1.95/x1.93/x1.99. Collecting m.group(0) for every match goes 5061.02ms -> 17.80ms at n=40000. test.test_re is unchanged at 166 tests, OK (skipped=14, expected failures=6), and a 2731-line differential over the is_ascii() boundary -- findall, finditer spans and groups, sub, split, match, search, fullmatch across subjects that are empty, ASCII, non-ASCII, or mixed -- is byte-identical to CPython 3.14.6. Introducing an off-by-one in AsciiStr::slice moves 1224 of those lines, so the comparison reaches the new code. Assisted-by: Claude --- crates/sre_engine/src/engine.rs | 6 +- crates/sre_engine/src/string.rs | 35 +++++++++++ crates/vm/src/stdlib/_sre.rs | 102 ++++++++++++++++++++++++++++++-- 3 files changed, 137 insertions(+), 6 deletions(-) diff --git a/crates/sre_engine/src/engine.rs b/crates/sre_engine/src/engine.rs index 690801e0d9d..c2a6ac81975 100644 --- a/crates/sre_engine/src/engine.rs +++ b/crates/sre_engine/src/engine.rs @@ -581,7 +581,11 @@ fn _match(req: &Request<'_, S>, state: &mut State, mut ctx: MatchCo ..ctx }; - for _ in group_start..group_end { + // Walk the group itself rather than counting to its + // width: `g_ctx` is already stepping over exactly + // the characters being compared, so its own cursor + // is the loop bound. + while g_ctx.cursor.position < group_end { #[allow(clippy::redundant_closure_call)] if ctx.at_end(req) || $f(ctx.peek_char::()) != $f(g_ctx.peek_char::()) diff --git a/crates/sre_engine/src/string.rs b/crates/sre_engine/src/string.rs index ca7303a2a7f..5cc1b04b9fc 100644 --- a/crates/sre_engine/src/string.rs +++ b/crates/sre_engine/src/string.rs @@ -1,5 +1,12 @@ use rustpython_wtf8::Wtf8; +/// A position in the subject, paired with the byte pointer it resolves to. +/// +/// `position` is a **character index**, never a byte offset. The engine does +/// arithmetic on it directly — it subtracts two positions to get a character +/// count, adds a repeat count to get a bound, and compares one against a +/// lookbehind width — so the unit is part of the [`StrDrive`] contract rather +/// than a detail each implementation may pick. #[derive(Debug, Clone, Copy)] pub struct StringCursor { pub(crate) ptr: *const u8, @@ -15,15 +22,43 @@ impl Default for StringCursor { } } +/// Random access over the subject being matched. +/// +/// An implementation chooses how a character is spelled in memory — one byte +/// for `&[u8]`, one code point for `&str` and `&Wtf8` — but **not** how +/// positions are counted. Every position this trait produces or consumes is a +/// character index: `count` is the subject's length in characters, and +/// `skip(n)` advances a cursor's `position` by exactly `n`. +/// +/// That is load-bearing, not incidental. The engine reads position arithmetic +/// as character arithmetic in several places — `_count` bounds a repeat with +/// `position + max_count` and reports the repeat's length as a difference of +/// positions, `ASSERT` tests `position < back` against a lookbehind width, and +/// `search_info` recovers a match start as `position - (len - 1)`. A drive +/// that stored byte offsets here would leave all of those type-correct and +/// silently wrong, and would index a lookbehind out of bounds. +/// +/// So a drive over a variable-width encoding pays for the mapping: `count` +/// and `create_cursor` have to resolve character indices, and cannot simply +/// hand back byte lengths and byte offsets. pub trait StrDrive: Copy { + /// The subject's length, in characters. fn count(&self) -> usize; + /// A cursor at character index `n`. fn create_cursor(&self, n: usize) -> StringCursor; + /// Move `cursor` to character index `n`, from wherever it is now. fn adjust_cursor(&self, cursor: &mut StringCursor, n: usize); + /// Consume one character, returning it; `position` grows by one. fn advance(cursor: &mut StringCursor) -> u32; + /// The character at `cursor`, without moving it. fn peek(cursor: &StringCursor) -> u32; + /// Skip `n` characters, so `position` grows by exactly `n`. fn skip(cursor: &mut StringCursor, n: usize); + /// Step back over one character, returning it; `position` shrinks by one. fn back_advance(cursor: &mut StringCursor) -> u32; + /// The character before `cursor`, without moving it. fn back_peek(cursor: &StringCursor) -> u32; + /// Step back `n` characters, so `position` shrinks by exactly `n`. fn back_skip(cursor: &mut StringCursor, n: usize); } diff --git a/crates/vm/src/stdlib/_sre.rs b/crates/vm/src/stdlib/_sre.rs index 18b4ffde818..6fe6b434702 100644 --- a/crates/vm/src/stdlib/_sre.rs +++ b/crates/vm/src/stdlib/_sre.rs @@ -22,7 +22,7 @@ mod _sre { use itertools::Itertools; use num_traits::ToPrimitive; use rustpython_sre_engine::{ - Request, SearchIter, SreFlag, State, StrDrive, + Request, SearchIter, SreFlag, State, StrDrive, StringCursor, string::{lower_ascii, lower_unicode}, }; @@ -83,6 +83,72 @@ mod _sre { } } + /// An all-ASCII `str` subject, driven over its bytes. + /// + /// For ASCII a character index *is* a byte index, so `&[u8]`'s cursor + /// arithmetic is already the right arithmetic: `count` is the byte length + /// and `create_cursor` is a pointer offset. The `&Wtf8` drive has to count + /// code points from the start of the subject to answer either, once per + /// `Request`, which makes a scan that restarts at successive positions -- + /// `finditer`, or `re` module functions called in a loop -- walk the + /// subject again on every call. + /// + /// Matching is unaffected: `StrDrive` carries no unicode semantics of its + /// own, because the engine keys every unicode decision on the compiled + /// pattern's opcode rather than on the subject type. Only `slice` differs + /// from the `&[u8]` impl, to hand back `str` instead of `bytes`. + #[derive(Clone, Copy)] + struct AsciiStr<'a>(&'a [u8]); + + impl StrDrive for AsciiStr<'_> { + fn count(&self) -> usize { + <&[u8] as StrDrive>::count(&self.0) + } + + fn create_cursor(&self, n: usize) -> StringCursor { + <&[u8] as StrDrive>::create_cursor(&self.0, n) + } + + fn adjust_cursor(&self, cursor: &mut StringCursor, n: usize) { + <&[u8] as StrDrive>::adjust_cursor(&self.0, cursor, n) + } + + fn advance(cursor: &mut StringCursor) -> u32 { + <&[u8] as StrDrive>::advance(cursor) + } + + fn peek(cursor: &StringCursor) -> u32 { + <&[u8] as StrDrive>::peek(cursor) + } + + fn skip(cursor: &mut StringCursor, n: usize) { + <&[u8] as StrDrive>::skip(cursor, n) + } + + fn back_advance(cursor: &mut StringCursor) -> u32 { + <&[u8] as StrDrive>::back_advance(cursor) + } + + fn back_peek(cursor: &StringCursor) -> u32 { + <&[u8] as StrDrive>::back_peek(cursor) + } + + fn back_skip(cursor: &mut StringCursor, n: usize) { + <&[u8] as StrDrive>::back_skip(cursor, n) + } + } + + impl SreStr for AsciiStr<'_> { + fn slice(&self, start: usize, end: usize, vm: &VirtualMachine) -> PyObjectRef { + let end = end.min(self.0.len()); + let start = start.min(end); + // The subject is ASCII, so any span of it is valid UTF-8 and the + // span is a reslice rather than a walk from the subject's start. + let s = str::from_utf8(&self.0[start..end]).expect("ascii subject"); + vm.ctx.new_str(s).into() + } + } + #[pyfunction] fn compile( pattern: PyObjectRef, @@ -200,13 +266,18 @@ mod _sre { } macro_rules! with_sre_str { - ($pattern:expr, $string:expr, $vm:expr, $f:expr) => { + ($pattern:expr, $string:expr, $vm:expr, $f:expr) => {{ + // Bind once: the branches only borrow the subject, and callers pass + // a temporary (`&x.clone()`) that would otherwise be rebuilt per arm. + let subject = $string; if $pattern.isbytes { - Pattern::with_bytes($string, $vm, $f) + Pattern::with_bytes(subject, $vm, $f) + } else if Pattern::is_ascii_str(subject) { + Pattern::with_ascii_str(subject, $vm, $f) } else { - Pattern::with_str($string, $vm, $f) + Pattern::with_str(subject, $vm, $f) } - }; + }}; } #[pyclass(with(Hashable, Comparable, Representable), flags(HAS_WEAKREF))] @@ -221,6 +292,27 @@ mod _sre { f(string.as_wtf8()) } + /// Whether a `str` subject can take the [`AsciiStr`] drive. + /// + /// `PyStr` already knows: `StrKind` is decided when the string is + /// built, so this is a field load rather than a scan. A non-`str` + /// argument answers `false` and is reported by [`Self::with_str`]. + fn is_ascii_str(string: &PyObject) -> bool { + string + .downcast_ref::() + .is_some_and(|s| s.kind().is_ascii()) + } + + fn with_ascii_str(string: &PyObject, vm: &VirtualMachine, f: F) -> PyResult + where + F: FnOnce(AsciiStr<'_>) -> PyResult, + { + let string = string.downcast_ref::().ok_or_else(|| { + vm.new_type_error(format!("expected string got '{}'", string.class())) + })?; + f(AsciiStr(string.as_wtf8().as_bytes())) + } + fn with_bytes(string: &PyObject, vm: &VirtualMachine, f: F) -> PyResult where F: FnOnce(&[u8]) -> PyResult, From 9f90967b0a165c40a8a497b5dab75cc89ecdbc9a Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:32:36 +0900 Subject: [PATCH 297/351] str: count a stepped slice's characters with a ceiling division (#8525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `do_stepped_slice` and `do_stepped_slice_reverse` took the character count of a non-ASCII result as `(range.len() / step) + 1`, which overshoots by one whenever the span is an exact multiple of the step: `"aéc"[::3]` is one character and reported two, `"가나다라"[::2]` two and reported three. ASCII subjects were unaffected, since that arm collects into an `AsciiString` whose length comes from the data. The count is stored as the string's character length, so the result then claimed a character its buffer does not hold, and `reversed()` on it indexed past the end and panicked: s = "".join(["a", "é", "c"]) list(reversed(s[::3])) # index out of bounds: the len is 1 but the index is 1 Use `div_ceil`, which is the number of elements the underlying range yields. A differential over 44331 subscript and slice shapes -- every combination of start, stop and step over strings straddling the ASCII split, the surrogate range and the astral plane -- now matches CPython 3.14 exactly, where it differed on 3100 lines before. Assisted-by: Claude --- crates/vm/src/builtins/str.rs | 8 ++--- .../snippets/builtin_str_unicode_slice.py | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index 07325159a39..ae8ba0ed07e 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -1870,14 +1870,14 @@ impl SliceableSequenceOp for PyStr { .collect::() .into(), PyKindStr::Utf8(s) => { - let char_len = (range.len() / step) + 1; + let char_len = range.len().div_ceil(step); let mut out = String::with_capacity(2 * char_len); out.extend(s.chars().skip(range.start).take(range.len()).step_by(step)); // SAFETY: char_len is accurate unsafe { Self::new_with_char_len(out, char_len) } } PyKindStr::Wtf8(w) => { - let char_len = (range.len() / step) + 1; + let char_len = range.len().div_ceil(step); let mut out = Wtf8Buf::with_capacity(2 * char_len); out.extend( w.code_points() @@ -1900,7 +1900,7 @@ impl SliceableSequenceOp for PyStr { .collect::() .into(), PyKindStr::Utf8(s) => { - let char_len = (range.len() / step) + 1; + let char_len = range.len().div_ceil(step); // not ascii, so the codepoints have to be at least 2 bytes each let mut out = String::with_capacity(2 * char_len); out.extend( @@ -1914,7 +1914,7 @@ impl SliceableSequenceOp for PyStr { unsafe { Self::new_with_char_len(out, char_len) } } PyKindStr::Wtf8(w) => { - let char_len = (range.len() / step) + 1; + let char_len = range.len().div_ceil(step); // not ascii, so the codepoints have to be at least 2 bytes each let mut out = Wtf8Buf::with_capacity(2 * char_len); out.extend( diff --git a/extra_tests/snippets/builtin_str_unicode_slice.py b/extra_tests/snippets/builtin_str_unicode_slice.py index 252f84b1c72..1d35c6c483c 100644 --- a/extra_tests/snippets/builtin_str_unicode_slice.py +++ b/extra_tests/snippets/builtin_str_unicode_slice.py @@ -59,3 +59,32 @@ def expect_index_error(s, index): assert len(hebrew_text[30:10:-3]) == 7 assert hebrew_text[30:10:-1] == "א ,םיִהֹלֱא אָרָּב ," assert len(hebrew_text[30:10:-1]) == 20 + + +# A stepped slice whose span is an exact multiple of the step ends on the last +# character it collects rather than one past it, so the character count is the +# span divided by the step and not one more. The subject goes through a +# variable because a constant subscript is folded at compile time and would +# never reach the runtime slice at all. +def stepped(s, step): + return s[::step] + + +for subject, step, expected in [ + ("a\u00e9c", 3, "a"), + ("가나다라", 2, "가다"), + ("가나다라마바", 3, "가라"), + ("가나다라", -2, "라나"), + ("가나다라마바", -3, "바다"), + ("\U0001f600\U0001f601\U0001f602\U0001f603", 2, "\U0001f600\U0001f602"), +]: + sliced = stepped(subject, step) + assert sliced == expected, (subject, step, sliced) + assert len(sliced) == len(expected), (subject, step, len(sliced)) + # An overstated count makes the string claim characters its buffer does not + # hold, which reversed() then reads past. + assert list(reversed(sliced)) == list(expected)[::-1] + +assert len(stepped(hebrew_text, 2)) == 30 +assert len(stepped(hebrew_text, 4)) == 15 +assert len(stepped(hebrew_text, -2)) == 30 From d6091086e00d1d446ef5a0f61adcdf5746c2a365 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:33:58 +0900 Subject: [PATCH 298/351] _sre: drive a non-ASCII str subject through a character index on the string (#8522) * common: index a WTF-8 buffer's code points for random access `Wtf8`'s iterators are sequential, so resolving a code point index through them is O(n) and code that indexes the same string repeatedly walks it once per index. `Wtf8Index` is a side table -- one 24-byte group per 64 code points, 0.375 bytes per code point -- that answers the same question in constant time; the layout is PyPy's `UTF8_INDEX_STORAGE`. `StrData` builds one on the first call to the new `char_index_to_byte`, in a slot published by compare-exchange, and drops it with the string. ASCII strings answer from the index itself and never build a table. A clone gets an empty slot, since it indexes its own copy of the buffer. Assisted-by: Claude * _sre: drive a non-ASCII str subject through the string's character index The `&Wtf8` drive answers `count` and `create_cursor` by decoding from the start of the subject, so a scan that restarts at successive positions walks the subject once per position, and `slice` walks it again per extracted group. `Utf8Str` holds the `PyStr` and asks it instead: `count` is the cached character length, and `create_cursor` and `slice` resolve their positions through `char_index_to_byte`. Stepping is the `&Wtf8` drive's, unchanged. The table lives on the string, so a `Match` that outlives the scan shares it -- `group` has no cursor of its own to move relative to. `SreStr for &Wtf8` has no callers left; the `StrDrive` impl stays, since `Utf8Str` steps through it. The three subject helpers now share one downcast. Assisted-by: Claude --- crates/common/src/lib.rs | 1 + crates/common/src/str.rs | 87 +++++++++++- crates/common/src/wtf8_index.rs | 229 ++++++++++++++++++++++++++++++++ crates/vm/src/builtins/str.rs | 7 + crates/vm/src/stdlib/_sre.rs | 101 +++++++++++--- 5 files changed, 407 insertions(+), 18 deletions(-) create mode 100644 crates/common/src/wtf8_index.rs diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 53a8e0d752b..d1e04b46d57 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -21,6 +21,7 @@ pub mod rc; pub mod refcount; pub mod static_cell; pub mod str; +pub mod wtf8_index; pub use rustpython_wtf8 as wtf8; diff --git a/crates/common/src/str.rs b/crates/common/src/str.rs index c006a5f4db4..8edb48fa4fa 100644 --- a/crates/common/src/str.rs +++ b/crates/common/src/str.rs @@ -1,7 +1,8 @@ // spell-checker:ignore uncomputed -use crate::atomic::{PyAtomic, Radium}; +use crate::atomic::{OncePtr, PyAtomic, Radium}; use crate::format::CharLen; use crate::wtf8::{CodePoint, Wtf8, Wtf8Buf}; +use crate::wtf8_index::Wtf8Index; use ascii::{AsciiChar, AsciiStr, AsciiString}; use core::fmt; use core::ops::{Bound, RangeBounds}; @@ -117,6 +118,55 @@ pub struct StrData { data: Box, kind: StrKind, len: StrLen, + index: Wtf8IndexSlot, +} + +/// A [`Wtf8Index`] built on first use. +/// +/// The table is a pure function of `data`, so publishing it races benignly: a +/// thread that loses the exchange drops its own copy and reads the winner's. +#[derive(Default)] +struct Wtf8IndexSlot(OncePtr); + +impl Wtf8IndexSlot { + #[inline(always)] + fn new() -> Self { + Self(OncePtr::new()) + } + + #[inline] + fn get_or_build(&self, data: &Wtf8, char_len: usize) -> &Wtf8Index { + let index = self + .0 + .get_or_init(|| Box::new(Wtf8Index::new(data, char_len))); + // The slot owns the table, never replaces it, and outlives the borrow. + unsafe { index.as_ref() } + } +} + +impl fmt::Debug for Wtf8IndexSlot { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self.0.get() { + Some(_) => f.write_str(""), + None => f.write_str(""), + } + } +} + +impl Clone for Wtf8IndexSlot { + /// A fresh slot: the clone copies the buffer, so it has to index that copy, + /// and the table is rebuilt on demand rather than eagerly here. + fn clone(&self) -> Self { + Self::new() + } +} + +impl Drop for Wtf8IndexSlot { + fn drop(&mut self) { + if let Some(index) = self.0.get() { + drop(unsafe { Box::from_raw(index.as_ptr()) }); + } + } } struct StrLen(PyAtomic); @@ -163,6 +213,7 @@ impl Default for StrData { data: >::default(), kind: StrKind::Ascii, len: StrLen::zero(), + index: Wtf8IndexSlot::new(), } } } @@ -193,6 +244,7 @@ impl From> for StrData { len: value.len().into(), data: value.into(), kind: StrKind::Ascii, + index: Wtf8IndexSlot::new(), } } } @@ -212,6 +264,7 @@ impl From for StrData { data: ch.to_string().into(), kind: StrKind::Utf8, len: 1.into(), + index: Wtf8IndexSlot::new(), } } } @@ -226,6 +279,7 @@ impl From for StrData { data: Wtf8Buf::from(ch).into(), kind: StrKind::Wtf8, len: 1.into(), + index: Wtf8IndexSlot::new(), } } } @@ -241,7 +295,12 @@ impl StrData { StrKind::Ascii => data.len().into(), _ => StrLen::uncomputed(), }; - Self { data, kind, len } + Self { + data, + kind, + len, + index: Wtf8IndexSlot::new(), + } } /// # Safety @@ -253,6 +312,7 @@ impl StrData { data, kind, len: char_len.into(), + index: Wtf8IndexSlot::new(), } } @@ -322,6 +382,29 @@ impl StrData { len } + /// The byte offset the `index`-th code point starts at. + /// + /// An `index` at or past the end answers the buffer's byte length, so a + /// caller walking to a bound does not have to special-case it. + /// + /// O(1), but the first call on a non-ASCII string builds an index over the + /// whole buffer, so a caller that resolves a single index and stops is + /// better served by [`Self::nth_char`]. + pub fn char_index_to_byte(&self, index: usize) -> usize { + // For ASCII the two units coincide, and the table would be a Nth entry + // saying N. + if self.kind.is_ascii() { + return index.min(self.data.len()); + } + let char_len = self.char_len(); + if index >= char_len { + return self.data.len(); + } + self.index + .get_or_build(&self.data, char_len) + .byte_offset(&self.data, index) + } + pub fn nth_char(&self, index: usize) -> CodePoint { match self.as_str_kind() { PyKindStr::Ascii(s) => s[index].into(), diff --git a/crates/common/src/wtf8_index.rs b/crates/common/src/wtf8_index.rs new file mode 100644 index 00000000000..a6121f1c03d --- /dev/null +++ b/crates/common/src/wtf8_index.rs @@ -0,0 +1,229 @@ +// spell-checker:ignore rpython rlib rutf +//! Random access into a WTF-8 buffer. +//! +//! WTF-8 is variable width, so a buffer's n-th code point can only be found by +//! decoding the n-1 before it: [`Wtf8`]'s iterators are sequential, and +//! resolving an index through them is O(n). Code that indexes the same string +//! repeatedly -- a regex scan restarting at successive positions, say -- then +//! walks the whole buffer once per index, which is quadratic in its length. +//! +//! [`Wtf8Index`] is the side table that makes the lookup O(1): one 24-byte +//! group per 64 code points, so 0.375 bytes per code point. It is a cache, and +//! holds no state of its own beyond the buffer's shape -- building it twice for +//! the same buffer yields the same table. +//! +//! The layout is PyPy's `UTF8_INDEX_STORAGE` (`rpython/rlib/rutf8.py`). + +use crate::wtf8::Wtf8; + +/// One group of 64 code points. +#[derive(Clone, Copy)] +struct Group { + /// The byte offset the group's first code point starts at. + base: usize, + /// `ofs[i]` is the byte offset of the group's `4 * i + 1`-th code point, + /// relative to `base`. One entry covers four code points, so the widest + /// offset an entry has to hold is that of the 61st code point of a group, + /// at most `61 * 4 = 244` bytes in -- inside a `u8`, which is what buys the + /// table its density. + ofs: [u8; 16], +} + +/// A code-point-index to byte-offset table for one WTF-8 buffer. +pub struct Wtf8Index { + groups: Box<[Group]>, +} + +impl Wtf8Index { + /// Builds the table for `data`, whose code point count is `char_len`. + /// + /// O(`data.len()`), and touches every byte, so it pays for itself only when + /// the caller goes on to index the buffer more than a couple of times. + #[must_use] + pub fn new(data: &Wtf8, char_len: usize) -> Self { + let mut groups = vec![ + Group { + base: 0, + ofs: [0; 16], + }; + char_len / 64 + 1 + ]; + // Signed: the countdown overshoots the last group -- the loop stops on + // the first negative value rather than at a group boundary. + let mut remaining = char_len as isize; + let mut base = 0; + let mut current = 0; + loop { + groups[current].base = base; + let mut next = base; + let mut group_filled = true; + for i in 0..16 { + // Past the end, step as if one more single-byte code point + // followed, so the entry stays in range and is never read. + next = if remaining == 0 { + next + 1 + } else { + next_pos(data, next) + }; + groups[current].ofs[i] = (next - base) as u8; + remaining -= 4; + if remaining < 0 { + debug_assert_eq!(current + 1, groups.len()); + group_filled = false; + break; + } + next = next_pos(data, next_pos(data, next_pos(data, next))); + } + if !group_filled { + break; + } + current += 1; + base = next; + } + Self { + groups: groups.into_boxed_slice(), + } + } + + /// The byte offset of `data`'s `index`-th code point. + /// + /// `data` must be the buffer the table was built for, and `index` must be + /// below its code point count. + #[inline] + #[must_use] + pub fn byte_offset(&self, data: &Wtf8, index: usize) -> usize { + let group = &self.groups[index >> 6]; + // The entry sits on the 4k+1-th code point of the group, so a lookup is + // one table read plus at most two steps in either direction. + let pos = group.base + group.ofs[(index >> 2) & 0x0F] as usize; + match index & 0x3 { + 0 => prev_pos(data, pos), + 1 => pos, + 2 => next_pos(data, pos), + _ => next_pos(data, next_pos(data, pos)), + } + } + + /// The table's heap footprint, in bytes. + #[must_use] + pub fn byte_size(&self) -> usize { + core::mem::size_of_val(&*self.groups) + } +} + +/// The byte offset of the code point after the one at `pos`. +/// +/// `data` must be well-formed WTF-8 and `pos` a code point boundary before its +/// end -- reading only the lead byte is what makes this branch-light. +#[inline] +fn next_pos(data: &Wtf8, pos: usize) -> usize { + match data.as_bytes()[pos] { + 0x00..=0x7F => pos + 1, + 0x80..=0xDF => pos + 2, + 0xE0..=0xEF => pos + 3, + _ => pos + 4, + } +} + +/// The byte offset of the code point before the one at `pos`, which must not be +/// zero. +/// +/// A `pos` one past the end reads as the extra code point [`Wtf8Index::new`] +/// steps over there. +#[inline] +fn prev_pos(data: &Wtf8, pos: usize) -> usize { + let data = data.as_bytes(); + let mut pos = pos - 1; + if pos >= data.len() || data[pos] <= 0x7F { + return pos; + } + pos -= 1; + if data[pos] >= 0xC0 { + return pos; + } + pos -= 1; + if data[pos] >= 0xC0 { + return pos; + } + pos - 1 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wtf8::{CodePoint, Wtf8Buf}; + + /// Every index of `s`, against the offsets its own iterator reports. + fn check(s: &Wtf8) { + let expected: Vec = s + .code_point_indices() + .map(|(byte_offset, _)| byte_offset) + .collect(); + let index = Wtf8Index::new(s, expected.len()); + for (i, &want) in expected.iter().enumerate() { + assert_eq!( + index.byte_offset(s, i), + want, + "index {i} of {s:?} ({} code points)", + expected.len() + ); + } + } + + fn wtf8(s: &str) -> Wtf8Buf { + Wtf8Buf::from(s) + } + + #[test] + fn empty() { + check(wtf8("").as_ref()); + } + + #[test] + fn widths() { + // One case per encoded width, and the boundaries between them. + check(wtf8("abc").as_ref()); + check(wtf8("\u{80}\u{7ff}").as_ref()); + check(wtf8("\u{800}\u{ffff}").as_ref()); + check(wtf8("\u{10000}\u{10ffff}").as_ref()); + check(wtf8("a\u{80}\u{800}\u{10000}").as_ref()); + } + + #[test] + fn group_boundaries() { + // A group covers 64 code points and an entry four, so the interesting + // lengths are the ones on and around both. + for len in [1, 3, 4, 5, 63, 64, 65, 127, 128, 129, 255, 256, 257] { + for unit in ["a", "\u{80}", "\u{800}", "\u{10000}"] { + check(wtf8(&unit.repeat(len)).as_ref()); + } + // Mixed widths, so a group's entries do not share a stride. + check(wtf8(&"a\u{80}\u{800}\u{10000}".repeat(len)).as_ref()); + } + } + + #[test] + fn lone_surrogates() { + let mut s = wtf8("a"); + for cp in [0xD800, 0xDBFF, 0xDC00, 0xDFFF] { + s.push(CodePoint::from_u32(cp).unwrap()); + s.push_str("b"); + } + check(s.as_ref()); + + // Surrogates only, spanning more than one group. + let mut s = wtf8(""); + for i in 0..200 { + s.push(CodePoint::from_u32(0xD800 + (i % 0x400)).unwrap()); + } + check(s.as_ref()); + } + + #[test] + fn byte_size_is_one_group_per_64_code_points() { + let s = wtf8(&"\u{10000}".repeat(200)); + let index = Wtf8Index::new(s.as_ref(), 200); + assert_eq!(index.byte_size(), (200 / 64 + 1) * size_of::()); + assert_eq!(size_of::(), 24); + } +} diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index ae8ba0ed07e..c25940cd3c6 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -712,6 +712,13 @@ impl PyStr { self.data.char_len() } + /// The byte offset the `index`-th character starts at, or the string's byte + /// length if `index` is at or past its end. + #[inline] + pub fn char_index_to_byte(&self, index: usize) -> usize { + self.data.char_index_to_byte(index) + } + #[pymethod] #[inline(always)] pub const fn isascii(&self) -> bool { diff --git a/crates/vm/src/stdlib/_sre.rs b/crates/vm/src/stdlib/_sre.rs index 6fe6b434702..168363103e4 100644 --- a/crates/vm/src/stdlib/_sre.rs +++ b/crates/vm/src/stdlib/_sre.rs @@ -70,15 +70,76 @@ mod _sre { } } - impl SreStr for &Wtf8 { + /// A `str` subject with non-ASCII characters, driven through the string's + /// own character-index table. + /// + /// The `&Wtf8` drive answers `count` and `create_cursor` by decoding from + /// the start of the subject, so both are O(n) and a scan that restarts at + /// successive positions walks the subject once per position. `PyStr` + /// already caches its character length and can resolve a character index to + /// a byte offset in constant time, so this drive asks the string instead of + /// re-deriving: the table it builds on the first lookup is shared by every + /// later one, including by `Match` objects that outlive the scan and have + /// no cursor of their own to move relative to. + /// + /// Stepping is the `&Wtf8` drive's, unchanged -- the subject is the same + /// buffer, decoded the same way. Only the two operations that resolve a + /// position from scratch differ. + #[derive(Clone, Copy)] + struct Utf8Str<'a>(&'a Py); + + impl StrDrive for Utf8Str<'_> { + fn count(&self) -> usize { + self.0.char_len() + } + + fn create_cursor(&self, n: usize) -> StringCursor { + // `StringCursor`'s pointer is private to the engine, so the cursor + // is taken from the `&Wtf8` drive at the start of the suffix that + // begins at `n` -- an O(1) reslice -- rather than built here. + let suffix = &self.0.as_wtf8()[self.0.char_index_to_byte(n)..]; + let mut cursor = <&Wtf8 as StrDrive>::create_cursor(&suffix, 0); + cursor.position = n; + cursor + } + + fn adjust_cursor(&self, cursor: &mut StringCursor, n: usize) { + // Rebuilding is O(1), so it is never the slower branch and the + // `&Wtf8` drive's walk-or-restart choice does not apply. + *cursor = self.create_cursor(n); + } + + fn advance(cursor: &mut StringCursor) -> u32 { + <&Wtf8 as StrDrive>::advance(cursor) + } + + fn peek(cursor: &StringCursor) -> u32 { + <&Wtf8 as StrDrive>::peek(cursor) + } + + fn skip(cursor: &mut StringCursor, n: usize) { + <&Wtf8 as StrDrive>::skip(cursor, n) + } + + fn back_advance(cursor: &mut StringCursor) -> u32 { + <&Wtf8 as StrDrive>::back_advance(cursor) + } + + fn back_peek(cursor: &StringCursor) -> u32 { + <&Wtf8 as StrDrive>::back_peek(cursor) + } + + fn back_skip(cursor: &mut StringCursor, n: usize) { + <&Wtf8 as StrDrive>::back_skip(cursor, n) + } + } + + impl SreStr for Utf8Str<'_> { fn slice(&self, start: usize, end: usize, vm: &VirtualMachine) -> PyObjectRef { + let end = self.0.char_index_to_byte(end); + let start = self.0.char_index_to_byte(start).min(end); vm.ctx - .new_str( - self.code_points() - .take(end) - .skip(start) - .collect::(), - ) + .new_str(self.0.as_wtf8()[start..end].to_owned()) .into() } } @@ -275,28 +336,31 @@ mod _sre { } else if Pattern::is_ascii_str(subject) { Pattern::with_ascii_str(subject, $vm, $f) } else { - Pattern::with_str(subject, $vm, $f) + Pattern::with_utf8_str(subject, $vm, $f) } }}; } #[pyclass(with(Hashable, Comparable, Representable), flags(HAS_WEAKREF))] impl Pattern { + fn downcast_str<'a>(string: &'a PyObject, vm: &VirtualMachine) -> PyResult<&'a Py> { + string.downcast_ref::().ok_or_else(|| { + vm.new_type_error(format!("expected string got '{}'", string.class())) + }) + } + fn with_str(string: &PyObject, vm: &VirtualMachine, f: F) -> PyResult where F: FnOnce(&Wtf8) -> PyResult, { - let string = string.downcast_ref::().ok_or_else(|| { - vm.new_type_error(format!("expected string got '{}'", string.class())) - })?; - f(string.as_wtf8()) + f(Self::downcast_str(string, vm)?.as_wtf8()) } /// Whether a `str` subject can take the [`AsciiStr`] drive. /// /// `PyStr` already knows: `StrKind` is decided when the string is /// built, so this is a field load rather than a scan. A non-`str` - /// argument answers `false` and is reported by [`Self::with_str`]. + /// argument answers `false` and is reported by [`Self::with_utf8_str`]. fn is_ascii_str(string: &PyObject) -> bool { string .downcast_ref::() @@ -307,12 +371,17 @@ mod _sre { where F: FnOnce(AsciiStr<'_>) -> PyResult, { - let string = string.downcast_ref::().ok_or_else(|| { - vm.new_type_error(format!("expected string got '{}'", string.class())) - })?; + let string = Self::downcast_str(string, vm)?; f(AsciiStr(string.as_wtf8().as_bytes())) } + fn with_utf8_str(string: &PyObject, vm: &VirtualMachine, f: F) -> PyResult + where + F: FnOnce(Utf8Str<'_>) -> PyResult, + { + f(Utf8Str(Self::downcast_str(string, vm)?)) + } + fn with_bytes(string: &PyObject, vm: &VirtualMachine, f: F) -> PyResult where F: FnOnce(&[u8]) -> PyResult, From 7e25617f766672f7b87bec9ebd397e30ac4da9fb Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:03:09 +0900 Subject: [PATCH 299/351] str: resolve subscripts and slices through the code point index (#8526) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `nth_char` and the four `SliceableSequenceOp` methods each walked the buffer to reach a character index, so `s[i]` and `s[a:b]` on a non-ASCII string were O(i) and O(b), and a loop over either was quadratic. They now resolve through `char_index_to_byte`, which is what the index table was added for: a plain slice becomes a byte reslice, and a stepped slice one lookup per collected character. An index within four code points of an end is still walked to, and so is a slice that reaches within four of both. PyPy draws the same line with `MAX_UNROLL_NEXT_CODEPOINT_POS`, in a guard that also asks the JIT whether the index is a constant; there is no JIT here, but the reason to skip the build survives it -- `s[0]` on a long string should not pay for a table. The stepped slices took their character count from `(range.len() / step) + 1`, which overshoots whenever the last step lands short: `"aéc"[::3]` reported a length of 2 for a one-character string, and `reversed()` on it read past the end of the buffer and panicked. The count is now the index iterator's own length, so it cannot drift from the characters actually collected. Assisted-by: Claude --- crates/common/src/str.rs | 77 ++++++++++++++++- crates/vm/src/builtins/str.rs | 150 ++++++++++++---------------------- 2 files changed, 128 insertions(+), 99 deletions(-) diff --git a/crates/common/src/str.rs b/crates/common/src/str.rs index 8edb48fa4fa..72aecb7931e 100644 --- a/crates/common/src/str.rs +++ b/crates/common/src/str.rs @@ -113,6 +113,16 @@ pub enum PyKindStr<'a> { Wtf8(&'a Wtf8), } +/// How far from an end an index is resolved by walking rather than by building +/// the code point index. +/// +/// PyPy spells this `MAX_UNROLL_NEXT_CODEPOINT_POS`, in a guard that also asks +/// the JIT whether the index is a constant, so that the walk unrolls. There is +/// no JIT here to ask, and the walk is short rather than free -- but four steps +/// still beat a pass over the whole buffer, and skipping the build is what +/// keeps `s[0]` and `s[1:-1]` on a long string from paying for a table. +const MAX_WALK_TO_INDEX: usize = 4; + #[derive(Debug, Clone)] pub struct StrData { data: Box, @@ -405,11 +415,74 @@ impl StrData { .byte_offset(&self.data, index) } + /// The byte offset of code point `index`, for a caller that resolves one + /// index and stops. + /// + /// Building the table costs a pass over the whole buffer, so it is worth it + /// only for a caller that comes back; an index within + /// [`MAX_WALK_TO_INDEX`] steps of either end is cheaper to walk to, and + /// walking keeps `s[0]` on a long string from paying for a table it will + /// never use again. Anything further in builds, on the reasoning that a + /// string indexed once in the middle tends to be indexed again. + fn char_index_to_byte_once(&self, index: usize) -> usize { + if index <= MAX_WALK_TO_INDEX { + return self + .data + .code_point_indices() + .nth(index) + .map_or(self.data.len(), |(byte, _)| byte); + } + let from_end = self.char_len() - index; + if from_end <= MAX_WALK_TO_INDEX { + return self + .data + .code_point_indices() + .nth_back(from_end - 1) + .map_or(self.data.len(), |(byte, _)| byte); + } + self.char_index_to_byte(index) + } + + /// The byte range spanned by the code points in `range`. + /// + /// A range that reaches within [`MAX_WALK_TO_INDEX`] of *both* ends is + /// walked to for the same reason a single index near one end is -- a slice + /// like `s[1:-1]` should not build a table over the whole string. + #[must_use] + pub fn char_range_to_bytes(&self, range: core::ops::Range) -> core::ops::Range { + if self.kind.is_ascii() { + return range; + } + let from_end = self.char_len() - range.end; + if range.start <= MAX_WALK_TO_INDEX && from_end <= MAX_WALK_TO_INDEX { + // Two walks over disjoint ends, each of at most MAX_WALK_TO_INDEX + // steps -- one iterator driven from both sides would have them meet + // on a short string. + let start = self + .data + .code_point_indices() + .nth(range.start) + .map_or(self.data.len(), |(byte, _)| byte); + let end = match from_end { + 0 => self.data.len(), + n => self + .data + .code_point_indices() + .nth_back(n - 1) + .map_or(self.data.len(), |(byte, _)| byte), + }; + return start..end; + } + self.char_index_to_byte(range.start)..self.char_index_to_byte(range.end) + } + pub fn nth_char(&self, index: usize) -> CodePoint { match self.as_str_kind() { PyKindStr::Ascii(s) => s[index].into(), - PyKindStr::Utf8(s) => s.chars().nth(index).unwrap().into(), - PyKindStr::Wtf8(w) => w.code_points().nth(index).unwrap(), + _ => self.data[self.char_index_to_byte_once(index)..] + .code_points() + .next() + .unwrap(), } } } diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index c25940cd3c6..06e36738603 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -1807,6 +1807,31 @@ pub(crate) fn init(ctx: &'static Context) { PyStrIterator::extend_class(ctx, ctx.types.str_iterator_type); } +impl PyStr { + /// The code points at `indices`, in that order, as a new string. + /// + /// Each index is resolved through the string's own index table, so the + /// cost is one lookup per collected character rather than a walk to the + /// furthest one. The iterator's length is the result's character count, + /// which is why it has to be exact. + fn gather_chars(&self, indices: impl ExactSizeIterator) -> Self { + let char_len = indices.len(); + // Not ascii, so the code points are at least two bytes each. + let mut out = Wtf8Buf::with_capacity(2 * char_len); + let s = self.as_wtf8(); + for index in indices { + out.push( + s[self.data.char_index_to_byte(index)..] + .code_points() + .next() + .expect("index is below the character count"), + ); + } + // SAFETY: char_len is accurate + unsafe { Self::new_with_char_len(out, char_len) } + } +} + impl SliceableSequenceOp for PyStr { type Item = CodePoint; type Sliced = Self; @@ -1816,125 +1841,56 @@ impl SliceableSequenceOp for PyStr { } fn do_slice(&self, range: Range) -> Self::Sliced { - match self.as_str_kind() { - PyKindStr::Ascii(s) => s[range].into(), - PyKindStr::Utf8(s) => { - let char_len = range.len(); - let out = rustpython_common::str::get_chars(s, range); - // SAFETY: char_len is accurate - unsafe { Self::new_with_char_len(out, char_len) } - } - PyKindStr::Wtf8(w) => { - let char_len = range.len(); - let out = rustpython_common::str::get_codepoints(w, range); - // SAFETY: char_len is accurate - unsafe { Self::new_with_char_len(out, char_len) } - } + if let PyKindStr::Ascii(s) = self.as_str_kind() { + return s[range].into(); } + // Both ends resolve through the string's own index, so the slice is a + // byte reslice rather than a walk to `range.start` and another to + // `range.end`. + let char_len = range.len(); + let bytes = self.data.char_range_to_bytes(range); + let out = &self.as_wtf8()[bytes]; + // SAFETY: char_len is accurate + unsafe { Self::new_with_char_len(out.to_owned(), char_len) } } fn do_slice_reverse(&self, range: Range) -> Self::Sliced { - match self.as_str_kind() { - PyKindStr::Ascii(s) => { - let mut out = s[range].to_owned(); - out.as_mut_slice().reverse(); - out.into() - } - PyKindStr::Utf8(s) => { - let char_len = range.len(); - let mut out = String::with_capacity(2 * char_len); - out.extend( - s.chars() - .rev() - .skip(self.char_len() - range.end) - .take(range.len()), - ); - // SAFETY: char_len is accurate - unsafe { Self::new_with_char_len(out, range.len()) } - } - PyKindStr::Wtf8(w) => { - let char_len = range.len(); - let mut out = Wtf8Buf::with_capacity(2 * char_len); - out.extend( - w.code_points() - .rev() - .skip(self.char_len() - range.end) - .take(range.len()), - ); - // SAFETY: char_len is accurate - unsafe { Self::new_with_char_len(out, char_len) } - } + if let PyKindStr::Ascii(s) = self.as_str_kind() { + let mut out = s[range].to_owned(); + out.as_mut_slice().reverse(); + return out.into(); } + let char_len = range.len(); + let bytes = self.data.char_range_to_bytes(range); + let mut out = Wtf8Buf::with_capacity(bytes.len()); + out.extend(self.as_wtf8()[bytes].code_points().rev()); + // SAFETY: char_len is accurate + unsafe { Self::new_with_char_len(out, char_len) } } fn do_stepped_slice(&self, range: Range, step: usize) -> Self::Sliced { - match self.as_str_kind() { - PyKindStr::Ascii(s) => s[range] + if let PyKindStr::Ascii(s) = self.as_str_kind() { + return s[range] .as_slice() .iter() .copied() .step_by(step) .collect::() - .into(), - PyKindStr::Utf8(s) => { - let char_len = range.len().div_ceil(step); - let mut out = String::with_capacity(2 * char_len); - out.extend(s.chars().skip(range.start).take(range.len()).step_by(step)); - // SAFETY: char_len is accurate - unsafe { Self::new_with_char_len(out, char_len) } - } - PyKindStr::Wtf8(w) => { - let char_len = range.len().div_ceil(step); - let mut out = Wtf8Buf::with_capacity(2 * char_len); - out.extend( - w.code_points() - .skip(range.start) - .take(range.len()) - .step_by(step), - ); - // SAFETY: char_len is accurate - unsafe { Self::new_with_char_len(out, char_len) } - } + .into(); } + self.gather_chars(range.step_by(step)) } fn do_stepped_slice_reverse(&self, range: Range, step: usize) -> Self::Sliced { - match self.as_str_kind() { - PyKindStr::Ascii(s) => s[range] + if let PyKindStr::Ascii(s) = self.as_str_kind() { + return s[range] .chars() .rev() .step_by(step) .collect::() - .into(), - PyKindStr::Utf8(s) => { - let char_len = range.len().div_ceil(step); - // not ascii, so the codepoints have to be at least 2 bytes each - let mut out = String::with_capacity(2 * char_len); - out.extend( - s.chars() - .rev() - .skip(self.char_len() - range.end) - .take(range.len()) - .step_by(step), - ); - // SAFETY: char_len is accurate - unsafe { Self::new_with_char_len(out, char_len) } - } - PyKindStr::Wtf8(w) => { - let char_len = range.len().div_ceil(step); - // not ascii, so the codepoints have to be at least 2 bytes each - let mut out = Wtf8Buf::with_capacity(2 * char_len); - out.extend( - w.code_points() - .rev() - .skip(self.char_len() - range.end) - .take(range.len()) - .step_by(step), - ); - // SAFETY: char_len is accurate - unsafe { Self::new_with_char_len(out, char_len) } - } + .into(); } + self.gather_chars(range.rev().step_by(step)) } fn empty() -> Self::Sliced { From 466d9da4acdc0e7fb07a2c3d54ef195e8e73109f Mon Sep 17 00:00:00 2001 From: Lee Dogeon Date: Sat, 15 Aug 2026 02:23:05 +0900 Subject: [PATCH 300/351] ci: fix OSCCA pull request permissions (#8515) Assisted-by: Codex:gpt-5.6-sol --- .github/workflows/oscca-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/oscca-pr.yml b/.github/workflows/oscca-pr.yml index f96c18fd3c3..38b67625ce0 100644 --- a/.github/workflows/oscca-pr.yml +++ b/.github/workflows/oscca-pr.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-slim timeout-minutes: 5 permissions: - issues: write + pull-requests: write steps: - name: Label and assign pull request uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 From 7a2cc655388f07484f88cd89c107d6642fc29210 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:40:34 +0900 Subject: [PATCH 301/351] build(deps): bump thiserror in the thiserror group across 1 directory (#8527) Bumps the thiserror group with 1 update in the / directory: [thiserror](https://github.com/dtolnay/thiserror). Updates `thiserror` from 2.0.19 to 2.0.20 - [Release notes](https://github.com/dtolnay/thiserror/releases) - [Commits](https://github.com/dtolnay/thiserror/compare/2.0.19...2.0.20) --- updated-dependencies: - dependency-name: thiserror dependency-version: 2.0.20 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: thiserror ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26015d39933..9ae2c1875de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4209,18 +4209,18 @@ checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", From a6fe919c22cb1b94a88b4ef59d751c7ad31ded20 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:40:43 +0900 Subject: [PATCH 302/351] build(deps): bump psm from 0.1.31 to 0.1.32 (#8528) Bumps [psm](https://github.com/rust-lang/stacker) from 0.1.31 to 0.1.32. - [Commits](https://github.com/rust-lang/stacker/compare/psm-0.1.31...psm-0.1.32) --- updated-dependencies: - dependency-name: psm dependency-version: 0.1.32 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ae2c1875de..c8d3f2f6e7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2766,9 +2766,9 @@ dependencies = [ [[package]] name = "psm" -version = "0.1.31" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" dependencies = [ "ar_archive_writer", "cc", From 2274cef8b58d675f3d466515dbe1a762dd9835e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 04:40:51 +0900 Subject: [PATCH 303/351] build(deps): bump quote from 1.0.46 to 1.0.47 (#8529) Bumps [quote](https://github.com/dtolnay/quote) from 1.0.46 to 1.0.47. - [Release notes](https://github.com/dtolnay/quote/releases) - [Commits](https://github.com/dtolnay/quote/compare/1.0.46...1.0.47) --- updated-dependencies: - dependency-name: quote dependency-version: 1.0.47 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c8d3f2f6e7c..7a9181d7c85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2847,9 +2847,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] From 5919be9c4c4ff16e9319de9f01046e5f2c1046cd Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:23:37 +0900 Subject: [PATCH 304/351] str: take search bounds through the character index (#8530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * str: count an empty needle in characters str.count with an empty needle ran find_iter over the range's bytes, which reports one position per encoded byte boundary rather than one per character: '가나다'.count('') answered 10 instead of 4. Count the range's code points instead; bytes.count keeps the byte-position answer. Assisted-by: Claude * str: take search bounds through the character index find, rfind, index, rindex, count, startswith and endswith resolved their start/stop bounds with AnyStr::get_chars, which walks the payload to both bounds, and find reported its hit by counting the code points in front of it -- so a sweep over a subject's own indices walked it once per call. Resolve the bounds with StrData::char_range_to_bytes instead, which is the converter the subscript and slice paths already use, and map the hit back with Wtf8Index::char_index_at_byte, added here as the table's inverse: a bracketed search over the groups, then at most an entry's worth of steps. The payload alone does not say whether it is ASCII, so get_chars walked there too; that path is now off it as well. n=8000, sweeping the bound over the subject, best of 3, interleaved: '가나다라'*n/4 'abcd'*n/4 find 18.00ms -> 0.15ms 3.48ms -> 0.14ms startswith 16.75ms -> 0.12ms count 22.78ms -> 4.02ms count stays linear in the range it is given, as it is in CPython. Assisted-by: Claude --- crates/common/src/str.rs | 22 +++++++- crates/common/src/wtf8_index.rs | 78 +++++++++++++++++++++++++++-- crates/vm/src/anystr.rs | 6 ++- crates/vm/src/builtins/str.rs | 75 ++++++++++++++++++--------- extra_tests/snippets/builtin_str.py | 9 ++++ 5 files changed, 161 insertions(+), 29 deletions(-) diff --git a/crates/common/src/str.rs b/crates/common/src/str.rs index 72aecb7931e..326f76c43cb 100644 --- a/crates/common/src/str.rs +++ b/crates/common/src/str.rs @@ -443,7 +443,8 @@ impl StrData { self.char_index_to_byte(index) } - /// The byte range spanned by the code points in `range`. + /// The byte range spanned by the code points in `range`, whose end must not + /// exceed the string's code point count. /// /// A range that reaches within [`MAX_WALK_TO_INDEX`] of *both* ends is /// walked to for the same reason a single index near one end is -- a slice @@ -476,6 +477,25 @@ impl StrData { self.char_index_to_byte(range.start)..self.char_index_to_byte(range.end) } + /// The character index of the character starting at byte offset `bytepos`, + /// the inverse of [`Self::char_index_to_byte`]. + /// + /// `bytepos` must be a character boundary at or before the end. + /// + /// Logarithmic rather than constant, because the index is keyed the other + /// way -- but a search whose bounds came from `char_index_to_byte` has the + /// table already, and this is what turns a byte offset back into the answer + /// a caller asked for in characters. + pub fn byte_to_char_index(&self, bytepos: usize) -> usize { + if self.kind.is_ascii() { + return bytepos; + } + let char_len = self.char_len(); + self.index + .get_or_build(&self.data, char_len) + .char_index_at_byte(&self.data, bytepos, char_len) + } + pub fn nth_char(&self, index: usize) -> CodePoint { match self.as_str_kind() { PyKindStr::Ascii(s) => s[index].into(), diff --git a/crates/common/src/wtf8_index.rs b/crates/common/src/wtf8_index.rs index a6121f1c03d..4b2e5ebee5b 100644 --- a/crates/common/src/wtf8_index.rs +++ b/crates/common/src/wtf8_index.rs @@ -104,6 +104,63 @@ impl Wtf8Index { } } + /// The index of the code point starting at byte offset `bytepos`, the + /// inverse of [`Self::byte_offset`]. + /// + /// `data` must be the buffer the table was built for, `char_len` its code + /// point count, and `bytepos` a code point boundary at or before its end. + /// + /// Logarithmic rather than constant: the table is keyed by code point + /// index, so going the other way is a search through it. The bracketing + /// below is what keeps that search short -- a code point occupies one to + /// four bytes, which pins the answer to a narrow band around `bytepos` + /// before the first comparison. + #[must_use] + pub fn char_index_at_byte(&self, data: &Wtf8, bytepos: usize, char_len: usize) -> usize { + let bytes_remaining = data.len() - bytepos; + // At least one byte per remaining code point, and at most four, so the + // group holding the answer lies between these. + let mut group_min = + usize::max(bytepos / 4, char_len.saturating_sub(bytes_remaining + 1)) >> 6; + let mut group_max = usize::min(bytepos, char_len.saturating_sub(bytes_remaining / 4)) >> 6; + while group_min < group_max { + let middle = group_min.midpoint(group_max) + 1; + if bytepos < self.groups[middle].base { + group_max = middle - 1; + } else { + group_min = middle; + } + } + + let base = self.groups[group_min].base; + if base == bytepos { + return group_min << 6; + } + // Walk the group's entries to the last one at or before `bytepos`, + // then step the remaining code points, of which there are at most + // three -- an entry covers four. + let entries = if group_min == self.groups.len() - 1 { + ((char_len - 1) >> 2) & 0x0F + } else { + 16 + }; + let mut index = group_min << 6; + let mut pos = base; + for entry in 0..entries { + let at = base + self.groups[group_min].ofs[entry] as usize; + if at >= bytepos { + break; + } + pos = at; + index = (group_min << 6) + (entry << 2) + 1; + } + while pos < bytepos { + pos = next_pos(data, pos); + index += 1; + } + index + } + /// The table's heap footprint, in bytes. #[must_use] pub fn byte_size(&self) -> usize { @@ -153,21 +210,34 @@ mod tests { use super::*; use crate::wtf8::{CodePoint, Wtf8Buf}; - /// Every index of `s`, against the offsets its own iterator reports. + /// Every index of `s`, both ways, against the offsets its own iterator + /// reports. fn check(s: &Wtf8) { let expected: Vec = s .code_point_indices() .map(|(byte_offset, _)| byte_offset) .collect(); - let index = Wtf8Index::new(s, expected.len()); + let char_len = expected.len(); + let index = Wtf8Index::new(s, char_len); for (i, &want) in expected.iter().enumerate() { assert_eq!( index.byte_offset(s, i), want, - "index {i} of {s:?} ({} code points)", - expected.len() + "index {i} of {s:?} ({char_len} code points)" + ); + assert_eq!( + index.char_index_at_byte(s, want, char_len), + i, + "byte {want} of {s:?} ({char_len} code points)" ); } + // One past the last code point is a boundary too, and the searches that + // use this ask for it as an end bound. + assert_eq!( + index.char_index_at_byte(s, s.len(), char_len), + char_len, + "end of {s:?}" + ); } fn wtf8(s: &str) -> Wtf8Buf { diff --git a/crates/vm/src/anystr.rs b/crates/vm/src/anystr.rs index 69ba525267a..45a69dbfe2f 100644 --- a/crates/vm/src/anystr.rs +++ b/crates/vm/src/anystr.rs @@ -147,7 +147,11 @@ pub(crate) trait AnyStr { fn as_bytes(&self) -> &[u8]; fn elements(&self) -> impl Iterator; fn get_bytes(&self, range: Range) -> &Self; - // FIXME: get_chars is expensive for str + /// The characters in `range`, which for a `str` payload means walking to + /// both bounds -- the payload does not carry the string's character index. + /// `PyStr` therefore converts its own ranges and does not reach the search + /// helpers below through this; what remains are the byte strings, where a + /// character range is already a byte range. fn get_chars(&self, range: Range) -> &Self; fn bytes_len(&self) -> usize; // NOTE: str::chars().count() consumes the O(n) time. But pystr::char_len does cache. diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index 06e36738603..2e099424a23 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -9,7 +9,7 @@ use super::{ use crate::{ AsObject, Context, Py, PyExact, PyObject, PyObjectRef, PyPayload, PyRef, PyRefExact, PyResult, TryFromBorrowedObject, VirtualMachine, - anystr::{self, AnyStr, AnyStrContainer, AnyStrWrapper, adjust_indices}, + anystr::{self, AnyStr, AnyStrContainer, AnyStrWrapper, StringRange, adjust_indices}, atomic_func, bytes_inner::{swapcase_ascii, title_ascii}, cformat::cformat_string, @@ -719,6 +719,13 @@ impl PyStr { self.data.char_index_to_byte(index) } + /// The character index of the character starting at byte offset `bytepos`, + /// which must be a character boundary at or before the end. + #[inline] + pub fn byte_to_char_index(&self, bytepos: usize) -> usize { + self.data.byte_to_char_index(bytepos) + } + #[pymethod] #[inline(always)] pub const fn isascii(&self) -> bool { @@ -924,11 +931,12 @@ impl PyStr { #[pymethod] fn endswith(&self, options: anystr::StartsEndsWithArgs, vm: &VirtualMachine) -> PyResult { - let (affix, substr) = - match options.prepare(self.as_wtf8(), self.len(), |s, r| s.get_chars(r)) { - Some(x) => x, - None => return Ok(false), - }; + let (affix, substr) = match options.prepare(self.as_wtf8(), self.len(), |s, r| { + &s[self.data.char_range_to_bytes(r)] + }) { + Some(x) => x, + None => return Ok(false), + }; substr.py_starts_ends_with( &affix, "endswith", @@ -944,11 +952,12 @@ impl PyStr { options: anystr::StartsEndsWithArgs, vm: &VirtualMachine, ) -> PyResult { - let (affix, substr) = - match options.prepare(self.as_wtf8(), self.len(), |s, r| s.get_chars(r)) { - Some(x) => x, - None => return Ok(false), - }; + let (affix, substr) = match options.prepare(self.as_wtf8(), self.len(), |s, r| { + &s[self.data.char_range_to_bytes(r)] + }) { + Some(x) => x, + None => return Ok(false), + }; substr.py_starts_ends_with( &affix, "startswith", @@ -1167,42 +1176,52 @@ impl PyStr { Ok(vm.ctx.new_str(joined)) } - // FIXME: two traversals of str is expensive + /// The bytes the character range `range` spans and the byte offset it + /// starts at, or `None` if the range is inverted. + /// + /// The bounds go through the string's character index, so reaching a range + /// deep in the subject costs a lookup rather than a walk to it. #[inline] - fn _to_char_idx(r: &Wtf8, byte_idx: usize) -> usize { - r[..byte_idx].code_points().count() + fn char_range_bytes(&self, range: Range) -> Option<(usize, &Wtf8)> { + if !range.is_normal() { + return None; + } + let bytes = self.data.char_range_to_bytes(range); + Some((bytes.start, &self.as_wtf8()[bytes])) } + /// Searches the character range `range` with `find`, which answers in bytes + /// relative to the range, and reports the hit as a character index. #[inline] fn _find(&self, args: FindArgs, find: F) -> Option where F: Fn(&Wtf8, &Wtf8) -> Option, { let (sub, range) = args.get_value(self.len()); - self.as_wtf8().py_find(sub.as_wtf8(), range, find) + let (start, haystack) = self.char_range_bytes(range)?; + let found = find(haystack, sub.as_wtf8())?; + Some(self.byte_to_char_index(start + found)) } #[pymethod] fn find(&self, args: FindArgs) -> isize { - self._find(args, |r, s| Some(Self::_to_char_idx(r, r.find(s)?))) - .map_or(-1, |v| v as isize) + self._find(args, Wtf8::find).map_or(-1, |v| v as isize) } #[pymethod] fn rfind(&self, args: FindArgs) -> isize { - self._find(args, |r, s| Some(Self::_to_char_idx(r, r.rfind(s)?))) - .map_or(-1, |v| v as isize) + self._find(args, Wtf8::rfind).map_or(-1, |v| v as isize) } #[pymethod] fn index(&self, args: FindArgs, vm: &VirtualMachine) -> PyResult { - self._find(args, |r, s| Some(Self::_to_char_idx(r, r.find(s)?))) + self._find(args, Wtf8::find) .ok_or_else(|| vm.new_value_error("substring not found")) } #[pymethod] fn rindex(&self, args: FindArgs, vm: &VirtualMachine) -> PyResult { - self._find(args, |r, s| Some(Self::_to_char_idx(r, r.rfind(s)?))) + self._find(args, Wtf8::rfind) .ok_or_else(|| vm.new_value_error("substring not found")) } @@ -1275,8 +1294,18 @@ impl PyStr { #[pymethod] fn count(&self, args: FindArgs) -> usize { let (needle, range) = args.get_value(self.len()); - self.as_wtf8() - .py_count(needle.as_wtf8(), range, |h, n| h.find_iter(n).count()) + let chars = range.len(); + self.char_range_bytes(range).map_or(0, |(_, haystack)| { + if needle.is_empty() { + // An empty needle sits between every pair of characters and at + // both ends, so it occurs once more than the range holds + // characters. Counting it in the bytes would answer in encoded + // positions instead. + chars + 1 + } else { + haystack.find_iter(needle.as_wtf8()).count() + } + }) } #[pymethod] diff --git a/extra_tests/snippets/builtin_str.py b/extra_tests/snippets/builtin_str.py index fde9deb8e0b..6eead5ddbfb 100644 --- a/extra_tests/snippets/builtin_str.py +++ b/extra_tests/snippets/builtin_str.py @@ -170,6 +170,15 @@ assert "aaa".count("a", 2, 2) == 0 assert "aaa".count("a", 2, 1) == 0 +# An empty needle is counted in characters, not in encoded positions. +assert "".count("") == 1 +assert "abc".count("") == 4 +assert "가나다".count("") == 4 +assert "가나다".count("", 1) == 3 +assert "가나다".count("", 1, 2) == 2 +assert "가나다".count("", 4, 4) == 0 +assert "a\U0001f600b".count("") == 4 + assert "___a__".find("a") == 3 assert "___a__".find("a", -10) == 3 assert "___a__".find("a", -3) == 3 From 833a2ba63dadb32cd525ac0704e61c428b28bc41 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:23:05 +0900 Subject: [PATCH 305/351] str, bytes: answer equality with equality rather than with an ordering (#8531) PyStr's comparison, PyBytesInner's, and the specialized CompareOpStr instruction all answered == and != by taking Ord::cmp of the two buffers and asking whether the result was Equal. An ordering has to read the bytes: it memcmps the common prefix even where the lengths already settle the question. CompareOpStr bypasses the Comparable slot, so it had also lost the identity shortcut that slot takes, and a string compared with itself was read end to end. Add PyComparisonOp::eval_eq, which settles Eq and Ne from an equality test and leaves an ordering operator to the caller, and answer through it in the three places: slice equality checks the length first, and CompareOpStr answers an object compared with itself the way the slot it specializes does. n=1,000,000, per comparison: before after s == s (the very same object) 23.21us 0.16us s == a string one shorter 24.63us 0.17us b == bytes one shorter 25.28us 0.20us ba == bytearray one shorter 27.68us 0.23us s == an equal, distinct string 23.78us 24.50us s < an equal string 29.87us 25.07us Assisted-by: Claude --- crates/vm/src/builtins/str.rs | 5 +++ crates/vm/src/bytes_inner.rs | 7 ++- crates/vm/src/frame.rs | 9 ++-- crates/vm/src/types/slot.rs | 23 ++++++++++ extra_tests/snippets/operator_comparison.py | 47 +++++++++++++++++++++ 5 files changed, 87 insertions(+), 4 deletions(-) diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index 2e099424a23..f3655d892ea 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -1592,6 +1592,11 @@ impl Comparable for PyStr { return Ok(res.into()); } let other = class_or_notimplemented!(Self, other); + // Equality does not need the ordering, and answers two strings of + // different length without reading either. + if let Some(res) = op.eval_eq(|| zelf.as_wtf8() == other.as_wtf8()) { + return Ok(res.into()); + } Ok(op.eval_ord(zelf.as_wtf8().cmp(other.as_wtf8())).into()) } } diff --git a/crates/vm/src/bytes_inner.rs b/crates/vm/src/bytes_inner.rs index 6c76808c5ec..6ceebb70d07 100644 --- a/crates/vm/src/bytes_inner.rs +++ b/crates/vm/src/bytes_inner.rs @@ -345,7 +345,12 @@ impl PyBytesInner { // but not memoryview, and not equal if compare with unicode str(PyStr) PyComparisonValue::from_option( other - .try_bytes_like(vm, |other| op.eval_ord(self.elements.as_slice().cmp(other))) + .try_bytes_like(vm, |other| { + // Equality does not need the ordering, and answers two + // buffers of different length without reading either. + op.eval_eq(|| self.elements.as_slice() == other) + .unwrap_or_else(|| op.eval_ord(self.elements.as_slice().cmp(other))) + }) .ok(), ) } diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index ebb158c8f71..a1e7a98d545 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -6894,10 +6894,13 @@ impl ExecutingFrame<'_> { b.downcast_ref_if_exact::(vm), ) { let op = self.compare_op_from_arg(arg); - if op != PyComparisonOp::Eq && op != PyComparisonOp::Ne { + // The same two shortcuts the unspecialized comparison takes: + // one object is equal to itself, and equality answers two + // strings of different length without reading either. + let Some(result) = op.eval_eq(|| a.is(b) || a_str.as_wtf8() == b_str.as_wtf8()) + else { return self.execute_compare(vm, arg); - } - let result = op.eval_ord(a_str.as_wtf8().cmp(b_str.as_wtf8())); + }; self.pop_value(); self.pop_value(); self.push_value(vm.ctx.new_bool(result).into()); diff --git a/crates/vm/src/types/slot.rs b/crates/vm/src/types/slot.rs index d834406cf80..c039e6b5b59 100644 --- a/crates/vm/src/types/slot.rs +++ b/crates/vm/src/types/slot.rs @@ -1995,6 +1995,29 @@ impl PyComparisonOp { self.map_eq(|| a.borrow().is(b.borrow())) } + /// The answer to this comparison for two operands that `equal` reports as + /// equal or not, or `None` for an ordering operator, which equality alone + /// cannot settle -- `equal` is not called in that case. + /// + /// This is what lets a type answer `==` and `!=` with an equality test + /// rather than with an ordering: the two agree on the answer, but equality + /// can settle a length mismatch without looking at the contents at all. + /// + /// The two neighbouring helpers answer different questions: [`Self::map_eq`] + /// answers only where its predicate holds, so a caller still handles the + /// other side, and [`Self::eq_only`] declares the comparison + /// `NotImplemented` for an ordering operator. This one leaves the ordering + /// operators to the caller, which is what a type with a real ordering + /// needs. + #[inline] + pub fn eval_eq(self, equal: impl FnOnce() -> bool) -> Option { + match self { + Self::Eq => Some(equal()), + Self::Ne => Some(!equal()), + _ => None, + } + } + /// Returns `Some(true)` when self is `Eq` and `f()` returns true. Returns `Some(false)` when self /// is `Ne` and `f()` returns true. Otherwise returns `None`. #[inline] diff --git a/extra_tests/snippets/operator_comparison.py b/extra_tests/snippets/operator_comparison.py index 71231f033dc..35a2083e94d 100644 --- a/extra_tests/snippets/operator_comparison.py +++ b/extra_tests/snippets/operator_comparison.py @@ -87,3 +87,50 @@ def test_type_error(x, y): assert not math.nan < 123 assert not math.nan >= 123 assert not math.nan <= 123 + + +# str and bytes comparisons, through a function so that the operands are not +# constants the compiler can fold, and in a loop so the specialized comparison +# is reached. +def cmp_all(a, b): + return (a == b, a != b, a < b, a <= b, a > b, a >= b) + + +def check(a, b, expected): + for _ in range(200): + assert cmp_all(a, b) == expected, (a, b, cmp_all(a, b), expected) + + +EQ = (True, False, False, True, False, True) +LT = (False, True, True, True, False, False) +GT = (False, True, False, False, True, True) + +same = "abc" * 3 +check(same, same, EQ) # the very same object +check(same, "abcabcabc", EQ) # equal, distinct objects +check("abc", "abd", LT) # same length, differing content +check("abc", "abcd", LT) # a prefix is less than what extends it +check("abcd", "abc", GT) +check("", "a", LT) +check("", "", EQ) +check("\ud800", "\ud800", EQ) # lone surrogates are compared as themselves +check("\ud800", "\udfff", LT) +check("a\U0001f600", "a\U0001f600", EQ) +check("가나다", "가나다", EQ) +check("가나", "가나다", LT) + +# Comparing with a non-string is never an error for == and !=. +assert not "abc" == 3 +assert "abc" != 3 + +bsame = b"abc" * 3 +check(bsame, bsame, EQ) +check(bsame, b"abcabcabc", EQ) +check(b"abc", b"abd", LT) +check(b"abc", b"abcd", LT) +check(b"abcd", b"abc", GT) +check(bytearray(b"abc"), bytearray(b"abcd"), LT) +check(bytearray(b"abc"), b"abc", EQ) # bytearray and bytes compare by content +check(b"abc", bytearray(b"abd"), LT) +assert not b"abc" == "abc" +assert b"abc" != "abc" From 36a59f5f7b7efbccd5b0396762d06e20a0ad02f8 Mon Sep 17 00:00:00 2001 From: Jiseok CHOI Date: Sat, 15 Aug 2026 21:04:45 +0900 Subject: [PATCH 306/351] sqlite3: validate narg before creating functions/aggregates (#8532) CPython 3.14 added check_num_params() which raises ProgrammingError when narg/n_arg/num_params is out of range (-1..=SQLITE_LIMIT_FUNCTION_ARG). RustPython passed invalid values directly to SQLite, resulting in an OperationalError instead. Add check_num_params() helper and call it in create_function(), create_aggregate(), and create_window_function(). Assisted-by: GitHub Copilot:claude-sonnet-4-6 --- Lib/test/test_sqlite3/test_userfunctions.py | 3 --- crates/stdlib/src/_sqlite3.rs | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_sqlite3/test_userfunctions.py b/Lib/test/test_sqlite3/test_userfunctions.py index e7cecb85213..6da92d77616 100644 --- a/Lib/test/test_sqlite3/test_userfunctions.py +++ b/Lib/test/test_sqlite3/test_userfunctions.py @@ -170,7 +170,6 @@ def setUp(self): def tearDown(self): self.con.close() - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for invalid num args def test_func_error_on_create(self): with self.assertRaisesRegex(sqlite.ProgrammingError, "not -100"): self.con.create_function("bla", -100, lambda x: 2*x) @@ -514,7 +513,6 @@ def test_win_sum_int(self): self.cur.execute(self.query % "sumint") self.assertEqual(self.cur.fetchall(), self.expected) - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for invalid num args def test_win_error_on_create(self): with self.assertRaisesRegex(sqlite.ProgrammingError, "not -100"): self.con.create_window_function("shouldfail", -100, WindowSumInt) @@ -649,7 +647,6 @@ def setUp(self): def tearDown(self): self.con.close() - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs for invalid num args def test_aggr_error_on_create(self): with self.assertRaisesRegex(sqlite.ProgrammingError, "not -100"): self.con.create_function("bla", -100, AggrSum) diff --git a/crates/stdlib/src/_sqlite3.rs b/crates/stdlib/src/_sqlite3.rs index 5348cc1f5ec..5576b84629f 100644 --- a/crates/stdlib/src/_sqlite3.rs +++ b/crates/stdlib/src/_sqlite3.rs @@ -1279,6 +1279,7 @@ mod _sqlite3 { SQLITE_UTF8 }; let db = self.db_lock(vm)?; + check_num_params(&db, args.narg, "narg", vm)?; let Some(data) = CallbackData::new(args.func, vm) else { return db.create_function( name.as_ptr(), @@ -1310,6 +1311,7 @@ mod _sqlite3 { fn create_aggregate(&self, args: CreateAggregateArgs, vm: &VirtualMachine) -> PyResult<()> { let name = args.name.to_cstring(vm)?; let db = self.db_lock(vm)?; + check_num_params(&db, args.narg, "n_arg", vm)?; let Some(data) = CallbackData::new(args.aggregate_class, vm) else { return db.create_function( name.as_ptr(), @@ -1392,6 +1394,7 @@ mod _sqlite3 { ) -> PyResult<()> { let name = name.to_cstring(vm)?; let db = self.db_lock(vm)?; + check_num_params(&db, narg, "num_params", vm)?; let Some(data) = CallbackData::new(aggregate_class, vm) else { unsafe { sqlite3_create_window_function( @@ -3475,6 +3478,22 @@ mod _sqlite3 { Ok(obj) } + fn check_num_params( + db: &Sqlite, + n: c_int, + param_name: &str, + vm: &VirtualMachine, + ) -> PyResult<()> { + let limit = unsafe { sqlite3_limit(db.db, SQLITE_LIMIT_FUNCTION_ARG, -1) }; + if n < -1 || n > limit { + return Err(new_programming_error( + vm, + format!("'{param_name}' must be between -1 and {limit}, not {n}"), + )); + } + Ok(()) + } + fn ptr_to_str<'a>(p: *const libc::c_char, vm: &VirtualMachine) -> PyResult<&'a str> { if p.is_null() { return Err(vm.new_memory_error("string pointer is null")); From 32b1f211179056c9d671c0428ad73f80b0f0d67f Mon Sep 17 00:00:00 2001 From: Jiseok CHOI Date: Sat, 15 Aug 2026 21:05:12 +0900 Subject: [PATCH 307/351] sqlite3: add Connection exception attributes and fix autocommit ValueError (#8533) Add DB-API 2.0 optional extension: expose exception classes as attributes on Connection objects (Warning, Error, InterfaceError, DatabaseError, DataError, OperationalError, IntegrityError, InternalError, ProgrammingError, NotSupportedError). Also fix autocommit validation to raise ValueError (not TypeError) when an unsupported type is passed. Assisted-by: GitHub Copilot:claude-sonnet-4-6 --- Lib/test/test_sqlite3/test_dbapi.py | 2 - Lib/test/test_sqlite3/test_transactions.py | 1 - crates/stdlib/src/_sqlite3.rs | 43 +++++++++++++++++++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_sqlite3/test_dbapi.py b/Lib/test/test_sqlite3/test_dbapi.py index 68f8969a00b..f99825f63d8 100644 --- a/Lib/test/test_sqlite3/test_dbapi.py +++ b/Lib/test/test_sqlite3/test_dbapi.py @@ -364,7 +364,6 @@ def test_use_after_close(self): with self.cx: pass - @unittest.expectedFailure # TODO: RUSTPYTHON def test_exceptions(self): # Optional DB-API extension. self.assertEqual(self.cx.Warning, sqlite.Warning) @@ -401,7 +400,6 @@ def test_in_transaction_ro(self): with self.assertRaises(AttributeError): self.cx.in_transaction = True - @unittest.expectedFailure # TODO: RUSTPYTHON def test_connection_exceptions(self): exceptions = [ "DataError", diff --git a/Lib/test/test_sqlite3/test_transactions.py b/Lib/test/test_sqlite3/test_transactions.py index d777af0ffe6..a3de7a7a82e 100644 --- a/Lib/test/test_sqlite3/test_transactions.py +++ b/Lib/test/test_sqlite3/test_transactions.py @@ -387,7 +387,6 @@ def test_autocommit_setget(self): cx.autocommit = mode self.assertEqual(cx.autocommit, mode) - @unittest.expectedFailure # TODO: RUSTPYTHON; autocommit validation error messages differ def test_autocommit_setget_invalid(self): msg = "autocommit must be True, False, or.*LEGACY" for mode in "a", 12, (), None: diff --git a/crates/stdlib/src/_sqlite3.rs b/crates/stdlib/src/_sqlite3.rs index 5576b84629f..559ef518e35 100644 --- a/crates/stdlib/src/_sqlite3.rs +++ b/crates/stdlib/src/_sqlite3.rs @@ -322,7 +322,7 @@ mod _sqlite3 { ))) } } else { - Err(vm.new_type_error(format!( + Err(vm.new_value_error(format!( "autocommit must be True, False, or sqlite3.LEGACY_TRANSACTION_CONTROL, not {}", obj.class().name() ))) @@ -1645,6 +1645,47 @@ mod _sqlite3 { fn total_changes(&self, vm: &VirtualMachine) -> PyResult { self._db_lock(vm).map(|x| x.total_changes()) } + + #[pygetset(name = "Warning")] + fn exc_warning(&self) -> PyTypeRef { + warning_type().to_owned() + } + #[pygetset(name = "Error")] + fn exc_error(&self) -> PyTypeRef { + error_type().to_owned() + } + #[pygetset(name = "InterfaceError")] + fn exc_interface_error(&self) -> PyTypeRef { + interface_error_type().to_owned() + } + #[pygetset(name = "DatabaseError")] + fn exc_database_error(&self) -> PyTypeRef { + database_error_type().to_owned() + } + #[pygetset(name = "DataError")] + fn exc_data_error(&self) -> PyTypeRef { + data_error_type().to_owned() + } + #[pygetset(name = "OperationalError")] + fn exc_operational_error(&self) -> PyTypeRef { + operational_error_type().to_owned() + } + #[pygetset(name = "IntegrityError")] + fn exc_integrity_error(&self) -> PyTypeRef { + integrity_error_type().to_owned() + } + #[pygetset(name = "InternalError")] + fn exc_internal_error(&self) -> PyTypeRef { + internal_error_type().to_owned() + } + #[pygetset(name = "ProgrammingError")] + fn exc_programming_error(&self) -> PyTypeRef { + programming_error_type().to_owned() + } + #[pygetset(name = "NotSupportedError")] + fn exc_not_supported_error(&self) -> PyTypeRef { + not_supported_error_type().to_owned() + } } #[pyattr] From 70b47dd7c7e048da56f743b762302b7aaa3b2e42 Mon Sep 17 00:00:00 2001 From: Jiseok CHOI Date: Sat, 15 Aug 2026 21:05:33 +0900 Subject: [PATCH 308/351] sqlite3: pass None for NULL authorizer args instead of crashing (#8534) * sqlite3: pass None for NULL authorizer args instead of crashing CPython's authorizer callback receives NULL for arg1/arg2/db_name/access when not applicable (e.g. SQLITE_READ on a table gives NULL for the database name in some versions). Previously RustPython passed these pointers to ptr_to_str which would crash or produce an error. Now ptr_to_str_or_none is used: NULL pointers become Python None, which matches CPython behavior and allows test_table_access and test_column_access to pass. Assisted-by: GitHub Copilot:claude-sonnet-4-6 * fix(sqlite3): avoid reentrant deadlock in Statement::new sqlite3_prepare_v2 can synchronously invoke the authorizer callback, which may call back into Connection methods (e.g. set_authorizer) that require the same db lock. Holding db_lock across the prepare() call caused a self-deadlock when a callback re-entered the connection. Release the lock after sql_limit check and copy the raw handle before calling prepare(), so FFI calls that can trigger Python re-entrancy happen outside the lock scope. Fixes hang in test_authorizer_concurrent_mutation_in_call --- Lib/test/test_sqlite3/test_userfunctions.py | 2 -- crates/stdlib/src/_sqlite3.rs | 30 ++++++++++++++------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/Lib/test/test_sqlite3/test_userfunctions.py b/Lib/test/test_sqlite3/test_userfunctions.py index 6da92d77616..d63bccf9696 100644 --- a/Lib/test/test_sqlite3/test_userfunctions.py +++ b/Lib/test/test_sqlite3/test_userfunctions.py @@ -800,13 +800,11 @@ def setUp(self): def tearDown(self): self.con.close() - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs def test_table_access(self): with self.assertRaises(sqlite.DatabaseError) as cm: self.con.execute("select * from t2") self.assertIn('prohibited', str(cm.exception)) - @unittest.expectedFailure # TODO: RUSTPYTHON; error message differs def test_column_access(self): with self.assertRaises(sqlite.DatabaseError) as cm: self.con.execute("select c2 from t1") diff --git a/crates/stdlib/src/_sqlite3.rs b/crates/stdlib/src/_sqlite3.rs index 559ef518e35..b887ed0f6c5 100644 --- a/crates/stdlib/src/_sqlite3.rs +++ b/crates/stdlib/src/_sqlite3.rs @@ -587,10 +587,10 @@ mod _sqlite3 { ) -> c_int { let (callable, vm) = unsafe { (*data.cast::()).retrieve() }; let f = || -> PyResult { - let arg1 = ptr_to_str(arg1, vm)?; - let arg2 = ptr_to_str(arg2, vm)?; - let db_name = ptr_to_str(db_name, vm)?; - let access = ptr_to_str(access, vm)?; + let arg1 = ptr_to_str_or_none(arg1, vm)?; + let arg2 = ptr_to_str_or_none(arg2, vm)?; + let db_name = ptr_to_str_or_none(db_name, vm)?; + let access = ptr_to_str_or_none(access, vm)?; let val = callable.call((action, arg1, arg2, db_name, access), vm)?; let Some(val) = val.downcast_ref::() else { @@ -2842,12 +2842,14 @@ mod _sqlite3 { } let sql_cstr = sql.to_cstring(vm)?; - let db = connection.db_lock(vm)?; - - db.sql_limit(sql.byte_len(), vm)?; + let raw = { + let db = connection.db_lock(vm)?; + db.sql_limit(sql.byte_len(), vm)?; + **db + }; let mut tail = null(); - let st = db.prepare(sql_cstr.as_ptr(), &mut tail, vm)?; + let st = raw.prepare(sql_cstr.as_ptr(), &mut tail, vm)?; let Some(st) = st else { return Ok(None); @@ -3540,7 +3542,17 @@ mod _sqlite3 { return Err(vm.new_memory_error("string pointer is null")); } unsafe { CStr::from_ptr(p).to_str() } - .map_err(|_| vm.new_value_error("Invalid UIF-8 codepoint")) + .map_err(|_| vm.new_value_error("Invalid UTF-8 codepoint")) + } + + fn ptr_to_str_or_none(p: *const libc::c_char, vm: &VirtualMachine) -> PyResult { + if p.is_null() { + return Ok(vm.ctx.none()); + } + let s = unsafe { CStr::from_ptr(p) } + .to_str() + .map_err(|_| vm.new_value_error("Invalid UTF-8 codepoint".to_owned()))?; + Ok(vm.ctx.new_str(s).into()) } fn ptr_to_string( From 7eb6d38c884bcb08e5bfddcdb723c276b17d59c9 Mon Sep 17 00:00:00 2001 From: Jiseok CHOI Date: Sun, 16 Aug 2026 20:13:10 +0900 Subject: [PATCH 309/351] sqlite3: add SQLITE_DBCONFIG constants, setconfig() and getconfig() (#8535) * sqlite3: add SQLITE_DBCONFIG constants, setconfig() and getconfig() Implement Connection.setconfig() and Connection.getconfig() using sqlite3_db_config(), and export all SQLITE_DBCONFIG_* integer constants to the module. Fixes test_connection_config in test_dbapi.py. Assisted-by: GitHub Copilot:claude-sonnet-4-6 * sqlite3: fix setconfig to propagate actual SQLite error When sqlite3_db_config() returns a non-OK result, propagate the actual SQLite error instead of replacing it with a generic 'Unable to set config' message, to match CPython's set_error_from_db() behavior. Assisted-by: GitHub Copilot:claude-sonnet-4-6 --- Lib/test/test_sqlite3/test_dbapi.py | 1 - crates/stdlib/src/_sqlite3.rs | 83 ++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_sqlite3/test_dbapi.py b/Lib/test/test_sqlite3/test_dbapi.py index f99825f63d8..ef8acd0f338 100644 --- a/Lib/test/test_sqlite3/test_dbapi.py +++ b/Lib/test/test_sqlite3/test_dbapi.py @@ -525,7 +525,6 @@ def test_connection_bad_reinit(self): cx.executemany, "insert into t values(?)", ((v,) for v in range(3))) - @unittest.expectedFailure # TODO: RUSTPYTHON; SQLITE_DBCONFIG constants not implemented def test_connection_config(self): op = sqlite.SQLITE_DBCONFIG_ENABLE_FKEY with memory_database() as cx: diff --git a/crates/stdlib/src/_sqlite3.rs b/crates/stdlib/src/_sqlite3.rs index b887ed0f6c5..02d40845058 100644 --- a/crates/stdlib/src/_sqlite3.rs +++ b/crates/stdlib/src/_sqlite3.rs @@ -31,11 +31,11 @@ mod _sqlite3 { sqlite3_column_double, sqlite3_column_int64, sqlite3_column_name, sqlite3_column_text, sqlite3_column_type, sqlite3_complete, sqlite3_context, sqlite3_context_db_handle, sqlite3_create_collation_v2, sqlite3_create_function_v2, sqlite3_create_window_function, - sqlite3_data_count, sqlite3_db_handle, sqlite3_errcode, sqlite3_errmsg, sqlite3_exec, - sqlite3_expanded_sql, sqlite3_extended_errcode, sqlite3_finalize, sqlite3_get_autocommit, - sqlite3_interrupt, sqlite3_last_insert_rowid, sqlite3_libversion, sqlite3_limit, - sqlite3_open_v2, sqlite3_prepare_v2, sqlite3_progress_handler, sqlite3_reset, - sqlite3_result_blob, sqlite3_result_double, sqlite3_result_error, + sqlite3_data_count, sqlite3_db_config, sqlite3_db_handle, sqlite3_errcode, sqlite3_errmsg, + sqlite3_exec, sqlite3_expanded_sql, sqlite3_extended_errcode, sqlite3_finalize, + sqlite3_get_autocommit, sqlite3_interrupt, sqlite3_last_insert_rowid, sqlite3_libversion, + sqlite3_limit, sqlite3_open_v2, sqlite3_prepare_v2, sqlite3_progress_handler, + sqlite3_reset, sqlite3_result_blob, sqlite3_result_double, sqlite3_result_error, sqlite3_result_error_nomem, sqlite3_result_error_toobig, sqlite3_result_int64, sqlite3_result_null, sqlite3_result_text, sqlite3_set_authorizer, sqlite3_sleep, sqlite3_step, sqlite3_stmt, sqlite3_stmt_busy, sqlite3_stmt_readonly, sqlite3_threadsafe, @@ -161,7 +161,17 @@ mod _sqlite3 { SQLITE_ALTER_TABLE, SQLITE_ANALYZE, SQLITE_ATTACH, SQLITE_CREATE_INDEX, SQLITE_CREATE_TABLE, SQLITE_CREATE_TEMP_INDEX, SQLITE_CREATE_TEMP_TABLE, SQLITE_CREATE_TEMP_TRIGGER, SQLITE_CREATE_TEMP_VIEW, SQLITE_CREATE_TRIGGER, - SQLITE_CREATE_VIEW, SQLITE_CREATE_VTABLE, SQLITE_DELETE, SQLITE_DENY, SQLITE_DETACH, + SQLITE_CREATE_VIEW, SQLITE_CREATE_VTABLE, SQLITE_DBCONFIG_DEFENSIVE, + SQLITE_DBCONFIG_DQS_DDL, SQLITE_DBCONFIG_DQS_DML, SQLITE_DBCONFIG_ENABLE_ATTACH_CREATE, + SQLITE_DBCONFIG_ENABLE_ATTACH_WRITE, SQLITE_DBCONFIG_ENABLE_COMMENTS, + SQLITE_DBCONFIG_ENABLE_FKEY, SQLITE_DBCONFIG_ENABLE_FTS3_TOKENIZER, + SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION, SQLITE_DBCONFIG_ENABLE_QPSG, + SQLITE_DBCONFIG_ENABLE_TRIGGER, SQLITE_DBCONFIG_ENABLE_VIEW, + SQLITE_DBCONFIG_LEGACY_ALTER_TABLE, SQLITE_DBCONFIG_LEGACY_FILE_FORMAT, + SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE, SQLITE_DBCONFIG_RESET_DATABASE, + SQLITE_DBCONFIG_REVERSE_SCANORDER, SQLITE_DBCONFIG_STMT_SCANSTATUS, + SQLITE_DBCONFIG_TRIGGER_EQP, SQLITE_DBCONFIG_TRUSTED_SCHEMA, + SQLITE_DBCONFIG_WRITABLE_SCHEMA, SQLITE_DELETE, SQLITE_DENY, SQLITE_DETACH, SQLITE_DROP_INDEX, SQLITE_DROP_TABLE, SQLITE_DROP_TEMP_INDEX, SQLITE_DROP_TEMP_TABLE, SQLITE_DROP_TEMP_TRIGGER, SQLITE_DROP_TEMP_VIEW, SQLITE_DROP_TRIGGER, SQLITE_DROP_VIEW, SQLITE_DROP_VTABLE, SQLITE_FUNCTION, SQLITE_IGNORE, SQLITE_INSERT, SQLITE_LIMIT_ATTACHED, @@ -1519,6 +1529,39 @@ mod _sqlite3 { self.db_lock(vm)?.limit(category, limit, vm) } + #[pymethod] + fn setconfig( + &self, + op: c_int, + enable: OptionalArg, + vm: &VirtualMachine, + ) -> PyResult<()> { + let db = self.db_lock(vm)?; + if !is_int_dbconfig(op) { + return Err(vm.new_value_error(format!("unknown config 'op': {op}"))); + } + let enable = enable.unwrap_or(true) as c_int; + let mut actual: c_int = 0; + let rc = unsafe { sqlite3_db_config(db.db, op, enable, &mut actual) }; + db.check(rc, vm)?; + if enable != actual { + return Err(new_operational_error(vm, "Unable to set config".to_owned())); + } + Ok(()) + } + + #[pymethod] + fn getconfig(&self, op: c_int, vm: &VirtualMachine) -> PyResult { + let db = self.db_lock(vm)?; + if !is_int_dbconfig(op) { + return Err(vm.new_value_error(format!("unknown config 'op': {op}"))); + } + let mut current: c_int = 0; + let rc = unsafe { sqlite3_db_config(db.db, op, -1, &mut current) }; + db.check(rc, vm)?; + Ok(current != 0) + } + #[pymethod] fn __enter__(zelf: PyRef) -> PyRef { zelf @@ -3537,6 +3580,34 @@ mod _sqlite3 { Ok(()) } + fn is_int_dbconfig(op: c_int) -> bool { + use libsqlite3_sys::*; + matches!( + op, + SQLITE_DBCONFIG_ENABLE_FKEY + | SQLITE_DBCONFIG_ENABLE_TRIGGER + | SQLITE_DBCONFIG_ENABLE_FTS3_TOKENIZER + | SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION + | SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE + | SQLITE_DBCONFIG_ENABLE_QPSG + | SQLITE_DBCONFIG_TRIGGER_EQP + | SQLITE_DBCONFIG_RESET_DATABASE + | SQLITE_DBCONFIG_DEFENSIVE + | SQLITE_DBCONFIG_WRITABLE_SCHEMA + | SQLITE_DBCONFIG_LEGACY_ALTER_TABLE + | SQLITE_DBCONFIG_DQS_DDL + | SQLITE_DBCONFIG_DQS_DML + | SQLITE_DBCONFIG_ENABLE_VIEW + | SQLITE_DBCONFIG_LEGACY_FILE_FORMAT + | SQLITE_DBCONFIG_TRUSTED_SCHEMA + | SQLITE_DBCONFIG_STMT_SCANSTATUS + | SQLITE_DBCONFIG_REVERSE_SCANORDER + | SQLITE_DBCONFIG_ENABLE_ATTACH_CREATE + | SQLITE_DBCONFIG_ENABLE_ATTACH_WRITE + | SQLITE_DBCONFIG_ENABLE_COMMENTS + ) + } + fn ptr_to_str<'a>(p: *const libc::c_char, vm: &VirtualMachine) -> PyResult<&'a str> { if p.is_null() { return Err(vm.new_memory_error("string pointer is null")); From aa3c8b5a0d7767305193d63282134b84006bae94 Mon Sep 17 00:00:00 2001 From: sijun-yang Date: Sun, 16 Aug 2026 20:13:54 +0900 Subject: [PATCH 310/351] Remove private comprehension flags (#8537) * Remove the private assignment-expression marker Why: extend_namedexpr_scope() already propagates assignment-expression targets and records DEF_GLOBAL or DEF_NONLOCAL in comprehension scopes. ASSIGNED_IN_COMPREHENSION remained only for conflict checks. Its private bit overlaps CPython's packed LOCAL scope bit. Changes: - Register assignment-expression targets as ordinary assignments. - Detect later target conflicts from existing declaration flags. Assisted-by: Codex:gpt-5.6-sol * Remove the private comprehension iterator flag Why: ITER duplicates the DEF_LOCAL and DEF_COMP_ITER facts already recorded for comprehension targets. Its private bit also overlaps CPython's packed scope field. Changes: - Use those flags for rebinding checks and local restoration around inlined comprehensions. Assisted-by: Codex:gpt-5.6-sol * Preserve source names in named-expression diagnostics Why: Assignment-expression diagnostics exposed the mangled keys used for symbol-table lookup. CPython reports identifiers as written in source. Changes: - Use source names in both comprehension conflict diagnostics. - Enable test_named_expression_invalid_mangled_class_variables. Assisted-by: Codex:gpt-5.6-sol --- Lib/test/test_named_expressions.py | 1 - crates/codegen/src/compile.rs | 4 +- crates/codegen/src/symboltable.rs | 79 +++++------------------------- 3 files changed, 14 insertions(+), 70 deletions(-) diff --git a/Lib/test/test_named_expressions.py b/Lib/test/test_named_expressions.py index 2e0643484fc..a859e051de2 100644 --- a/Lib/test/test_named_expressions.py +++ b/Lib/test/test_named_expressions.py @@ -365,7 +365,6 @@ def test_named_expression_invalid_dict_comprehension_iterable_expression(self): with self.assertRaisesRegex(SyntaxError, msg): exec(f"lambda: {code}", {}) # Function scope - @unittest.expectedFailure # TODO: RUSTPYTHON; wrong error message def test_named_expression_invalid_mangled_class_variables(self): code = """class Foo: def bar(self): diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index afe868dab6c..fe0a983187a 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -10919,9 +10919,7 @@ impl<'warnings> Compiler<'warnings> { if sym.flags.contains(SymbolFlags::DEF_PARAM) { continue; // skip .0 } - let is_local = sym - .flags - .intersects(SymbolFlags::DEF_LOCAL | SymbolFlags::ITER) + let is_local = sym.flags.contains(SymbolFlags::DEF_LOCAL) && !sym.flags.contains(SymbolFlags::DEF_NONLOCAL); if is_local { pushed_locals.push(name.clone()); diff --git a/crates/codegen/src/symboltable.rs b/crates/codegen/src/symboltable.rs index a66410f9018..a771e19d36f 100644 --- a/crates/codegen/src/symboltable.rs +++ b/crates/codegen/src/symboltable.rs @@ -312,19 +312,8 @@ bitflags! { Self::DEF_LOCAL.bits() | Self::DEF_PARAM.bits() | Self::DEF_IMPORT.bits() - | Self::ITER.bits() | Self::DEF_TYPE_PARAM.bits() ); - - - // TODO: Remove these, RustPython specific - - // indicates if the symbol gets a value assigned by a named expression in a comprehension - // this is required to correct the scope in the analysis. - const ASSIGNED_IN_COMPREHENSION = 2 << 11; - // indicates that the symbol is used a bound iterator variable. We distinguish this case - // from normal assignment to detect disallowed re-assignment to iterator variables. - const ITER = 2 << 12; } } @@ -1030,7 +1019,6 @@ enum SymbolUsage { AnnotationAssigned, Parameter, AnnotationParameter, - AssignedNamedExprInComprehension, Iter, TypeParam, } @@ -1048,8 +1036,6 @@ struct SymbolTableBuilder { varnames_stack: Vec>, // Track if we're inside an iterable definition expression (for nested comprehensions) in_iter_def_exp: bool, - // Track if we're scanning an inner loop iteration target (not the first generator) - in_comp_inner_loop_target: bool, // yield/yield from inside comprehension scopes is rejected with a // message that names the comprehension kind. comprehension_yield_context: Option<&'static str>, @@ -1084,7 +1070,6 @@ impl SymbolTableBuilder { current_varnames: Vec::new(), varnames_stack: Vec::new(), in_iter_def_exp: false, - in_comp_inner_loop_target: false, comprehension_yield_context: None, in_conditional_block: false, recursion_depth: 0, @@ -2525,24 +2510,8 @@ impl SymbolTableBuilder { self.scan_expression(value, ExpressionContext::Load)?; - // special handling for assigned identifier in named expressions - // that are used in comprehensions. This required to correctly - // propagate the scope of the named assigned named and not to - // propagate inner names. if let Some((id, target_range)) = named_target { - let table = self.tables.last().unwrap(); - if table.typ == CompilerScope::Comprehension { - self.register_name( - id, - SymbolUsage::AssignedNamedExprInComprehension, - target_range, - )?; - } else { - // omit one recursion. When the handling of an store changes for - // Identifiers this needs adapted - more forward safe would be - // calling scan_expression directly. - self.register_name(id, SymbolUsage::Assigned, target_range)?; - } + self.register_name(id, SymbolUsage::Assigned, target_range)?; } else { self.scan_expression(target, ExpressionContext::Store)?; } @@ -2613,9 +2582,7 @@ impl SymbolTableBuilder { } for generator in &generators[1..] { - self.in_comp_inner_loop_target = true; self.scan_expression(&generator.target, ExpressionContext::Iter)?; - self.in_comp_inner_loop_target = false; let was_in_iter_def_exp = self.in_iter_def_exp; self.in_iter_def_exp = true; self.scan_expression(&generator.iter, ExpressionContext::IterDefinitionExp)?; @@ -3037,11 +3004,15 @@ impl SymbolTableBuilder { if self.tables[table_idx] .symbols .get(mangled.as_str()) - .is_some_and(|symbol| symbol.flags.contains(SymbolFlags::ITER)) + .is_some_and(|symbol| { + symbol + .flags + .contains(SymbolFlags::DEF_LOCAL | SymbolFlags::DEF_COMP_ITER) + }) { return Err(SymbolTableError { error: format!( - "assignment expression cannot rebind comprehension iteration variable '{mangled}'" + "assignment expression cannot rebind comprehension iteration variable '{name}'" ), location, }); @@ -3151,7 +3122,6 @@ impl SymbolTableBuilder { | SymbolUsage::AnnotationAssigned | SymbolUsage::Parameter | SymbolUsage::AnnotationParameter - | SymbolUsage::AssignedNamedExprInComprehension | SymbolUsage::Iter | SymbolUsage::TypeParam ) { @@ -3179,16 +3149,16 @@ impl SymbolTableBuilder { let symbol = if let Some(symbol) = table.symbols.get_mut(name.as_ref()) { let flags = &symbol.flags; - // INNER_LOOP_CONFLICT: comprehension inner loop cannot rebind - // a variable that was used as a named expression target + // Mirrors CPython's INNER_LOOP_CONFLICT check. extend_namedexpr_scope() + // marks named-expression targets as global or nonlocal in the comprehension. // Example: [i for i in range(5) if (j := 0) for j in range(5)] // Here 'j' is used in named expr first, then as inner loop iter target - if self.in_comp_inner_loop_target - && flags.contains(SymbolFlags::ASSIGNED_IN_COMPREHENSION) + if matches!(role, SymbolUsage::Iter) + && flags.intersects(SymbolFlags::DEF_GLOBAL | SymbolFlags::DEF_NONLOCAL) { return Err(SymbolTableError { error: format!( - "comprehension inner loop cannot rebind assignment expression target '{name}'" + "comprehension inner loop cannot rebind assignment expression target '{original_name}'" ), location, }); @@ -3345,9 +3315,6 @@ impl SymbolTableBuilder { SymbolUsage::Assigned => { flags.insert(SymbolFlags::DEF_LOCAL); } - SymbolUsage::AssignedNamedExprInComprehension => { - flags.insert(SymbolFlags::DEF_LOCAL | SymbolFlags::ASSIGNED_IN_COMPREHENSION); - } SymbolUsage::Global => { symbol.scope = SymbolScope::GlobalExplicit; flags.insert(SymbolFlags::DEF_GLOBAL); @@ -3356,33 +3323,13 @@ impl SymbolTableBuilder { flags.insert(SymbolFlags::USE); } SymbolUsage::Iter => { - // CPython symtable_add_def_helper() records an inlined - // comprehension target as a local definition as well as a - // comprehension iterator. Keep ITER as the internal - // re-assignment check marker; DEF_LOCAL is part of the public - // ste_symbols flags exposed by _symtable. - flags.insert( - SymbolFlags::DEF_LOCAL | SymbolFlags::ITER | SymbolFlags::DEF_COMP_ITER, - ); + flags.insert(SymbolFlags::DEF_LOCAL | SymbolFlags::DEF_COMP_ITER); } SymbolUsage::TypeParam => { flags.insert(SymbolFlags::DEF_LOCAL | SymbolFlags::DEF_TYPE_PARAM); } } - // and even more checking - // it is not allowed to assign to iterator variables (by named expressions) - if flags.contains(SymbolFlags::ITER) - && flags.contains(SymbolFlags::ASSIGNED_IN_COMPREHENSION) - { - return Err(SymbolTableError { - error: format!( - "assignment expression cannot rebind comprehension iteration variable '{}'", - symbol.name - ), - location, - }); - } Ok(()) } } From 5c13fa1e25dfcc57cf21f288173eecf32ad02287 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:21:31 +0900 Subject: [PATCH 311/351] Per-interpreter runtime state and a process-wide GC stop-the-world (#8517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * vm: add per-interpreter runtime state and interpreter registry - vm/runtime.rs: process-global interpreter registry (monotonic ids, weak entries), InterpreterWhence/InterpreterInfo, process main id recording via main_interpreter_id(), a threading-gated owner map (store_owned_interpreter/ take_owned_interpreter/is_owned_interpreter/owned_interpreter_count), and the SUPPORTS_ISOLATED_INTERPRETERS constant. - PyGlobalState gains interpreter_id/whence/is_main and is_main_interpreter(); PyConfig/Settings derive Clone so a subinterpreter can clone parent config. - Interpreter: id()/whence()/is_main()/is_process_main(), create_subinterpreter() and create_owned_subinterpreter(); unregister on Drop. - thread.rs: per-interpreter thread slots (INTERP_THREAD_SLOTS), slot swap when switching interpreters on one OS thread, cleanup keyed by interpreter id. - Install signal handlers and init the main-thread ident only on the main interpreter; _thread._is_main_interpreter reflects the current interpreter. - sys.implementation.supports_isolated_interpreters reads the constant. - Guard the registry static for non-threading builds where rc::Weak is !Send. Assisted-by: Claude Code:claude-opus-4-8 * gc: stop every interpreter while collecting The generation lists are process-global, so a collection reads and frees objects owned by every interpreter. CollectStopTheWorld stopped only the collecting interpreter, leaving other interpreters' threads free to mutate the same object graph during the reference-subtraction, reachability and snapshot phases. Stop all live interpreters instead, in runtime id order, and restart them in reverse. The global `collecting` mutex serializes collectors process-wide, so no second collector takes these exclusions in another order; fork acquires a single interpreter's exclusion, so the orders cannot cycle. - runtime: add live_interpreter_states(), ordered by interpreter id. - StopTheWorldState methods take &PyGlobalState instead of &VirtualMachine, so an interpreter's world can be stopped without a VM for it; update the call sites in frame, _thread, posix, faulthandler and capi. - Document in gc_state() that the collector is process-wide: gc.disable(), thresholds, gc.garbage and gc.get_objects() observe process-wide state, and a per-interpreter collector additionally needs untrack_object (called from default_dealloc with no VM in scope) routed to the owning interpreter. Tests: stop_the_world_parks_threads_of_another_interpreter asserts a thread entered in one interpreter parks another interpreter's threads (it fails without this change), plus a collect-while-another-interpreter-churns test. Assisted-by: Claude Code:claude-opus-4-8 * vm: scope the interpreter registry per thread without threading The registry held `rc::Weak` in a process-global `OnceLock` and covered the resulting `!Send`/`!Sync` with an `unsafe impl` justifying it as "non-threading builds are single-threaded". That is not this codebase's model: `static_cell!` is thread-local without the `threading` feature precisely so each OS thread can own its own `Context::genesis()` and `GcState`, so two threads could reach the same `Rc` counts through the registry. Use `static_cell!` for the registry as well, matching `gc_state()`, and drop the `unsafe impl`. Ids are consequently unique per registry rather than per process in non-threading builds, which is documented on `alloc_interpreter_id`. Move the recorded main interpreter id into the registry so it follows the same scoping instead of living in a separate global `OnceLock`. Also make `CollectStopTheWorld::new` accumulate into a live guard: it built a bare `Vec` and only moved it into the restarting `Drop` type after stopping every interpreter, so an unwind partway through the loop left the already stopped interpreters parked forever with their exclusion held. Assisted-by: Claude Code:claude-opus-4-8 * vm: keep the interpreter registry usable across bootstrap, drop and fork Three gaps where the registry did not describe the interpreters that actually exist, each of which hides an interpreter from the collector's stop-the-world. Register before `initialize()`. Registration ran as the last step of `initialize_vm`, so the whole bootstrap — which executes Python bytecode and allocates GC-tracked objects — was invisible to `live_interpreter_states()`. It still cannot run any earlier than this: the init hooks take `PyRc::get_mut` on the state, which fails as soon as the registry holds a weak reference to it. Stop unregistering in `Interpreter::drop`. The handle does not decide the interpreter's lifetime — every `ThreadedVirtualMachine` from `new_thread()` holds its own `PyRc` — so an interpreter with running workers disappeared from the registry while its threads kept mutating the object graph. The entries are weak, so lifetime is already correct without the removal; dead entries are now reaped when registering instead. Interpreters are consequently released rather than unregistered at a fixed point, so the two tests asserting disappearance now wait for it: a collection in progress legitimately holds a reference to every live interpreter. Repair other interpreters after fork. `py_os_after_fork_child` only fixed the forking interpreter, leaving every other one with slots for threads that did not survive (still ATTACHED if they were running bytecode) plus locks and stop-the-world flags held by them. Since a collection stops all interpreters, the child's first collection would wait for threads that no longer exist. Reset their locks, stop-the-world state and thread tables, drop this thread's cached slots for them, and reinit the registry's own locks first, since enumerating interpreters now takes them. Tests: test_gc, test_threading and test_fork1 pass, as do the vm tests in both the threading and default configurations. Assisted-by: Claude Code:claude-opus-4-8 * vm: attach and detach thread slots when switching interpreters `enter_vm` decided whether to attach from `was_outermost` (an empty VM_STACK), which held while a thread could only ever be in one interpreter. With a slot per (thread, interpreter) pair, entering interpreter B from a thread already inside interpreter A's section switched CURRENT_THREAD_SLOT to B's slot but attached nothing: the thread then ran B's bytecode with B's slot DETACHED while A's slot stayed ATTACHED. A collector stopping B force-parks the DETACHED slot and concludes B is stopped, and then walks the object graph this thread is still mutating. Pair the attach/detach with the slot switch instead (≈ `_PyThreadState_Swap`): `begin_interpreter_section` detaches the enclosing interpreter's slot, makes the target slot current and attaches it, and `end_interpreter_section` undoes that and re-attaches the enclosing interpreter. Both live in `set_current_vm`, which every path making a VM current already goes through, so `enter_vm` and `VmBootstrapGuard` no longer track outermost-ness themselves. `nested_enter_of_subinterpreter_is_stoppable` covers this: it runs a subinterpreter nested inside the parent's section and asserts the sub's threads park when the sub's world is stopped. It fails with the previous attach-at-outermost-only behavior. Assisted-by: Claude Code:claude-opus-4-8 * vm: list only the current interpreter's subclasses Interpreters share the context, so `class Foo(int)` in one of them pushes onto the same `int.subclasses` every other one reads, and `int.__subclasses__()` returned types no other interpreter can reach. Record the creating interpreter on `HeapTypeExt` and filter `__subclasses__` by it, the way `lookup_tp_subclasses` reads `tp_subclasses` out of per-interpreter state for static builtin types. Types built before any interpreter exists — the ones the shared context creates, including the exception hierarchy — carry no id and stay visible to every interpreter, which is what `_PyStaticType_InitBuiltin` produces by registering the builtin subclass links once per interpreter. The other walks over `subclasses` (version-tag invalidation, abc flag propagation, mro updates, slot propagation) are left as they are: each starts from a type being mutated, so from a heap type, whose subclasses all live in the interpreter that created it. `subinterpreter_subclasses_are_scoped_to_their_interpreter` covers this and fails without the filter. Assisted-by: Claude Code:claude-opus-5 * gc: give each interpreter its own collector state The generation lists stay process-wide, because an object is untracked from `default_dealloc`, where no interpreter is in scope to route to. What changes is that a collection no longer acts on every interpreter's objects, and the gc module no longer reports one interpreter's state to another. `track_object` stamps the running interpreter into a new `gc_owner` word on the object header, and a collection takes as candidates only the objects carrying its own tag plus the ones carrying none. `gc_owner` fits in the padding the header's alignment already forces, so objects do not grow; an assertion on the header size keeps it that way. Objects allocated with no interpreter running — everything the shared context builds — carry no owner and stay candidates for every interpreter, which is where they were before. Objects that outlive the interpreter that tracked them are adopted the same way by the next full collection, rather than being left to a collector that will never come. `enabled`, the thresholds, the debug flags, the statistics, `gc.garbage` and `gc.callbacks` move onto `PyGlobalState` — the last two off the shared `Context` — so `gc.disable()`, `gc.set_threshold()`, `gc.get_stats()`, `gc.get_objects()` and `gc.garbage` describe the interpreter that asks. The occupancy counts behind `gc.get_count()` and `gc.get_freeze_count()` stay process-wide: they measure how full the shared lists are. Their decrements are saturating now, since a collection zeroes the generations it emptied while another interpreter's objects are still sitting in them. Stop-the-world still stops every interpreter. Unowned objects are candidates and any interpreter can incref one, so the refcounts a collection reads are only stable while all of them are parked. This also fixes a deadlock it exposed: `CollectStopTheWorld` dropped its references to the stopped interpreters while the collection still held the generation read locks, so releasing the last reference to one — which frees its objects, and so untracks them — waited for a write lock behind that read lock. The references are now held until the guard itself drops. `collections_only_reach_the_collecting_interpreter` and `get_objects_only_reports_the_calling_interpreter` cover this and both fail without the owner check. Assisted-by: Claude Code:claude-opus-5 * _queue, _thread: detach before taking locks held across waits `_queue.Semaphore` holds its mutex across the `allow_threads` condvar wait, and `join_internal` holds a thread handle's completion mutex the same way. Stop-the-world can stop a thread while it holds either one. The remaining acquisitions ran attached, so a thread blocking on such a mutex had no safepoint left to reach: the stop never completed, and the holder was never resumed to release it. Route those acquisitions through helpers that detach first. The fork-child reinit paths keep their direct locks. Assisted-by: Claude * _io, _winapi: detach on the remaining stopped-holdable lock takes `TextIOWrapper.__repr__` took `data` directly while every other method takes it through `lock_opt`, which detaches. `Overlapped` holds `inner` across the `allow_threads` in `GetOverlappedResult`, and all four of its takes were direct. A thread stopped by stop-the-world can be holding either mutex, so taking one while attached left the blocked thread with no safepoint to reach. Assisted-by: Claude * gc: size the interpreter owner tag to the header padding `gc_owner` was a u32. With 4-byte pointers its alignment pushed it out of the padding that follows the gc bits and generation, growing every object by a word and tripping the `SIZEOF_PYOBJECT_HEAD` assertion on 32-bit targets. Introduce `GcOwner = u16`, which the `repr(C)` layout places at offset 10 on 32-bit and 18 on 64-bit, leaving the header at 6 words on both. Tags now run out after 65535 interpreters; `alloc_owner` already falls back to `GC_NO_OWNER`, so an interpreter past that collects as it did before tagging. Also widen three test deadlines that measure liveness, not speed. Assisted-by: Claude * vm: reuse cleared frame blocks and shorten interpreter hot paths - `datastack` remembers the most recently popped frame block. `push_frame` reports an exact LIFO reuse, and `setup_datastack_frame` then skips zero-filling localsplus. - Small-int loads push the context's cached int as a borrowed stack reference instead of taking a new one. - Calls to jitted functions go straight to `execute_call_vectorcall`. - Binary-op specialization reads ints through the new `PyInt::try_to_i64_fast` instead of the generic primitive conversion, and `try_to_bool` moves its non-bool path into a `#[cold]` helper. - Dict caches read an entry through a keys-version stamp (`get_index_if_keys_version`) rather than an entry-index hint. - Frame publishing caches a pointer to `ThreadSlot::top_iframe` in a thread-local `Cell` instead of borrowing `CURRENT_THREAD_SLOT`. * gc, vm: address review notes on owner tags and interpreter docs Look up retired owner tags with a sorted binary search instead of a linear scan, which every scanned object paid for once per dropped interpreter. Drop the claim that clearing a tag frees it for reuse; `alloc_owner` only ever hands out new tags. Also correct the tag space it mentions, which is 16-bit since the tag was sized to the header padding. Document `is_main` as "top-level interpreter" rather than "the process main": every top-level interpreter sets it, and only the first registered one becomes the main `main_interpreter_id` reports. Assert membership rather than an exact owned-interpreter count delta; the owned table is process-global and other tests store into it in parallel. Assisted-by: Claude * vm: gate interpreter registration on stop-the-world admission A collection snapshots the registry, stops the interpreters it found, and then reads tracked objects with their threads parked. An interpreter that registered after the snapshot was taken was absent from it, so nothing stopped it and its bootstrap ran Python — allocating and mutating the shared generation lists — underneath that scan. Registration and the stop now share a process-global gate: the collection holds it from before the snapshot until the restart, and registration takes it around the registry insert. The insert runs detached, since a thread that waited for the gate, or re-attached while holding it, would leave the stop it waits for no safepoint to complete at. Assisted-by: Claude * gc: keep the tracking counters off the barrier path Every tracked allocation and every free went through sequentially consistent counter updates, and each free through a `fetch_update` CAS loop. The counters drive only the gen0 threshold and `gc.get_count()`, and the generation locks — not the counters — order the list changes they describe, so they are relaxed now and the decrement is a load plus a conditional `fetch_sub`. `is_enabled` and `threshold`, both read once per allocation, are relaxed for the same reason: an allocation racing `gc.disable()` may use either value. Drop `alloc_count`, which nothing has ever read. Assisted-by: Claude * dict: settle lookups and iteration steps under one read guard A lookup probed under a read guard, dropped it with the matched entry in hand, and then took the lock again to re-find the entry. `lookup_extract` reads the entry while the probe still holds the guard when key identity settles the match, which is the case that cannot run Python. Dict and set iterators took one lock to compare the size and another to read the entry, then cloned both the key and the value even though a keys or values view keeps only one of them. `next_entry_checked` does the size check and the read under one guard, and clones through a per-view projection. A store that missed its inline-cache hint re-probed the dict afterwards only to recover the entry index the store had just computed; `unchecked_push` reports that index instead. Assisted-by: Claude * vm: build a call's argument vector once The specialized CALL handlers collected the positional arguments into one vector and then copied them into a second one to put `self` in front, so every specialized builtin, method-descriptor, class and non-Python call paid two allocations, a copy and two frees. The arguments are already laid out on the value stack in vectorcall order, so `take_call_args` fills one vector by index — the shape `execute_call_vectorcall` already used. `vectorcall_native_function` and the keyword path of `vectorcall_function` then cloned that vector again to build `FuncArgs`; both now move it in through `from_vectorcall_owned`, as the other vectorcall slots do. Assisted-by: Claude * vm: reach an instance dict without cloning it The specialized attribute instructions cloned the instance dict — a rwlock round-trip plus a refcount round-trip — for two things that only look at it: `LoadAttrMethodLazyDict` asking whether the dict exists, and the keys-version stamp check that is the whole of `shadowing_instance_attr`'s fast path. Both now read it borrowed, through `has_instance_dict` / `with_instance_dict`. `generic_getattr_opt` probed the dict with the name's `&Wtf8`, which hashes the name on every lookup and can never match a key by pointer. Passing the `Py` uses the string's cached hash and the interned-key identity check, and drops an allocation when a stored key is not an exact `str`. Assisted-by: Claude * vm: shorten the per-instruction safepoint and the call preamble The dispatch loop asks once per instruction whether stop-the-world wants this thread, and that read went through `CURRENT_THREAD_SLOT` — a `RefCell` borrow, so two stores to thread-local memory around an `Option` test. Cache the `stop_requested` pointer in a plain `Cell` at the same three places the frame pointers are cached, and the safepoint becomes one relaxed load. `lasti` is advanced from the index the loop just read, rather than reloaded to increment it, and `Resume` reads `quickened` before swapping it, so a call to an already-quickened code object costs a load instead of an atomic read-modify-write. An exact-args vectorcall to a Python function built a heap `FrameObject` where the equivalent `invoke` path uses a data stack frame; it now does the same when tracing is off. Assisted-by: Claude * vm: give KwArgs a zero-sized hasher `KwArgs::default()` is built for every call, keyword-less ones included, and it seeded a `RandomState` each time — a thread-local read and 16 bytes in every `FuncArgs`. Keyword names come from the program text, so the map now uses `BuildHasherDefault`, whose `Default` is a zero-init. Assisted-by: Claude * vm: promote borrowed stack refs before a yield `yield ` tripped the "borrowed refs on stack at yield point" assertion: `LoadSmallInt` pushes a borrowed ref, and a yield saves the stack with the frame, which is exactly what that assertion forbids. Promote the stack first, so a suspended frame owns everything it holds. Assisted-by: Claude * vm: run the exact-args vectorcall on a heap frame again `vectorcall_function` took the data stack path for an exact-args call whenever tracing was off. A data stack frame that materializes — one traceback entry is enough — copies its localsplus into the `FrameObject`, so every value it holds gains a reference for as long as the frame runs, and a tail call holds the callee's function object until the caller's next call or return. Both are reachable without this hunk, through the `CallPyExactArgs` specialization, but routing the vectorcall entry point through the data stack made them apply from a call site's first execution: test_enumerate, test_memoryio, test_sys, test_tempfile and test_traceback all read a refcount or a finalization that moved. Assisted-by: Claude * vm: release a tail call's function at the callee's return The trampoline moved the callee's function object into the caller's `SuspendedFrame` and dropped it only once the caller had run again, so a function entered through a tail call kept one reference past its return: `sys.getrefcount` on it read one high for the caller's next stretch of bytecode, and anything the function reached stayed alive that much longer. The callee's frame is released before the `ReturnValue` or `Unwind` action is formed, and a materialized frame object holds its own references, so the owner is dropped where the suspended caller is popped instead. Assisted-by: Claude * vm: stop copying a running frame's locals when it materializes `materialize_slow` cloned the source frame's fast locals into the new `FrameObject`, so every value in a data stack frame gained a reference lasting as long as that frame object: one traceback entry through a frame kept all of its locals alive, and `sys.getrefcount` read one high for each of them. Nothing read that copy while the frame ran. `find_live_source_iframe` already routes reads to the live frame, and `exit_iframe` fills the slots from it at return, so the copy now starts empty as `materialize_slow_chain`'s does, and `snapshot_to_heap` goes with its last caller. Two readers had to be brought to the live frame the same way. `has_active_hidden_locals` read the frame object's own slots, which let `locals()` in a class body write an inlined comprehension's hidden variable into the class namespace; it now shares `live_fastlocals` with the other two. Another thread's frame has no live frame to reach from here, so the six cross-thread sites fill the copy under stop-the-world through `materialize_with_locals`. Assisted-by: Claude * vm: key data stack frame reuse on the exact frame size push_frame and pop_frame compared 16-byte-aligned sizes, so two frames whose sizes differ by less than the alignment matched as a reuse. The caller reads that as "every slot was cleared by the previous frame", but the larger frame's tail slots were never touched by it. Assisted-by: Claude * vm: store the frame object payload address in the cross-thread slot set_current_frame and reinit_frame_slot_after_fork wrote the address of the Py, while the cross-thread readers hand that pointer to Py::from_payload_ptr, which subtracts the object header from it. Both writers now store the payload address. Assisted-by: Claude * vm: copy a foreign thread's frames instead of linking them sys._current_frames(), _thread._current_exceptions() and the cross-thread f_back path handed back frame objects still linked to the running frames. The owning thread wrote its locals into those same buffers at exit_iframe while the reader was cloning out of them. materialize_detached and materialize_detached_chain copy the values under stop-the-world and leave nothing for the owner to write into; materialize_with_locals is gone. FrameColdData gains attached_tid, the thread still running the frame a frame object was materialized from, which exit_iframe clears once it has written the values in. check_locals_access now rejects a read from any other thread while it is set; the FrameOwner test could not, because materialize_slow forces FrameObject ownership. Also removes with_iframe, which had no callers. Assisted-by: Claude * vm: resolve the two rustdoc links this branch added MAIN_INTERPRETER_ID is not in scope in interpreter.rs, and new_thread only exists in a threading build, so neither link resolved. Assisted-by: Claude --- crates/capi/src/objimpl.rs | 28 +- crates/capi/src/pystate.rs | 4 +- crates/stdlib/src/_queue.rs | 33 +- crates/stdlib/src/faulthandler.rs | 4 +- crates/vm/src/builtins/bool.rs | 7 + crates/vm/src/builtins/builtin_func.rs | 4 +- crates/vm/src/builtins/dict.rs | 89 +- crates/vm/src/builtins/frame.rs | 27 +- crates/vm/src/builtins/function.rs | 39 +- crates/vm/src/builtins/int.rs | 16 + crates/vm/src/builtins/set.rs | 14 +- crates/vm/src/builtins/type.rs | 37 +- crates/vm/src/datastack.rs | 48 +- crates/vm/src/dict_inner.rs | 178 +++- crates/vm/src/frame.rs | 628 ++++++----- crates/vm/src/function/argument.rs | 19 +- crates/vm/src/function/mod.rs | 4 +- crates/vm/src/gc_state.rs | 650 ++++++++---- crates/vm/src/lib.rs | 6 +- crates/vm/src/object/core.rs | 78 +- crates/vm/src/object/mod.rs | 2 +- crates/vm/src/protocol/object.rs | 5 +- crates/vm/src/stdlib/_ast/python.rs | 4 +- crates/vm/src/stdlib/_ctypes/structure.rs | 3 +- crates/vm/src/stdlib/_ctypes/union.rs | 3 +- crates/vm/src/stdlib/_functools.rs | 3 +- crates/vm/src/stdlib/_io.rs | 5 +- crates/vm/src/stdlib/_signal.rs | 4 +- crates/vm/src/stdlib/_thread.rs | 82 +- crates/vm/src/stdlib/_winapi.rs | 21 +- crates/vm/src/stdlib/gc.rs | 60 +- crates/vm/src/stdlib/posix.rs | 69 +- crates/vm/src/stdlib/sys.rs | 3 +- crates/vm/src/stdlib/sys/monitoring.rs | 2 +- crates/vm/src/vm/context.rs | 16 +- crates/vm/src/vm/interpreter.rs | 1169 +++++++++++++++++++-- crates/vm/src/vm/mod.rs | 281 ++--- crates/vm/src/vm/runtime.rs | 326 ++++++ crates/vm/src/vm/setting.rs | 2 + crates/vm/src/vm/thread.rs | 374 +++++-- 40 files changed, 3358 insertions(+), 989 deletions(-) create mode 100644 crates/vm/src/vm/runtime.rs diff --git a/crates/capi/src/objimpl.rs b/crates/capi/src/objimpl.rs index 99b0be7cc68..aa74bb69379 100644 --- a/crates/capi/src/objimpl.rs +++ b/crates/capi/src/objimpl.rs @@ -9,7 +9,7 @@ pub unsafe extern "C" fn PyObject_GC_Track(op: *mut PyObject) { with_vm(|_vm| { let obj = unsafe { &*op }; if !obj.is_gc_tracked() { - unsafe { gc_state::gc_state().track_object(obj.into()) }; + unsafe { gc_state::gc_state().track_object(obj.into(), gc_state::current_owner()) }; } }) } @@ -36,29 +36,33 @@ pub unsafe extern "C" fn PyObject_GC_IsFinalized(op: *mut PyObject) -> c_int { #[unsafe(no_mangle)] pub extern "C" fn PyGC_Collect() -> isize { - let result = gc_state::gc_state().collect(2); - (result.collected + result.uncollectable) as isize + with_vm(|vm| { + let result = vm.state.gc.collect(2); + (result.collected + result.uncollectable) as isize + }) } #[unsafe(no_mangle)] pub extern "C" fn PyGC_Enable() -> c_int { - let gc = gc_state::gc_state(); - let was_enabled = gc.is_enabled(); - gc.enable(); - was_enabled.into() + with_vm(|vm| { + let was_enabled: c_int = vm.state.gc.is_enabled().into(); + vm.state.gc.enable(); + was_enabled + }) } #[unsafe(no_mangle)] pub extern "C" fn PyGC_Disable() -> c_int { - let gc = gc_state::gc_state(); - let was_enabled = gc.is_enabled(); - gc.disable(); - was_enabled.into() + with_vm(|vm| { + let was_enabled: c_int = vm.state.gc.is_enabled().into(); + vm.state.gc.disable(); + was_enabled + }) } #[unsafe(no_mangle)] pub extern "C" fn PyGC_IsEnabled() -> c_int { - gc_state::gc_state().is_enabled().into() + with_vm(|vm| -> c_int { vm.state.gc.is_enabled().into() }) } #[unsafe(no_mangle)] diff --git a/crates/capi/src/pystate.rs b/crates/capi/src/pystate.rs index 865a116443b..cec3bc240b1 100644 --- a/crates/capi/src/pystate.rs +++ b/crates/capi/src/pystate.rs @@ -109,8 +109,8 @@ mod tests { current_vm_is_set(), "This thread did not have a vm attached" ); - vm.state.stop_the_world.stop_the_world(vm); - vm.state.stop_the_world.start_the_world(vm); + vm.state.stop_the_world.stop_the_world(&vm.state); + vm.state.stop_the_world.start_the_world(&vm.state); }); }); }); diff --git a/crates/stdlib/src/_queue.rs b/crates/stdlib/src/_queue.rs index 1c8a4b0b21b..96e77a34a0b 100644 --- a/crates/stdlib/src/_queue.rs +++ b/crates/stdlib/src/_queue.rs @@ -74,9 +74,20 @@ mod _queue { } } - fn release(&self) { + /// Take `mutex`, detaching first so that blocking on it cannot stall a + /// stop-the-world request. + /// + /// A waiter holds this mutex across its `allow_threads` wait, so it can + /// still hold it when it is stopped. An attached thread blocking on it + /// would then never reach a safepoint, the stop would never complete, + /// and the holder would never be resumed to release it. + fn lock_count(&self, vm: &VirtualMachine) -> parking_lot::MutexGuard<'_, usize> { + vm.allow_threads(|| self.mutex.lock()) + } + + fn release(&self, vm: &VirtualMachine) { { - let mut count = self.mutex.lock(); + let mut count = self.lock_count(vm); *count += 1; } // lock dropped. now we can notify a waiting thread @@ -95,7 +106,7 @@ mod _queue { // Guard must be dropped before check_signals() below, since a // signal handler may call back into this same queue. { - let mut count = self.mutex.lock(); + let mut count = self.lock_count(vm); if *count > 0 { *count -= 1; @@ -151,11 +162,15 @@ mod _queue { } impl PySimpleQueue { - fn push(&self, item: PyObjectRef) { + #[cfg_attr( + not(feature = "threading"), + expect(unused_variables, reason = "only the semaphore needs the vm") + )] + fn push(&self, item: PyObjectRef, vm: &VirtualMachine) { self.buf.lock().push_back(item); #[cfg(feature = "threading")] - self.sem.release(); + self.sem.release(vm); } /// Returns a strong reference from the head of the buffer. @@ -221,14 +236,14 @@ mod _queue { } #[pymethod] - fn put(&self, args: PutArgs) { + fn put(&self, args: PutArgs, vm: &VirtualMachine) { let PutArgs { item, .. } = args; - self.push(item); + self.push(item, vm); } #[pymethod] - fn put_nowait(&self, item: PyObjectRef) { - self.push(item); + fn put_nowait(&self, item: PyObjectRef, vm: &VirtualMachine) { + self.push(item, vm); } #[pymethod] diff --git a/crates/stdlib/src/faulthandler.rs b/crates/stdlib/src/faulthandler.rs index 6edd023f1eb..3fbb8391bec 100644 --- a/crates/stdlib/src/faulthandler.rs +++ b/crates/stdlib/src/faulthandler.rs @@ -260,8 +260,8 @@ mod decl { use core::sync::atomic::Ordering; let current_tid = rustpython_vm::stdlib::_thread::get_ident(); { - vm.state.stop_the_world.stop_the_world(vm); - scopeguard::defer! { vm.state.stop_the_world.start_the_world(vm); } + vm.state.stop_the_world.stop_the_world(&vm.state); + scopeguard::defer! { vm.state.stop_the_world.start_the_world(&vm.state); } let registry = vm.state.thread_frames.lock(); #[expect( clippy::iter_over_hash_type, diff --git a/crates/vm/src/builtins/bool.rs b/crates/vm/src/builtins/bool.rs index 4bb980d71a2..1cfa8cc27ee 100644 --- a/crates/vm/src/builtins/bool.rs +++ b/crates/vm/src/builtins/bool.rs @@ -34,6 +34,7 @@ impl<'a> TryFromBorrowedObject<'a> for bool { impl PyObjectRef { /// Convert Python bool into Rust bool. + #[inline(always)] pub fn try_to_bool(self, vm: &VirtualMachine) -> PyResult { if self.is(&vm.ctx.true_value) { return Ok(true); @@ -41,6 +42,12 @@ impl PyObjectRef { return Ok(false); } + self.try_to_bool_slow(vm) + } + + #[cold] + #[inline(never)] + fn try_to_bool_slow(self, vm: &VirtualMachine) -> PyResult { let slots = &self.class().slots; // 1. Try nb_bool slot first diff --git a/crates/vm/src/builtins/builtin_func.rs b/crates/vm/src/builtins/builtin_func.rs index eabe8d4ea27..b34447b79bf 100644 --- a/crates/vm/src/builtins/builtin_func.rs +++ b/crates/vm/src/builtins/builtin_func.rs @@ -247,9 +247,9 @@ fn vectorcall_native_function( let mut all_args = Vec::with_capacity(args.len() + 1); all_args.push(self_obj); all_args.extend(args); - FuncArgs::from_vectorcall(&all_args, nargs + 1, kwnames) + FuncArgs::from_vectorcall_owned(all_args, nargs + 1, kwnames) } else { - FuncArgs::from_vectorcall(&args, nargs, kwnames) + FuncArgs::from_vectorcall_owned(args, nargs, kwnames) }; (zelf.value.func)(vm, func_args) diff --git a/crates/vm/src/builtins/dict.rs b/crates/vm/src/builtins/dict.rs index 1a380d74d02..d2b9dea31fa 100644 --- a/crates/vm/src/builtins/dict.rs +++ b/crates/vm/src/builtins/dict.rs @@ -114,11 +114,6 @@ impl PyDict { &self.entries } - /// Monotonically increasing version for mutation tracking. - pub(crate) fn version(&self) -> u64 { - self.entries.version() - } - /// Returns all keys as a Vec, atomically under a single read lock. /// Thread-safe: prevents "dictionary changed size during iteration" errors. pub fn keys_vec(&self) -> Vec { @@ -821,18 +816,15 @@ impl Py { } } - /// Fast lookup using a cached entry index hint. - pub(crate) fn get_item_opt_hint( + /// Read a cached exact-dict entry after validating its key-layout stamp. + #[inline] + pub(crate) fn get_item_by_index_and_keys_version( &self, - key: &K, - hint: u16, - vm: &VirtualMachine, - ) -> PyResult> { - if self.exact_dict(vm) { - self.entries.get_hint(vm, key, usize::from(hint)) - } else { - self.get_item_opt(key, vm) - } + version: u16, + index: u16, + ) -> Option { + self.entries + .get_index_if_keys_version(u32::from(version), usize::from(index)) } /// Lookup trying a cached entry index hint first. @@ -1098,6 +1090,7 @@ macro_rules! dict_view { $class_name: literal, $iter_class_name: literal, $reverse_iter_class_name: literal, + $project_fn: expr, $result_fn: expr ) => { #[pyclass(module = false, name = $class_name)] @@ -1120,7 +1113,7 @@ macro_rules! dict_view { } fn item(vm: &VirtualMachine, key: PyObjectRef, value: PyObjectRef) -> PyObjectRef { - $result_fn(vm, key, value) + $result_fn(vm, $project_fn(&key, &value)) } fn __reversed__(&self) -> Self::ReverseIter { @@ -1206,7 +1199,7 @@ macro_rules! dict_view { while let Some((next_position, key, value)) = dict.entries.next_entry(position) { - entries.push(($result_fn)(vm, key, value)); + entries.push(($result_fn)(vm, ($project_fn)(&key, &value))); position = next_position; } entries @@ -1223,18 +1216,22 @@ macro_rules! dict_view { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { let mut internal = zelf.internal.lock(); let next = if let IterStatus::Active(dict) = &internal.status { - if dict.entries.has_changed_size(&zelf.size) { - internal.status = IterStatus::Exhausted; - return Err( - vm.new_runtime_error("dictionary changed size during iteration") - ); - } - match dict.entries.next_entry(internal.position) { - Some((position, key, value)) => { + match dict.entries.next_entry_checked( + internal.position, + &zelf.size, + $project_fn, + ) { + Err(dict_inner::DictChanged) => { + internal.status = IterStatus::Exhausted; + return Err( + vm.new_runtime_error("dictionary changed size during iteration") + ); + } + Ok(Some((position, item))) => { internal.position = position; - PyIterReturn::Return(($result_fn)(vm, key, value)) + PyIterReturn::Return(($result_fn)(vm, item)) } - None => { + Ok(None) => { internal.status = IterStatus::Exhausted; PyIterReturn::StopIteration(None) } @@ -1282,7 +1279,7 @@ macro_rules! dict_view { while let Some((found_index, key, value)) = dict.entries.prev_entry(position) { - entries.push(($result_fn)(vm, key, value)); + entries.push(($result_fn)(vm, ($project_fn)(&key, &value))); if found_index == 0 { break; } @@ -1309,22 +1306,26 @@ macro_rules! dict_view { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { let mut internal = zelf.internal.lock(); let next = if let IterStatus::Active(dict) = &internal.status { - if dict.entries.has_changed_size(&zelf.size) { - internal.status = IterStatus::Exhausted; - return Err( - vm.new_runtime_error("dictionary changed size during iteration") - ); - } - match dict.entries.prev_entry(internal.position) { - Some((found_index, key, value)) => { + match dict.entries.prev_entry_checked( + internal.position, + &zelf.size, + $project_fn, + ) { + Err(dict_inner::DictChanged) => { + internal.status = IterStatus::Exhausted; + return Err( + vm.new_runtime_error("dictionary changed size during iteration") + ); + } + Ok(Some((found_index, item))) => { if found_index == 0 { internal.status = IterStatus::Exhausted; } else { internal.position = found_index - 1; } - PyIterReturn::Return(($result_fn)(vm, key, value)) + PyIterReturn::Return(($result_fn)(vm, item)) } - None => { + Ok(None) => { internal.status = IterStatus::Exhausted; PyIterReturn::StopIteration(None) } @@ -1348,7 +1349,8 @@ dict_view! { "dict_keys", "dict_keyiterator", "dict_reversekeyiterator", - |_vm: &VirtualMachine, key: PyObjectRef, _value: PyObjectRef| key + |key: &PyObjectRef, _value: &PyObjectRef| key.clone(), + |_vm: &VirtualMachine, key: PyObjectRef| key } dict_view! { @@ -1361,7 +1363,8 @@ dict_view! { "dict_values", "dict_valueiterator", "dict_reversevalueiterator", - |_vm: &VirtualMachine, _key: PyObjectRef, value: PyObjectRef| value + |_key: &PyObjectRef, value: &PyObjectRef| value.clone(), + |_vm: &VirtualMachine, value: PyObjectRef| value } dict_view! { @@ -1374,7 +1377,9 @@ dict_view! { "dict_items", "dict_itemiterator", "dict_reverseitemiterator", - |vm: &VirtualMachine, key: PyObjectRef, value: PyObjectRef| + |key: &PyObjectRef, value: &PyObjectRef| (key.clone(), value.clone()), + // Builds a tuple, so it runs after the dict's read guard is released. + |vm: &VirtualMachine, (key, value): (PyObjectRef, PyObjectRef)| vm.new_tuple((key, value)).into() } diff --git a/crates/vm/src/builtins/frame.rs b/crates/vm/src/builtins/frame.rs index 94ec827b7a6..e4b3aa2b184 100644 --- a/crates/vm/src/builtins/frame.rs +++ b/crates/vm/src/builtins/frame.rs @@ -512,7 +512,7 @@ impl FrameObject { let live = self.find_live_source_iframe(); if !live.is_null() { // Read live prev_line. Use read_volatile to bypass LLVM noalias - // on the &mut InterpreterFrame borrow in with_iframe. + // on the &mut InterpreterFrame borrow held by the running frame. let prev = unsafe { let field_ptr = core::ptr::addr_of!((*live).prev_line); core::ptr::read_volatile(field_ptr as *const u32) @@ -897,32 +897,19 @@ impl Py { { // Enter STW before dereferencing `prev` — the owning thread may // return and free the stack-allocated iframe at any time. - vm.state.stop_the_world.stop_the_world(vm); - scopeguard::defer! { vm.state.stop_the_world.start_the_world(vm); } + vm.state.stop_the_world.stop_the_world(&vm.state); + scopeguard::defer! { vm.state.stop_the_world.start_the_world(&vm.state); } let prev_ref = unsafe { &*prev }; // Fast path: already materialized. if let Some(fo) = prev_ref.frame_obj() { fo.mark_escaped(); return Some(fo.to_owned()); } - // Slow path: materialize the entire chain and link retained_back. - let mut cur = prev; - let mut child_fo: Option> = None; - while !cur.is_null() { - let iframe = unsafe { &*cur }; - let fo = iframe.materialize(vm).to_owned(); - if let Some(child) = child_fo.take() { - let mut guard = child.iframe().cold().retained_back.lock(); - if guard.is_none() { - *guard = Some(fo.clone()); - } - } - child_fo = Some(fo); - cur = iframe.previous(); - } - let fo = prev_ref.materialize(vm); + // Slow path: copy the whole chain, linked through retained_back. + // SAFETY: the world is stopped, so the owning thread is parked. + let fo = unsafe { prev_ref.materialize_detached_chain(vm) }; fo.mark_escaped(); - return Some(fo.to_owned()); + return Some(fo); } #[allow(unreachable_code)] diff --git a/crates/vm/src/builtins/function.rs b/crates/vm/src/builtins/function.rs index 90315bcb194..a5342d1df3a 100644 --- a/crates/vm/src/builtins/function.rs +++ b/crates/vm/src/builtins/function.rs @@ -550,6 +550,20 @@ impl Py { self.code.flags.contains(bytecode::CodeFlags::OPTIMIZED) } + /// Whether this function currently has native JIT code. Adaptive Python + /// call specializations must yield to that entry point. + #[inline] + pub(crate) fn is_jitted(&self) -> bool { + #[cfg(feature = "jit")] + { + self.jitted_code.lock().is_some() + } + #[cfg(not(feature = "jit"))] + { + false + } + } + pub fn invoke_with_locals( &self, func_args: FuncArgs, @@ -643,8 +657,8 @@ impl Py { .and_then(|()| vm.run_frame_fast(iframe)); // Release data stack memory — must happen on both success and error. unsafe { - if let Some(base) = iframe.release_datastack_frame() { - vm.datastack_pop(base); + if let Some((base, size)) = iframe.release_datastack_frame() { + vm.datastack_pop_frame(base, size); } } result @@ -669,7 +683,10 @@ impl Py { ); // SAFETY: the frame is alive (held by `frame`) and untracked. unsafe { - crate::gc_state::gc_state().track_object(core::ptr::NonNull::from(frame.as_object())); + crate::gc_state::gc_state().track_object( + core::ptr::NonNull::from(frame.as_object()), + crate::gc_state::current_owner(), + ); } frame.set_generator(&obj); obj @@ -820,8 +837,8 @@ impl Py { let result = vm.run_frame_fast(iframe); unsafe { - if let Some(base) = iframe.release_datastack_frame() { - vm.datastack_pop(base); + if let Some((base, size)) = iframe.release_datastack_frame() { + vm.datastack_pop_frame(base, size); } } result @@ -1616,6 +1633,16 @@ pub(crate) fn vectorcall_function( let code: &Py = &zelf.code; let has_kwargs = kwnames.is_some_and(|kw| !kw.is_empty()); + if zelf.is_jitted() { + let func_args = if has_kwargs { + FuncArgs::from_vectorcall_owned(args, nargs, kwnames) + } else { + args.truncate(nargs); + FuncArgs::from(args) + }; + return zelf.invoke(func_args, vm); + } + let is_simple = !has_kwargs && code.flags.contains(bytecode::CodeFlags::OPTIMIZED) && !code.flags.contains(bytecode::CodeFlags::VARARGS) @@ -1640,7 +1667,7 @@ pub(crate) fn vectorcall_function( // SLOW PATH: construct FuncArgs from owned Vec and delegate to invoke() let func_args = if has_kwargs { - FuncArgs::from_vectorcall(&args, nargs, kwnames) + FuncArgs::from_vectorcall_owned(args, nargs, kwnames) } else { args.truncate(nargs); FuncArgs::from(args) diff --git a/crates/vm/src/builtins/int.rs b/crates/vm/src/builtins/int.rs index c12bf2c721c..134617ab7ea 100644 --- a/crates/vm/src/builtins/int.rs +++ b/crates/vm/src/builtins/int.rs @@ -305,6 +305,22 @@ impl PyInt { &self.value } + /// Extract the inline magnitude without the generic primitive-conversion path. + #[inline(always)] + pub(crate) fn try_to_i64_fast(&self) -> Option { + let bits = self.value.bits(); + if bits > i64::BITS as u64 { + return None; + } + let magnitude = self.value.iter_u64_digits().next().unwrap_or(0); + let signed_magnitude = i64::try_from(magnitude).ok(); + match self.value.sign() { + Sign::Minus if magnitude == 1u64 << 63 => Some(i64::MIN), + Sign::Minus => signed_magnitude.map(|value| -value), + Sign::NoSign | Sign::Plus => signed_magnitude, + } + } + /// Fast decimal string conversion, using i64 path when possible. #[inline] #[must_use] diff --git a/crates/vm/src/builtins/set.rs b/crates/vm/src/builtins/set.rs index 860f86f4319..d737612b158 100644 --- a/crates/vm/src/builtins/set.rs +++ b/crates/vm/src/builtins/set.rs @@ -1533,16 +1533,16 @@ impl IterNext for PySetIterator { fn next(zelf: &crate::Py, vm: &VirtualMachine) -> PyResult { let mut internal = zelf.internal.lock(); let next = if let IterStatus::Active(dict) = &internal.status { - if dict.has_changed_size(&zelf.size) { - internal.status = IterStatus::Exhausted; - return Err(vm.new_runtime_error("set changed size during iteration")); - } - match dict.next_entry(internal.position) { - Some((position, key, _)) => { + match dict.next_entry_checked(internal.position, &zelf.size, |key, ()| key.clone()) { + Err(crate::dict_inner::DictChanged) => { + internal.status = IterStatus::Exhausted; + return Err(vm.new_runtime_error("set changed size during iteration")); + } + Ok(Some((position, key))) => { internal.position = position; PyIterReturn::Return(key) } - None => { + Ok(None) => { internal.status = IterStatus::Exhausted; PyIterReturn::StopIteration(None) } diff --git a/crates/vm/src/builtins/type.rs b/crates/vm/src/builtins/type.rs index 1776270751e..5a7169983af 100644 --- a/crates/vm/src/builtins/type.rs +++ b/crates/vm/src/builtins/type.rs @@ -294,6 +294,16 @@ pub struct HeapTypeExt { pub slots: Option>>, pub type_data: PyRwLock>, pub specialization_cache: TypeSpecializationCache, + /// The interpreter this type was created in, or `None` for the types the + /// shared context builds before any interpreter exists. + pub interpreter_id: Option, +} + +impl HeapTypeExt { + /// The interpreter a type created right now belongs to. + fn creating_interpreter_id() -> Option { + crate::vm::thread::try_with_current_vm(|vm| vm.state.interpreter_id) + } } pub struct TypeSpecializationCache { @@ -553,6 +563,22 @@ impl PyType { self.modified_inner(); } + /// Whether the interpreter with `interpreter_id` can see this type. + /// + /// Interpreters share the context, so a subclass of a shared type is + /// recorded on an object every interpreter reaches. Only the interpreter + /// that created it can name it, so only that one lists it. + pub fn is_visible_to_interpreter(&self, interpreter_id: i64) -> bool { + match self + .heaptype_ext + .as_ref() + .and_then(|ext| ext.interpreter_id) + { + Some(owner) => owner == interpreter_id, + None => true, + } + } + pub fn new_simple_heap( name: &str, base: &Py, @@ -589,6 +615,7 @@ impl PyType { slots: None, type_data: PyRwLock::new(None), specialization_cache: TypeSpecializationCache::new(), + interpreter_id: HeapTypeExt::creating_interpreter_id(), }; let base = bases[0].clone(); @@ -1948,13 +1975,18 @@ impl PyType { } #[pymethod] - fn __subclasses__(&self) -> PyList { + fn __subclasses__(&self, vm: &VirtualMachine) -> PyList { let mut subclasses = self.subclasses.write(); subclasses.retain(|x| x.upgrade().is_some()); + let interpreter_id = vm.state.interpreter_id; PyList::from( subclasses .iter() - .map(|x| x.upgrade().unwrap()) + .filter_map(|x| x.upgrade()) + .filter(|obj| { + obj.downcast_ref::() + .is_none_or(|typ| typ.is_visible_to_interpreter(interpreter_id)) + }) .collect::>(), ) } @@ -2368,6 +2400,7 @@ impl Constructor for PyType { slots: heaptype_slots.clone(), type_data: PyRwLock::new(None), specialization_cache: TypeSpecializationCache::new(), + interpreter_id: HeapTypeExt::creating_interpreter_id(), }; (slots, heaptype_ext) }; diff --git a/crates/vm/src/datastack.rs b/crates/vm/src/datastack.rs index 101369fba57..ec4c22ae808 100644 --- a/crates/vm/src/datastack.rs +++ b/crates/vm/src/datastack.rs @@ -61,6 +61,9 @@ pub struct DataStack { top: *mut u8, /// End of usable space in the current chunk. limit: *mut u8, + /// Most recently popped full-frame allocation whose localsplus slots were + /// cleared before the pop. An exact LIFO reuse can skip zero-filling them. + reusable_frame: Option<(*mut u8, usize)>, } impl DataStack { @@ -73,7 +76,12 @@ impl DataStack { // Skip one ALIGN-sized slot in the root chunk so that `pop()` never // frees it (`push_chunk` convention). let top = unsafe { top.add(ALIGN) }; - Self { chunk, top, limit } + Self { + chunk, + top, + limit, + reusable_frame: None, + } } /// Check if the current chunk has at least `size` bytes available. @@ -91,6 +99,26 @@ impl DataStack { /// (LIFO order). #[inline(always)] pub fn push(&mut self, size: usize) -> *mut u8 { + self.reusable_frame = None; + self.push_inner(size) + } + + /// Allocate a full interpreter frame and report whether it exactly reuses + /// a just-cleared frame block. + #[inline(always)] + pub fn push_frame(&mut self, size: usize) -> (*mut u8, bool) { + let reusable_frame = self.reusable_frame.take(); + let ptr = self.push_inner(size); + // Exact sizes, not aligned ones: the caller reads "reused" as "every + // slot of this frame was cleared by the last one", and two frames whose + // sizes differ by less than ALIGN share an aligned size while the + // larger one's tail slots were never touched, let alone cleared. + let reused = reusable_frame.is_some_and(|(base, old_size)| base == ptr && old_size == size); + (ptr, reused) + } + + #[inline(always)] + fn push_inner(&mut self, size: usize) -> *mut u8 { let aligned_size = (size + ALIGN - 1) & !(ALIGN - 1); unsafe { if self.top.add(aligned_size) <= self.limit { @@ -138,6 +166,24 @@ impl DataStack { /// and all allocations made after it must already have been popped. #[inline(always)] pub unsafe fn pop(&mut self, base: *mut u8) { + self.reusable_frame = None; + unsafe { self.pop_inner(base) }; + } + + /// Pop a full frame whose localsplus slots have already been cleared. + /// + /// # Safety + /// `base` and `size` must describe the most recent allocation returned by + /// `push_frame`, every later allocation must already be popped, and all + /// localsplus slots in the frame must have been cleared. + #[inline(always)] + pub unsafe fn pop_frame(&mut self, base: *mut u8, size: usize) { + unsafe { self.pop_inner(base) }; + self.reusable_frame = Some((base, size)); + } + + #[inline(always)] + unsafe fn pop_inner(&mut self, base: *mut u8) { debug_assert!(!base.is_null()); if self.is_in_current_chunk(base) { // Common case: base is within the current chunk. diff --git a/crates/vm/src/dict_inner.rs b/crates/vm/src/dict_inner.rs index 9dda6194a0c..76d2c50f0cb 100644 --- a/crates/vm/src/dict_inner.rs +++ b/crates/vm/src/dict_inner.rs @@ -20,7 +20,7 @@ use alloc::fmt; use core::mem::size_of; use core::ops::ControlFlow; use core::sync::atomic::{ - AtomicU32, AtomicU64, + AtomicU32, Ordering::{AcqRel, Acquire, Relaxed, Release}, }; use num_traits::ToPrimitive; @@ -39,7 +39,6 @@ type EntryIndex = usize; pub(crate) struct Dict { inner: PyRwLock>, - version: AtomicU64, /// Keys-version stamp, assigned lazily by `assign_keys_version` and /// reset to 0 whenever the key set changes. Value-only updates keep it. /// @@ -202,7 +201,6 @@ impl Clone for Dict { fn clone(&self) -> Self { Self { inner: PyRwLock::new(self.inner.read().clone()), - version: AtomicU64::new(0), keys_version: AtomicU32::new(0), } } @@ -217,7 +215,6 @@ impl Default for Dict { indices: vec![IndexEntry::FREE; 8], entries: Vec::new(), }), - version: AtomicU64::new(0), keys_version: AtomicU32::new(0), } } @@ -240,6 +237,10 @@ pub struct DictSize { filled: usize, } +/// The dict was resized under an iterator holding an older [`DictSize`]. +#[derive(Debug)] +pub(crate) struct DictChanged; + struct GenIndexes { idx: HashIndex, perturb: HashValue, @@ -309,7 +310,7 @@ impl DictInner { key: PyObjectRef, value: T, index_entry: IndexEntry, - ) { + ) -> usize { let entry = DictEntry { hash: hash_value, key, @@ -330,6 +331,9 @@ impl DictInner { self.resize(new_size) } } + // A resize keeps entry positions and rewrites only the index-index, so + // this stays the entry's index afterwards. + entry_index } const fn size(&self) -> DictSize { @@ -362,16 +366,6 @@ impl DictInner { type PopInnerResult = ControlFlow>>; impl Dict { - /// Monotonically increasing version counter for mutation tracking. - pub(crate) fn version(&self) -> u64 { - self.version.load(Acquire) - } - - /// Bump the version counter after any mutation. - fn bump_version(&self) { - self.version.fetch_add(1, Release); - } - /// Current keys-version stamp, or 0 if none has been assigned since the /// last key-set change. Equal nonzero stamps guarantee an unchanged key /// set (values may differ). @@ -481,7 +475,26 @@ impl Dict { where K: DictKey + ?Sized, { - let _removed = loop { + self.insert_known_hash_indexed(vm, key, hash, value)?; + Ok(()) + } + + /// [`Self::insert_known_hash`], also reporting the entry index it stored to. + /// + /// The index doubles as a `hint` for [`Self::get_hint`] / + /// [`Self::insert_with_hint`], so a caller that wants one gets it from the + /// store itself instead of probing the dict a second time. + fn insert_known_hash_indexed( + &self, + vm: &VirtualMachine, + key: &K, + hash: HashValue, + value: T, + ) -> PyResult + where + K: DictKey + ?Sized, + { + let (stored_index, _removed) = loop { let (entry_index, index_index) = self.lookup(vm, key, hash, None)?; let mut inner = self.write(); if let Some(index) = entry_index.index() { @@ -500,9 +513,8 @@ impl Dict { )] if entry.index == index_index { let removed = core::mem::replace(&mut entry.value, value); - self.bump_version(); // defer dec RC - break Some(removed); + break (index, Some(removed)); } else { // stuff shifted around, let's try again } @@ -516,12 +528,17 @@ impl Dict { continue; } self.invalidate_keys_version(); - inner.unchecked_push(index_index, hash, key.to_pyobject(vm), value, entry_index); - self.bump_version(); - break None; + let stored = inner.unchecked_push( + index_index, + hash, + key.to_pyobject(vm), + value, + entry_index, + ); + break (stored, None); } }; - Ok(()) + Ok(stored_index) } pub(crate) fn contains( @@ -616,7 +633,6 @@ impl Dict { match inner.entries.get_mut(hint) { Some(Some(entry)) if key.key_is(&entry.key) => { let removed = core::mem::replace(&mut entry.value, value); - self.bump_version(); drop(inner); // defer dec RC until after the lock is released drop(removed); @@ -625,8 +641,9 @@ impl Dict { _ => value, } }; - self.insert(vm, key, value)?; - self.hint_for_key(vm, key) + let hash = key.key_hash(vm)?; + let stored = self.insert_known_hash_indexed(vm, key, hash, value)?; + Ok(u16::try_from(stored).ok()) } /// Fast path lookup using a cached entry index (`hint`). @@ -656,6 +673,22 @@ impl Dict { } } + /// Read an entry directly when a cached keys-version still describes the + /// dictionary layout. The version is rechecked while holding the read lock + /// so the entry index and value are observed from the same key-set state. + #[inline] + pub(crate) fn get_index_if_keys_version(&self, version: u32, index: usize) -> Option { + let inner = self.read(); + if self.keys_version.load(Acquire) != version { + return None; + } + inner + .entries + .get(index) + .and_then(Option::as_ref) + .map(|entry| entry.value.clone()) + } + fn _get_inner( &self, vm: &VirtualMachine, @@ -663,7 +696,12 @@ impl Dict { hash: HashValue, ) -> PyResult> { let ret = loop { - let (entry, index_index) = self.lookup(vm, key, hash, None)?; + let (entry, index_index) = + match self.lookup_extract(vm, key, hash, None, |entry| entry.value.clone())? { + // Read under the probe's own guard: nothing to re-check. + (_, Some(value)) => break Some(value), + (lookup, None) => lookup, + }; if let Some(index) = entry.index() { let inner = self.read(); if let Some(entry) = inner.get_entry_checked(index, index_index) { @@ -701,7 +739,6 @@ impl Dict { inner.indices.resize(8, IndexEntry::FREE); inner.used = 0; inner.filled = 0; - self.bump_version(); // defer dec rc core::mem::take(&mut inner.entries) }; @@ -830,7 +867,6 @@ impl Dict { } self.invalidate_keys_version(); inner.unchecked_push(index_index, hash, key.to_owned(), value, entry); - self.bump_version(); break None; }; Ok(()) @@ -867,7 +903,6 @@ impl Dict { value.clone(), index_entry, ); - self.bump_version(); return Ok(value); } } @@ -905,7 +940,6 @@ impl Dict { let ret = (key_obj.clone(), value.clone()); self.invalidate_keys_version(); inner.unchecked_push(index_index, hash, key_obj, value, index_entry); - self.bump_version(); return Ok(ret); } } @@ -918,6 +952,58 @@ impl Dict { self.read().size() } + /// Step to the first live entry at or after `position`, verifying the size + /// against `old` under the same read guard. + /// + /// `project` runs under that guard, so it must not run Python or take + /// another dict lock; it is there so an iterator clones only the field it + /// keeps rather than both the key and the value. + pub(crate) fn next_entry_checked( + &self, + mut position: EntryIndex, + old: &DictSize, + project: impl FnOnce(&PyObjectRef, &T) -> R, + ) -> Result, DictChanged> { + let inner = self.read(); + if inner.size() != *old { + return Err(DictChanged); + } + loop { + let Some(entry) = inner.entries.get(position) else { + return Ok(None); + }; + position += 1; + if let Some(entry) = entry { + return Ok(Some((position, project(&entry.key, &entry.value)))); + } + } + } + + /// [`Self::next_entry_checked`] in reverse. + pub(crate) fn prev_entry_checked( + &self, + mut position: EntryIndex, + old: &DictSize, + project: impl FnOnce(&PyObjectRef, &T) -> R, + ) -> Result, DictChanged> { + let inner = self.read(); + if inner.size() != *old { + return Err(DictChanged); + } + loop { + let Some(entry) = inner.entries.get(position) else { + return Ok(None); + }; + if let Some(entry) = entry { + return Ok(Some((position, project(&entry.key, &entry.value)))); + } + if position == 0 { + return Ok(None); + } + position -= 1; + } + } + pub(crate) fn next_entry(&self, mut position: EntryIndex) -> Option<(usize, PyObjectRef, T)> { let inner = self.read(); loop { @@ -1004,8 +1090,30 @@ impl Dict { vm: &VirtualMachine, key: &K, hash_value: HashValue, - mut lock: Option>>, + lock: Option>>, ) -> PyResult { + let (ret, _) = self.lookup_extract(vm, key, hash_value, lock, |_| ())?; + Ok(ret) + } + + /// [`Self::lookup`], additionally reading the matched entry when the probe + /// settles it by key identity. + /// + /// That is the common case, and it is decided while the read guard is still + /// held — so a caller that only wants the entry's value gets it here instead + /// of taking the lock a second time to re-find what the probe already had. + /// `extract` therefore runs under the guard and must not run Python. It is + /// not called when the key had to be compared with `key_eq`, which does run + /// Python and so releases the guard first. + #[cfg_attr(feature = "flame-it", flame("Dict"))] + fn lookup_extract( + &self, + vm: &VirtualMachine, + key: &K, + hash_value: HashValue, + mut lock: Option>>, + extract: impl Fn(&DictEntry) -> R, + ) -> PyResult<(LookupResult, Option)> { let mut idxs = None; let mut free_slot = None; let ret = 'outer: loop { @@ -1035,7 +1143,7 @@ impl Dict { Some(free) => (IndexEntry::DUMMY, free), None => (IndexEntry::FREE, index_index), }; - return Ok(idxs); + return Ok((idxs, None)); } idx => { let entry = unsafe { @@ -1051,7 +1159,7 @@ impl Dict { reason = "Keeping the empty `else` block here for documentation" )] if key.key_is(&entry.key) { - break 'outer ret; + return Ok((ret, Some(extract(entry)))); } else if entry.hash == hash_value { break (entry.key.clone(), ret); } else { @@ -1076,7 +1184,7 @@ impl Dict { // warn!("Perturb value: {}", i); }; - Ok(ret) + Ok((ret, None)) } // returns Err(()) if changed since lookup @@ -1117,7 +1225,6 @@ impl Dict { } = IndexEntry::DUMMY; inner.used -= 1; let removed = slot.take(); - self.bump_version(); Ok(ControlFlow::Break(removed)) } @@ -1152,7 +1259,6 @@ impl Dict { // entry.index always refers valid index inner.indices.get_unchecked_mut(entry.index) } = IndexEntry::DUMMY; - self.bump_version(); Some((entry.key, entry.value)) } diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index a1e7a98d545..69417ee61b7 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -387,33 +387,6 @@ impl LocalsPlus { Some(base) } - /// Create a new heap-backed LocalsPlus that is a clone of the - /// fastlocals portion of this one. Stack slots are NOT copied - /// (stack_top = 0, stacksize = 0). - /// - /// # Safety - /// The caller must ensure that `self.fastlocals()` is a valid slice - /// (backing storage is alive, not concurrently mutated). - pub(crate) unsafe fn snapshot_to_heap(&self) -> Self { - let n = self.nlocalsplus as usize; - let src = self.fastlocals(); - let mut data = vec![0usize; n]; - // Clone each Option into the heap buffer. - for (i, slot) in src.iter().enumerate() { - if let Some(obj) = slot { - let cloned: Option = Some(obj.clone()); - // SAFETY: Option has the same layout as usize. - data[i] = unsafe { core::mem::transmute_copy(&cloned) }; - core::mem::forget(cloned); - } - } - Self { - data: LocalsPlusData::Heap(data.into_boxed_slice()), - nlocalsplus: self.nlocalsplus, - stack_top: 0, - } - } - /// Update fastlocals in `self` from `src`. For each slot, drops the old /// value and clones the new one. `self` must be heap-backed. /// @@ -562,6 +535,19 @@ impl LocalsPlus { unsafe { core::mem::transmute::>(raw) } } + /// Give every borrowed stack ref its own reference. + /// + /// A borrowed ref is only sound while whatever it points at is guaranteed + /// to outlive it, which stops holding where the frame itself outlives the + /// running block — at a yield, where the stack is saved with the frame. + fn promote_stack(&mut self) { + for idx in 0..self.stack_top as usize { + if let Some(stack_ref) = self.stack_index_mut(idx) { + stack_ref.promote(); + } + } + } + /// Immutable view of the active stack as `Option` slice. #[inline(always)] fn stack_as_slice(&self) -> &[Option] { @@ -813,6 +799,12 @@ pub(crate) struct FrameColdData { pub retained_back: PyMutex>, pub pending_stack_pops: PyAtomic, pub pending_unwind_from_stack: PyAtomic, + /// Thread that is still running the frame this one was materialized from, + /// or 0 once that frame has returned (and for every frame object that was + /// not materialized from a running frame). Only a thread id, never a + /// pointer: reading it can never chase freed memory, so it stays usable + /// as the gate for frames that belong to another thread. + pub attached_tid: atomic::AtomicU64, } impl Default for FrameColdData { @@ -828,6 +820,7 @@ impl Default for FrameColdData { retained_back: PyMutex::new(None), pending_stack_pops: Default::default(), pending_unwind_from_stack: Default::default(), + attached_tid: atomic::AtomicU64::new(0), } } } @@ -899,6 +892,7 @@ impl InterpreterFrame { /// For stack-allocated frames (future), the pointers remain valid for the /// frame's lifetime on the native stack. #[allow(clippy::too_many_arguments)] + #[inline(always)] pub(crate) fn new( code: &Py, globals: &Py, @@ -968,9 +962,10 @@ impl InterpreterFrame { /// Returns a mutable reference whose lifetime is bounded by the data /// stack's LIFO discipline. The caller must call /// `release_datastack_frame()` (unsafe) when done, then - /// `vm.datastack_pop(base)`. The reference must not be used after + /// `vm.datastack_pop_frame(base, size)`. The reference must not be used after /// `release_datastack_frame` returns. #[allow(clippy::too_many_arguments)] + #[inline(always)] pub(crate) fn new_on_datastack<'a>( code: &Py, globals: &Py, @@ -987,7 +982,7 @@ impl InterpreterFrame { .expect("LocalsPlus capacity overflow"); let total_bytes = datastack_iframe_total_bytes(nlocalsplus, stacksize); - let base = vm.datastack_push(total_bytes); + let (base, reused_cleared_frame) = vm.datastack_push_frame(total_bytes); // InterpreterFrame lives at the start of the allocation. let iframe_ptr = base as *mut Self; @@ -995,8 +990,10 @@ impl InterpreterFrame { let localsplus_data_ptr = unsafe { base.add(datastack_iframe_localsplus_offset()) } as *mut usize; - // Zero-initialize localsplus data. - unsafe { core::ptr::write_bytes(localsplus_data_ptr, 0, capacity) }; + if !reused_cleared_frame { + // Fresh or differently shaped storage may contain old frame data. + unsafe { core::ptr::write_bytes(localsplus_data_ptr, 0, capacity) }; + } let nlocalsplus_u32 = u32::try_from(nlocalsplus).expect("nlocalsplus exceeds u32"); let localsplus = LocalsPlus { @@ -1038,11 +1035,15 @@ impl InterpreterFrame { /// After this call, the InterpreterFrame at `self` is logically dead — /// the caller must not use `self` again except to pass the returned /// base to `vm.datastack_pop()`. - pub(crate) unsafe fn release_datastack_frame(&mut self) -> Option<*mut u8> { + pub(crate) unsafe fn release_datastack_frame(&mut self) -> Option<(*mut u8, usize)> { let base = self.datastack_base; if base.is_null() { return None; } + let total_bytes = datastack_iframe_total_bytes( + self.localsplus.nlocalsplus as usize, + self.localsplus.stack_capacity(), + ); self.datastack_base = core::ptr::null_mut(); // Drop all localsplus values while the backing store is still valid. self.localsplus.drop_values(); @@ -1055,7 +1056,7 @@ impl InterpreterFrame { // SAFETY: `self` points to valid, initialized memory on the data // stack. After this call the memory is logically dead. unsafe { core::ptr::drop_in_place(self) }; - Some(base) + Some((base, total_bytes)) } /// Get the last instruction index. @@ -1093,6 +1094,62 @@ impl InterpreterFrame { self.materialize_slow(vm) } + /// Take a standalone copy of this frame, values included, for a thread + /// that does not own it. + /// + /// Nothing links the copy back to this frame: the owning thread will not + /// find it at `exit_iframe` and so never writes into it once the world + /// restarts. That is the whole point — a linked copy is a buffer the owner + /// rewrites slot by slot while the reader clones out of it. + /// + /// # Safety + /// Caller must hold the world stopped, so the owning thread is parked and + /// its fast locals are not moving while they are read. + #[cfg(feature = "threading")] + #[cold] + #[inline(never)] + pub(crate) unsafe fn materialize_detached(&self, vm: &VirtualMachine) -> FrameObjectRef { + // Deliberately not `materialize_chain`: that hands back an existing + // linked copy when the owning thread has already made one. + let fo = self.materialize_slow_chain(vm); + unsafe { + fo.iframe_mut() + .localsplus + .sync_fastlocals_from(&self.localsplus) + }; + fo + } + + /// Copy this frame and everything it was called from for a thread that + /// does not own them, linking `f_back` along the way, and return the copy + /// of this frame. The links are `retained_back`, so the chain keeps + /// resolving once the world restarts and the real frames return. + /// + /// # Safety + /// Caller must hold the world stopped, so the owning thread is parked and + /// the chain is not being popped while it is walked. + #[cfg(feature = "threading")] + #[cold] + #[inline(never)] + pub(crate) unsafe fn materialize_detached_chain(&self, vm: &VirtualMachine) -> FrameObjectRef { + let top = unsafe { self.materialize_detached(vm) }; + let mut child = top.clone(); + let mut cur = self.previous(); + while !cur.is_null() { + let caller = unsafe { &*cur }; + let caller_fo = unsafe { caller.materialize_detached(vm) }; + { + let mut guard = child.iframe().cold().retained_back.lock(); + if guard.is_none() { + *guard = Some(caller_fo.clone()); + } + } + child = caller_fo; + cur = caller.previous(); + } + top + } + /// Create a lightweight FrameObject with empty localsplus, suitable for /// f_back chain building (retained_back). Unlike `materialize`, this does /// NOT store into `temporary_refs` or set the `materialized` pointer, so @@ -1117,9 +1174,18 @@ impl InterpreterFrame { let builtins: PyObjectRef = self.builtins().to_owned(); let func_obj: Option = self.func_obj().map(|o| o.to_owned()); - // Copy localsplus from the stack frame so materialized frames have - // usable fastlocals (for locals(), f_locals, tracebacks, etc). - let localsplus = unsafe { self.localsplus.snapshot_to_heap() }; + // Empty localsplus, sized for the code object. While the source frame + // runs, every reader resolves it through `find_live_source_iframe`, and + // `exit_iframe` fills these slots from the live frame as it returns. + // Copying the values here instead would give each of them a second + // reference lasting as long as this FrameObject — a frame reached by + // one traceback entry would keep all of its locals alive. + let nlocalsplus = code.localspluskinds.len() as u32; + let localsplus = LocalsPlus { + data: LocalsPlusData::Heap(vec![0usize; nlocalsplus as usize].into_boxed_slice()), + nlocalsplus, + stack_top: 0, + }; // Copy the locals mapping if it exists. let locals = match self.locals.get() { @@ -1143,14 +1209,16 @@ impl InterpreterFrame { // that become dangling after their call returns. The f_back chain // is resolved through the TLS CURRENT_FRAME chain instead. previous: Radium::new(0), - // Materialized frame is a detached snapshot — always FrameObject-owned. - // If we copied Thread from the source iframe, frame.clear() would - // reject the frame with "cannot clear an executing frame". + // Always FrameObject-owned. If we copied Thread from the source + // iframe, frame.clear() would reject the frame with "cannot clear + // an executing frame"; `attached_tid` carries the "still running" + // half of that state instead, so the owner field does not have to. owner: atomic::AtomicI8::new(FrameOwner::FrameObject as i8), datastack_base: core::ptr::null_mut(), materialized: Radium::new(0), cold: OnceCell::from(Box::new(FrameColdData { escaped: atomic::AtomicBool::new(true), + attached_tid: atomic::AtomicU64::new(current_thread_ident()), ..FrameColdData::default() })), }; @@ -1177,8 +1245,8 @@ impl InterpreterFrame { self.materialized.store(fo_ptr, Relaxed); // Keep the FrameObject alive by storing it in temporary_refs. - // GC tracking is deferred to with_iframe cleanup, where the frame - // is no longer executing and temporary_refs is cleared — at that + // GC tracking is deferred to `exit_iframe`, where the frame is no + // longer executing and temporary_refs is cleared — at that // point the FrameObject is self-sustaining and GC can safely // traverse and collect it. self.cold() @@ -1288,6 +1356,22 @@ impl InterpreterFrame { pub(crate) fn cold_opt(&self) -> Option<&FrameColdData> { self.cold.get().map(|b| &**b) } + + /// Thread still running the frame this one was materialized from, or 0. + #[inline] + pub(crate) fn attached_tid(&self) -> u64 { + self.cold_opt() + .map_or(0, |c| c.attached_tid.load(atomic::Ordering::Acquire)) + } + + /// Mark the frame this one was materialized from as returned, so its + /// values may be read from here. + #[inline] + pub(crate) fn detach(&self) { + if let Some(cold) = self.cold_opt() { + cold.attached_tid.store(0, atomic::Ordering::Release); + } + } } /// Python-visible frame object. Currently always wraps an `InterpreterFrame`. @@ -1718,10 +1802,29 @@ impl FrameObject { self.iframe().lasti.store(val, Relaxed); } + /// Fast-local slots of the live source frame when this frame object's + /// frame is still running on this thread, and this frame object's own + /// slots otherwise. A running frame's slots live on the data stack; the + /// frame object's are empty until `exit_iframe` fills them. + /// + /// # Safety + /// Caller must ensure no concurrent mutable access: either the frame is + /// not executing (callers pass through `check_locals_access`), or this is + /// a trace callback on the thread that is executing it. + unsafe fn live_fastlocals(&self) -> &[Option] { + let live = self.find_live_source_iframe(); + if live.is_null() { + unsafe { self.iframe_ref().localsplus.fastlocals() } + } else { + unsafe { (*live).localsplus.fastlocals() } + } + } + fn has_active_hidden_locals(&self) -> bool { use rustpython_compiler_core::bytecode::{CO_FAST_CELL, CO_FAST_FREE, CO_FAST_HIDDEN}; let code = self.iframe().code(); - let fastlocals = unsafe { self.iframe_ref().localsplus.fastlocals() }; + // SAFETY: reached from `locals()` on the thread running this frame. + let fastlocals = unsafe { self.live_fastlocals() }; let is_optimized = code.flags.contains(bytecode::CodeFlags::OPTIMIZED); !is_optimized && code.localspluskinds.iter().enumerate().any(|(i, &kind)| { @@ -1753,15 +1856,7 @@ impl FrameObject { // SAFETY: Either the frame is not executing (caller checked owner), // or we're in a trace callback on the same thread that's executing. let code = self.iframe().code(); - // If this FrameObject has a live source iframe on the TLS chain, read - // its localsplus for up-to-date values (the materialized copy is a - // stale snapshot from materialize time). - let live = self.find_live_source_iframe(); - let fastlocals = if !live.is_null() { - unsafe { (*live).localsplus.fastlocals() } - } else { - unsafe { self.iframe_ref().localsplus.fastlocals() } - }; + let fastlocals = unsafe { self.live_fastlocals() }; // Iterate through all localsplus slots using localspluskinds let nlocalsplus = code.localspluskinds.len(); @@ -1865,6 +1960,15 @@ impl FrameObject { /// builtin, trace callbacks) is fine: the frame sits on the current /// thread's frame chain and is at a bytecode boundary. pub(crate) fn check_locals_access(&self, vm: &VirtualMachine) -> PyResult<()> { + // A frame object materialized from a running data stack frame is + // FrameObject-owned, so the owner test below cannot speak for it: the + // thread running that frame fills these slots when it returns. + let attached = self.iframe().attached_tid(); + if attached != 0 && attached != current_thread_ident() { + return Err(vm.new_runtime_error( + "cannot access frame locals while the frame is executing in another thread", + )); + } let owner = FrameOwner::from_i8(self.iframe().owner.load(atomic::Ordering::Acquire)); if owner != FrameOwner::Thread { return Ok(()); @@ -1948,13 +2052,7 @@ impl FrameObject { use rustpython_compiler_core::bytecode::{CO_FAST_CELL, CO_FAST_FREE}; // SAFETY: callers first pass through `check_locals_access`, so the // frame is not executing on another thread. - // Use live source iframe if available for up-to-date values. - let live = self.find_live_source_iframe(); - let fastlocals = if !live.is_null() { - unsafe { (*live).localsplus.fastlocals() } - } else { - unsafe { self.iframe_ref().localsplus.fastlocals() } - }; + let fastlocals = unsafe { self.live_fastlocals() }; let obj = fastlocals.get(i)?.as_ref()?; let kind = self .iframe() @@ -2297,6 +2395,21 @@ impl Py { } } +/// Identity of the calling thread, or 0 where there is only one thread to be. +/// 0 doubles as "no thread", which is what `attached_tid` wants for a build +/// that cannot have a frame running anywhere else. +#[inline] +fn current_thread_ident() -> u64 { + #[cfg(feature = "threading")] + { + crate::stdlib::_thread::get_ident() + } + #[cfg(not(feature = "threading"))] + { + 0 + } +} + /// Byte offset from the start of a datastack allocation to the LocalsPlus data, /// accounting for alignment padding after the InterpreterFrame header. #[inline] @@ -2457,11 +2570,11 @@ pub(crate) struct ExecutingFrame<'a> { } #[inline] -fn specialization_compact_int_value(i: &PyInt, vm: &VirtualMachine) -> Option { +fn specialization_compact_int_value(i: &PyInt) -> Option { // _PyLong_IsCompact(): a one-digit PyLong (base 2^30), // i.e. abs(value) <= 2^30 - 1. const CPYTHON_COMPACT_LONG_ABS_MAX: i64 = (1i64 << 30) - 1; - let v = i.try_to_primitive::(vm).ok()?; + let v = i.try_to_i64_fast()?; if (-CPYTHON_COMPACT_LONG_ABS_MAX..=CPYTHON_COMPACT_LONG_ABS_MAX).contains(&v) { Some(v as isize) } else { @@ -2472,7 +2585,7 @@ fn specialization_compact_int_value(i: &PyInt, vm: &VirtualMachine) -> Option Option { obj.downcast_ref_if_exact::(vm) - .and_then(|i| specialization_compact_int_value(i, vm)) + .and_then(|i| specialization_compact_int_value(i)) } #[inline] @@ -2611,7 +2724,10 @@ pub(crate) fn release_datastack_frame(frame: &Py, vm: &VirtualMachi ); // SAFETY: the frame is alive (held by `frame` and the escaped reference) // and untracked. - unsafe { crate::gc_state::gc_state().track_object(NonNull::from(frame_obj)) }; + unsafe { + crate::gc_state::gc_state() + .track_object(NonNull::from(frame_obj), crate::gc_state::current_owner()) + }; } type BinaryOpExtendGuard = fn(&PyObject, &PyObject, &VirtualMachine) -> bool; @@ -2976,8 +3092,9 @@ impl ExecutingFrame<'_> { // Advance lasti past the current instruction BEFORE firing the // line event. This ensures that f_lineno (which reads // locations[lasti - 1]) returns the line of the instruction - // being traced, not the previous one. - self.update_lasti(|i| *i += 1); + // being traced, not the previous one. Stored from `idx` rather + // than read-modify-written, which would re-load what was just read. + self.lasti.store(idx as u32 + 1, Relaxed); // Fire 'line' trace event when line number changes. // Only fire if this frame has a per-frame trace function set @@ -4286,9 +4403,10 @@ impl ExecutingFrame<'_> { Ok(None) } Instruction::LoadSmallInt { i: idx } => { - // Push small integer (-5..=256) directly without constant table lookup - let value = vm.ctx.new_int(idx.get(arg) as i32); - self.push_value(value.into()); + // Cached small integers live for the whole Context, so the value stack can + // borrow them without touching the refcount. + let value = vm.ctx.cached_int(idx.get(arg) as i32); + unsafe { self.push_borrowed(value.as_object()) }; Ok(None) } Instruction::LoadDeref { i } => { @@ -4791,8 +4909,12 @@ impl ExecutingFrame<'_> { } Instruction::RaiseVarargs { argc: kind } => self.execute_raise(vm, kind.get(arg)), Instruction::Resume { .. } | Instruction::ResumeCheck => { - // Lazy quickening: initialize adaptive counters on first execution - if !self.code.quickened.swap(true, atomic::Ordering::Relaxed) { + // Lazy quickening: initialize adaptive counters on first execution. + // Read before the swap so that the steady state — every call after + // the first — costs a load rather than a read-modify-write. + if !self.code.quickened.load(atomic::Ordering::Relaxed) + && !self.code.quickened.swap(true, atomic::Ordering::Relaxed) + { self.code.instructions.quicken(); atomic::fence(atomic::Ordering::Release); } @@ -5050,6 +5172,9 @@ impl ExecutingFrame<'_> { Ok(None) } Instruction::YieldValue { .. } => { + // The frame outlives this block from here on, so nothing it + // still holds may be a borrow of something else's slot. + self.localsplus.promote_stack(); debug_assert!( self.localsplus .stack_as_slice() @@ -5259,7 +5384,7 @@ impl ExecutingFrame<'_> { if type_version != 0 && owner.class().tp_version_tag.load(Acquire) == type_version - && owner.dict().is_none() + && !owner.has_instance_dict() && let Some(func) = self.try_read_cached_descriptor(cache_base, type_version) { let owner = self.pop_value(); @@ -5683,8 +5808,8 @@ impl ExecutingFrame<'_> { b.downcast_ref_if_exact::(vm), ) { let result = a_str.as_wtf8().py_add(b_str.as_wtf8()); - self.pop_value(); - self.pop_value(); + self.pop_stackref(); + self.pop_stackref(); self.push_value(result.to_pyobject(vm)); Ok(None) } else { @@ -5843,6 +5968,9 @@ impl ExecutingFrame<'_> { && func.func_version() == cached_version && cached_version != 0 { + if func.is_jitted() { + return self.execute_call_vectorcall(nargs, vm); + } let effective_nargs = nargs + u32::from(self_or_null_is_some); if !func.has_exact_argcount(effective_nargs) { return self.execute_call_vectorcall(nargs, vm); @@ -5905,6 +6033,9 @@ impl ExecutingFrame<'_> { && func.func_version() == cached_version && cached_version != 0 { + if func.is_jitted() { + return self.execute_call_vectorcall(nargs, vm); + } if !func.has_exact_argcount(nargs + 1) { return self.execute_call_vectorcall(nargs, vm); } @@ -6076,15 +6207,8 @@ impl ExecutingFrame<'_> { | PyMethodFlags::O | PyMethodFlags::KEYWORDS); if call_conv == PyMethodFlags::O && effective_nargs == 1 { - let nargs_usize = nargs as usize; - let pos_args: Vec = self.pop_multiple(nargs_usize).collect(); - let self_or_null = self.pop_value_opt(); - let callable = self.pop_value(); - let mut args_vec = Vec::with_capacity(effective_nargs as usize); - if let Some(self_val) = self_or_null { - args_vec.push(self_val); - } - args_vec.extend(pos_args); + let (callable, args_vec) = self.take_call_args(nargs as usize); + debug_assert_eq!(args_vec.len(), effective_nargs as usize); let result = callable.vectorcall(args_vec, effective_nargs as usize, None, vm)?; self.push_value(result); @@ -6110,15 +6234,8 @@ impl ExecutingFrame<'_> { | PyMethodFlags::O | PyMethodFlags::KEYWORDS); if call_conv == PyMethodFlags::FASTCALL { - let nargs_usize = nargs as usize; - let pos_args: Vec = self.pop_multiple(nargs_usize).collect(); - let self_or_null = self.pop_value_opt(); - let callable = self.pop_value(); - let mut args_vec = Vec::with_capacity(effective_nargs as usize); - if let Some(self_val) = self_or_null { - args_vec.push(self_val); - } - args_vec.extend(pos_args); + let (callable, args_vec) = self.take_call_args(nargs as usize); + debug_assert_eq!(args_vec.len(), effective_nargs as usize); let result = callable.vectorcall(args_vec, effective_nargs as usize, None, vm)?; self.push_value(result); @@ -6140,21 +6257,14 @@ impl ExecutingFrame<'_> { && func.func_version() == cached_version && cached_version != 0 { + if func.is_jitted() { + return self.execute_call_vectorcall(nargs, vm); + } if self.specialization_call_recursion_guard(vm) { return self.execute_call_vectorcall(nargs, vm); } - let nargs_usize = nargs as usize; - let pos_args: Vec = self.pop_multiple(nargs_usize).collect(); - let self_or_null = self.pop_value_opt(); - let callable = self.pop_value(); - let (args_vec, effective_nargs) = if let Some(self_val) = self_or_null { - let mut v = Vec::with_capacity(nargs_usize + 1); - v.push(self_val); - v.extend(pos_args); - (v, nargs_usize + 1) - } else { - (pos_args, nargs_usize) - }; + let (callable, args_vec) = self.take_call_args(nargs as usize); + let effective_nargs = args_vec.len(); let result = vectorcall_function(&callable, args_vec, effective_nargs, None, vm)?; self.push_value(result); @@ -6186,16 +6296,18 @@ impl ExecutingFrame<'_> { && func.func_version() == cached_version && cached_version != 0 { + if func.is_jitted() { + return self.execute_call_vectorcall(nargs, vm); + } if self.specialization_call_recursion_guard(vm) { return self.execute_call_vectorcall(nargs, vm); } let nargs_usize = nargs as usize; - let pos_args: Vec = self.pop_multiple(nargs_usize).collect(); - self.pop_value_opt(); // null (self_or_null) - self.pop_value(); // callable (bound method) let mut args_vec = Vec::with_capacity(nargs_usize + 1); args_vec.push(bound_self); - args_vec.extend(pos_args); + args_vec.extend(self.pop_multiple(nargs_usize)); + self.pop_value_opt(); // null (self_or_null) + self.pop_value(); // callable (bound method) let result = vectorcall_function( &bound_function, args_vec, @@ -6277,15 +6389,8 @@ impl ExecutingFrame<'_> { .is_some_and(|self_obj| self_obj.class().is(descr.objclass)) { let func = descr.method.func; - let positional_args: Vec = - self.pop_multiple(nargs as usize).collect(); - let self_or_null = self.pop_value_opt(); - self.pop_value(); // callable - let mut all_args = Vec::with_capacity(total_nargs as usize); - if let Some(self_val) = self_or_null { - all_args.push(self_val); - } - all_args.extend(positional_args); + let (_callable, all_args) = self.take_call_args(nargs as usize); + debug_assert_eq!(all_args.len(), total_nargs as usize); let args = FuncArgs { args: all_args, kwargs: Default::default(), @@ -6324,15 +6429,8 @@ impl ExecutingFrame<'_> { .is_some_and(|self_obj| self_obj.class().is(descr.objclass)) { let func = descr.method.func; - let positional_args: Vec = - self.pop_multiple(nargs as usize).collect(); - let self_or_null = self.pop_value_opt(); - self.pop_value(); // callable - let mut all_args = Vec::with_capacity(total_nargs as usize); - if let Some(self_val) = self_or_null { - all_args.push(self_val); - } - all_args.extend(positional_args); + let (_callable, all_args) = self.take_call_args(nargs as usize); + debug_assert_eq!(all_args.len(), total_nargs as usize); let args = FuncArgs { args: all_args, kwargs: Default::default(), @@ -6371,15 +6469,8 @@ impl ExecutingFrame<'_> { .is_some_and(|self_obj| self_obj.class().is(descr.objclass)) { let func = descr.method.func; - let positional_args: Vec = - self.pop_multiple(nargs as usize).collect(); - let self_or_null = self.pop_value_opt(); - self.pop_value(); // callable - let mut all_args = Vec::with_capacity(total_nargs as usize); - if let Some(self_val) = self_or_null { - all_args.push(self_val); - } - all_args.extend(positional_args); + let (_callable, all_args) = self.take_call_args(nargs as usize); + debug_assert_eq!(all_args.len(), total_nargs as usize); let args = FuncArgs { args: all_args, kwargs: Default::default(), @@ -6396,22 +6487,9 @@ impl ExecutingFrame<'_> { if let Some(cls) = callable.downcast_ref::() && cls.slots.vectorcall.load().is_some() { - let nargs_usize = nargs as usize; - let pos_args: Vec = self.pop_multiple(nargs_usize).collect(); - let self_or_null = self.pop_value_opt(); - let callable = self.pop_value(); - let self_is_some = self_or_null.is_some(); - let mut args_vec = Vec::with_capacity(nargs_usize + usize::from(self_is_some)); - if let Some(self_val) = self_or_null { - args_vec.push(self_val); - } - args_vec.extend(pos_args); - let result = callable.vectorcall( - args_vec, - nargs_usize + usize::from(self_is_some), - None, - vm, - )?; + let (callable, args_vec) = self.take_call_args(nargs as usize); + let effective_nargs = args_vec.len(); + let result = callable.vectorcall(args_vec, effective_nargs, None, vm)?; self.push_value(result); return Ok(None); } @@ -6496,15 +6574,8 @@ impl ExecutingFrame<'_> { .is_some_and(|self_obj| self_obj.class().is(descr.objclass)) { let func = descr.method.func; - let positional_args: Vec = - self.pop_multiple(nargs as usize).collect(); - let self_or_null = self.pop_value_opt(); - self.pop_value(); // callable - let mut all_args = Vec::with_capacity(total_nargs as usize); - if let Some(self_val) = self_or_null { - all_args.push(self_val); - } - all_args.extend(positional_args); + let (_callable, all_args) = self.take_call_args(nargs as usize); + debug_assert_eq!(all_args.len(), total_nargs as usize); let args = FuncArgs { args: all_args, kwargs: Default::default(), @@ -6533,15 +6604,8 @@ impl ExecutingFrame<'_> { | PyMethodFlags::O | PyMethodFlags::KEYWORDS); if call_conv == (PyMethodFlags::FASTCALL | PyMethodFlags::KEYWORDS) { - let nargs_usize = nargs as usize; - let pos_args: Vec = self.pop_multiple(nargs_usize).collect(); - let self_or_null = self.pop_value_opt(); - let callable = self.pop_value(); - let mut args_vec = Vec::with_capacity(effective_nargs as usize); - if let Some(self_val) = self_or_null { - args_vec.push(self_val); - } - args_vec.extend(pos_args); + let (callable, args_vec) = self.take_call_args(nargs as usize); + debug_assert_eq!(args_vec.len(), effective_nargs as usize); let result = callable.vectorcall(args_vec, effective_nargs as usize, None, vm)?; self.push_value(result); @@ -6565,22 +6629,13 @@ impl ExecutingFrame<'_> { { return self.execute_call_vectorcall(nargs, vm); } - let nargs_usize = nargs as usize; - let pos_args: Vec = self.pop_multiple(nargs_usize).collect(); - let self_or_null = self.pop_value_opt(); - let callable = self.pop_value(); - let mut args_vec = - Vec::with_capacity(nargs_usize + usize::from(self_or_null_is_some)); - if let Some(self_val) = self_or_null { - args_vec.push(self_val); - } - args_vec.extend(pos_args); - let result = callable.vectorcall( - args_vec, - nargs_usize + usize::from(self_or_null_is_some), - None, - vm, - )?; + let (callable, args_vec) = self.take_call_args(nargs as usize); + debug_assert_eq!( + args_vec.len(), + nargs as usize + usize::from(self_or_null_is_some) + ); + let effective_nargs = args_vec.len(); + let result = callable.vectorcall(args_vec, effective_nargs, None, vm)?; self.push_value(result); Ok(None) } @@ -6598,6 +6653,9 @@ impl ExecutingFrame<'_> { && func.func_version() == cached_version && cached_version != 0 { + if func.is_jitted() { + return self.execute_call_kw_vectorcall(nargs, vm); + } if self.specialization_call_recursion_guard(vm) { return self.execute_call_kw_vectorcall(nargs, vm); } @@ -6656,6 +6714,9 @@ impl ExecutingFrame<'_> { && func.func_version() == cached_version && cached_version != 0 { + if func.is_jitted() { + return self.execute_call_kw_vectorcall(nargs, vm); + } let nargs_usize = nargs as usize; let kwarg_names_obj = self.pop_value(); let kwarg_names_tuple = kwarg_names_obj @@ -6851,14 +6912,16 @@ impl ExecutingFrame<'_> { a.downcast_ref_if_exact::(vm), b.downcast_ref_if_exact::(vm), ) && let (Some(a_val), Some(b_val)) = ( - specialization_compact_int_value(a_int, vm), - specialization_compact_int_value(b_int, vm), + specialization_compact_int_value(a_int), + specialization_compact_int_value(b_int), ) { let op = self.compare_op_from_arg(arg); let result = op.eval_ord(a_val.cmp(&b_val)); - self.pop_value(); - self.pop_value(); - self.push_value(vm.ctx.new_bool(result).into()); + self.pop_stackref(); + self.pop_stackref(); + if !self.try_fused_compare_int_jump(result, vm) { + self.push_value(vm.ctx.new_bool(result).into()); + } Ok(None) } else { self.execute_compare(vm, arg) @@ -7184,17 +7247,16 @@ impl ExecutingFrame<'_> { // Keep specialized opcode on guard miss (JUMP_TO_PREDICTED behavior). let cached_version = self.code.instructions.read_cache_u16(cache_base + 1); let cached_index = self.code.instructions.read_cache_u16(cache_base + 3); - if let Ok(current_version) = u16::try_from(self.globals.version()) - && cached_version == current_version + if cached_version != 0 + && let Some(x) = self + .globals + .get_item_by_index_and_keys_version(cached_version, cached_index) { - let name = self.code.names[(oparg >> 1) as usize]; - if let Some(x) = self.globals.get_item_opt_hint(name, cached_index, vm)? { - self.push_value(x); - if (oparg & 1) != 0 { - self.push_value_opt(None); - } - return Ok(None); + self.push_value(x); + if (oparg & 1) != 0 { + self.push_value_opt(None); } + return Ok(None); } let name = self.code.names[(oparg >> 1) as usize]; let x = self.load_global_or_builtin(name, vm)?; @@ -7210,20 +7272,19 @@ impl ExecutingFrame<'_> { let cached_globals_ver = self.code.instructions.read_cache_u16(cache_base + 1); let cached_builtins_ver = self.code.instructions.read_cache_u16(cache_base + 2); let cached_index = self.code.instructions.read_cache_u16(cache_base + 3); - if let Ok(current_globals_ver) = u16::try_from(self.globals.version()) + if cached_globals_ver != 0 + && cached_builtins_ver != 0 + && let Ok(current_globals_ver) = u16::try_from(self.globals.keys_version()) && cached_globals_ver == current_globals_ver && let Some(builtins_dict) = self.builtins.downcast_ref_if_exact::(vm) - && let Ok(current_builtins_ver) = u16::try_from(builtins_dict.version()) - && cached_builtins_ver == current_builtins_ver + && let Some(x) = builtins_dict + .get_item_by_index_and_keys_version(cached_builtins_ver, cached_index) { - let name = self.code.names[(oparg >> 1) as usize]; - if let Some(x) = builtins_dict.get_item_opt_hint(name, cached_index, vm)? { - self.push_value(x); - if (oparg & 1) != 0 { - self.push_value_opt(None); - } - return Ok(None); + self.push_value(x); + if (oparg & 1) != 0 { + self.push_value_opt(None); } + return Ok(None); } let name = self.code.names[(oparg >> 1) as usize]; let x = self.load_global_or_builtin(name, vm)?; @@ -7311,6 +7372,7 @@ impl ExecutingFrame<'_> { self.unwind_blocks(vm, UnwindReason::Returning { value }) } Instruction::InstrumentedYieldValue => { + self.localsplus.promote_stack(); debug_assert!( self.localsplus .stack_as_slice() @@ -8567,7 +8629,7 @@ impl ExecutingFrame<'_> { a_ref.downcast_ref_if_exact::(vm), b_ref.downcast_ref_if_exact::(vm), ) { - Ok(Self::int_add(a.as_bigint(), b.as_bigint(), vm)) + Ok(Self::int_add(a, b, vm)) } else if matches!(op, bytecode::BinaryOperator::Add) { vm._add(a_ref, b_ref) } else { @@ -8579,7 +8641,7 @@ impl ExecutingFrame<'_> { a_ref.downcast_ref_if_exact::(vm), b_ref.downcast_ref_if_exact::(vm), ) { - Ok(Self::int_sub(a.as_bigint(), b.as_bigint(), vm)) + Ok(Self::int_sub(a, b, vm)) } else if matches!(op, bytecode::BinaryOperator::Subtract) { vm._sub(a_ref, b_ref) } else { @@ -8591,7 +8653,7 @@ impl ExecutingFrame<'_> { a_ref.downcast_ref_if_exact::(vm), b_ref.downcast_ref_if_exact::(vm), ) { - Ok(Self::int_mul(a.as_bigint(), b.as_bigint(), vm)) + Ok(Self::int_mul(a, b, vm)) } else if matches!(op, bytecode::BinaryOperator::Multiply) { vm._mul(a_ref, b_ref) } else { @@ -8657,36 +8719,37 @@ impl ExecutingFrame<'_> { /// small-int cache is consulted identically. #[inline] fn int_fast_op( - a: &BigInt, - b: &BigInt, + a: &PyInt, + b: &PyInt, vm: &VirtualMachine, checked: fn(i64, i64) -> Option, fallback: impl FnOnce(&BigInt, &BigInt) -> BigInt, ) -> PyObjectRef { - use num_traits::ToPrimitive; - if let (Some(av), Some(bv)) = (a.to_i64(), b.to_i64()) + if let (Some(av), Some(bv)) = (a.try_to_i64_fast(), b.try_to_i64_fast()) && let Some(result) = checked(av, bv) { return vm.ctx.new_int(result).into(); } - vm.ctx.new_int(fallback(a, b)).into() + vm.ctx + .new_int(fallback(a.as_bigint(), b.as_bigint())) + .into() } /// Int addition with i64 fast path to avoid BigInt heap allocation. #[inline] - fn int_add(a: &BigInt, b: &BigInt, vm: &VirtualMachine) -> PyObjectRef { + fn int_add(a: &PyInt, b: &PyInt, vm: &VirtualMachine) -> PyObjectRef { Self::int_fast_op(a, b, vm, i64::checked_add, |a, b| a + b) } /// Int subtraction with i64 fast path to avoid BigInt heap allocation. #[inline] - fn int_sub(a: &BigInt, b: &BigInt, vm: &VirtualMachine) -> PyObjectRef { + fn int_sub(a: &PyInt, b: &PyInt, vm: &VirtualMachine) -> PyObjectRef { Self::int_fast_op(a, b, vm, i64::checked_sub, |a, b| a - b) } /// Int multiplication with i64 fast path to avoid BigInt heap allocation. #[inline] - fn int_mul(a: &BigInt, b: &BigInt, vm: &VirtualMachine) -> PyObjectRef { + fn int_mul(a: &PyInt, b: &PyInt, vm: &VirtualMachine) -> PyObjectRef { Self::int_fast_op(a, b, vm, i64::checked_mul, |a, b| a * b) } @@ -8984,13 +9047,19 @@ impl ExecutingFrame<'_> { attr_name: &'static PyStrInterned, vm: &VirtualMachine, ) -> PyResult> { - let Some(dict) = self.top_value().dict() else { - return Ok(None); - }; let stamp = self.code.instructions.read_cache_ptr(cache_base + 3); - if stamp != 0 && stamp == dict.keys_version() as usize { + // Take the stamp check first, on a borrowed dict: a hit is the whole + // fast path, and cloning the dict for it would cost more than the + // comparison it exists to make. + let stamped = self.top_value().with_instance_dict(|dict| { + dict.is_some_and(|d| stamp != 0 && stamp == d.keys_version() as usize) + }); + if stamped { return Ok(None); } + let Some(dict) = self.top_value().dict() else { + return Ok(None); + }; // Take the stamp before probing so it attests the probed key set. let stamp = dict.assign_keys_version(vm); if let Some(value) = dict.get_item_opt(attr_name, vm)? { @@ -9831,7 +9900,7 @@ impl ExecutingFrame<'_> { fn execute_binary_op_int( &mut self, vm: &VirtualMachine, - op: impl FnOnce(&BigInt, &BigInt, &VirtualMachine) -> PyObjectRef, + op: impl FnOnce(&PyInt, &PyInt, &VirtualMachine) -> PyObjectRef, deopt_op: bytecode::BinaryOperator, ) -> FrameResult { let b = self.top_value(); @@ -9840,9 +9909,9 @@ impl ExecutingFrame<'_> { a.downcast_ref_if_exact::(vm), b.downcast_ref_if_exact::(vm), ) { - let result = op(a_int.as_bigint(), b_int.as_bigint(), vm); - self.pop_value(); - self.pop_value(); + let result = op(a_int, b_int, vm); + self.pop_stackref(); + self.pop_stackref(); self.push_value(result); Ok(None) } else { @@ -9899,7 +9968,7 @@ impl ExecutingFrame<'_> { let callable = self.nth_value(nargs + 1); if let Some(func) = callable.downcast_ref_if_exact::(vm) { - if self.specialization_eval_frame_active(vm) { + if self.specialization_eval_frame_active(vm) || func.is_jitted() { unsafe { self.code.instructions.write_adaptive_counter( cache_base, @@ -9962,7 +10031,7 @@ impl ExecutingFrame<'_> { .function_obj() .downcast_ref_if_exact::(vm) { - if self.specialization_eval_frame_active(vm) { + if self.specialization_eval_frame_active(vm) || func.is_jitted() { unsafe { self.code.instructions.write_adaptive_counter( cache_base, @@ -10257,7 +10326,7 @@ impl ExecutingFrame<'_> { let callable = self.nth_value(nargs + 2); if let Some(func) = callable.downcast_ref_if_exact::(vm) { - if self.specialization_eval_frame_active(vm) { + if self.specialization_eval_frame_active(vm) || func.is_jitted() { unsafe { self.code.instructions.write_adaptive_counter( cache_base, @@ -10308,7 +10377,7 @@ impl ExecutingFrame<'_> { .function_obj() .downcast_ref_if_exact::(vm) { - if self.specialization_eval_frame_active(vm) { + if self.specialization_eval_frame_active(vm) || func.is_jitted() { unsafe { self.code.instructions.write_adaptive_counter( cache_base, @@ -10451,8 +10520,8 @@ impl ExecutingFrame<'_> { a.downcast_ref_if_exact::(vm), b.downcast_ref_if_exact::(vm), ) { - if specialization_compact_int_value(a_int, vm).is_some() - && specialization_compact_int_value(b_int, vm).is_some() + if specialization_compact_int_value(a_int).is_some() + && specialization_compact_int_value(b_int).is_some() { Some(Instruction::CompareOpInt) } else { @@ -10483,6 +10552,37 @@ impl ExecutingFrame<'_> { .into() } + /// Execute an immediately following conditional jump without materializing + /// the comparison result as a Python bool. This is the adaptive interpreter + /// equivalent of keeping the result virtual across the two-opcode trace. + #[inline] + fn try_fused_compare_int_jump(&mut self, result: bool, vm: &VirtualMachine) -> bool { + if self.specialization_eval_frame_active(vm) { + return false; + } + + let jump_idx = self.lasti() as usize + Instruction::CompareOpInt.cache_entries(); + if jump_idx >= self.code.instructions.len() { + return false; + } + + let jump_op = self.code.instructions.read_op(jump_idx); + let jump_on = match jump_op { + Instruction::PopJumpIfFalse { .. } => false, + Instruction::PopJumpIfTrue { .. } => true, + _ => return false, + }; + let jump_delta = self.code.instructions.read_arg(jump_idx).as_u32(); + let after_jump = jump_idx as u32 + 1 + jump_op.cache_entries() as u32; + let target = if result == jump_on { + after_jump + jump_delta + } else { + after_jump + }; + self.update_lasti(|i| *i = target); + true + } + /// Recover the BinaryOperator from the instruction arg byte. /// `replace_op` preserves the arg byte, so the original op remains accessible. fn binary_op_from_arg(&self, arg: bytecode::OpArg) -> bytecode::BinaryOperator { @@ -10683,11 +10783,10 @@ impl ExecutingFrame<'_> { } } - // Pop the callable and transfer ownership to the trampoline via - // the VM side channel, avoiding a per-frame mutex lock on - // temporary_refs. + // Pop the callable and transfer ownership to the trampoline. This one + // reference keeps every field borrowed by the callee frame alive. let callable = self.pop_value(); - unsafe { &mut *vm.pending_tailcall_refs.get() }.push(callable); + vm.set_pending_tailcall_owner(callable); vm.set_pending_tailcall(callee_iframe); } @@ -10737,13 +10836,13 @@ impl ExecutingFrame<'_> { *dst = Some(arg); } self.pop_value_opt(); // null (self_or_null) - let callable = self.pop_value(); // callable (bound method) + self.pop_value(); // callable (bound method) fastlocals[0] = Some(bound_self); - // Transfer ownership to the trampoline via the VM side channel. - let refs = unsafe { &mut *vm.pending_tailcall_refs.get() }; - refs.push(bound_function); - refs.push(callable); + // The function owns every field borrowed by the callee frame. + // bound_self is owned by fastlocals; the bound-method object itself is + // no longer needed and was dropped above, matching the recursive path. + vm.set_pending_tailcall_owner(bound_function); vm.set_pending_tailcall(callee_iframe); } @@ -10795,7 +10894,7 @@ impl ExecutingFrame<'_> { return; } let name = self.code.names[(oparg >> 1) as usize]; - let Ok(globals_version) = u16::try_from(self.globals.version()) else { + let Ok(globals_version @ 1..) = u16::try_from(self.globals.assign_keys_version(vm)) else { unsafe { self.code.instructions.write_adaptive_counter( cache_base, @@ -10823,7 +10922,7 @@ impl ExecutingFrame<'_> { if let Some(builtins_dict) = self.builtins.downcast_ref_if_exact::(vm) && let Ok(Some(builtins_hint)) = builtins_dict.hint_for_key(name, vm) - && let Ok(builtins_version) = u16::try_from(builtins_dict.version()) + && let Ok(builtins_version @ 1..) = u16::try_from(builtins_dict.assign_keys_version(vm)) { unsafe { self.code @@ -11352,6 +11451,49 @@ impl ExecutingFrame<'_> { } } + /// Take a call's `[self_or_null, arg1, ..., argN]` off the stack as one + /// vectorcall argument list, along with the callable underneath them. + /// + /// The stack already holds the arguments in vectorcall order, so filling a + /// single vector by index costs one allocation — collecting the positional + /// arguments first and then pushing `self` in front of them costs two plus + /// a copy. + fn take_call_args(&mut self, nargs: usize) -> (PyObjectRef, Vec) { + let stack_len = self.localsplus.stack_len(); + debug_assert!( + stack_len >= nargs + 2, + "CALL stack underflow: need callable + self_or_null + {nargs} args, have {stack_len}" + ); + let callable_idx = stack_len - nargs - 2; + let self_or_null_idx = callable_idx + 1; + + let self_or_null = self + .localsplus + .stack_index_mut(self_or_null_idx) + .take() + .map(|sr| sr.to_pyobj()); + let mut args = Vec::with_capacity(nargs + usize::from(self_or_null.is_some())); + args.extend(self_or_null); + for stack_idx in self_or_null_idx + 1..stack_len { + let val = self + .localsplus + .stack_index_mut(stack_idx) + .take() + .unwrap() + .to_pyobj(); + args.push(val); + } + + let callable = self + .localsplus + .stack_index_mut(callable_idx) + .take() + .unwrap() + .to_pyobj(); + self.localsplus.stack_truncate(callable_idx); + (callable, args) + } + /// Pop multiple values from the stack. Panics if any slot is NULL. fn pop_multiple(&mut self, count: usize) -> impl ExactSizeIterator + '_ { let stack_len = self.localsplus.stack_len(); diff --git a/crates/vm/src/function/argument.rs b/crates/vm/src/function/argument.rs index aabe484c282..6bf4ae2107b 100644 --- a/crates/vm/src/function/argument.rs +++ b/crates/vm/src/function/argument.rs @@ -8,6 +8,7 @@ use crate::{ use core::ops::{Deref, DerefMut, RangeInclusive}; use indexmap::IndexMap; use itertools::Itertools; +use std::hash::DefaultHasher; pub trait IntoFuncArgs: Sized { fn into_args(self, vm: &VirtualMachine) -> FuncArgs; @@ -414,16 +415,24 @@ impl FromArgOptional for T { // issue #8228). `PyStr` is WTF-8 backed, and CPython only requires that a // keyword key be a `str`, not that it be valid UTF-8. #[derive(Clone, Debug)] -pub struct KwArgs(IndexMap); +pub struct KwArgs(KwArgsMap); + +/// The map behind [`KwArgs`]. +/// +/// The hasher is zero-sized rather than the randomly seeded default: a +/// `KwArgs` is built for every call, including the far more common +/// keyword-less one, and seeding reads a thread-local. Keyword names come +/// from the program text, so per-process hash randomization buys nothing. +pub type KwArgsMap = IndexMap>; impl Default for KwArgs { fn default() -> Self { - Self(IndexMap::new()) + Self(KwArgsMap::default()) } } impl Deref for KwArgs { - type Target = IndexMap; + type Target = KwArgsMap; fn deref(&self) -> &Self::Target { &self.0 @@ -447,7 +456,7 @@ where impl KwArgs { #[must_use] - pub const fn new(map: IndexMap) -> Self { + pub const fn new(map: KwArgsMap) -> Self { Self(map) } @@ -508,7 +517,7 @@ where T: TryFromObject, { fn from_args(vm: &VirtualMachine, args: &mut FuncArgs) -> Result { - let mut kwargs = IndexMap::new(); + let mut kwargs = KwArgsMap::default(); for (name, value) in args.remaining_keywords() { kwargs.insert(name, value.try_into_value(vm)?); } diff --git a/crates/vm/src/function/mod.rs b/crates/vm/src/function/mod.rs index 7eb87fea3ed..2ec1d09e8ef 100644 --- a/crates/vm/src/function/mod.rs +++ b/crates/vm/src/function/mod.rs @@ -11,8 +11,8 @@ mod protocol; mod time; pub use argument::{ - ArgumentError, FromArgOptional, FromArgs, FuncArgs, IntoFuncArgs, KwArgs, OptionalArg, - OptionalOption, PosArgs, + ArgumentError, FromArgOptional, FromArgs, FuncArgs, IntoFuncArgs, KwArgs, KwArgsMap, + OptionalArg, OptionalOption, PosArgs, }; pub use arithmetic::{PyArithmeticValue, PyComparisonValue}; pub use buffer::{ArgAsciiBuffer, ArgBytesLike, ArgMemoryBuffer, ArgStrOrBytesLike}; diff --git a/crates/vm/src/gc_state.rs b/crates/vm/src/gc_state.rs index 9744d4ae992..a9b8c7be171 100644 --- a/crates/vm/src/gc_state.rs +++ b/crates/vm/src/gc_state.rs @@ -4,10 +4,10 @@ use crate::common::linked_list::LinkedList; use crate::common::lock::{PyMutex, PyRwLock}; -use crate::object::{GC_PERMANENT, GC_UNTRACKED, GcLink}; +use crate::object::{GC_NO_OWNER, GC_PERMANENT, GC_UNTRACKED, GcLink, GcOwner}; use crate::{AsObject, PyObject, PyObjectRef}; use core::ptr::NonNull; -use core::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, AtomicUsize, Ordering}; use std::collections::HashSet; fn elapsed_secs( @@ -56,10 +56,12 @@ pub struct GcStats { pub duration: f64, } -/// A single GC generation with intrusive linked list +/// One generation's collection policy and statistics, per interpreter. +/// +/// The objects themselves live in the process-wide lists on [`GcState`], so the +/// occupancy count sits there; what an interpreter owns is when to collect and +/// what its own collections have done. pub struct GcGeneration { - /// Number of objects in this generation - count: AtomicUsize, /// Threshold for triggering collection threshold: AtomicU32, /// Collection statistics @@ -70,7 +72,6 @@ impl GcGeneration { #[must_use] pub const fn new(threshold: u32) -> Self { Self { - count: AtomicUsize::new(0), threshold: AtomicU32::new(threshold), stats: PyMutex::new(GcStats { collections: 0, @@ -82,16 +83,14 @@ impl GcGeneration { } } - pub fn count(&self) -> usize { - self.count.load(Ordering::SeqCst) - } - + /// Relaxed: this is policy read once per allocation, and a collection + /// racing `gc.set_threshold()` may use either value. pub fn threshold(&self) -> u32 { - self.threshold.load(Ordering::SeqCst) + self.threshold.load(Ordering::Relaxed) } pub fn set_threshold(&self, value: u32) { - self.threshold.store(value, Ordering::SeqCst); + self.threshold.store(value, Ordering::Relaxed); } pub fn stats(&self) -> GcStats { @@ -131,6 +130,26 @@ impl GcGeneration { } } +/// Drop one from a generation's occupancy. +/// +/// A collection resets the counts of the generations it emptied, but it only +/// empties its own interpreter's objects; another interpreter's stay behind with +/// the count already zeroed, and untracking one of those must not wrap. +fn release_count(count: &AtomicUsize) { + if count.load(Ordering::Relaxed) > 0 { + count.fetch_sub(1, Ordering::Relaxed); + } +} + +/// Whether `owner`'s collections act on `obj`. +/// +/// Objects with no owner — everything the shared context allocates, and anything +/// allocated with no interpreter current — belong to all of them. +fn is_owned_by(obj: &PyObject, owner: GcOwner) -> bool { + let obj_owner = obj.gc_owner(); + obj_owner == owner || obj_owner == GC_NO_OWNER +} + /// Wrapper for NonNull to impl Hash/Eq for use in temporary collection sets. /// Only used within collect_inner, never shared across threads. #[derive(Clone, Copy, PartialEq, Eq, Hash)] @@ -146,41 +165,83 @@ struct GcPtr(NonNull); /// well-defined while all other threads are parked at a safepoint. Restarting /// happens explicitly once the snapshot has pinned every object; `Drop` is a /// backstop that also restarts on the early-return paths. +/// +/// A collection acts on one interpreter's objects, but its candidates include +/// the ones no interpreter owns, which every interpreter can reference and so +/// incref. Reading a refcount that another interpreter is changing is what +/// makes an object look unreachable when it is not, so every live interpreter +/// is stopped, not just the collecting one. Stopping in `runtime` id order +/// keeps exclusion acquisition ordered; the `collecting` mutex additionally +/// serializes collections process-wide, so no second collector can take these +/// exclusions in another order. #[cfg(feature = "threading")] struct CollectStopTheWorld { - vm: *const crate::VirtualMachine, - stopped: bool, + /// Stopped interpreter states, in stop order. Held as strong references so + /// an interpreter cannot be dropped between stop and restart, and kept past + /// the restart so that releasing the last one — which frees that + /// interpreter's objects, and so removes them from these lists — happens + /// after the collection has let go of the generation locks. + stopped: Vec>, + /// Keeps interpreters from registering between the snapshot below and the + /// restart. One registered in that window would be missing from `stopped`, + /// so its bootstrap would keep running — and mutating the shared generation + /// lists — while this collection reads them. + admission: Option>, + restarted: bool, } #[cfg(feature = "threading")] impl CollectStopTheWorld { - /// Request stop-the-world when the current thread has an attached VM. - /// Falls back to no barrier when no VM is attached (the tracked-object - /// reads then run without other threads only if the caller guarantees it). + /// Request stop-the-world on every live interpreter when the current thread + /// has an attached VM. Falls back to no barrier when no VM is attached (the + /// tracked-object reads then run without other threads only if the caller + /// guarantees it). fn new() -> Self { - let vm = crate::vm::thread::try_with_current_vm(|vm| { - vm.state.stop_the_world.stop_the_world(vm); - vm as *const crate::VirtualMachine - }); - match vm { - Some(vm) => Self { vm, stopped: true }, - None => Self { - vm: core::ptr::null(), - stopped: false, - }, + // No attached VM means no interpreter is running Python on this thread; + // keep the historical no-barrier fallback. + if !crate::vm::thread::current_vm_is_set() { + return Self { + stopped: Vec::new(), + admission: None, + restarted: true, + }; + } + + // Accumulate into a live `Self` rather than a bare Vec: if a later + // `stop_the_world` unwinds, dropping this guard restarts the + // interpreters already stopped, instead of leaving their threads parked + // and their exclusion held forever. + let mut guard = Self { + stopped: Vec::new(), + admission: Some(crate::vm::runtime::lock_admission_for_stop()), + restarted: false, + }; + for state in crate::vm::runtime::live_interpreter_states() { + state.stop_the_world.stop_the_world(&state); + guard.stopped.push(state); } + guard } /// Restart the world. Idempotent. fn restart(&mut self) { - if self.stopped { - // SAFETY: the current thread stays attached to this VM for the - // whole collection — the VM is never popped from the thread's VM - // stack while collecting — so the pointer is valid here. - let vm = unsafe { &*self.vm }; - vm.state.stop_the_world.start_the_world(vm); - self.stopped = false; + if self.restarted { + return; + } + self.restarted = true; + // Reverse of the stop order. The references stay until this guard is + // dropped; see the field comment. + for state in self.stopped.iter().rev() { + state.stop_the_world.start_the_world(state); } + // Nothing is parked any more, so registration may resume. + self.admission = None; + } + + /// Whether this collection actually stopped the world. + #[cfg(all(unix, debug_assertions))] + fn is_stopped(&self) -> bool { + !self.stopped.is_empty() } } @@ -191,29 +252,35 @@ impl Drop for CollectStopTheWorld { } } -/// Global GC state +/// The process-wide object lists every interpreter's collections walk. +/// +/// Interpreter-owned policy and results live in [`GcInterpreterState`]; what is +/// here is shared because the lists are: an object is untracked from +/// `default_dealloc`, where no interpreter is in scope, so it has to be findable +/// without one. pub struct GcState { - /// 3 generations (0 = youngest, 2 = oldest) - pub generations: [GcGeneration; 3], - /// Permanent generation (frozen objects) - pub permanent: GcGeneration, - /// GC enabled flag - pub enabled: AtomicBool, /// Per-generation intrusive linked lists for object tracking. /// Objects start in gen0, survivors are promoted to gen1, then gen2. generation_lists: [PyRwLock>; 3], /// Frozen/permanent objects (excluded from normal GC) permanent_list: PyRwLock>, - /// Debug flags - pub debug: AtomicU32, - /// gc.garbage list (uncollectable objects with __del__) - pub garbage: PyMutex>, - /// gc.callbacks list - pub callbacks: PyMutex>, + /// Number of tracked objects per generation, across all interpreters. + /// + /// Advisory: they drive the collection threshold and `gc.get_count()`, and + /// the generation locks — not these counters — order the list changes they + /// describe. Every access is therefore relaxed, which keeps the tracking and + /// untracking of every object off the barrier path. + counts: [AtomicUsize; 3], + /// Number of frozen objects. Advisory, like `counts`. + permanent_count: AtomicUsize, /// Mutex for collection (prevents concurrent collections) collecting: PyMutex<()>, - /// Allocation counter for gen0 - alloc_count: AtomicUsize, + /// Next `gc_owner` tag to hand to an interpreter. + next_owner: AtomicU16, + /// Tags of interpreters that are gone. Their objects outlived them, so a + /// collection adopts them — tags them `GC_NO_OWNER` again — as it walks, + /// rather than leaving them for a collector that will never come. + retired: PyMutex>, } // SAFETY: All fields are either inherently Send/Sync (atomics, RwLock, Mutex) or protected by PyMutex. @@ -233,103 +300,70 @@ impl GcState { #[must_use] pub const fn new() -> Self { Self { - generations: [ - GcGeneration::new(2000), // young - GcGeneration::new(10), // old[0] - GcGeneration::new(0), // old[1] - ], - permanent: GcGeneration::new(0), - enabled: AtomicBool::new(true), generation_lists: [ PyRwLock::new(LinkedList::new()), PyRwLock::new(LinkedList::new()), PyRwLock::new(LinkedList::new()), ], permanent_list: PyRwLock::new(LinkedList::new()), - debug: AtomicU32::new(0), - garbage: PyMutex::new(Vec::new()), - callbacks: PyMutex::new(Vec::new()), + counts: [ + AtomicUsize::new(0), + AtomicUsize::new(0), + AtomicUsize::new(0), + ], + permanent_count: AtomicUsize::new(0), collecting: PyMutex::new(()), - alloc_count: AtomicUsize::new(0), + next_owner: AtomicU16::new(GC_NO_OWNER + 1), + retired: PyMutex::new(Vec::new()), } } - /// Check if GC is enabled - pub fn is_enabled(&self) -> bool { - self.enabled.load(Ordering::SeqCst) - } - - /// Enable GC - pub fn enable(&self) { - self.enabled.store(true, Ordering::SeqCst); - } - - /// Disable GC - pub fn disable(&self) { - self.enabled.store(false, Ordering::SeqCst); - } - - /// Get debug flags - pub fn get_debug(&self) -> GcDebugFlags { - GcDebugFlags::from_bits_truncate(self.debug.load(Ordering::SeqCst)) - } - - /// Set debug flags - pub fn set_debug(&self, flags: GcDebugFlags) { - self.debug.store(flags.bits(), Ordering::SeqCst); - } - - /// Get thresholds for all generations - pub fn get_threshold(&self) -> (u32, u32, u32) { - ( - self.generations[0].threshold(), - self.generations[1].threshold(), - self.generations[2].threshold(), - ) + /// Reserve a tag for a new interpreter. Tags are never reused; exhausting + /// the tag space falls back to `GC_NO_OWNER`, which costs isolation but + /// stays correct, rather than aliasing a live interpreter. + fn alloc_owner(&self) -> GcOwner { + self.next_owner + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |next| { + next.checked_add(1) + }) + .unwrap_or(GC_NO_OWNER) } - /// Set thresholds - pub fn set_threshold(&self, t0: u32, t1: Option, t2: Option) { - self.generations[0].set_threshold(t0); - if let Some(t1) = t1 { - self.generations[1].set_threshold(t1); - } - if let Some(t2) = t2 { - self.generations[2].set_threshold(t2); + /// Record that `owner`'s interpreter is gone, so the next collection adopts + /// whatever it left behind. Retagging the objects here would mean walking + /// every list under an interpreter drop, which happens while a collection + /// holds the collecting lock. + fn retire_owner(&self, owner: GcOwner) { + if owner == GC_NO_OWNER { + return; } + self.retired.lock().push(owner); } - /// Get counts for all generations + /// Get counts for all generations. Tracked objects are shared, so these are + /// process-wide even though the thresholds they are compared against are + /// per interpreter. pub fn get_count(&self) -> (usize, usize, usize) { ( - self.generations[0].count(), - self.generations[1].count(), - self.generations[2].count(), + self.counts[0].load(Ordering::Relaxed), + self.counts[1].load(Ordering::Relaxed), + self.counts[2].load(Ordering::Relaxed), ) } - /// Get statistics for all generations - pub fn get_stats(&self) -> [GcStats; 3] { - [ - self.generations[0].stats(), - self.generations[1].stats(), - self.generations[2].stats(), - ] - } - - /// Track a new object (add to gen0). + /// Track a new object (add to gen0) as owned by `owner`. /// O(1) — intrusive linked list push_front, no hashing. /// /// # Safety /// obj must be a valid pointer to a PyObject - pub unsafe fn track_object(&self, obj: NonNull) { + pub unsafe fn track_object(&self, obj: NonNull, owner: GcOwner) { let obj_ref = unsafe { obj.as_ref() }; obj_ref.set_gc_tracked(); obj_ref.set_gc_generation(0); + obj_ref.set_gc_owner(owner); self.generation_lists[0].write().push_front(obj); - self.generations[0].count.fetch_add(1, Ordering::SeqCst); - self.alloc_count.fetch_add(1, Ordering::SeqCst); + self.counts[0].fetch_add(1, Ordering::Relaxed); } /// Untrack an object (remove from GC lists). @@ -348,10 +382,10 @@ impl GcState { ( &self.generation_lists[obj_gen as usize] as &PyRwLock>, - &self.generations[obj_gen as usize].count, + &self.counts[obj_gen as usize], ) } else if obj_gen == GC_PERMANENT { - (&self.permanent_list, &self.permanent.count) + (&self.permanent_list, &self.permanent_count) } else { return; // GC_UNTRACKED or unknown — already untracked }; @@ -363,7 +397,7 @@ impl GcState { continue; // Retry with the updated generation } if unsafe { list.remove(obj) }.is_some() { - count.fetch_sub(1, Ordering::SeqCst); + release_count(count); obj_ref.clear_gc_tracked(); obj_ref.set_gc_generation(GC_UNTRACKED); } else { @@ -381,14 +415,18 @@ impl GcState { } } - /// Get tracked objects (for gc.get_objects) - /// If generation is None, returns all tracked objects. - /// If generation is Some(n), returns objects in generation n only. - pub fn get_objects(&self, generation: Option) -> Vec { + /// Get the objects `owner` tracks (for gc.get_objects), plus the ones no + /// interpreter owns. + /// If generation is None, returns all such objects. + /// If generation is Some(n), returns those in generation n only. + pub fn get_objects(&self, generation: Option, owner: GcOwner) -> Vec { fn collect_from_list( list: &LinkedList, + owner: GcOwner, ) -> impl Iterator + '_ { - list.iter().filter_map(|obj| obj.try_to_owned()) + list.iter() + .filter(move |obj| is_owned_by(obj, owner)) + .filter_map(|obj| obj.try_to_owned()) } match generation { @@ -396,14 +434,14 @@ impl GcState { // Return all tracked objects from all generations + permanent let mut result = Vec::new(); for gen_list in &self.generation_lists { - result.extend(collect_from_list(&gen_list.read())); + result.extend(collect_from_list(&gen_list.read(), owner)); } - result.extend(collect_from_list(&self.permanent_list.read())); + result.extend(collect_from_list(&self.permanent_list.read(), owner)); result } Some(g) if (0..=2).contains(&g) => { let guard = self.generation_lists[g as usize].read(); - collect_from_list(&guard).collect() + collect_from_list(&guard, owner).collect() } _ => Vec::new(), } @@ -412,14 +450,14 @@ impl GcState { /// Check if automatic GC should run and run it if needed. /// Called after object allocation. /// Returns true if GC was run, false otherwise. - pub fn maybe_collect(&self) -> bool { - if !self.is_enabled() { + fn maybe_collect(&self, gc: &GcInterpreterState) -> bool { + if !gc.is_enabled() { return false; } // Check gen0 threshold - let count0 = self.generations[0].count.load(Ordering::SeqCst) as u32; - let threshold0 = self.generations[0].threshold(); + let count0 = self.counts[0].load(Ordering::Relaxed) as u32; + let threshold0 = gc.generations[0].threshold(); if threshold0 > 0 && count0 >= threshold0 { #[cfg(feature = "threading")] { @@ -435,7 +473,7 @@ impl GcState { // thread whose frames could be read mid-mutation, so collect inline. #[cfg(not(feature = "threading"))] { - self.collect(0); + self.collect_inner(gc, 0, false); return true; } } @@ -443,18 +481,13 @@ impl GcState { false } - /// Perform garbage collection on the given generation - pub fn collect(&self, generation: usize) -> CollectResult { - self.collect_inner(generation, false) - } - - /// Force collection even if GC is disabled (for manual gc.collect() calls) - pub fn collect_force(&self, generation: usize) -> CollectResult { - self.collect_inner(generation, true) - } - - fn collect_inner(&self, generation: usize, force: bool) -> CollectResult { - if !force && !self.is_enabled() { + fn collect_inner( + &self, + gc: &GcInterpreterState, + generation: usize, + force: bool, + ) -> CollectResult { + if !force && !gc.is_enabled() { return CollectResult::default(); } @@ -473,7 +506,7 @@ impl GcState { core::sync::atomic::fence(Ordering::SeqCst); let generation = generation.min(2); - let debug = self.get_debug(); + let debug = gc.get_debug(); // Clear the method cache to release strong references that // might prevent cycle collection (_PyType_ClearCache). @@ -511,26 +544,56 @@ impl GcState { .map(|i| self.generation_lists[i].read()) .collect(); + // Only this interpreter's objects, plus the ones no interpreter owns. + // Another interpreter's objects stay out of the candidate set, so they + // act as external roots: anything they reference survives this pass. + let owner = gc.owner; + // Sorted so that the test below, which every scanned object pays for, + // stays logarithmic in the number of interpreters that have been + // dropped instead of linear. + let retired = { + let mut retired = self.retired.lock().clone(); + retired.sort_unstable(); + retired + }; let mut collecting: HashSet = HashSet::new(); for gen_list in &gen_locks { for obj in gen_list.iter() { - if obj.strong_count() > 0 { + if retired.binary_search(&obj.gc_owner()).is_ok() { + obj.set_gc_owner(GC_NO_OWNER); + } + if obj.strong_count() > 0 && is_owned_by(obj, owner) { collecting.insert(GcPtr(NonNull::from(obj))); } } } + // A full collection is the only one that sees every generation, so it + // is where adoption finishes and the tags stop being tracked. + if generation == 2 && !retired.is_empty() { + for obj in self.permanent_list.read().iter() { + if retired.binary_search(&obj.gc_owner()).is_ok() { + obj.set_gc_owner(GC_NO_OWNER); + } + } + // Only the tags this scan saw: one retired while it ran still has + // objects nobody has adopted. + self.retired + .lock() + .retain(|tag| retired.binary_search(tag).is_err()); + } + if collecting.is_empty() { // Reset counts for generations whose objects were promoted away. // For gen2 (oldest), survivors stay in-place so don't reset gen2 count. let reset_end = if generation >= 2 { 2 } else { generation + 1 }; for i in 0..reset_end { - self.generations[i].count.store(0, Ordering::SeqCst); + self.counts[i].store(0, Ordering::Relaxed); } let duration = elapsed_secs(start_time); - self.generations[generation].update_stats(0, 0, 0, duration); + gc.generations[generation].update_stats(0, 0, 0, duration); return CollectResult { collected: 0, uncollectable: 0, @@ -638,7 +701,7 @@ impl GcState { // because stack-allocated frames update only CURRENT_FRAME (via // set_current_frame_nosave), not top_frame. #[cfg(all(unix, feature = "threading", debug_assertions))] - if stw.stopped { + if stw.is_stopped() { let unreachable_set: HashSet = unreachable.iter().copied().collect(); let mut cur = crate::vm::thread::get_current_frame(); while !cur.is_null() { @@ -701,12 +764,12 @@ impl GcState { self.promote_survivors(generation, &survivor_refs); let reset_end = if generation >= 2 { 2 } else { generation + 1 }; for i in 0..reset_end { - self.generations[i].count.store(0, Ordering::SeqCst); + self.counts[i].store(0, Ordering::Relaxed); } let duration = elapsed_secs(start_time); - self.generations[generation].update_stats(0, 0, candidates, duration); + gc.generations[generation].update_stats(0, 0, candidates, duration); return CollectResult { collected: 0, uncollectable: 0, @@ -724,12 +787,12 @@ impl GcState { self.promote_survivors(generation, &survivor_refs); let reset_end = if generation >= 2 { 2 } else { generation + 1 }; for i in 0..reset_end { - self.generations[i].count.store(0, Ordering::SeqCst); + self.counts[i].store(0, Ordering::Relaxed); } let duration = elapsed_secs(start_time); - self.generations[generation].update_stats(0, 0, candidates, duration); + gc.generations[generation].update_stats(0, 0, candidates, duration); return CollectResult { collected: 0, uncollectable: 0, @@ -849,7 +912,7 @@ impl GcState { } if debug.contains(GcDebugFlags::SAVEALL) { - let mut garbage_guard = self.garbage.lock(); + let mut garbage_guard = gc.garbage.lock(); for obj_ref in &truly_dead { garbage_guard.push(obj_ref.clone()); } @@ -926,7 +989,10 @@ impl GcState { reason = "Iteration order doesn't matter here" )] for &ptr in &late_resurrected { - unsafe { self.track_object(ptr.0) }; + // Re-tracking a resurrected object: it keeps the owner it + // was allocated under. + let owner = unsafe { ptr.0.as_ref() }.gc_owner(); + unsafe { self.track_object(ptr.0, owner) }; } } rustpython_common::refcount::with_deferred_drops(|| { @@ -950,12 +1016,12 @@ impl GcState { // For gen2 (oldest), survivors stay in-place so don't reset gen2 count. let reset_end = if generation >= 2 { 2 } else { generation + 1 }; for i in 0..reset_end { - self.generations[i].count.store(0, Ordering::SeqCst); + self.counts[i].store(0, Ordering::Relaxed); } let duration = elapsed_secs(start_time); - self.generations[generation].update_stats(collected, 0, candidates, duration); + gc.generations[generation].update_stats(collected, 0, candidates, duration); CollectResult { collected, @@ -998,14 +1064,10 @@ impl GcState { } if unsafe { src.remove(ptr) }.is_some() { - self.generations[src_gen] - .count - .fetch_sub(1, Ordering::SeqCst); + release_count(&self.counts[src_gen]); dst.push_front(ptr); - self.generations[next_gen] - .count - .fetch_add(1, Ordering::SeqCst); + self.counts[next_gen].fetch_add(1, Ordering::Relaxed); obj.set_gc_generation(next_gen as u8); } @@ -1015,45 +1077,66 @@ impl GcState { /// Get count of frozen objects pub fn get_freeze_count(&self) -> usize { - self.permanent.count() + self.permanent_count.load(Ordering::Relaxed) } - /// Freeze all tracked objects (move to permanent generation). + /// Freeze the objects `owner` could collect (move them to the permanent + /// generation). /// Lock order: generation_lists[i] → permanent_list (consistent with unfreeze). - pub fn freeze(&self) { + fn freeze(&self, owner: GcOwner) { let mut count = 0usize; for (gen_idx, gen_list) in self.generation_lists.iter().enumerate() { let mut list = gen_list.write(); let mut perm = self.permanent_list.write(); - while let Some(ptr) = list.pop_front() { + let moving: Vec<_> = list + .iter() + .filter(|obj| is_owned_by(obj, owner)) + .map(NonNull::from) + .collect(); + for ptr in moving { + if unsafe { list.remove(ptr) }.is_none() { + continue; + } perm.push_front(ptr); unsafe { ptr.as_ref().set_gc_generation(GC_PERMANENT) }; count += 1; + release_count(&self.counts[gen_idx]); } - self.generations[gen_idx].count.store(0, Ordering::SeqCst); } - self.permanent.count.fetch_add(count, Ordering::SeqCst); + self.permanent_count.fetch_add(count, Ordering::Relaxed); } - /// Unfreeze all objects (move from permanent to gen2). + /// Unfreeze the objects `owner` froze (move them from permanent to gen2). /// Lock order: generation_lists[2] → permanent_list (consistent with freeze). - pub fn unfreeze(&self) { + fn unfreeze(&self, owner: GcOwner) { let mut count = 0usize; { let mut gen2 = self.generation_lists[2].write(); let mut perm_list = self.permanent_list.write(); - while let Some(ptr) = perm_list.pop_front() { + let moving: Vec<_> = perm_list + .iter() + .filter(|obj| is_owned_by(obj, owner)) + .map(NonNull::from) + .collect(); + for ptr in moving { + if unsafe { perm_list.remove(ptr) }.is_none() { + continue; + } gen2.push_front(ptr); unsafe { ptr.as_ref().set_gc_generation(2) }; count += 1; } - self.permanent.count.store(0, Ordering::SeqCst); + let _ = self.permanent_count.fetch_update( + Ordering::Relaxed, + Ordering::Relaxed, + |permanent| Some(permanent.saturating_sub(count)), + ); } - self.generations[2].count.fetch_add(count, Ordering::SeqCst); + self.counts[2].fetch_add(count, Ordering::Relaxed); } /// Reset all locks to unlocked state after fork(). @@ -1070,13 +1153,7 @@ impl GcState { unsafe { reinit_mutex_after_fork(&self.collecting); - reinit_mutex_after_fork(&self.garbage); - reinit_mutex_after_fork(&self.callbacks); - - for generation in &self.generations { - generation.reinit_stats_after_fork(); - } - self.permanent.reinit_stats_after_fork(); + reinit_mutex_after_fork(&self.retired); for rw in &self.generation_lists { reinit_rwlock_after_fork(rw); @@ -1086,11 +1163,196 @@ impl GcState { } } +/// Per-interpreter garbage collector state (≈ `PyInterpreterState.gc`). +/// +/// The generation lists are process-wide (see [`GcState`]); what an interpreter +/// owns is the policy applied to them and the results — which objects its +/// collections consider, whether they run automatically, and where uncollectable +/// objects end up. +pub struct GcInterpreterState { + /// Tag written into every object this interpreter tracks. + owner: GcOwner, + /// Per-generation thresholds and statistics. + pub generations: [GcGeneration; 3], + /// GC enabled flag + enabled: AtomicBool, + /// Debug flags + debug: AtomicU32, + /// Uncollectable objects saved by this interpreter's collections, drained + /// into `py_garbage` by `gc.collect()`. + pub garbage: PyMutex>, + /// `gc.garbage` + pub py_garbage: crate::builtins::PyListRef, + /// `gc.callbacks` + pub py_callbacks: crate::builtins::PyListRef, +} + +impl GcInterpreterState { + pub fn new(ctx: &crate::vm::Context) -> Self { + Self { + owner: gc_state().alloc_owner(), + generations: [ + GcGeneration::new(2000), // young + GcGeneration::new(10), // old[0] + GcGeneration::new(0), // old[1] + ], + enabled: AtomicBool::new(true), + debug: AtomicU32::new(0), + garbage: PyMutex::new(Vec::new()), + py_garbage: ctx.new_list(Vec::new()), + py_callbacks: ctx.new_list(Vec::new()), + } + } + + /// Check if GC is enabled. + /// + /// Relaxed, like [`GcGeneration::threshold`]: it is read once per + /// allocation, and an allocation racing `gc.disable()` may use either value. + pub fn is_enabled(&self) -> bool { + self.enabled.load(Ordering::Relaxed) + } + + /// Enable GC + pub fn enable(&self) { + self.enabled.store(true, Ordering::Relaxed); + } + + /// Disable GC + pub fn disable(&self) { + self.enabled.store(false, Ordering::Relaxed); + } + + /// Get debug flags + pub fn get_debug(&self) -> GcDebugFlags { + GcDebugFlags::from_bits_truncate(self.debug.load(Ordering::SeqCst)) + } + + /// Set debug flags + pub fn set_debug(&self, flags: GcDebugFlags) { + self.debug.store(flags.bits(), Ordering::SeqCst); + } + + /// Get thresholds for all generations + pub fn get_threshold(&self) -> (u32, u32, u32) { + ( + self.generations[0].threshold(), + self.generations[1].threshold(), + self.generations[2].threshold(), + ) + } + + /// Set thresholds + pub fn set_threshold(&self, t0: u32, t1: Option, t2: Option) { + self.generations[0].set_threshold(t0); + if let Some(t1) = t1 { + self.generations[1].set_threshold(t1); + } + if let Some(t2) = t2 { + self.generations[2].set_threshold(t2); + } + } + + /// Get statistics for all generations + pub fn get_stats(&self) -> [GcStats; 3] { + [ + self.generations[0].stats(), + self.generations[1].stats(), + self.generations[2].stats(), + ] + } + + /// Perform garbage collection on the given generation + pub fn collect(&self, generation: usize) -> CollectResult { + gc_state().collect_inner(self, generation, false) + } + + /// Force collection even if GC is disabled (for manual gc.collect() calls) + pub fn collect_force(&self, generation: usize) -> CollectResult { + gc_state().collect_inner(self, generation, true) + } + + /// The tracked objects this interpreter can reach (for gc.get_objects). + pub fn get_objects(&self, generation: Option) -> Vec { + gc_state().get_objects(generation, self.owner) + } + + /// Move the objects this interpreter could collect into the permanent + /// generation. + pub fn freeze(&self) { + gc_state().freeze(self.owner); + } + + /// Move them back out of it. + pub fn unfreeze(&self) { + gc_state().unfreeze(self.owner); + } + + /// Reset this interpreter's GC locks to unlocked state after fork(). + /// + /// # Safety + /// Must only be called after fork() in the child process when no other + /// threads exist. The calling thread must NOT hold any of these locks. + #[cfg(all(unix, feature = "threading"))] + pub unsafe fn reinit_after_fork(&self) { + unsafe { + crate::common::lock::reinit_mutex_after_fork(&self.garbage); + for generation in &self.generations { + generation.reinit_stats_after_fork(); + } + } + } +} + +impl Drop for GcInterpreterState { + fn drop(&mut self) { + // Objects this interpreter tracked can outlive it (another interpreter + // may still hold one). Clearing the tag hands them to every collection + // instead of stranding them. The tag itself is not handed back: it stays + // retired so that a later interpreter cannot inherit these objects. + gc_state().retire_owner(self.owner); + } +} + +/// The tag `track_object` should write for the interpreter running now. +#[must_use] +pub fn current_owner() -> GcOwner { + // SAFETY: the pointee is owned by the `PyGlobalState` of the VM on top of + // this thread's VM stack, which outlives the section this call runs in. + crate::vm::thread::current_gc_state().map_or(GC_NO_OWNER, |gc| unsafe { gc.as_ref() }.owner) +} + +/// Track a freshly allocated object under the interpreter running now, and let +/// it collect if the allocation pushed gen0 past its threshold. +/// +/// # Safety +/// obj must be a valid pointer to a PyObject that is not already tracked. +pub(crate) unsafe fn track_new_object(obj: NonNull) { + let state = gc_state(); + let Some(gc) = crate::vm::thread::current_gc_state() else { + // No interpreter is running: the shared context builds its own objects + // this way. They are left unowned, so every interpreter collects them. + unsafe { state.track_object(obj, GC_NO_OWNER) }; + return; + }; + // SAFETY: as in `current_owner`. + let gc = unsafe { gc.as_ref() }; + unsafe { state.track_object(obj, gc.owner) }; + state.maybe_collect(gc); +} + /// Get a reference to the GC state. /// /// In threading mode this is a true global (OnceLock). /// In non-threading mode this is thread-local, because PyRwLock/PyMutex /// use Cell-based locks that are not Sync. +/// +/// Every interpreter's tracked objects live in these lists, because untracking +/// happens in `default_dealloc`, where no interpreter is in scope to route to. +/// What a collection *acts on* is still one interpreter's own objects, selected +/// by the `gc_owner` tag; [`GcInterpreterState`] holds the rest of the state +/// that goes with that. The counts here, and so `gc.get_count()` and +/// `gc.get_freeze_count()`, stay process-wide: they measure how full these +/// lists are. pub fn gc_state() -> &'static GcState { rustpython_common::static_cell! { static GC_STATE: GcState; @@ -1102,18 +1364,21 @@ pub fn gc_state() -> &'static GcState { mod tests { use super::*; + fn interpreter_state() -> GcInterpreterState { + GcInterpreterState::new(crate::vm::Context::genesis()) + } + #[test] fn gc_state_default() { - let state = GcState::new(); + let state = interpreter_state(); assert!(state.is_enabled()); assert_eq!(state.get_debug(), GcDebugFlags::empty()); assert_eq!(state.get_threshold(), (2000, 10, 0)); - assert_eq!(state.get_count(), (0, 0, 0)); } #[test] fn gc_enable_disable() { - let state = GcState::new(); + let state = interpreter_state(); assert!(state.is_enabled()); state.disable(); assert!(!state.is_enabled()); @@ -1123,18 +1388,29 @@ mod tests { #[test] fn gc_threshold() { - let state = GcState::new(); + let state = interpreter_state(); state.set_threshold(100, Some(20), Some(30)); assert_eq!(state.get_threshold(), (100, 20, 30)); } #[test] fn gc_debug_flags() { - let state = GcState::new(); + let state = interpreter_state(); state.set_debug(GcDebugFlags::STATS | GcDebugFlags::COLLECTABLE); assert_eq!( state.get_debug(), GcDebugFlags::STATS | GcDebugFlags::COLLECTABLE ); } + + /// Live interpreters never share an owner tag, or their collections would + /// reach each other's objects. + #[test] + fn gc_owner_tags_are_distinct_while_live() { + let first = interpreter_state(); + let second = interpreter_state(); + assert_ne!(first.owner, second.owner); + assert_ne!(first.owner, GC_NO_OWNER); + assert_ne!(second.owner, GC_NO_OWNER); + } } diff --git a/crates/vm/src/lib.rs b/crates/vm/src/lib.rs index df67c979739..a15e30c34a3 100644 --- a/crates/vm/src/lib.rs +++ b/crates/vm/src/lib.rs @@ -109,7 +109,11 @@ pub use self::object::{ AsObject, Py, PyAtomicRef, PyExact, PyObject, PyObjectRef, PyPayload, PyRef, PyRefExact, PyResult, PyStackRef, PyWeakRef, }; -pub use self::vm::{Context, Interpreter, InterpreterBuilder, Settings, VirtualMachine}; +pub use self::vm::runtime; +pub use self::vm::{ + Context, Interpreter, InterpreterBuilder, InterpreterInfo, InterpreterWhence, + MAIN_INTERPRETER_ID, Settings, VirtualMachine, +}; pub use rustpython_common as common; pub use rustpython_compiler_core::{bytecode, frozen}; diff --git a/crates/vm/src/object/core.rs b/crates/vm/src/object/core.rs index 0ee7a062ee7..1e36d57ab31 100644 --- a/crates/vm/src/object/core.rs +++ b/crates/vm/src/object/core.rs @@ -303,6 +303,18 @@ bitflags::bitflags! { /// GC generation constants pub(crate) const GC_UNTRACKED: u8 = 0xFF; pub(crate) const GC_PERMANENT: u8 = 3; +/// Width of an interpreter's `gc_owner` tag. +/// +/// Sized to the padding the header alignment already forces, so the tag costs +/// no space on either pointer width. Running out of tags is not an error: an +/// interpreter that gets none uses [`GC_NO_OWNER`] and its objects stay +/// collectable by every interpreter, which is how they behaved before tagging. +pub(crate) type GcOwner = u16; + +/// `gc_owner` of an object that belongs to no single interpreter: everything +/// the shared context allocates, and anything allocated with no interpreter +/// current. Every interpreter collects these. +pub(crate) const GC_NO_OWNER: GcOwner = 0; /// Link implementation for GC intrusive linked list tracking pub(crate) struct GcLink; @@ -389,6 +401,10 @@ pub(super) struct PyInner { /// GC generation index (0-2=gen, GC_PERMANENT=permanent, GC_UNTRACKED=not tracked). /// Uses PyAtomic for interior mutability (writes happen through &self under list locks). pub(super) gc_generation: PyAtomic, + /// Interpreter that tracked this object, or `GC_NO_OWNER`. Written by + /// `track_object`; read to scope a collection to one interpreter. + /// Sits in what would otherwise be padding, so it costs no space. + pub(super) gc_owner: PyAtomic, /// Intrusive linked list pointers for GC generational tracking pub(super) gc_pointers: Pointers, @@ -398,6 +414,11 @@ pub(super) struct PyInner { } pub(crate) const SIZEOF_PYOBJECT_HEAD: usize = core::mem::size_of::>(); +// ref_count, vtable, gc_pointers (two) and typ are one word each; the gc bits, +// generation and owner share the word of padding their alignment forces. Adding +// to that group is free only while this holds. +const _: () = assert!(SIZEOF_PYOBJECT_HEAD == 6 * core::mem::size_of::()); + impl PyInner { /// Read type flags and member_count via raw pointers to avoid Stacked Borrows /// violations during bootstrap, where type objects have self-referential typ pointers. @@ -1052,6 +1073,16 @@ impl InstanceDict { self.d.read().clone() } + /// Run `f` on the dict without cloning it. + /// + /// For callers that only need to look at the dict — a predicate, a version + /// stamp — this drops the refcount round-trip [`Self::get`] pays. `f` runs + /// under the read guard, so it must not run Python or take this lock again. + #[inline] + pub(crate) fn with(&self, f: impl FnOnce(Option<&Py>) -> R) -> R { + f(self.d.read().as_deref()) + } + #[inline] pub(crate) fn set(&self, d: Option) { self.replace(d); @@ -1216,6 +1247,7 @@ impl PyInner { vtable: PyObjVTable::of::(), gc_bits: Radium::new(0), gc_generation: Radium::new(GC_UNTRACKED), + gc_owner: Radium::new(GC_NO_OWNER), gc_pointers: Pointers::new(), typ: PyAtomicRef::from(typ), payload, @@ -1228,6 +1260,7 @@ impl PyInner { vtable: PyObjVTable::of::(), gc_bits: Radium::new(0), gc_generation: Radium::new(GC_UNTRACKED), + gc_owner: Radium::new(GC_NO_OWNER), gc_pointers: Pointers::new(), typ: PyAtomicRef::from(typ), payload, @@ -1615,6 +1648,28 @@ impl PyObject { self.instance_dict().and_then(|d| d.get()) } + /// Whether this object currently has an instance dict, without cloning it. + /// + /// `false` both for an object with no dict slot and for one whose slot is + /// still empty, which is what `dict().is_none()` reports. + #[inline(always)] + pub fn has_instance_dict(&self) -> bool { + self.instance_dict() + .is_some_and(|d| d.with(|dict| dict.is_some())) + } + + /// Run `f` on the instance dict without cloning it; see [`InstanceDict::with`]. + #[inline(always)] + pub(crate) fn with_instance_dict( + &self, + f: impl FnOnce(Option<&Py>) -> R, + ) -> R { + match self.instance_dict() { + Some(d) => d.with(f), + None => f(None), + } + } + /// Set the dict field. Returns `Err(dict)` if this object does not have a dict field /// in the first place. pub fn set_dict(&self, dict: Option) -> Result<(), Option> { @@ -1734,6 +1789,20 @@ impl PyObject { self.0.gc_generation.store(generation, Ordering::Relaxed); } + /// The interpreter whose collections consider this object. + #[inline] + pub(crate) fn gc_owner(&self) -> GcOwner { + self.0.gc_owner.load(Ordering::Relaxed) + } + + /// Set the owning interpreter. Written by `track_object` before the object + /// enters a generation list, and reset to `GC_NO_OWNER` when the owning + /// interpreter goes away. + #[inline] + pub(crate) fn set_gc_owner(&self, owner: GcOwner) { + self.0.gc_owner.store(owner, Ordering::Relaxed); + } + /// _PyObject_GC_TRACK #[inline] pub(crate) fn set_gc_tracked(&self) { @@ -2392,12 +2461,11 @@ impl PyRef { if (::HAS_TRAVERSE || has_dict || is_heaptype) && !T::NEW_REF_UNTRACKED { - let gc = crate::gc_state::gc_state(); + // Tracks under the interpreter running now and collects if this + // allocation pushed gen0 past its threshold. unsafe { - gc.track_object(ptr.cast()); + crate::gc_state::track_new_object(ptr.cast()); } - // Check if automatic GC should run - gc.maybe_collect(); } Self { ptr } @@ -2645,6 +2713,7 @@ pub(crate) fn init_type_hierarchy() -> (PyTypeRef, PyTypeRef, PyTypeRef) { vtable: PyObjVTable::of::(), gc_bits: Radium::new(0), gc_generation: Radium::new(GC_UNTRACKED), + gc_owner: Radium::new(GC_NO_OWNER), gc_pointers: Pointers::new(), payload: type_payload, }, @@ -2660,6 +2729,7 @@ pub(crate) fn init_type_hierarchy() -> (PyTypeRef, PyTypeRef, PyTypeRef) { vtable: PyObjVTable::of::(), gc_bits: Radium::new(0), gc_generation: Radium::new(GC_UNTRACKED), + gc_owner: Radium::new(GC_NO_OWNER), gc_pointers: Pointers::new(), payload: object_payload, }, diff --git a/crates/vm/src/object/mod.rs b/crates/vm/src/object/mod.rs index b06957e1bc6..becfcabb1d4 100644 --- a/crates/vm/src/object/mod.rs +++ b/crates/vm/src/object/mod.rs @@ -9,5 +9,5 @@ pub use self::core::*; pub use self::ext::*; pub use self::payload::*; pub(crate) use core::SIZEOF_PYOBJECT_HEAD; -pub(crate) use core::{GC_PERMANENT, GC_UNTRACKED, GcLink}; +pub(crate) use core::{GC_NO_OWNER, GC_PERMANENT, GC_UNTRACKED, GcLink, GcOwner}; pub use traverse::{MaybeTraverse, Traverse, TraverseFn}; diff --git a/crates/vm/src/protocol/object.rs b/crates/vm/src/protocol/object.rs index 4974fca9343..993f3442aa3 100644 --- a/crates/vm/src/protocol/object.rs +++ b/crates/vm/src/protocol/object.rs @@ -230,7 +230,6 @@ impl PyObject { dict: Option, vm: &VirtualMachine, ) -> PyResult> { - let name = name_str.as_wtf8(); let obj_cls = self.class(); let cls_attr_name = vm.ctx.interned_str(name_str); let cls_attr = match cls_attr_name.and_then(|name| obj_cls.get_attr(name)) { @@ -251,7 +250,9 @@ impl PyObject { let dict = dict.or_else(|| self.dict()); let attr = if let Some(dict) = dict { - dict.get_item_opt(name, vm)? + // `Py` rather than its `&Wtf8`: the key type carries the + // cached hash and compares interned keys by pointer. + dict.get_item_opt(name_str, vm)? } else { None }; diff --git a/crates/vm/src/stdlib/_ast/python.rs b/crates/vm/src/stdlib/_ast/python.rs index db92f20db17..b6f7948293d 100644 --- a/crates/vm/src/stdlib/_ast/python.rs +++ b/crates/vm/src/stdlib/_ast/python.rs @@ -10,13 +10,11 @@ pub(crate) mod _ast { AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, builtins::{PyDictRef, PySet, PyStr, PyTupleRef, PyType, PyTypeRef}, class::{PyClassImpl, StaticType}, - common::wtf8::Wtf8Buf, function::{ArgIterable, FuncArgs, KwArgs, PyMethodDef, PyMethodFlags}, stdlib::_ast::repr, types::{Constructor, Initializer}, warn, }; - use indexmap::IndexMap; #[pyattr] #[pyclass(module = "_ast", name = "AST")] #[derive(Debug, PyPayload)] @@ -295,7 +293,7 @@ pub(crate) mod _ast { .map_err(|_| vm.new_type_error("keywords must be strings"))?; Ok((key.as_wtf8().to_owned(), value)) }) - .collect::>>()?; + .collect::>>()?; let result = type_obj.call(FuncArgs::new(vec![], KwArgs::new(kwargs)), vm)?; Ok(result) } diff --git a/crates/vm/src/stdlib/_ctypes/structure.rs b/crates/vm/src/stdlib/_ctypes/structure.rs index 34f53f52d60..1632d745dc6 100644 --- a/crates/vm/src/stdlib/_ctypes/structure.rs +++ b/crates/vm/src/stdlib/_ctypes/structure.rs @@ -1,6 +1,5 @@ use super::base::{CDATA_BUFFER_METHODS, PyCData, PyCField, StgInfo, StgInfoFlags}; use crate::builtins::{PyList, PyStr, PyTuple, PyType, PyTypeRef, PyUtf8Str}; -use crate::common::wtf8::Wtf8Buf; use crate::convert::ToPyObject; use crate::function::{FuncArgs, OptionalArg, PySetterValue}; use crate::protocol::{BufferDescriptor, PyBuffer, PyNumberMethods}; @@ -713,7 +712,7 @@ impl PyCStructure { self_obj: &Py, type_obj: &Py, args: &[PyObjectRef], - kwargs: &indexmap::IndexMap, + kwargs: &crate::function::KwArgsMap, index: usize, vm: &VirtualMachine, ) -> PyResult { diff --git a/crates/vm/src/stdlib/_ctypes/union.rs b/crates/vm/src/stdlib/_ctypes/union.rs index 727ad0118ad..8fe2e8348a5 100644 --- a/crates/vm/src/stdlib/_ctypes/union.rs +++ b/crates/vm/src/stdlib/_ctypes/union.rs @@ -1,7 +1,6 @@ use super::base::{CDATA_BUFFER_METHODS, StgInfoFlags}; use super::{PyCData, PyCField, StgInfo}; use crate::builtins::{PyList, PyStr, PyTuple, PyType, PyTypeRef, PyUtf8Str}; -use crate::common::wtf8::Wtf8Buf; use crate::convert::ToPyObject; use crate::function::{ArgBytesLike, FuncArgs, OptionalArg, PySetterValue}; use crate::protocol::{BufferDescriptor, PyBuffer}; @@ -582,7 +581,7 @@ impl PyCUnion { self_obj: &Py, type_obj: &Py, args: &[PyObjectRef], - kwargs: &indexmap::IndexMap, + kwargs: &crate::function::KwArgsMap, index: usize, vm: &VirtualMachine, ) -> PyResult { diff --git a/crates/vm/src/stdlib/_functools.rs b/crates/vm/src/stdlib/_functools.rs index 9b49e564562..944a2e8abdb 100644 --- a/crates/vm/src/stdlib/_functools.rs +++ b/crates/vm/src/stdlib/_functools.rs @@ -15,7 +15,6 @@ mod _functools { recursion::ReprGuard, types::{Callable, Constructor, GetDescriptor, Representable}, }; - use indexmap::IndexMap; use rustpython_common::wtf8::Wtf8Buf; #[derive(FromArgs)] @@ -432,7 +431,7 @@ mod _functools { combined_args.extend(new_args_iter.cloned()); // Merge keywords from self.keywords and args.kwargs - let mut final_kwargs = IndexMap::new(); + let mut final_kwargs = crate::function::KwArgsMap::default(); // Add keywords from self.keywords for (key, value) in &*keywords { diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index ab1be4297ec..80085a82290 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -4078,7 +4078,10 @@ mod _io { vm.new_runtime_error(format!("reentrant call inside {type_name}.__repr__")) ); }; - let Some(data) = zelf.data.lock() else { + // Detach while blocked, like `lock_opt`: another thread can be + // stopped holding this mutex, and blocking on it while attached + // would leave no safepoint for that stop to complete at. + let Some(data) = zelf.data.lock_wrapped(|do_lock| vm.allow_threads(do_lock)) else { // Reentrant call return Ok(vm.ctx.new_str(Wtf8Buf::from(format!("<{type_name}>")))); }; diff --git a/crates/vm/src/stdlib/_signal.rs b/crates/vm/src/stdlib/_signal.rs index 5879f877676..5abfd327553 100644 --- a/crates/vm/src/stdlib/_signal.rs +++ b/crates/vm/src/stdlib/_signal.rs @@ -177,7 +177,9 @@ pub(crate) mod _signal { module: &Py, vm: &VirtualMachine, ) { - if vm.state.config.settings.install_signal_handlers { + // Process-global signal disposition is owned by the main interpreter only. + // Subinterpreters (PEP 734) must not reinstall SIGINT / probe handlers. + if vm.state.is_main_interpreter() && vm.state.config.settings.install_signal_handlers { let sig_dfl = vm.new_pyobj(SIG_DFL as u8); let sig_ign = vm.new_pyobj(SIG_IGN as u8); diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index 377c68dca74..de942f947d3 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -796,9 +796,8 @@ pub(crate) mod _thread { } #[pyfunction] - fn _is_main_interpreter() -> bool { - // RustPython only has one interpreter - true + fn _is_main_interpreter(vm: &VirtualMachine) -> bool { + vm.state.is_main_interpreter() } /// Initialize the main thread ident. Should be called once at interpreter startup. @@ -1194,8 +1193,8 @@ pub(crate) mod _thread { { use core::sync::atomic::Ordering; let current_ident = get_ident(); - vm.state.stop_the_world.stop_the_world(vm); - scopeguard::defer! { vm.state.stop_the_world.start_the_world(vm); } + vm.state.stop_the_world.stop_the_world(&vm.state); + scopeguard::defer! { vm.state.stop_the_world.start_the_world(&vm.state); } let registry = vm.state.thread_frames.lock(); registry .iter() @@ -1218,26 +1217,10 @@ pub(crate) mod _thread { let iframe_ptr = slot.top_iframe.load(Ordering::Relaxed) as *const crate::frame::InterpreterFrame; if !iframe_ptr.is_null() { - // Materialize the entire frame chain and link - // retained_back so f_back works after STW ends. - let mut cur = iframe_ptr; - let mut child_fo: Option> = - None; - while !cur.is_null() { - let iframe = unsafe { &*cur }; - let fo = iframe.materialize(vm).to_owned(); - if let Some(child) = child_fo.take() { - let mut guard = child.iframe().cold().retained_back.lock(); - if guard.is_none() { - *guard = Some(fo.clone()); - } - } - child_fo = Some(fo); - cur = iframe.previous(); - } let iframe = unsafe { &*iframe_ptr }; - let fo = iframe.materialize(vm); - Some((*id, fo.to_owned())) + // SAFETY: world stopped -> owning thread parked. + let fo = unsafe { iframe.materialize_detached_chain(vm) }; + Some((*id, fo)) } else { None } @@ -1250,8 +1233,8 @@ pub(crate) mod _thread { { use core::sync::atomic::Ordering; let current_ident = get_ident(); - vm.state.stop_the_world.stop_the_world(vm); - scopeguard::defer! { vm.state.stop_the_world.start_the_world(vm); } + vm.state.stop_the_world.stop_the_world(&vm.state); + scopeguard::defer! { vm.state.stop_the_world.start_the_world(&vm.state); } let registry = vm.state.thread_frames.lock(); registry .iter() @@ -1267,24 +1250,10 @@ pub(crate) mod _thread { let iframe_ptr = slot.top_iframe.load(Ordering::Relaxed) as *const crate::frame::InterpreterFrame; if !iframe_ptr.is_null() { - let mut cur = iframe_ptr; - let mut child_fo: Option> = - None; - while !cur.is_null() { - let iframe = unsafe { &*cur }; - let fo = iframe.materialize(vm).to_owned(); - if let Some(child) = child_fo.take() { - let mut guard = child.iframe().cold().retained_back.lock(); - if guard.is_none() { - *guard = Some(fo.clone()); - } - } - child_fo = Some(fo); - cur = iframe.previous(); - } let iframe = unsafe { &*iframe_ptr }; - let fo = iframe.materialize(vm); - Some((*id, fo.to_owned())) + // SAFETY: world stopped -> owning thread parked. + let fo = unsafe { iframe.materialize_detached_chain(vm) }; + Some((*id, fo)) } else { // Fall back to frames stack for FrameObject-only path let frames = slot.frames.lock(); @@ -1386,6 +1355,19 @@ pub(crate) mod _thread { } } + /// Take a thread handle's completion mutex, detaching first. + /// + /// A joiner holds this mutex across its `allow_threads` wait, so it can + /// still hold it when stop-the-world stops it. An attached thread that + /// blocked on it would never reach a safepoint, so the stop could never + /// complete and the holder would never be resumed to release it. + fn lock_done<'a>( + lock: &'a parking_lot::Mutex, + vm: &VirtualMachine, + ) -> parking_lot::MutexGuard<'a, bool> { + vm.allow_threads(|| lock.lock()) + } + /// Reset a parking_lot::Mutex to unlocked state after fork. #[cfg(all(unix, feature = "host_env"))] fn reinit_parking_lot_mutex(mutex: &parking_lot::Mutex) { @@ -1468,7 +1450,7 @@ pub(crate) mod _thread { // Wait for thread completion using Condvar (supports timeout) // Loop to handle spurious wakeups let (lock, cvar) = &**done_event; - let mut done = lock.lock(); + let mut done = lock_done(lock, vm); // ThreadHandle_join semantics: self-join/finalizing checks // apply only while target thread has not reported it is exiting yet. @@ -1528,7 +1510,7 @@ pub(crate) mod _thread { drop(inner_guard); // Wait on done_event let (lock, cvar) = &**done_event; - let mut done = lock.lock(); + let mut done = lock_done(lock, vm); while !*done { vm.allow_threads(|| cvar.wait(&mut done)); } @@ -1590,7 +1572,7 @@ pub(crate) mod _thread { remove_from_shutdown_handles(vm, inner, done_event); let (lock, cvar) = &**done_event; - *lock.lock() = true; + *lock_done(lock, vm) = true; cvar.notify_all(); Ok(()) } @@ -1660,7 +1642,7 @@ pub(crate) mod _thread { // before returning True. let done = { let (lock, _) = &*self.done_event; - *lock.lock() + *lock_done(lock, vm) }; if !done { return Ok(false); @@ -1856,7 +1838,7 @@ pub(crate) mod _thread { // Starting a handle always resets the completion event. { let (done_lock, _) = &*handle.done_event; - *done_lock.lock() = false; + *lock_done(done_lock, vm) = false; } // Add non-daemon threads to shutdown registry so _shutdown() will wait for them @@ -1938,7 +1920,7 @@ pub(crate) mod _thread { // This must be LAST to ensure all cleanup is complete before join() returns { let (lock, cvar) = &*done_event_for_cleanup; - *lock.lock() = true; + *lock_done(lock, vm) = true; cvar.notify_all(); } } @@ -1973,7 +1955,7 @@ pub(crate) mod _thread { } { let (done_lock, done_cvar) = &*handle.done_event; - *done_lock.lock() = true; + *lock_done(done_lock, vm) = true; done_cvar.notify_all(); } if !daemon { diff --git a/crates/vm/src/stdlib/_winapi.rs b/crates/vm/src/stdlib/_winapi.rs index 0d54530d4b2..34e7b897e12 100644 --- a/crates/vm/src/stdlib/_winapi.rs +++ b/crates/vm/src/stdlib/_winapi.rs @@ -8,7 +8,7 @@ mod _winapi { use crate::{ Py, PyObjectRef, PyPayload, PyResult, TryFromObject, VirtualMachine, builtins::PyStrRef, - common::lock::PyMutex, + common::lock::{PyMutex, PyMutexGuard}, convert::ToPyException, function::{ArgMapping, ArgSequence, OptionalArg}, types::Constructor, @@ -566,9 +566,18 @@ mod _winapi { .map_err(|e| e.to_pyexception(vm)) } + /// Take `inner`, detaching while blocked. + /// + /// `GetOverlappedResult` holds this mutex across its `allow_threads` + /// wait, so a stopped thread can still be holding it. Blocking on it + /// while attached would leave no safepoint for that stop to complete at. + fn lock_inner(&self, vm: &VirtualMachine) -> PyMutexGuard<'_, host_overlapped::Operation> { + vm.allow_threads(|| self.inner.lock()) + } + #[pymethod] fn GetOverlappedResult(&self, wait: bool, vm: &VirtualMachine) -> PyResult<(u32, u32)> { - let mut inner = self.inner.lock(); + let mut inner = self.lock_inner(vm); vm.allow_threads(|| inner.get_result(wait)) .map(|result| (result.transferred, result.error)) .map_err(|e| e.to_pyexception(vm)) @@ -576,7 +585,7 @@ mod _winapi { #[pymethod] fn getbuffer(&self, vm: &VirtualMachine) -> PyResult> { - let inner = self.inner.lock(); + let inner = self.lock_inner(vm); if !inner.is_completed() { return Err(vm.new_value_error( "can't get read buffer before GetOverlappedResult() signals the operation completed", @@ -589,13 +598,13 @@ mod _winapi { #[pymethod] fn cancel(&self, vm: &VirtualMachine) -> PyResult<()> { - let mut inner = self.inner.lock(); + let mut inner = self.lock_inner(vm); inner.cancel().map_err(|e| e.to_pyexception(vm)) } #[pygetset] - fn event(&self) -> isize { - let inner = self.inner.lock(); + fn event(&self, vm: &VirtualMachine) -> isize { + let inner = self.lock_inner(vm); inner.event() as isize } } diff --git a/crates/vm/src/stdlib/gc.rs b/crates/vm/src/stdlib/gc.rs index b0007b4c867..af861862edb 100644 --- a/crates/vm/src/stdlib/gc.rs +++ b/crates/vm/src/stdlib/gc.rs @@ -23,20 +23,20 @@ mod gc { /// Enable automatic garbage collection. #[pyfunction] - fn enable() { - gc_state::gc_state().enable(); + fn enable(vm: &VirtualMachine) { + vm.state.gc.enable(); } /// Disable automatic garbage collection. #[pyfunction] - fn disable() { - gc_state::gc_state().disable(); + fn disable(vm: &VirtualMachine) { + vm.state.gc.disable(); } /// Return true if automatic gc is enabled. #[pyfunction] - fn isenabled() -> bool { - gc_state::gc_state().is_enabled() + fn isenabled(vm: &VirtualMachine) -> bool { + vm.state.gc.is_enabled() } /// Run a garbage collection. Returns the number of unreachable objects found. @@ -58,15 +58,14 @@ mod gc { invoke_callbacks(vm, "start", generation_num as usize, &Default::default()); // Manual gc.collect() should run even if GC is disabled - let gc = gc_state::gc_state(); + let gc = &vm.state.gc; let result = gc.collect_force(generation_num as usize); - // Move objects from gc_state.garbage to vm.ctx.gc_garbage (for DEBUG_SAVEALL) + // Publish what the collection saved as gc.garbage (for DEBUG_SAVEALL) { let mut state_garbage = gc.garbage.lock(); if !state_garbage.is_empty() { - let py_garbage = &vm.ctx.gc_garbage; - let mut garbage_vec = py_garbage.borrow_vec_mut(); + let mut garbage_vec = gc.py_garbage.borrow_vec_mut(); for obj in state_garbage.drain(..) { garbage_vec.push(obj); } @@ -82,7 +81,7 @@ mod gc { /// Return the current collection thresholds as a tuple. #[pyfunction] fn get_threshold(vm: &VirtualMachine) -> PyObjectRef { - let (t0, t1, t2) = gc_state::gc_state().get_threshold(); + let (t0, t1, t2) = vm.state.gc.get_threshold(); vm.ctx .new_tuple(vec![ vm.ctx.new_int(t0).into(), @@ -94,8 +93,13 @@ mod gc { /// Set the collection thresholds. #[pyfunction] - fn set_threshold(threshold0: u32, threshold1: OptionalArg, threshold2: OptionalArg) { - gc_state::gc_state().set_threshold( + fn set_threshold( + threshold0: u32, + threshold1: OptionalArg, + threshold2: OptionalArg, + vm: &VirtualMachine, + ) { + vm.state.gc.set_threshold( threshold0, threshold1.into_option(), threshold2.into_option(), @@ -117,20 +121,22 @@ mod gc { /// Return the current debugging flags. #[pyfunction] - fn get_debug() -> u32 { - gc_state::gc_state().get_debug().bits() + fn get_debug(vm: &VirtualMachine) -> u32 { + vm.state.gc.get_debug().bits() } /// Set the debugging flags. #[pyfunction] - fn set_debug(flags: u32) { - gc_state::gc_state().set_debug(gc_state::GcDebugFlags::from_bits_truncate(flags)); + fn set_debug(flags: u32, vm: &VirtualMachine) { + vm.state + .gc + .set_debug(gc_state::GcDebugFlags::from_bits_truncate(flags)); } /// Return a list of per-generation gc stats. #[pyfunction] fn get_stats(vm: &VirtualMachine) -> PyResult { - let stats = gc_state::gc_state().get_stats(); + let stats = vm.state.gc.get_stats(); let mut result = Vec::with_capacity(3); for stat in &stats { @@ -165,7 +171,7 @@ mod gc { { return Err(vm.new_value_error(format!("generation must be in range(0, 3), not {g}"))); } - let objects = gc_state::gc_state().get_objects(generation_opt); + let objects = vm.state.gc.get_objects(generation_opt); Ok(vm.ctx.new_list(objects)) } @@ -208,7 +214,7 @@ mod gc { let mut result = Vec::new(); // Scan all tracked objects across all generations - let all_objects = gc_state::gc_state().get_objects(None); + let all_objects = vm.state.gc.get_objects(None); for obj in all_objects { let obj_ptr = obj.as_ref() as *const crate::PyObject as usize; if stack_frames.contains(&obj_ptr) { @@ -241,14 +247,14 @@ mod gc { /// Freeze all objects tracked by gc. #[pyfunction] - fn freeze() { - gc_state::gc_state().freeze(); + fn freeze(vm: &VirtualMachine) { + vm.state.gc.freeze(); } /// Unfreeze all objects in the permanent generation. #[pyfunction] - fn unfreeze() { - gc_state::gc_state().unfreeze(); + fn unfreeze(vm: &VirtualMachine) { + vm.state.gc.unfreeze(); } /// Return the number of objects in the permanent generation. @@ -260,13 +266,13 @@ mod gc { /// gc.garbage - list of uncollectable objects #[pyattr] fn garbage(vm: &VirtualMachine) -> PyListRef { - vm.ctx.gc_garbage.clone() + vm.state.gc.py_garbage.clone() } /// gc.callbacks - list of callbacks to be invoked #[pyattr] fn callbacks(vm: &VirtualMachine) -> PyListRef { - vm.ctx.gc_callbacks.clone() + vm.state.gc.py_callbacks.clone() } /// Helper function to invoke GC callbacks @@ -276,7 +282,7 @@ mod gc { generation: usize, result: &gc_state::CollectResult, ) { - let callbacks_list = &vm.ctx.gc_callbacks; + let callbacks_list = &vm.state.gc.py_callbacks; let callbacks: Vec = callbacks_list.borrow_vec().to_vec(); if callbacks.is_empty() { return; diff --git a/crates/vm/src/stdlib/posix.rs b/crates/vm/src/stdlib/posix.rs index 6b91950e907..f4d5a70db34 100644 --- a/crates/vm/src/stdlib/posix.rs +++ b/crates/vm/src/stdlib/posix.rs @@ -626,10 +626,17 @@ pub mod module { crate::stdlib::_imp::acquire_imp_lock_for_fork(vm); #[cfg(feature = "threading")] - vm.state.stop_the_world.stop_the_world(vm); + vm.state.stop_the_world.stop_the_world(&vm.state); } fn py_os_after_fork_child(vm: &VirtualMachine) { + // The interpreter registry is reachable from every thread, so repair it + // before anything enumerates interpreters. + #[cfg(all(unix, feature = "threading"))] + unsafe { + crate::vm::runtime::reinit_after_fork() + }; + #[cfg(feature = "threading")] vm.state.stop_the_world.reset_after_fork(); @@ -639,6 +646,12 @@ pub mod module { #[cfg(feature = "threading")] reinit_locks_after_fork(vm); + // The collector stops every interpreter, so interpreters other than the + // forking one must be repaired too; otherwise the child's first + // collection waits for threads that did not survive the fork. + #[cfg(all(unix, feature = "threading"))] + reinit_other_interpreters_after_fork(vm); + // Reinit per-object IO buffer locks on std streams. // BufferedReader/Writer/TextIOWrapper use PyThreadMutex which can be // held by dead parent threads, causing deadlocks on any IO in the child. @@ -719,17 +732,67 @@ pub mod module { // Codec registry RwLock vm.state.codec_registry.reinit_after_fork(); - // GC state (multiple Mutex + RwLock) + // GC state (multiple Mutex + RwLock), shared lists and this + // interpreter's own policy state. crate::gc_state::gc_state().reinit_after_fork(); + vm.state.gc.reinit_after_fork(); // Import lock (RawReentrantMutex) crate::stdlib::_imp::reinit_imp_lock_after_fork(); } } + /// Repair every live interpreter other than the forking one after `fork()`. + /// + /// Only the forking thread survives, so each other interpreter is left with + /// slots for threads that no longer exist (still ATTACHED if they were + /// running bytecode) and possibly locks or stop-the-world flags held by + /// them. Since a collection stops all interpreters, that state would hang + /// the child's first collection. + /// + /// # Safety + /// Must only be called after `fork()` in the child, when no other threads exist. + #[cfg(all(unix, feature = "threading"))] + fn reinit_other_interpreters_after_fork(vm: &VirtualMachine) { + use rustpython_common::lock::reinit_mutex_after_fork; + + for state in crate::vm::runtime::live_interpreter_states() { + if state.interpreter_id == vm.state.interpreter_id { + continue; + } + + unsafe { + reinit_mutex_after_fork(&state.before_forkers); + reinit_mutex_after_fork(&state.after_forkers_child); + reinit_mutex_after_fork(&state.after_forkers_parent); + reinit_mutex_after_fork(&state.atexit_funcs); + reinit_mutex_after_fork(&state.global_trace_func); + reinit_mutex_after_fork(&state.global_profile_func); + reinit_mutex_after_fork(&state.type_mutex); + reinit_mutex_after_fork(&state.monitoring); + reinit_mutex_after_fork(&state.thread_frames); + reinit_mutex_after_fork(&state.thread_handles); + reinit_mutex_after_fork(&state.shutdown_handles); + + state.codec_registry.reinit_after_fork(); + state.gc.reinit_after_fork(); + } + + state.stop_the_world.reset_after_fork(); + + // Every thread registered here belongs to the parent, including any + // slot the forking thread itself registered before the fork. + state.thread_frames.lock().clear(); + state.thread_handles.lock().clear(); + state.shutdown_handles.lock().clear(); + } + + crate::vm::thread::purge_other_interpreter_slots_after_fork(vm.state.interpreter_id); + } + fn py_os_after_fork_parent(vm: &VirtualMachine) { #[cfg(feature = "threading")] - vm.state.stop_the_world.start_the_world(vm); + vm.state.stop_the_world.start_the_world(&vm.state); #[cfg(feature = "threading")] crate::stdlib::_imp::release_imp_lock_after_fork_parent(); diff --git a/crates/vm/src/stdlib/sys.rs b/crates/vm/src/stdlib/sys.rs index 66257806e22..5ee36b450d4 100644 --- a/crates/vm/src/stdlib/sys.rs +++ b/crates/vm/src/stdlib/sys.rs @@ -669,7 +669,8 @@ pub mod sys { "_multiarch" => ctx.new_str(multiarch()), "version" => PyVersionInfo::from_data(VersionInfoData::IMPLEMENTATION, vm), "hexversion" => ctx.new_int(version::VERSION_HEX_IMPL), - "supports_isolated_interpreters" => ctx.new_bool(false), + "supports_isolated_interpreters" => + ctx.new_bool(crate::vm::runtime::SUPPORTS_ISOLATED_INTERPRETERS), }) } diff --git a/crates/vm/src/stdlib/sys/monitoring.rs b/crates/vm/src/stdlib/sys/monitoring.rs index 7e47185bbe5..a4be3ba5a5a 100644 --- a/crates/vm/src/stdlib/sys/monitoring.rs +++ b/crates/vm/src/stdlib/sys/monitoring.rs @@ -529,7 +529,7 @@ fn update_events_mask(vm: &VirtualMachine, state: &MonitoringState) { // own local events), preventing e.g. INSTRUCTION from being applied to // unrelated code objects. // Re-instrument all frames on the current thread's stack, including - // stack-allocated iframes (with_iframe path) that have no FrameObject. + // data stack frames that have no FrameObject. { let mut cur = crate::vm::thread::get_current_frame(); while !cur.is_null() { diff --git a/crates/vm/src/vm/context.rs b/crates/vm/src/vm/context.rs index 9a545663576..71d017c6d1c 100644 --- a/crates/vm/src/vm/context.rs +++ b/crates/vm/src/vm/context.rs @@ -54,10 +54,7 @@ pub struct Context { pub(crate) string_pool: StringPool, pub(crate) slot_new_wrapper: PyMethodDef, pub names: ConstName, - // GC module state (callbacks and garbage lists) - pub gc_callbacks: PyListRef, - pub gc_garbage: PyListRef, } macro_rules! declare_const_name { @@ -363,8 +360,6 @@ impl Context { let empty_bytes = create_object(PyBytes::from(Vec::new()), types.bytes_type); // GC callbacks and garbage lists - let gc_callbacks = PyRef::new_ref(PyList::default(), types.list_type.to_owned(), None); - let gc_garbage = PyRef::new_ref(PyList::default(), types.list_type.to_owned(), None); Self { true_value, @@ -387,9 +382,6 @@ impl Context { string_pool, slot_new_wrapper, names, - - gc_callbacks, - gc_garbage, } } @@ -442,6 +434,14 @@ impl Context { PyInt::from(i).into_ref(self) } + /// Borrow a cached small integer whose lifetime is tied to this context. + #[inline(always)] + pub(crate) fn cached_int(&self, i: i32) -> &PyIntRef { + debug_assert!(Self::INT_CACHE_POOL_RANGE.contains(&i)); + let inner_idx = (i - Self::INT_CACHE_POOL_MIN) as usize; + &self.int_cache_pool[inner_idx] + } + #[inline] pub fn new_bigint(&self, i: &BigInt) -> PyIntRef { if let Some(i) = i.to_i32() diff --git a/crates/vm/src/vm/interpreter.rs b/crates/vm/src/vm/interpreter.rs index f456e8587ea..c538eb32ec8 100644 --- a/crates/vm/src/vm/interpreter.rs +++ b/crates/vm/src/vm/interpreter.rs @@ -1,6 +1,11 @@ #[cfg(feature = "threading")] use super::StopTheWorldState; -use super::{Context, PyConfig, PyGlobalState, VirtualMachine, setting::Settings, thread}; +use super::{ + Context, PyConfig, PyGlobalState, VirtualMachine, + runtime::{self, InterpreterWhence}, + setting::Settings, + thread, +}; use crate::{ PyResult, builtins, common::rc::PyRc, frozen::FrozenModule, getpath, py_freeze, stdlib::atexit, vm::PyBaseExceptionRef, @@ -36,18 +41,34 @@ pub struct InterpreterBuilder { init_hooks: Vec, } -/// Private helper to initialize a VM with settings, context, and custom initialization. -fn initialize_main_vm( +/// Options for constructing a main or sub-interpreter VM. +struct InitializeVmOpts<'a> { settings: Settings, ctx: PyRc, module_defs: Vec<&'static builtins::PyModuleDef>, frozen_modules: Vec<(&'static str, FrozenModule)>, init_hooks: Vec, - init: F, -) -> (VirtualMachine, PyRc) + is_main: bool, + whence: InterpreterWhence, + /// When `Some`, reuse parent module_defs/frozen/config seeds for a subinterpreter. + parent_state: Option<&'a PyGlobalState>, +} + +/// Shared constructor for main and sub-interpreters. +fn initialize_vm(opts: InitializeVmOpts<'_>, init: F) -> (VirtualMachine, PyRc) where F: FnOnce(&mut VirtualMachine), { + let InitializeVmOpts { + settings, + ctx, + module_defs, + frozen_modules, + init_hooks, + is_main, + whence, + parent_state, + } = opts; use crate::codecs::CodecsRegistry; use crate::common::hash::HashSecret; use crate::common::lock::PyMutex; @@ -55,55 +76,85 @@ where use core::sync::atomic::{AtomicBool, AtomicU64}; use crossbeam_utils::atomic::AtomicCell; - let paths = getpath::init_path_config(&settings); - let config = PyConfig::new(settings, paths); + let (config, all_module_defs, frozen, hash_secret, int_max_str_digits) = + if let Some(parent) = parent_state { + // Subinterpreter: clone config and module tables from parent, fresh runtime state. + let int_max_str_digits = AtomicCell::new(parent.int_max_str_digits.load()); + ( + parent.config.clone(), + parent.module_defs.clone(), + parent.frozen.clone(), + parent.hash_secret, + int_max_str_digits, + ) + } else { + let paths = getpath::init_path_config(&settings); + let config = PyConfig::new(settings, paths); - // Build module_defs map from builtin modules + additional modules - let mut all_module_defs: BTreeMap<&'static str, &'static builtins::PyModuleDef> = - crate::stdlib::builtin_module_defs(&ctx) - .into_iter() - .chain(module_defs) - .map(|def| (def.name.as_str(), def)) - .collect(); + // Build module_defs map from builtin modules + additional modules + let mut all_module_defs: BTreeMap<&'static str, &'static builtins::PyModuleDef> = + crate::stdlib::builtin_module_defs(&ctx) + .into_iter() + .chain(module_defs) + .map(|def| (def.name.as_str(), def)) + .collect(); - // Register sysconfigdata under platform-specific name as well - if let Some(&sysconfigdata_def) = all_module_defs.get("_sysconfigdata") { - use std::sync::OnceLock; - static SYSCONFIGDATA_NAME: OnceLock<&'static str> = OnceLock::new(); - let leaked_name = *SYSCONFIGDATA_NAME.get_or_init(|| { - let name = crate::stdlib::sys::sysconfigdata_name(); - Box::leak(name.into_boxed_str()) - }); - all_module_defs.insert(leaked_name, sysconfigdata_def); - } + // Register sysconfigdata under platform-specific name as well + if let Some(&sysconfigdata_def) = all_module_defs.get("_sysconfigdata") { + use std::sync::OnceLock; + static SYSCONFIGDATA_NAME: OnceLock<&'static str> = OnceLock::new(); + let leaked_name = *SYSCONFIGDATA_NAME.get_or_init(|| { + let name = crate::stdlib::sys::sysconfigdata_name(); + Box::leak(name.into_boxed_str()) + }); + all_module_defs.insert(leaked_name, sysconfigdata_def); + } - // Create hash secret - let seed = match config.settings.hash_seed { - Some(seed) => seed, - None => super::process_hash_secret_seed(), - }; - let hash_secret = HashSecret::new(seed); + let seed = match config.settings.hash_seed { + Some(seed) => seed, + None => super::process_hash_secret_seed(), + }; + let hash_secret = HashSecret::new(seed); + + let int_max_str_digits = AtomicCell::new(match config.settings.int_max_str_digits { + -1 => 4300, + other => other, + } as usize); + + let mut frozen: std::collections::HashMap< + &'static str, + FrozenModule, + rapidhash::quality::RandomState, + > = core_frozen_inits().collect(); + frozen.extend(frozen_modules); + + ( + config, + all_module_defs, + frozen, + hash_secret, + int_max_str_digits, + ) + }; - // Create codec registry and warnings state + // Per-interpreter ephemeral state (must not be shared across interpreters). let codec_registry = CodecsRegistry::new(&ctx); let warnings = WarningsState::init_state(&ctx); - // Create int_max_str_digits - let int_max_str_digits = AtomicCell::new(match config.settings.int_max_str_digits { - -1 => 4300, - other => other, - } as usize); - - // Initialize frozen modules (core + user-provided) - let mut frozen: std::collections::HashMap< - &'static str, - FrozenModule, - rapidhash::quality::RandomState, - > = core_frozen_inits().collect(); - frozen.extend(frozen_modules); - - // Create PyGlobalState + let interpreter_id = runtime::alloc_interpreter_id(); + + // Process main OS thread identity is process-global; subinterpreters inherit + // it from the parent so `is_main_thread()` stays correct when running on the + // main OS thread under a subinterpreter. + #[cfg(feature = "threading")] + let main_thread_ident = AtomicCell::new(parent_state.map_or(0, |p| p.main_thread_ident.load())); + + // Create PyGlobalState (≈ PyInterpreterState) let global_state = PyRc::new(PyGlobalState { + gc: crate::gc_state::GcInterpreterState::new(&ctx), + interpreter_id, + whence, + is_main, config, module_defs: all_module_defs, frozen, @@ -124,7 +175,7 @@ where global_profile_func: PyMutex::default(), type_mutex: PyMutex::default(), #[cfg(feature = "threading")] - main_thread_ident: AtomicCell::new(0), + main_thread_ident, #[cfg(feature = "threading")] thread_frames: parking_lot::Mutex::new(std::collections::HashMap::new()), #[cfg(feature = "threading")] @@ -150,6 +201,13 @@ where // Call custom init function (can mutate vm.state) init(&mut vm); + // Register before `initialize()` runs any Python: it allocates GC-tracked + // objects, so a collection on another thread has to be able to stop this + // interpreter while that happens. It cannot be registered earlier — the + // hooks above take `PyRc::get_mut` on the state, which fails once the + // registry holds a weak reference to it. + runtime::register_interpreter(&vm.state); + // `initialize()` runs Python bytecode directly (e.g. importing `codecs` // and `encodings`) before any `enter_vm` scope exists, so attach this // thread for the duration so type cache reads see it as ATTACHED. @@ -271,12 +329,17 @@ impl InterpreterBuilder { /// This consumes the configuration and returns a fully initialized Interpreter. #[must_use] pub fn build(self) -> Interpreter { - let (vm, global_state) = initialize_main_vm( - self.settings, - self.ctx, - self.module_defs, - self.frozen_modules, - self.init_hooks, + let (vm, global_state) = initialize_vm( + InitializeVmOpts { + settings: self.settings, + ctx: self.ctx, + module_defs: self.module_defs, + frozen_modules: self.frozen_modules, + init_hooks: self.init_hooks, + is_main: true, + whence: InterpreterWhence::Runtime, + parent_state: None, + }, |_| {}, // No additional init needed ); Interpreter { global_state, vm } @@ -295,7 +358,13 @@ impl Default for InterpreterBuilder { } } -/// The general interface for the VM +/// One isolated Python interpreter in the process (≈ CPython `PyInterpreterState` + main tstate). +/// +/// Historically RustPython exposed a single process-level `Interpreter`. For PEP 734 +/// (multiple interpreters / subinterpreters) this type is now the owned handle for +/// **one** interpreter. Use [`Interpreter::create_subinterpreter`] to create additional +/// isolated interpreters that share the process-wide type context but not modules or +/// `PyGlobalState`. /// /// # Examples /// Runs a simple embedded hello world program. @@ -350,17 +419,113 @@ impl Interpreter { where F: FnOnce(&mut VirtualMachine), { - let (vm, global_state) = initialize_main_vm( - settings, - Context::genesis().clone(), - Vec::new(), // No module_defs - Vec::new(), // No frozen_modules - Vec::new(), // No init_hooks + let (vm, global_state) = initialize_vm( + InitializeVmOpts { + settings, + ctx: Context::genesis().clone(), + module_defs: Vec::new(), + frozen_modules: Vec::new(), + init_hooks: Vec::new(), + is_main: true, + whence: InterpreterWhence::Runtime, + parent_state: None, + }, init, ); Self { global_state, vm } } + /// Process-global interpreter id (main is [`super::MAIN_INTERPRETER_ID`]). + #[inline] + #[must_use] + pub fn id(&self) -> i64 { + self.global_state.interpreter_id + } + + /// Where this interpreter was created. + #[inline] + #[must_use] + pub fn whence(&self) -> InterpreterWhence { + self.global_state.whence + } + + /// Whether this is a top-level interpreter rather than a subinterpreter. + /// + /// Every top-level interpreter answers `true`; for *the* process main, use + /// [`Interpreter::is_process_main`]. + #[inline] + #[must_use] + pub fn is_main(&self) -> bool { + self.global_state.is_main + } + + /// Whether this is the PEP 734 process main interpreter (`get_main()`). + /// + /// Unlike [`Interpreter::is_main`], which is set for every top-level + /// interpreter, this is true for only the single first-registered main. + #[inline] + #[must_use] + pub fn is_process_main(&self) -> bool { + runtime::main_interpreter_id() == Some(self.id()) + } + + /// Create a subinterpreter and hand ownership to the runtime, returning its + /// id. The runtime keeps it alive until [`runtime::take_owned_interpreter`]. + /// + /// This is the shape `_interpreters.create()` will use: Python receives an + /// id, not an owned handle. + #[cfg(feature = "threading")] + #[must_use] + pub fn create_owned_subinterpreter(&self) -> i64 { + runtime::store_owned_interpreter(self.create_subinterpreter()) + } + + /// Create an isolated subinterpreter sharing this interpreter's type context + /// (`Context`) and module definitions, but with its own `sys.modules`, + /// builtins module instance, thread registry, and stop-the-world state. + /// + /// This is the Rust-side foundation for PEP 734 / `_interpreters.create()`. + /// It does not yet expose a Python module API. + /// + /// May be called while the parent is entered (matching CPython, where + /// `_interpreters.create()` runs under the main interpreter). When the + /// calling thread is currently attached to a VM, that attachment is + /// temporarily saved so the subinterpreter can bootstrap as an outermost + /// enter (correct thread-slot / stop-the-world state). + #[must_use] + pub fn create_subinterpreter(&self) -> Self { + // Suspend the caller's current VM attachment (if any) for the duration + // of subinterpreter initialization. Nested bootstrap would otherwise + // swap `CURRENT_THREAD_SLOT` to the new interpreter while leaving the + // outer interpreter's attach state inconsistent. Always restore, even + // if initialization panics. + #[cfg(feature = "threading")] + let _restore_parent = { + let saved = thread::current_vm_is_set().then(thread::save_current_thread); + scopeguard::guard(saved, |saved| { + if let Some(saved) = saved { + thread::restore_current_thread(saved); + } + }) + }; + + let (vm, global_state) = initialize_vm( + InitializeVmOpts { + // settings unused when parent_state is Some + settings: Settings::default(), + ctx: self.vm.ctx.clone(), + module_defs: Vec::new(), + frozen_modules: Vec::new(), + init_hooks: Vec::new(), + is_main: false, + whence: InterpreterWhence::Stdlib, + parent_state: Some(&self.global_state), + }, + |_| {}, + ); + Self { global_state, vm } + } + /// Run a function with the main virtual machine and return a PyResult of the result. /// /// To enter vm context multiple times or to avoid buffer/exception management, this function is preferred. @@ -456,7 +621,7 @@ impl Interpreter { vm.state.finalizing.store(true, Ordering::Release); // GC pass - collect cycles before module cleanup - crate::gc_state::gc_state().collect_force(2); + vm.state.gc.collect_force(2); // Module finalization: remove modules from sys.modules, GC collect // (while builtins is still available for __del__), then clear module dicts. @@ -582,8 +747,9 @@ fn core_frozen_inits() -> impl Iterator { mod tests { use super::*; use crate::{ - PyObjectRef, + AsObject, PyObjectRef, builtins::{PyStr, int}, + vm::{MAIN_INTERPRETER_ID, runtime}, }; use malachite_bigint::ToBigInt; @@ -608,4 +774,883 @@ mod tests { assert_eq!(value.as_wtf8(), "Hello Hello Hello Hello ") }) } + + /// Main interpreter is marked main with Runtime whence and is registered. + #[test] + fn main_interpreter_identity() { + let main = Interpreter::without_stdlib(Default::default()); + assert!(main.is_main()); + assert_eq!(main.whence(), InterpreterWhence::Runtime); + assert!( + runtime::list_interpreters() + .iter() + .any(|info| info.id == main.id() && info.whence == InterpreterWhence::Runtime) + ); + // When this is the sole sequential main in a quiet process, id is 0; + // under parallel tests the id is still unique and registered. + assert!(main.id() >= MAIN_INTERPRETER_ID); + } + + /// Subinterpreters get distinct ids, Stdlib whence, and appear in the registry. + #[test] + fn create_subinterpreter_registers_distinct_ids() { + let main = Interpreter::without_stdlib(Default::default()); + let sub1 = main.create_subinterpreter(); + let sub2 = main.create_subinterpreter(); + + assert!(main.is_main()); + assert!(!sub1.is_main()); + assert!(!sub2.is_main()); + assert_eq!(sub1.whence(), InterpreterWhence::Stdlib); + assert_eq!(sub2.whence(), InterpreterWhence::Stdlib); + assert_ne!(main.id(), sub1.id()); + assert_ne!(main.id(), sub2.id()); + assert_ne!(sub1.id(), sub2.id()); + + let ids: Vec = runtime::list_interpreters() + .into_iter() + .map(|i| i.id) + .collect(); + assert!(ids.contains(&main.id())); + assert!(ids.contains(&sub1.id())); + assert!(ids.contains(&sub2.id())); + } + + /// An interpreter stays looked-up-able until nothing holds its state. + /// + /// Dropping the handle is not the end of its life: `new_thread()` workers + /// hold their own reference, and a collection in progress holds one for + /// every live interpreter while the world is stopped. So the registry entry + /// goes away eventually rather than at the drop. + fn wait_until_unregistered(id: i64) { + use core::time::Duration; + use std::time::Instant; + + let deadline = Instant::now() + Duration::from_secs(30); + while runtime::lookup_interpreter(id).is_some() { + assert!( + Instant::now() < deadline, + "interpreter {id} still registered long after its last reference" + ); + std::thread::yield_now(); + } + } + + /// A collection snapshots the registry and then reads tracked objects with + /// the interpreters it found parked. An interpreter that registered inside + /// that window would be missing from the snapshot, so nothing would stop it + /// and its bootstrap would run under the scan; registration therefore waits + /// for the stop to end. + #[cfg(feature = "threading")] + #[test] + fn registering_waits_for_an_in_flight_stop() { + use core::time::Duration; + use std::sync::mpsc; + + // Stands in for a collector between its snapshot and its restart. + let admission = runtime::lock_admission_for_stop(); + + let (tx, rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + let interp = Interpreter::without_stdlib(Default::default()); + tx.send(interp.id()).expect("receiver is alive"); + interp + }); + + assert!( + matches!( + rx.recv_timeout(Duration::from_millis(200)), + Err(mpsc::RecvTimeoutError::Timeout) + ), + "an interpreter registered while a stop-the-world was in flight" + ); + + drop(admission); + let id = rx + .recv_timeout(Duration::from_secs(30)) + .expect("registration proceeds once the world restarts"); + assert!(runtime::lookup_interpreter(id).is_some()); + drop(worker.join().expect("worker did not panic")); + wait_until_unregistered(id); + } + + /// Dropping a subinterpreter releases it; main remains. + #[test] + fn drop_subinterpreter_unregisters() { + let main = Interpreter::without_stdlib(Default::default()); + let sub_id = { + let sub = main.create_subinterpreter(); + let id = sub.id(); + assert!(runtime::lookup_interpreter(id).is_some()); + id + }; + wait_until_unregistered(sub_id); + assert!(runtime::lookup_interpreter(main.id()).is_some()); + } + + /// Each interpreter has its own `sys.modules` / builtins module instance. + #[test] + fn subinterpreters_isolate_modules() { + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + + let (main_sys_ptr, main_builtins_ptr, main_ctx_ptr, main_state_ptr) = main.enter(|vm| { + ( + vm.sys_module.as_object() as *const _, + vm.builtins.as_object() as *const _, + PyRc::as_ptr(&vm.ctx), + PyRc::as_ptr(&vm.state), + ) + }); + let (sub_sys_ptr, sub_builtins_ptr, sub_ctx_ptr, sub_state_ptr) = sub.enter(|vm| { + ( + vm.sys_module.as_object() as *const _, + vm.builtins.as_object() as *const _, + PyRc::as_ptr(&vm.ctx), + PyRc::as_ptr(&vm.state), + ) + }); + + assert_ne!(main_sys_ptr, sub_sys_ptr); + assert_ne!(main_builtins_ptr, sub_builtins_ptr); + // Distinct per-interpreter state. + assert_ne!(main_state_ptr, sub_state_ptr); + // Shared process-wide type context (immortal / builtin types). + assert_eq!(main_ctx_ptr, sub_ctx_ptr); + } + + /// Mutations to interpreter-owned modules must not leak between interpreters. + #[test] + fn subinterpreters_behaviorally_isolate_builtins_and_sys_modules() { + const PROBE: &str = "__rustpython_subinterpreter_isolation_probe__"; + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + + main.enter(|vm| { + vm.builtins + .set_attr(PROBE, vm.ctx.new_int(11_i32), vm) + .unwrap(); + vm.sys_module + .get_attr("modules", vm) + .unwrap() + .set_item(PROBE, vm.ctx.new_int(12_i32).into(), vm) + .unwrap(); + }); + + sub.enter(|vm| { + assert!(vm.builtins.get_attr(PROBE, vm).is_err()); + let modules = vm.sys_module.get_attr("modules", vm).unwrap(); + assert!(modules.get_item(PROBE, vm).is_err()); + + vm.builtins + .set_attr(PROBE, vm.ctx.new_int(21_i32), vm) + .unwrap(); + modules + .set_item(PROBE, vm.ctx.new_int(22_i32).into(), vm) + .unwrap(); + }); + + main.enter(|vm| { + let builtin_probe = vm.builtins.get_attr(PROBE, vm).unwrap(); + assert_eq!(*int::get_value(&builtin_probe), 11_i32.to_bigint().unwrap()); + + let module_probe = vm + .sys_module + .get_attr("modules", vm) + .unwrap() + .get_item(PROBE, vm) + .unwrap(); + assert_eq!(*int::get_value(&module_probe), 12_i32.to_bigint().unwrap()); + }); + } + + /// Creating a subinterpreter while the parent is entered must not corrupt + /// the parent's current-VM / thread-slot state. + #[test] + fn create_subinterpreter_while_parent_entered() { + let main = Interpreter::without_stdlib(Default::default()); + main.enter(|vm| { + let before = vm.state.interpreter_id; + let sub = main.create_subinterpreter(); + assert_ne!(sub.id(), before); + // Still the parent after create returns. + assert_eq!(vm.state.interpreter_id, before); + // Can still use the parent VM. + let n: PyObjectRef = vm.ctx.new_int(7_i32).into(); + assert_eq!(int::get_value(&n), &7_i32.to_bigint().unwrap()); + // And the sub is independently usable after parent section. + drop(sub); + }); + } + + /// Sequential enter of main then sub on the same OS thread is safe. + #[test] + fn sequential_enter_main_and_sub() { + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + + main.enter(|vm| { + assert!(vm.state.is_main_interpreter()); + let a: PyObjectRef = vm.ctx.new_int(1_i32).into(); + let b: PyObjectRef = vm.ctx.new_int(2_i32).into(); + let res = vm._add(&a, &b).unwrap(); + assert_eq!(*int::get_value(&res), 3_i32.to_bigint().unwrap()); + }); + sub.enter(|vm| { + assert!(!vm.state.is_main_interpreter()); + let a: PyObjectRef = vm.ctx.new_int(10_i32).into(); + let b: PyObjectRef = vm.ctx.new_int(5_i32).into(); + let res = vm._mul(&a, &b).unwrap(); + assert_eq!(*int::get_value(&res), 50_i32.to_bigint().unwrap()); + }); + // Re-enter main after sub. + main.enter(|vm| { + assert!(vm.state.is_main_interpreter()); + }); + } + + /// Concurrent use of main + subinterpreter on different OS threads. + #[cfg(feature = "threading")] + #[test] + fn concurrent_main_and_subinterpreter_threads() { + use alloc::sync::Arc; + use core::sync::atomic::{AtomicUsize, Ordering}; + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + let counter = Arc::new(AtomicUsize::new(0)); + + let c1 = Arc::clone(&counter); + let h_main = main.enter(|vm| { + let thread_vm = vm.new_thread(); + let c = Arc::clone(&c1); + std::thread::spawn(move || { + thread_vm.run(|vm| { + for _ in 0..100 { + let a: PyObjectRef = vm.ctx.new_int(1_i32).into(); + let b: PyObjectRef = vm.ctx.new_int(1_i32).into(); + let _ = vm._add(&a, &b).unwrap(); + c.fetch_add(1, Ordering::Relaxed); + } + assert!(vm.state.is_main_interpreter()); + }); + }) + }); + + let c2 = Arc::clone(&counter); + let h_sub = sub.enter(|vm| { + let thread_vm = vm.new_thread(); + let c = Arc::clone(&c2); + std::thread::spawn(move || { + thread_vm.run(|vm| { + for _ in 0..100 { + let a: PyObjectRef = vm.ctx.new_int(2_i32).into(); + let b: PyObjectRef = vm.ctx.new_int(3_i32).into(); + let _ = vm._mul(&a, &b).unwrap(); + c.fetch_add(1, Ordering::Relaxed); + } + assert!(!vm.state.is_main_interpreter()); + }); + }) + }); + + h_main.join().expect("main worker panicked"); + h_sub.join().expect("sub worker panicked"); + assert_eq!(counter.load(Ordering::Relaxed), 200); + } + + /// Entering one interpreter must not serialize entry into another interpreter. + #[cfg(feature = "threading")] + #[test] + fn main_and_subinterpreter_run_sections_overlap() { + use alloc::sync::Arc; + use core::time::Duration; + use std::{ + sync::{Condvar, Mutex}, + time::Instant, + }; + + #[derive(Default)] + struct OverlapState { + entered: usize, + release: bool, + } + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + let state = Arc::new((Mutex::new(OverlapState::default()), Condvar::new())); + + let spawn_worker = |interpreter: &Interpreter| { + let state = Arc::clone(&state); + interpreter.enter(|vm| { + let thread_vm = vm.new_thread(); + std::thread::spawn(move || { + thread_vm.run(|vm| { + let a: PyObjectRef = vm.ctx.new_int(20_i32).into(); + let b: PyObjectRef = vm.ctx.new_int(22_i32).into(); + assert_eq!( + *int::get_value(&vm._add(&a, &b).unwrap()), + 42_i32.to_bigint().unwrap() + ); + + let (lock, ready) = &*state; + let mut state = lock.lock().unwrap(); + state.entered += 1; + ready.notify_all(); + while !state.release { + state = ready.wait(state).unwrap(); + } + }); + }) + }) + }; + + let main_worker = spawn_worker(&main); + let sub_worker = spawn_worker(&sub); + + let (lock, ready) = &*state; + let deadline = Instant::now() + Duration::from_secs(30); + let mut state_guard = lock.lock().unwrap(); + while state_guard.entered < 2 { + let now = Instant::now(); + if now >= deadline { + break; + } + let (next, _) = ready.wait_timeout(state_guard, deadline - now).unwrap(); + state_guard = next; + } + let overlapped = state_guard.entered == 2; + state_guard.release = true; + ready.notify_all(); + drop(state_guard); + + main_worker.join().expect("main worker panicked"); + sub_worker.join().expect("subinterpreter worker panicked"); + assert!( + overlapped, + "main and subinterpreter run sections were serialized" + ); + } + + /// A busy interpreter must not prevent another interpreter from making progress. + #[cfg(feature = "threading")] + #[test] + fn busy_main_interpreter_does_not_block_subinterpreter() { + use alloc::sync::Arc; + use core::{ + sync::atomic::{AtomicBool, Ordering}, + time::Duration, + }; + use std::time::Instant; + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + let main_started = Arc::new(AtomicBool::new(false)); + let sub_finished = Arc::new(AtomicBool::new(false)); + + let main_started_worker = Arc::clone(&main_started); + let sub_finished_worker = Arc::clone(&sub_finished); + let main_worker = main.enter(|vm| { + let thread_vm = vm.new_thread(); + std::thread::spawn(move || { + thread_vm.run(|vm| { + main_started_worker.store(true, Ordering::Release); + let deadline = Instant::now() + Duration::from_secs(30); + let mut operations = 0; + while !sub_finished_worker.load(Ordering::Acquire) && Instant::now() < deadline + { + let a: PyObjectRef = vm.ctx.new_int(20_i32).into(); + let b: PyObjectRef = vm.ctx.new_int(22_i32).into(); + let result = vm._add(&a, &b).unwrap(); + assert_eq!(*int::get_value(&result), 42_i32.to_bigint().unwrap()); + operations += 1; + std::thread::yield_now(); + } + (sub_finished_worker.load(Ordering::Acquire), operations) + }) + }) + }); + + let main_started_worker = Arc::clone(&main_started); + let sub_finished_worker = Arc::clone(&sub_finished); + let sub_worker = sub.enter(|vm| { + let thread_vm = vm.new_thread(); + std::thread::spawn(move || { + while !main_started_worker.load(Ordering::Acquire) { + std::thread::yield_now(); + } + thread_vm.run(|vm| { + let a: PyObjectRef = vm.ctx.new_int(6_i32).into(); + let b: PyObjectRef = vm.ctx.new_int(7_i32).into(); + let result = vm._mul(&a, &b).unwrap(); + assert_eq!(*int::get_value(&result), 42_i32.to_bigint().unwrap()); + sub_finished_worker.store(true, Ordering::Release); + }); + }) + }); + + let (sub_progressed_while_main_was_busy, main_operations) = + main_worker.join().expect("main worker panicked"); + sub_worker.join().expect("subinterpreter worker panicked"); + + assert!(main_operations > 0); + assert!( + sub_progressed_while_main_was_busy, + "subinterpreter made no progress until the busy main interpreter exited" + ); + } + + /// `new_thread` on a subinterpreter shares that subinterpreter's state, not main's. + #[cfg(feature = "threading")] + #[test] + fn subinterpreter_new_thread_shares_sub_state() { + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + let sub_id = sub.id(); + + let handle = sub.enter(|vm| { + let thread_vm = vm.new_thread(); + std::thread::spawn(move || { + thread_vm.run(|vm| { + assert_eq!(vm.state.interpreter_id, sub_id); + assert!(!vm.state.is_main_interpreter()); + }); + }) + }); + handle.join().expect("thread panicked"); + } + + /// Multiple subinterpreters can each run bytecode via compile+exec. + #[cfg(feature = "rustpython-compiler")] + #[test] + fn subinterpreter_runs_python_code() { + use crate::compiler::Mode; + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + + sub.enter(|vm| { + let scope = vm.new_scope_with_builtins(); + let source = "x = 40 + 2\n"; + let code = vm + .compile(source, Mode::Exec, "") + .map_err(|err| err.into_pyexception(vm, Some(source))) + .unwrap(); + vm.run_code_obj(code, scope.clone()).unwrap(); + let x = scope.globals.get_item("x", vm).unwrap(); + assert_eq!(*int::get_value(&x), 42_i32.to_bigint().unwrap()); + }); + } + + /// Subclassing a shared type records the subclass on an object every + /// interpreter reaches, but only the interpreter that created it lists it. + fn run(vm: &VirtualMachine, scope: &crate::scope::Scope, source: &str) { + let code = vm + .compile(source, crate::compiler::Mode::Exec, "") + .map_err(|err| err.into_pyexception(vm, Some(source))) + .unwrap(); + vm.run_code_obj(code, scope.clone()).unwrap(); + } + + #[test] + fn subinterpreter_subclasses_are_scoped_to_their_interpreter() { + use crate::scope::Scope; + + fn lists_subclass(vm: &VirtualMachine, scope: &Scope, name: &str) -> bool { + run( + vm, + scope, + &format!("found = any(c.__name__ == {name:?} for c in int.__subclasses__())\n"), + ); + let found = scope.globals.get_item("found", vm).unwrap(); + found.try_to_bool(vm).unwrap() + } + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + + // The scopes are what keep the classes alive; a subclass list holds + // only weak references, so both must outlive every assertion below. + let main_scope = main.enter(|vm| { + let scope = vm.new_scope_with_builtins(); + run(vm, &scope, "class MainOnly(int): pass\n"); + scope + }); + let sub_scope = sub.enter(|vm| { + let scope = vm.new_scope_with_builtins(); + run(vm, &scope, "class SubOnly(int): pass\n"); + scope + }); + + main.enter(|vm| { + assert!(lists_subclass(vm, &main_scope, "MainOnly")); + assert!(!lists_subclass(vm, &main_scope, "SubOnly")); + // A subclass built before either interpreter existed belongs to the + // shared context, so it stays visible to both. + assert!(lists_subclass(vm, &main_scope, "bool")); + }); + sub.enter(|vm| { + assert!(lists_subclass(vm, &sub_scope, "SubOnly")); + assert!(!lists_subclass(vm, &sub_scope, "MainOnly")); + assert!(lists_subclass(vm, &sub_scope, "bool")); + }); + + main.enter(|_| drop(main_scope)); + sub.enter(|_| drop(sub_scope)); + } + + /// A cycle allocated in one interpreter is not the parent's to collect. + #[test] + fn collections_only_reach_the_collecting_interpreter() { + use core::time::Duration; + use std::time::Instant; + + const CYCLE: &str = "class Node:\n pass\n\ + a = Node()\n\ + b = Node()\n\ + a.other = b\n\ + b.other = a\n\ + del a\n\ + del b\n"; + + fn live_nodes(vm: &VirtualMachine) -> usize { + vm.state + .gc + .get_objects(None) + .iter() + .filter(|obj| &*obj.class().name() == "Node") + .count() + } + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + + let sub_scope = sub.enter(|vm| { + let scope = vm.new_scope_with_builtins(); + run(vm, &scope, CYCLE); + assert_eq!(live_nodes(vm), 2); + scope + }); + + // A collection in the parent walks its own tracked objects and leaves + // the sub's cycle where it is. Collections are serialized process-wide + // by a `try_lock`, so one running elsewhere in the suite makes + // `collect_force` a no-op; retry until this one gets to run. Each retry + // waits outside `enter`, since a thread that is entered but not running + // bytecode never reaches a safepoint, and the collection this is + // waiting for cannot stop it. + let deadline = Instant::now() + Duration::from_secs(30); + while !main.enter(|vm| vm.state.gc.collect_force(2).candidates > 0) { + assert!( + Instant::now() < deadline, + "no collection ran in the parent interpreter" + ); + std::thread::sleep(Duration::from_millis(5)); + } + sub.enter(|vm| assert_eq!(live_nodes(vm), 2)); + + sub.enter(|_| drop(sub_scope)); + } + + /// And it is not the parent's to enumerate either. + #[test] + fn get_objects_only_reports_the_calling_interpreter() { + fn tracks_class(vm: &VirtualMachine, name: &str) -> bool { + vm.state + .gc + .get_objects(None) + .iter() + .any(|obj| &*obj.class().name() == name) + } + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + + let main_scope = main.enter(|vm| { + let scope = vm.new_scope_with_builtins(); + run(vm, &scope, "class MainNode:\n pass\nkeep = MainNode()\n"); + scope + }); + let sub_scope = sub.enter(|vm| { + let scope = vm.new_scope_with_builtins(); + run(vm, &scope, "class SubNode:\n pass\nkeep = SubNode()\n"); + scope + }); + + main.enter(|vm| { + assert!(tracks_class(vm, "MainNode")); + assert!(!tracks_class(vm, "SubNode")); + }); + sub.enter(|vm| { + assert!(tracks_class(vm, "SubNode")); + assert!(!tracks_class(vm, "MainNode")); + }); + + main.enter(|_| drop(main_scope)); + sub.enter(|_| drop(sub_scope)); + } + + /// The runtime can own a subinterpreter by id and hand it back on destroy. + #[cfg(feature = "threading")] + #[test] + fn runtime_owned_interpreter_lifecycle() { + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + let id = sub.id(); + + assert_eq!(runtime::store_owned_interpreter(sub), id); + assert!(runtime::is_owned_interpreter(id)); + assert!(runtime::lookup_interpreter(id).is_some()); + // The owned table is process-global and other tests store into it in + // parallel, so only this entry's own membership is deterministic. + assert!(runtime::owned_interpreter_count() >= 1); + + // Reclaiming removes ownership but keeps the interpreter alive while the + // returned handle is held. + let reclaimed = runtime::take_owned_interpreter(id).expect("owned by runtime"); + assert_eq!(reclaimed.id(), id); + assert!(!runtime::is_owned_interpreter(id)); + assert!(runtime::lookup_interpreter(id).is_some()); + assert!(runtime::take_owned_interpreter(id).is_none()); + + // Dropping the reclaimed handle releases it. + drop(reclaimed); + wait_until_unregistered(id); + } + + /// `create_owned_subinterpreter` stores the sub and returns only its id. + #[cfg(feature = "threading")] + #[test] + fn create_owned_subinterpreter_returns_id() { + let main = Interpreter::without_stdlib(Default::default()); + let id = main.create_owned_subinterpreter(); + assert!(runtime::is_owned_interpreter(id)); + assert_ne!(id, main.id()); + + let sub = runtime::take_owned_interpreter(id).expect("owned by runtime"); + assert_eq!(sub.id(), id); + assert!(!sub.is_main()); + } + + /// A collection must stop every interpreter, not just the collecting one: + /// the generation lists are process-global, so the reachability walk reads + /// objects owned by other interpreters while their threads would otherwise + /// still be mutating them. + #[cfg(all(feature = "threading", feature = "rustpython-compiler"))] + #[test] + fn gc_collect_is_safe_while_another_interpreter_runs() { + use crate::compiler::Mode; + use alloc::sync::Arc; + use core::{ + sync::atomic::{AtomicBool, Ordering}, + time::Duration, + }; + use std::time::Instant; + + // Each interpreter churns reference cycles so both contribute tracked + // objects to the shared generation lists. + const CHURN: &str = "\ +for _ in range(40): + a = {} + b = {'peer': a} + a['peer'] = b +"; + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + let stop = Arc::new(AtomicBool::new(false)); + + let run_source = |vm: &VirtualMachine, source: &str| { + let scope = vm.new_scope_with_builtins(); + let code = vm + .compile(source, Mode::Exec, "") + .map_err(|err| err.into_pyexception(vm, Some(source))) + .unwrap(); + vm.run_code_obj(code, scope).unwrap(); + }; + + // Subinterpreter thread: allocate cycles continuously. + let stop_worker = Arc::clone(&stop); + let churner = sub.enter(|vm| { + let thread_vm = vm.new_thread(); + std::thread::spawn(move || { + thread_vm.run(|vm| { + while !stop_worker.load(Ordering::Acquire) { + run_source(vm, CHURN); + } + }); + }) + }); + + // Main interpreter: force collections while the sub keeps mutating. + main.enter(|vm| { + run_source(vm, CHURN); + let deadline = Instant::now() + Duration::from_secs(2); + let mut collections = 0; + while Instant::now() < deadline && collections < 20 { + vm.state.gc.collect_force(2); + collections += 1; + } + assert!(collections > 0); + }); + + stop.store(true, Ordering::Release); + churner.join().expect("churn worker panicked"); + } + + /// A thread entered in one interpreter can park another interpreter's + /// threads. This is what makes a collection safe: the generation lists are + /// process-global, so the collector must be able to stop every interpreter, + /// not only its own. + #[cfg(all(feature = "threading", feature = "rustpython-compiler"))] + #[test] + fn stop_the_world_parks_threads_of_another_interpreter() { + use crate::compiler::Mode; + use alloc::sync::Arc; + use core::{ + sync::atomic::{AtomicBool, AtomicU64, Ordering}, + time::Duration, + }; + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + let sub_state = sub.enter(|vm| vm.state.clone()); + + let progress = Arc::new(AtomicU64::new(0)); + let stop = Arc::new(AtomicBool::new(false)); + + // Sub-interpreter worker: runs bytecode (so it reaches safepoints) and + // reports progress every iteration. + let progress_worker = Arc::clone(&progress); + let stop_worker = Arc::clone(&stop); + let worker = sub.enter(|vm| { + let thread_vm = vm.new_thread(); + std::thread::spawn(move || { + thread_vm.run(|vm| { + let source = "x = 1 + 1\n"; + let code = vm + .compile(source, Mode::Exec, "") + .map_err(|err| err.into_pyexception(vm, Some(source))) + .unwrap(); + while !stop_worker.load(Ordering::Acquire) { + let scope = vm.new_scope_with_builtins(); + vm.run_code_obj(code.clone(), scope).unwrap(); + progress_worker.fetch_add(1, Ordering::Release); + } + }); + }) + }); + + // Wait until the worker is actually running. + while progress.load(Ordering::Acquire) == 0 { + std::thread::yield_now(); + } + + main.enter(|_vm| { + // Stop the *subinterpreter* from a thread whose current interpreter + // is main — the cross-interpreter stop a collection performs. + sub_state.stop_the_world.stop_the_world(&sub_state); + + let parked_at = progress.load(Ordering::Acquire); + std::thread::sleep(Duration::from_millis(50)); + assert_eq!( + progress.load(Ordering::Acquire), + parked_at, + "subinterpreter thread kept running while its world was stopped" + ); + + sub_state.stop_the_world.start_the_world(&sub_state); + }); + + // After restart the worker makes progress again. + let resumed_from = progress.load(Ordering::Acquire); + while progress.load(Ordering::Acquire) == resumed_from { + std::thread::yield_now(); + } + + stop.store(true, Ordering::Release); + worker.join().expect("worker panicked"); + } + + /// Entering a subinterpreter from inside the parent's `enter` must attach + /// the subinterpreter's thread slot (and detach the parent's). Otherwise the + /// thread runs the sub's bytecode with a DETACHED slot, and a collector + /// stopping that interpreter force-parks the slot and wrongly concludes the + /// world is stopped while this thread keeps mutating objects. + #[cfg(all(feature = "threading", feature = "rustpython-compiler"))] + #[test] + fn nested_enter_of_subinterpreter_is_stoppable() { + use crate::compiler::Mode; + use alloc::sync::Arc; + use core::{ + sync::atomic::{AtomicBool, AtomicU64, Ordering}, + time::Duration, + }; + + let main = Interpreter::without_stdlib(Default::default()); + let sub = main.create_subinterpreter(); + let sub_state = sub.enter(|vm| vm.state.clone()); + + let progress = Arc::new(AtomicU64::new(0)); + let stop = Arc::new(AtomicBool::new(false)); + + // Worker runs the SUB nested inside an active MAIN section. + let progress_worker = Arc::clone(&progress); + let stop_worker = Arc::clone(&stop); + let main_vm = main.enter(|vm| vm.new_thread()); + let sub_vm = sub.enter(|vm| vm.new_thread()); + let worker = std::thread::spawn(move || { + main_vm.run(|_main| { + sub_vm.run(|vm| { + let source = "x = 1 + 1\n"; + let code = vm + .compile(source, Mode::Exec, "") + .map_err(|err| err.into_pyexception(vm, Some(source))) + .unwrap(); + while !stop_worker.load(Ordering::Acquire) { + let scope = vm.new_scope_with_builtins(); + vm.run_code_obj(code.clone(), scope).unwrap(); + progress_worker.fetch_add(1, Ordering::Release); + } + }); + }); + }); + + while progress.load(Ordering::Acquire) == 0 { + std::thread::yield_now(); + } + + sub_state.stop_the_world.stop_the_world(&sub_state); + let parked_at = progress.load(Ordering::Acquire); + std::thread::sleep(Duration::from_millis(50)); + assert_eq!( + progress.load(Ordering::Acquire), + parked_at, + "nested subinterpreter thread kept running while the sub's world was stopped" + ); + sub_state.stop_the_world.start_the_world(&sub_state); + + let resumed_from = progress.load(Ordering::Acquire); + while progress.load(Ordering::Acquire) == resumed_from { + std::thread::yield_now(); + } + + stop.store(true, Ordering::Release); + worker.join().expect("nested worker panicked"); + } + + /// The process main id is recorded once and is stable across later creates. + #[test] + fn process_main_id_recorded_and_stable() { + // At least one main exists by now (this one, if not an earlier test), so + // `get_main()` is populated. + let main = Interpreter::without_stdlib(Default::default()); + let recorded = runtime::main_interpreter_id().expect("a process main exists"); + + // Recording is once-only: further interpreters do not displace it. + let _sub = main.create_subinterpreter(); + let _main2 = Interpreter::without_stdlib(Default::default()); + assert_eq!(runtime::main_interpreter_id(), Some(recorded)); + } } diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 7c7d017c1fd..c3861797b24 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -13,6 +13,7 @@ mod interpreter; mod method; #[cfg(feature = "rustpython-compiler")] mod python_run; +pub mod runtime; mod setting; pub mod thread; mod vm_new; @@ -61,16 +62,22 @@ use std::{ pub use context::Context; pub use interpreter::{Interpreter, InterpreterBuilder}; pub(crate) use method::PyMethod; +pub use runtime::{InterpreterInfo, InterpreterWhence, MAIN_INTERPRETER_ID}; pub use setting::{CheckHashPycsMode, Paths, PyConfig, Settings}; pub const MAX_MEMORY_SIZE: usize = isize::MAX as usize; // Objects are live when they are on stack, or referenced by a name (for now) -/// Top level container of a python virtual machine. In theory you could -/// create more instances of this struct and have them operate fully isolated. +/// Per-thread execution context for a single interpreter (≈ CPython `PyThreadState`). /// -/// To construct this, please refer to the [`Interpreter`] +/// A `VirtualMachine` holds thread-local eval state (exceptions, recursion, frames, +/// datastack) plus shared references to interpreter-owned data (`state`, +/// `builtins`, `sys_module`, `ctx`). Multiple VMs may share the same +/// [`PyGlobalState`] via `VirtualMachine::new_thread`; distinct interpreters +/// each have their own `PyGlobalState` (see [`Interpreter::create_subinterpreter`]). +/// +/// To construct the main VM of an interpreter, use [`Interpreter`]. pub struct VirtualMachine { pub builtins: PyRef, pub sys_module: PyRef, @@ -110,11 +117,11 @@ pub struct VirtualMachine { /// pointer here before returning `ExecutionResult::TailCall`. /// Access only via `set_pending_tailcall` / `take_pending_tailcall`. pending_tailcall_frame: Cell>, - /// Owned references that keep callee raw pointers valid during TailCall. - /// Set by `tailcall_prepare_frame`, drained by the trampoline into - /// its local `owned_refs` Vec. Uses UnsafeCell because the VM is - /// per-thread and this field is only accessed on the owning thread. - pub(crate) pending_tailcall_refs: core::cell::UnsafeCell>, + /// Owned reference that keeps callee raw pointers valid during TailCall. + /// Set by the exact-call handlers and moved into the trampoline's + /// `SuspendedFrame`. Uses UnsafeCell because the VM is per-thread and this + /// field is only accessed on the owning thread. + pending_tailcall_owner: core::cell::UnsafeCell>, } /// Non-owning frame pointer for the non-unix threading frames stack. @@ -257,9 +264,9 @@ impl StopTheWorldState { } #[inline] - fn init_thread_countdown(&self, vm: &VirtualMachine) -> i64 { + fn init_thread_countdown(&self, state: &PyGlobalState) -> i64 { let requester = self.requester.load(Ordering::Relaxed); - let registry = vm.state.thread_frames.lock(); + let registry = state.thread_frames.lock(); // Keep requested/count initialization serialized with thread-slot // registration (which also takes this lock), matching the // HEAD_LOCK-guarded stop-the-world bookkeeping. @@ -288,10 +295,10 @@ impl StopTheWorldState { /// Try to CAS detached threads directly to SUSPENDED and check whether /// stop countdown reached zero after parking detached threads. - fn park_detached_threads(&self, vm: &VirtualMachine) -> bool { + fn park_detached_threads(&self, state: &PyGlobalState) -> bool { use thread::{THREAD_ATTACHED, THREAD_DETACHED, THREAD_SUSPENDED}; let requester = self.requester.load(Ordering::Relaxed); - let registry = vm.state.thread_frames.lock(); + let registry = state.thread_frames.lock(); let mut attached_seen = 0u64; let mut forced_parks = 0u64; @@ -413,23 +420,23 @@ impl StopTheWorldState { /// Takes the shared exclusion first so at most one requester (fork or GC) /// drives the stop→start span at a time; it is released by /// `start_the_world`/`reset_after_fork`. - pub fn stop_the_world(&self, vm: &VirtualMachine) { + pub fn stop_the_world(&self, state: &PyGlobalState) { self.acquire_exclusion(); let start = std::time::Instant::now(); let requester_ident = crate::stdlib::_thread::get_ident(); self.requester.store(requester_ident, Ordering::Relaxed); self.stats_stop_calls.fetch_add(1, Ordering::Relaxed); - let initial_countdown = self.init_thread_countdown(vm); + let initial_countdown = self.init_thread_countdown(state); stw_trace(format_args!("stop begin requester={requester_ident}")); // Park detached threads and set stop bits, then confirm every other // thread is SUSPENDED. The completion condition is level-triggered // (`all_non_requester_suspended`) so an already-suspended thread that // was counted but will not notify again cannot stall the stop. - self.park_detached_threads(vm); - if initial_countdown == 0 || self.all_non_requester_suspended(vm) { + self.park_detached_threads(state); + if initial_countdown == 0 || self.all_non_requester_suspended(state) { self.world_stopped.store(true, Ordering::Release); #[cfg(debug_assertions)] - self.debug_assert_all_non_requester_suspended(vm); + self.debug_assert_all_non_requester_suspended(state); stw_trace(format_args!( "stop end requester={requester_ident} wait_ns=0 polls=0" )); @@ -438,8 +445,8 @@ impl StopTheWorldState { let mut polls = 0u64; loop { - self.park_detached_threads(vm); - if self.all_non_requester_suspended(vm) { + self.park_detached_threads(state); + if self.all_non_requester_suspended(state) { break; } polls = polls.saturating_add(1); @@ -447,7 +454,7 @@ impl StopTheWorldState { // Re-check under the wait mutex first to avoid a lost-wake race: // a thread may have suspended and notified right before we enter wait. let guard = self.notify_mutex.lock().unwrap(); - if self.all_non_requester_suspended(vm) { + if self.all_non_requester_suspended(state) { drop(guard); break; } @@ -476,18 +483,18 @@ impl StopTheWorldState { } self.world_stopped.store(true, Ordering::Release); #[cfg(debug_assertions)] - self.debug_assert_all_non_requester_suspended(vm); + self.debug_assert_all_non_requester_suspended(state); stw_trace(format_args!( "stop end requester={requester_ident} wait_ns={wait_ns} polls={polls}" )); } /// Resume all suspended threads (`start_the_world`). - pub fn start_the_world(&self, vm: &VirtualMachine) { + pub fn start_the_world(&self, state: &PyGlobalState) { use thread::{THREAD_DETACHED, THREAD_SUSPENDED}; let requester = self.requester.load(Ordering::Relaxed); stw_trace(format_args!("start begin requester={requester}")); - let registry = vm.state.thread_frames.lock(); + let registry = state.thread_frames.lock(); // Clear the request flag BEFORE waking threads. Otherwise a thread // returning from allow_threads → attach_thread could observe // `requested == true`, re-suspend itself, and stay parked forever. @@ -521,7 +528,7 @@ impl StopTheWorldState { self.thread_countdown.store(0, Ordering::Release); self.requester.store(0, Ordering::Relaxed); #[cfg(debug_assertions)] - self.debug_assert_all_non_requester_detached(vm); + self.debug_assert_all_non_requester_detached(state); // Release the exclusion last, ending the stop→start span so the next // requester (fork or GC) can proceed. self.release_exclusion(); @@ -604,10 +611,10 @@ impl StopTheWorldState { /// lost-decrement race under rapid back-to-back stops: a thread that is /// already SUSPENDED when a new stop counts it neither notifies nor is /// force-parked again, so an edge-based countdown could never reach zero. - fn all_non_requester_suspended(&self, vm: &VirtualMachine) -> bool { + fn all_non_requester_suspended(&self, state: &PyGlobalState) -> bool { use thread::THREAD_SUSPENDED; let requester = self.requester.load(Ordering::Relaxed); - let registry = vm.state.thread_frames.lock(); + let registry = state.thread_frames.lock(); #[expect( clippy::iter_over_hash_type, @@ -625,10 +632,10 @@ impl StopTheWorldState { } #[cfg(debug_assertions)] - fn debug_assert_all_non_requester_suspended(&self, vm: &VirtualMachine) { + fn debug_assert_all_non_requester_suspended(&self, state: &PyGlobalState) { use thread::THREAD_SUSPENDED; let requester = self.requester.load(Ordering::Relaxed); - let registry = vm.state.thread_frames.lock(); + let registry = state.thread_frames.lock(); #[expect( clippy::iter_over_hash_type, @@ -648,10 +655,10 @@ impl StopTheWorldState { } #[cfg(debug_assertions)] - fn debug_assert_all_non_requester_detached(&self, vm: &VirtualMachine) { + fn debug_assert_all_non_requester_detached(&self, state: &PyGlobalState) { use thread::THREAD_SUSPENDED; let requester = self.requester.load(Ordering::Relaxed); - let registry = vm.state.thread_frames.lock(); + let registry = state.thread_frames.lock(); #[expect( clippy::iter_over_hash_type, @@ -732,7 +739,20 @@ pub(crate) struct CallableCache { pub builtin_any: Option, } +/// Per-interpreter shared state (≈ CPython `PyInterpreterState`). +/// +/// Not process-global: each [`Interpreter`] (main or subinterpreter) owns its own +/// `PyGlobalState`. Process-wide pieces live elsewhere (`Context::genesis`, +/// GC, the interpreter registry in [`runtime`]). pub struct PyGlobalState { + /// Unique process-global interpreter id (main is [`MAIN_INTERPRETER_ID`]). + pub interpreter_id: i64, + /// How this interpreter was created. + pub whence: runtime::InterpreterWhence, + /// True for every top-level (non-sub) interpreter, each of which keeps its + /// own signal and main-thread bookkeeping. Only the first one registered + /// becomes *the* process main — see [`runtime::main_interpreter_id`]. + pub is_main: bool, pub config: PyConfig, pub module_defs: BTreeMap<&'static str, &'static builtins::PyModuleDef>, pub frozen: HashMap<&'static str, FrozenModule, rapidhash::quality::RandomState>, @@ -777,6 +797,16 @@ pub struct PyGlobalState { /// Stop-the-world state for pre-fork thread suspension #[cfg(feature = "threading")] pub stop_the_world: StopTheWorldState, + /// This interpreter's garbage collector policy and results. + pub gc: crate::gc_state::GcInterpreterState, +} + +impl PyGlobalState { + #[inline] + #[must_use] + pub fn is_main_interpreter(&self) -> bool { + self.is_main + } } pub fn process_hash_secret_seed() -> u32 { @@ -828,11 +858,12 @@ pub(crate) struct IframeEntryState { struct SuspendedFrame { iframe: *mut crate::frame::InterpreterFrame, entry_state: IframeEntryState, - /// Owned references that keep callee's raw pointers (code, globals, - /// builtins borrowed from PyFunction) valid. Drained from - /// `vm.pending_tailcall_refs` when the callee's TailCall is consumed. - /// Dropped when this SuspendedFrame is popped (after callee returns/errors). - owned_refs: Vec, + /// Function that owns the callee's raw pointers (code, globals, builtins, + /// closure, and func_obj). Moved from `vm.pending_tailcall_owner` when the + /// callee's TailCall is consumed. + /// Dropped as soon as this SuspendedFrame is popped — the callee has + /// returned or raised and its frame is already released by then. + callee_owner: PyObjectRef, /// True for the initial frame passed into the trampoline by the caller. /// The caller owns the datastack allocation for the entry frame, so the /// trampoline must NOT release it — only callee-allocated frames are @@ -865,6 +896,13 @@ impl VirtualMachine { unsafe { (*self.datastack.get()).push(size) } } + /// Bump-allocate a full frame, returning whether the same cleared LIFO + /// block and size were reused. + #[inline(always)] + pub(crate) fn datastack_push_frame(&self, size: usize) -> (*mut u8, bool) { + unsafe { (*self.datastack.get()).push_frame(size) } + } + /// Check whether the thread data stack currently has room for `size` bytes. #[inline(always)] pub(crate) fn datastack_has_space(&self, size: usize) -> bool { @@ -881,6 +919,12 @@ impl VirtualMachine { unsafe { (*self.datastack.get()).pop(base) } } + /// Pop a full frame after its localsplus slots have been cleared. + #[inline(always)] + pub(crate) unsafe fn datastack_pop_frame(&self, base: *mut u8, size: usize) { + unsafe { (*self.datastack.get()).pop_frame(base, size) } + } + /// Temporarily detach the current thread (ATTACHED → DETACHED) while /// running `f`, then re-attach afterwards. Allows `stop_the_world` to /// park this thread during blocking syscalls. @@ -955,7 +999,7 @@ impl VirtualMachine { callable_cache: CallableCache::default(), audit_hooks: RefCell::new(vec![]), pending_tailcall_frame: Cell::new(None), - pending_tailcall_refs: core::cell::UnsafeCell::new(Vec::with_capacity(2)), + pending_tailcall_owner: core::cell::UnsafeCell::new(None), }; if vm.state.hash_secret.hash_str("") @@ -1083,9 +1127,12 @@ impl VirtualMachine { assert!(!self.initialized, "Double Initialize Error"); - // Initialize main thread ident before any threading operations + // Process main-thread identity is owned by the main interpreter only + // (used for signal handling / `_thread._is_main_interpreter` helpers). #[cfg(feature = "threading")] - stdlib::_thread::init_main_thread_ident(self); + if self.state.is_main_interpreter() { + stdlib::_thread::init_main_thread_ident(self); + } stdlib::builtins::init_module(self, &self.builtins); let callable_cache_init = self.init_callable_cache(); @@ -1406,6 +1453,22 @@ impl VirtualMachine { .set(Some(PendingFrame(core::ptr::NonNull::from(iframe)))); } + /// Store the function that owns the fields borrowed by the pending callee. + #[inline(always)] + pub(crate) fn set_pending_tailcall_owner(&self, owner: PyObjectRef) { + let slot = unsafe { &mut *self.pending_tailcall_owner.get() }; + debug_assert!(slot.is_none(), "pending TailCall owner was not consumed"); + *slot = Some(owner); + } + + /// Take the pending callee owner, resetting the side channel. + #[inline(always)] + fn take_pending_tailcall_owner(&self) -> PyObjectRef { + unsafe { &mut *self.pending_tailcall_owner.get() } + .take() + .expect("TailCall without pending owner") + } + /// Take the pending tailcall frame pointer, resetting the side channel. #[inline(always)] fn take_pending_tailcall(&self) -> *mut crate::frame::InterpreterFrame { @@ -1466,18 +1529,11 @@ impl VirtualMachine { } let initial_ptr = self.take_pending_tailcall(); - // Drain the refs that keep the initial callee's raw pointers alive. - #[allow( - clippy::drain_collect, - reason = "`pending_tailcall_refs`'s allocation is intentionally reused" - )] - let initial_refs = unsafe { &mut *self.pending_tailcall_refs.get() } - .drain(..) - .collect(); + let initial_owner = self.take_pending_tailcall_owner(); frame_stack.push(SuspendedFrame { iframe: iframe as *mut crate::frame::InterpreterFrame, entry_state, - owned_refs: initial_refs, + callee_owner: initial_owner, is_entry: true, }); let mut action = Action::EnterCallee(initial_ptr); @@ -1490,8 +1546,8 @@ impl VirtualMachine { Ok(state) => state, Err(exc) => { unsafe { - if let Some(base) = callee.release_datastack_frame() { - self.datastack_pop(base); + if let Some((base, size)) = callee.release_datastack_frame() { + self.datastack_pop_frame(base, size); } } action = Action::Unwind(exc); @@ -1502,17 +1558,11 @@ impl VirtualMachine { let result = crate::frame::run_iframe(callee, self); match result { Ok(ExecutionResult::TailCall) => { - #[allow( - clippy::drain_collect, - reason = "`pending_tailcall_refs`'s allocation is intentionally reused" - )] - let refs = unsafe { &mut *self.pending_tailcall_refs.get() } - .drain(..) - .collect(); + let callee_owner = self.take_pending_tailcall_owner(); frame_stack.push(SuspendedFrame { iframe: callee_ptr, entry_state: callee_entry, - owned_refs: refs, + callee_owner, is_entry: false, }); action = Action::EnterCallee(self.take_pending_tailcall()); @@ -1520,8 +1570,8 @@ impl VirtualMachine { Ok(ExecutionResult::Return(value)) => { self.exit_iframe(callee_entry); unsafe { - if let Some(base) = callee.release_datastack_frame() { - self.datastack_pop(base); + if let Some((base, size)) = callee.release_datastack_frame() { + self.datastack_pop_frame(base, size); } } action = Action::ReturnValue(value); @@ -1530,8 +1580,8 @@ impl VirtualMachine { Err(exc) => { self.exit_iframe(callee_entry); unsafe { - if let Some(base) = callee.release_datastack_frame() { - self.datastack_pop(base); + if let Some((base, size)) = callee.release_datastack_frame() { + self.datastack_pop_frame(base, size); } } action = Action::Unwind(exc); @@ -1547,38 +1597,38 @@ impl VirtualMachine { let SuspendedFrame { iframe: caller_iframe_ptr, entry_state: caller_entry, - owned_refs: _caller_refs, + callee_owner, is_entry: caller_is_entry, } = caller; + // The callee's frame was released before this action was + // formed, and a materialized frame object holds its own + // references, so nothing borrows the callee's function any + // more. Release it here, at the callee's return, rather than + // holding it across the caller's next stretch of bytecode. + drop(callee_owner); let caller_iframe = unsafe { &mut *caller_iframe_ptr }; caller_iframe.localsplus.push_stack(value); let result = crate::frame::run_iframe(caller_iframe, self); match result { Ok(ExecutionResult::TailCall) => { - #[allow( - clippy::drain_collect, - reason = "`pending_tailcall_refs`'s allocation is intentionally reused" - )] - let refs = unsafe { &mut *self.pending_tailcall_refs.get() } - .drain(..) - .collect(); - drop(_caller_refs); + let next_callee_owner = self.take_pending_tailcall_owner(); frame_stack.push(SuspendedFrame { iframe: caller_iframe_ptr, entry_state: caller_entry, - owned_refs: refs, + callee_owner: next_callee_owner, is_entry: caller_is_entry, }); action = Action::EnterCallee(self.take_pending_tailcall()); } Ok(ExecutionResult::Return(value)) => { - drop(_caller_refs); self.exit_iframe(caller_entry); if !caller_is_entry { unsafe { - if let Some(base) = caller_iframe.release_datastack_frame() { - self.datastack_pop(base); + if let Some((base, size)) = + caller_iframe.release_datastack_frame() + { + self.datastack_pop_frame(base, size); } } } @@ -1586,12 +1636,13 @@ impl VirtualMachine { } Ok(ExecutionResult::Yield(_)) => panic!("Yield in non-generator frame"), Err(exc) => { - drop(_caller_refs); self.exit_iframe(caller_entry); if !caller_is_entry { unsafe { - if let Some(base) = caller_iframe.release_datastack_frame() { - self.datastack_pop(base); + if let Some((base, size)) = + caller_iframe.release_datastack_frame() + { + self.datastack_pop_frame(base, size); } } } @@ -1607,9 +1658,13 @@ impl VirtualMachine { let SuspendedFrame { iframe: caller_iframe_ptr, entry_state: caller_entry, - owned_refs: _caller_refs, + callee_owner, is_entry: caller_is_entry, } = caller; + // Released at the callee's return, for the same reason as + // in `ReturnValue`: the exception carries owned references + // through its traceback, not borrows into the callee frame. + drop(callee_owner); let caller_iframe = unsafe { &mut *caller_iframe_ptr }; let handled = @@ -1621,31 +1676,23 @@ impl VirtualMachine { let result = crate::frame::run_iframe(caller_iframe, self); match result { Ok(ExecutionResult::TailCall) => { - #[allow( - clippy::drain_collect, - reason = "`pending_tailcall_refs`'s allocation is intentionally reused" - )] - let refs = unsafe { &mut *self.pending_tailcall_refs.get() } - .drain(..) - .collect(); - drop(_caller_refs); + let next_callee_owner = self.take_pending_tailcall_owner(); frame_stack.push(SuspendedFrame { iframe: caller_iframe_ptr, entry_state: caller_entry, - owned_refs: refs, + callee_owner: next_callee_owner, is_entry: caller_is_entry, }); action = Action::EnterCallee(self.take_pending_tailcall()); } Ok(ExecutionResult::Return(value)) => { - drop(_caller_refs); self.exit_iframe(caller_entry); if !caller_is_entry { unsafe { - if let Some(base) = + if let Some((base, size)) = caller_iframe.release_datastack_frame() { - self.datastack_pop(base); + self.datastack_pop_frame(base, size); } } } @@ -1655,14 +1702,13 @@ impl VirtualMachine { panic!("Yield in non-generator frame") } Err(new_exc) => { - drop(_caller_refs); self.exit_iframe(caller_entry); if !caller_is_entry { unsafe { - if let Some(base) = + if let Some((base, size)) = caller_iframe.release_datastack_frame() { - self.datastack_pop(base); + self.datastack_pop_frame(base, size); } } } @@ -1671,12 +1717,13 @@ impl VirtualMachine { } } Ok(Some(ExecutionResult::Return(value))) => { - drop(_caller_refs); self.exit_iframe(caller_entry); if !caller_is_entry { unsafe { - if let Some(base) = caller_iframe.release_datastack_frame() { - self.datastack_pop(base); + if let Some((base, size)) = + caller_iframe.release_datastack_frame() + { + self.datastack_pop_frame(base, size); } } } @@ -1686,12 +1733,13 @@ impl VirtualMachine { panic!("Unexpected execution result in trampoline unwind") } Err(new_exc) => { - drop(_caller_refs); self.exit_iframe(caller_entry); if !caller_is_entry { unsafe { - if let Some(base) = caller_iframe.release_datastack_frame() { - self.datastack_pop(base); + if let Some((base, size)) = + caller_iframe.release_datastack_frame() + { + self.datastack_pop_frame(base, size); } } } @@ -1783,14 +1831,14 @@ impl VirtualMachine { // Phase 4: GC collect — modules removed from sys.modules are freed, // exposing cycles (e.g., dict ↔ function.__globals__). GC collects // these and calls __del__ while module dicts are still intact. - crate::gc_state::gc_state().collect_force(2); + self.state.gc.collect_force(2); // Phase 5: Clear module dicts in reverse import order using 2-pass algorithm. // Skip builtins and sys — those are cleared last. self.finalize_clear_module_dicts(&module_weakrefs); // Phase 6: GC collect — pick up anything freed by dict clearing. - crate::gc_state::gc_state().collect_force(2); + self.state.gc.collect_force(2); // Phase 7: Clear sys and builtins dicts last self.finalize_clear_sys_builtins_dict(); @@ -2148,7 +2196,7 @@ impl VirtualMachine { self.restore_exception(saved_exc); } // Clear previous before popping — it may point to a stack-allocated - // iframe that will be freed when the caller's with_iframe exits. + // iframe that will be freed when the caller releases its frame. { #[allow(unused_imports)] use rustpython_common::atomic::Radium; @@ -2261,6 +2309,9 @@ impl VirtualMachine { core::sync::atomic::Ordering::Relaxed, ); } + // The slots above are the last write this thread makes into + // the frame object, so it is now readable from anywhere. + fo.iframe().detach(); if !old_chain.is_null() { let prev_iframe = unsafe { &*old_chain }; let back_fo = prev_iframe.materialize_chain(self); @@ -2277,7 +2328,7 @@ impl VirtualMachine { self.restore_exception(saved_exc); } // Clear previous before popping — it may point to a stack-allocated - // iframe that will be freed when the caller's with_iframe exits. + // iframe that will be freed when the caller releases its frame. { #[allow(unused_imports)] use rustpython_common::atomic::Radium; @@ -2300,8 +2351,10 @@ impl VirtualMachine { if mat_ptr != 0 { let fo = unsafe { &*(mat_ptr as *const crate::Py) }; unsafe { - crate::gc_state::gc_state() - .track_object(core::ptr::NonNull::from(fo.as_object())); + crate::gc_state::gc_state().track_object( + core::ptr::NonNull::from(fo.as_object()), + crate::gc_state::current_owner(), + ); let live_iframe = &*iframe_ptr; live_iframe.cold().temporary_refs.lock().clear(); } @@ -2309,20 +2362,6 @@ impl VirtualMachine { } } - pub fn with_iframe( - &self, - iframe: &mut crate::frame::InterpreterFrame, - f: impl FnOnce(&mut crate::frame::InterpreterFrame) -> PyResult, - ) -> PyResult { - let state = self.enter_iframe(iframe)?; - // Ensure exit_iframe runs even if f(iframe) panics. - let guard = scopeguard::guard(state, |s| self.exit_iframe(s)); - let result = f(iframe); - let state = scopeguard::ScopeGuard::into_inner(guard); - self.exit_iframe(state); - result - } - /// FrameObject execution for generator/coroutine resume. /// Pushes a new exc_info slot (gi_exc_state) onto the chain, /// linking the generator's saved handled-exception. @@ -2364,7 +2403,7 @@ impl VirtualMachine { frame.iframe().owner.store(old_owner, core::sync::atomic::Ordering::Release); self.pop_exception(); // Clear previous before popping — it may point to a stack-allocated - // iframe that will be freed when the caller's with_iframe exits. + // iframe that will be freed when the caller releases its frame. { #[allow(unused_imports)] use rustpython_common::atomic::Radium; @@ -2780,7 +2819,7 @@ impl VirtualMachine { #[cfg(feature = "threading")] pub(crate) fn run_scheduled_gc(&self) { if crate::signal::take_gc_scheduled() { - crate::gc_state::gc_state().collect(0); + self.state.gc.collect(0); } } diff --git a/crates/vm/src/vm/runtime.rs b/crates/vm/src/vm/runtime.rs new file mode 100644 index 00000000000..7c5168c45b1 --- /dev/null +++ b/crates/vm/src/vm/runtime.rs @@ -0,0 +1,326 @@ +//! Process-global runtime support for multiple interpreters (PEP 734 preparation). +//! +//! CPython maps roughly as: +//! - this module ≈ `_PyRuntimeState.interpreters` + ID allocation +//! - [`crate::vm::PyGlobalState`] ≈ `PyInterpreterState` +//! - [`crate::VirtualMachine`] ≈ `PyThreadState` (plus shared refs to interpreter state) +//! +//! Multiple [`crate::Interpreter`] instances can coexist in one process. Each owns +//! an isolated `PyGlobalState` (modules, codecs, thread registry, stop-the-world, …) +//! while sharing the process-wide [`crate::Context`] (builtin types / immortals). + +use crate::common::rc::PyRc; +use crate::vm::PyGlobalState; +use core::sync::atomic::{AtomicI64, Ordering}; +use parking_lot::Mutex; +use std::collections::HashMap; + +/// Where an interpreter state came from (mirrors CPython `_PyInterpreterState_GetWhence`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(i32)] +pub enum InterpreterWhence { + /// Unknown / not recorded. + Unknown = 0, + /// Created as the process main interpreter at runtime init. + Runtime = 1, + /// Legacy C-API creation path (reserved for C-API parity). + LegacyCapi = 2, + /// Modern C-API creation path (reserved for C-API parity). + Capi = 3, + /// Cross-interpreter C-API (reserved). + Xi = 4, + /// Created via the stdlib / Rust subinterpreter API (PEP 734). + Stdlib = 5, +} + +impl InterpreterWhence { + #[must_use] + pub const fn as_i32(self) -> i32 { + self as i32 + } +} + +/// Snapshot of a registered interpreter for enumeration APIs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InterpreterInfo { + pub id: i64, + pub whence: InterpreterWhence, +} + +struct RegistryEntry { + whence: InterpreterWhence, + /// Weak handle so the registry does not keep interpreters alive. + /// Type matches `PyRc` (Arc when threading, Rc otherwise). + #[cfg(feature = "threading")] + state: alloc::sync::Weak, + #[cfg(not(feature = "threading"))] + state: alloc::rc::Weak, +} + +/// `main_id` value before any main interpreter has been registered. +const NO_MAIN_INTERPRETER: i64 = -1; + +struct InterpreterRegistry { + next_id: AtomicI64, + /// Id of the first registered `is_main` interpreter (PEP 734 `get_main()`), + /// or [`NO_MAIN_INTERPRETER`]. + main_id: AtomicI64, + /// id → entry. Main interpreter is always id 0 when created first. + entries: Mutex>, +} + +impl InterpreterRegistry { + fn new() -> Self { + Self { + // Monotonic ids starting at 0. Concurrent Interpreter construction + // (e.g. cargo test threads) must never share an id. + next_id: AtomicI64::new(0), + main_id: AtomicI64::new(NO_MAIN_INTERPRETER), + entries: Mutex::new(HashMap::new()), + } + } +} + +/// The interpreter registry. +/// +/// With `threading` this is one process-global table. Without it, `PyRc` is +/// `Rc` and each OS thread owns an independent `Context::genesis()` and +/// `GcState`, so the registry is thread-local for the same reason `gc_state()` +/// is: an `Rc` handle must never be reachable from another thread. +/// `static_cell!` provides exactly that split. +fn registry() -> &'static InterpreterRegistry { + rustpython_common::static_cell! { + static REGISTRY: InterpreterRegistry; + } + REGISTRY.get_or_init(InterpreterRegistry::new) +} + +/// Conventional id of the first process main interpreter when allocation is +/// sequential (CPython parity). Concurrent construction may assign other ids; +/// use [`PyGlobalState::is_main`] / [`crate::Interpreter::is_main`] to identify +/// a main interpreter, not this constant alone. +pub const MAIN_INTERPRETER_ID: i64 = 0; + +/// Backs `sys.implementation.supports_isolated_interpreters`. +/// +/// The Rust substrate already isolates interpreters (`PyGlobalState` per +/// interpreter, per-interpreter thread slots / stop-the-world). This stays +/// `false` until the Python-facing `_interpreters` module is wired up; flip it +/// in the commit that lands `_interpreters`. +pub const SUPPORTS_ISOLATED_INTERPRETERS: bool = false; + +/// Id of the main interpreter (PEP 734 `get_main()`), or `None` before any +/// interpreter has been created. +/// +/// This is distinct from [`PyGlobalState::is_main`]: every top-level (non-sub) +/// interpreter carries `is_main` for its own signal / main-thread bookkeeping, +/// but only the first one registered becomes *the* main. +#[must_use] +pub fn main_interpreter_id() -> Option { + match registry().main_id.load(Ordering::Acquire) { + NO_MAIN_INTERPRETER => None, + id => Some(id), + } +} + +/// Allocate a unique interpreter id. +/// +/// Ids are strictly monotonic and never reused for the lifetime of the +/// registry, so concurrent `Interpreter` construction (parallel unit tests, +/// multi-threaded embedding) never shares an id. Without `threading` the +/// registry — like `Context::genesis()` and the GC state — is per OS thread, so +/// ids are unique within a thread rather than across the process. +pub(crate) fn alloc_interpreter_id() -> i64 { + registry().next_id.fetch_add(1, Ordering::Relaxed) +} + +/// Gate between registering an interpreter and a collection's stop-the-world. +/// +/// A collection snapshots the registry, stops every interpreter in the +/// snapshot, and then reads tracked objects with those threads parked. An +/// interpreter that registered after the snapshot was taken would not be in it, +/// so nothing would stop it, and its bootstrap — which runs Python and mutates +/// the shared generation lists — would run underneath that scan. Registration +/// therefore waits for an in-flight stop to end; the next collection's snapshot +/// then contains the new interpreter. +fn admission() -> &'static Mutex<()> { + static ADMISSION: std::sync::OnceLock> = std::sync::OnceLock::new(); + ADMISSION.get_or_init(|| Mutex::new(())) +} + +/// Take the admission gate for the duration of a stop-the-world. +#[cfg(feature = "threading")] +pub(crate) fn lock_admission_for_stop() -> parking_lot::MutexGuard<'static, ()> { + admission().lock() +} + +/// Add the registry entry, behind the admission gate. +/// +/// Only ever called with this thread detached, because the gate is held across +/// a stop-the-world: an attached thread waiting here, or re-attaching while +/// holding the gate, would leave that stop no safepoint to complete at. Nothing +/// under the gate blocks or allocates a tracked object, so this cannot re-enter +/// the collection it waits for. +fn insert_registry_entry(state: &PyRc) { + let _admission = admission().lock(); + let mut entries = registry().entries.lock(); + // Entries are weak and an interpreter's lifetime is decided by its last + // `PyRc` — which outlives the `Interpreter` handle whenever + // `new_thread()` workers are still running — so nothing removes them at a + // fixed point. Reap the dead ones here to bound the table instead. + entries.retain(|_, entry| entry.state.strong_count() > 0); + entries.insert( + state.interpreter_id, + RegistryEntry { + whence: state.whence, + state: PyRc::downgrade(state), + }, + ); +} + +/// Register an interpreter state in the registry. +pub(crate) fn register_interpreter(state: &PyRc) { + let id = state.interpreter_id; + if state.is_main { + // First `is_main` interpreter defines the main for `get_main()`. + // Additional top-level Interpreters (embedding) keep their own `is_main` + // flag but do not displace the recorded main. + let _ = registry().main_id.compare_exchange( + NO_MAIN_INTERPRETER, + id, + Ordering::AcqRel, + Ordering::Relaxed, + ); + } + // A subinterpreter is registered by a thread that is running its parent, so + // detach for the whole insert rather than only for the wait. + let detached = crate::vm::thread::try_with_current_vm(|vm| { + vm.allow_threads(|| insert_registry_entry(state)) + }); + if detached.is_none() { + insert_registry_entry(state); + } +} + +/// Look up a live interpreter state by id. +#[must_use] +pub fn lookup_interpreter(id: i64) -> Option> { + let entries = registry().entries.lock(); + entries.get(&id).and_then(|e| e.state.upgrade()) +} + +/// List all currently registered (still-alive) interpreters. +#[must_use] +pub fn list_interpreters() -> Vec { + let entries = registry().entries.lock(); + let mut out: Vec = entries + .iter() + .filter_map(|(&id, entry)| { + // Drop dead weak refs from the listing. + if entry.state.strong_count() == 0 { + return None; + } + Some(InterpreterInfo { + id, + whence: entry.whence, + }) + }) + .collect(); + out.sort_by_key(|info| info.id); + out +} + +/// Number of registered interpreters that are still alive. +#[must_use] +pub fn interpreter_count() -> usize { + list_interpreters().len() +} + +/// Reset the registry's locks after `fork()`. +/// +/// The tables are reachable from every thread, so a thread that died in the +/// fork may have left one locked; the child would then deadlock the first time +/// it enumerates interpreters (which the collector now does on every stop). +/// +/// # Safety +/// Must only be called after `fork()` in the child process, when no other +/// threads exist and the calling thread holds none of these locks. +#[cfg(all(unix, feature = "threading"))] +pub unsafe fn reinit_after_fork() { + unsafe { + crate::common::lock::reinit_mutex_after_fork(®istry().entries); + crate::common::lock::reinit_mutex_after_fork(owned_interpreters()); + crate::common::lock::reinit_mutex_after_fork(admission()); + } +} + +/// All live interpreter states, ordered by id. +/// +/// Used by the cyclic collector, which must stop every interpreter's threads +/// (not just the collecting one) because GC-tracked objects from all +/// interpreters share one object graph. Ordering is deterministic so that +/// multiple stop-the-world requesters always take exclusions in the same order. +#[must_use] +pub fn live_interpreter_states() -> Vec> { + let entries = registry().entries.lock(); + let mut states: Vec<(i64, PyRc)> = entries + .iter() + .filter_map(|(&id, entry)| entry.state.upgrade().map(|state| (id, state))) + .collect(); + drop(entries); + states.sort_by_key(|(id, _)| *id); + states.into_iter().map(|(_, state)| state).collect() +} + +/// Runtime-owned interpreters (the ownership anchor for the Python +/// `_interpreters` API). +/// +/// A Rust [`crate::Interpreter`] handle is normally owned by its Rust caller. +/// For PEP 734, `_interpreters.create()` returns only an id and the runtime +/// must keep the interpreter alive until `_interpreters.destroy(id)`. These +/// functions hold that ownership, keyed by interpreter id, while the weak +/// [`registry`] above still drives enumeration and lookup. +/// +/// Only available with the `threading` feature: a runtime-owned interpreter is +/// reachable from other OS threads, which requires `Interpreter: Send` (true +/// only when `PyObjectRef` is `Arc`-backed). +#[cfg(feature = "threading")] +fn owned_interpreters() -> &'static Mutex> { + use std::sync::OnceLock; + static OWNED: OnceLock>> = OnceLock::new(); + OWNED.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Transfer ownership of `interp` to the runtime, returning its id. +#[cfg(feature = "threading")] +pub fn store_owned_interpreter(interp: crate::Interpreter) -> i64 { + let id = interp.id(); + // Ids are strictly monotonic, so this never displaces (and drops) an + // existing entry under the lock. + owned_interpreters().lock().insert(id, interp); + id +} + +/// Reclaim a runtime-owned interpreter, removing it from the owner table. +/// +/// The returned handle is dropped by the caller *outside* the owner lock; its +/// `Drop` unregisters the interpreter from the weak [`registry`]. +#[cfg(feature = "threading")] +#[must_use] +pub fn take_owned_interpreter(id: i64) -> Option { + owned_interpreters().lock().remove(&id) +} + +/// Whether `id` refers to a runtime-owned interpreter. +#[cfg(feature = "threading")] +#[must_use] +pub fn is_owned_interpreter(id: i64) -> bool { + owned_interpreters().lock().contains_key(&id) +} + +/// Number of runtime-owned interpreters currently alive. +#[cfg(feature = "threading")] +#[must_use] +pub fn owned_interpreter_count() -> usize { + owned_interpreters().lock().len() +} diff --git a/crates/vm/src/vm/setting.rs b/crates/vm/src/vm/setting.rs index 7298c95ab08..3c42ca0b6fc 100644 --- a/crates/vm/src/vm/setting.rs +++ b/crates/vm/src/vm/setting.rs @@ -25,6 +25,7 @@ pub struct Paths { /// Combined configuration: user settings + computed paths /// CPython directly exposes every fields under both of them. /// We separate them to maintain better ownership discipline. +#[derive(Clone)] pub struct PyConfig { pub settings: Settings, pub paths: Paths, @@ -39,6 +40,7 @@ impl PyConfig { /// User-configurable settings for the python vm. #[non_exhaustive] +#[derive(Clone)] pub struct Settings { /// -I pub isolated: bool, diff --git a/crates/vm/src/vm/thread.rs b/crates/vm/src/vm/thread.rs index 1bab539a0a6..3b378acd6d7 100644 --- a/crates/vm/src/vm/thread.rs +++ b/crates/vm/src/vm/thread.rs @@ -8,11 +8,6 @@ use alloc::sync::Arc; #[cfg(all(unix, feature = "threading"))] use crate::frame::FrameObject; use crate::frame::InterpreterFrame; -#[cfg(all(unix, feature = "threading"))] -use crate::{AsObject, Py, PyObject, VirtualMachine}; -#[cfg(all(not(unix), feature = "threading"))] -use crate::{AsObject, PyObject, VirtualMachine}; -#[cfg(not(feature = "threading"))] use crate::{AsObject, PyObject, VirtualMachine}; #[cfg(all(unix, feature = "threading"))] use core::sync::atomic::AtomicPtr; @@ -22,6 +17,8 @@ use core::{ sync::atomic::{AtomicUsize, Ordering}, }; use itertools::Itertools; +#[cfg(feature = "threading")] +use std::collections::HashMap; use std::thread_local; // Thread states for stop-the-world support. @@ -90,7 +87,17 @@ thread_local! { pub(crate) static COROUTINE_ORIGIN_TRACKING_DEPTH: Cell = const { Cell::new(0) }; - /// Current thread's slot for sys._current_frames() and sys._current_exceptions() + /// Per-interpreter thread slots for this OS thread (PEP 734 multi-interpreter). + /// + /// CPython keeps a `PyThreadState` per (thread, interpreter) pair. RustPython + /// mirrors that: each interpreter's `PyGlobalState.thread_frames` gets its own + /// [`ThreadSlot`] for this OS thread. `CURRENT_THREAD_SLOT` always points at + /// the slot for the currently entered interpreter. + #[cfg(feature = "threading")] + static INTERP_THREAD_SLOTS: RefCell> = + RefCell::new(HashMap::new()); + + /// Current thread's slot for the currently entered interpreter. #[cfg(feature = "threading")] static CURRENT_THREAD_SLOT: RefCell> = const { RefCell::new(None) }; @@ -110,6 +117,21 @@ thread_local! { static CURRENT_TOP_FRAME_SLOT: Cell<*const AtomicPtr> = const { Cell::new(core::ptr::null()) }; + /// Cached pointer to this thread's `ThreadSlot::top_iframe` for the hot + /// light-frame push/pop path. The slot's Arc keeps the pointee alive. + #[cfg(feature = "threading")] + static CURRENT_TOP_IFRAME_SLOT: Cell<*const AtomicUsize> = + const { Cell::new(core::ptr::null()) }; + + /// Cached pointer to this thread's `ThreadSlot::stop_requested`, for the + /// safepoint the dispatch loop takes once per instruction. Reading it + /// through `CURRENT_THREAD_SLOT` costs a `RefCell` borrow — two stores to + /// thread-local memory — where this costs one relaxed load. The slot's Arc + /// keeps the pointee alive, as with the frame pointers above. + #[cfg(feature = "threading")] + static CURRENT_STOP_REQUESTED: Cell<*const core::sync::atomic::AtomicBool> = + const { Cell::new(core::ptr::null()) }; + } #[must_use] @@ -131,15 +153,42 @@ pub fn with_current_vm(f: impl FnOnce(&VirtualMachine) -> R) -> R { } fn set_current_vm(vm: &VirtualMachine, f: impl FnOnce() -> R) -> R { + // Attach to this VM's interpreter, detaching the enclosing one if this is a + // switch between interpreters on the same OS thread. + #[cfg(feature = "threading")] + let switched = begin_interpreter_section(vm); + VM_STACK.with(|vms| { vms.borrow_mut().push(vm.into()); scopeguard::defer! { vms.borrow_mut().pop(); + #[cfg(feature = "threading")] + end_interpreter_section(switched); } f() }) } +/// Pointer to the GC state of the interpreter running on this thread. +/// +/// The pointee belongs to the `PyGlobalState` of the VM on top of `VM_STACK`, +/// which is borrowed for the whole `set_current_vm` scope — so the pointer stays +/// valid as long as the caller remains inside that scope. +pub(crate) fn current_gc_state() -> Option> { + // Reached from every tracked allocation, including ones a thread-local + // destructor makes while the VM stack is being torn down, so neither a + // destroyed key nor an outstanding borrow may panic here. + VM_STACK + .try_with(|vms| { + let vm = vms.try_borrow().ok()?.last().copied()?; + // SAFETY: entries in VM_STACK either borrow a VM for the dynamic + // scope of a set_current_vm()/enter_vm() call or point at GILSTATE_VM. + Some(NonNull::from(&unsafe { vm.as_ref() }.state.gc)) + }) + .ok() + .flatten() +} + pub fn try_with_current_vm(f: impl FnOnce(&VirtualMachine) -> R) -> Option { VM_STACK.with(|vms| { let vm = vms.borrow().last().copied()?; @@ -150,27 +199,8 @@ pub fn try_with_current_vm(f: impl FnOnce(&VirtualMachine) -> R) -> Option } pub fn enter_vm(vm: &VirtualMachine, f: impl FnOnce() -> R) -> R { - // Outermost enter_vm: transition DETACHED → ATTACHED - #[cfg(feature = "threading")] - let was_outermost = !current_vm_is_set(); - - // Initialize thread slot for this thread if not already done - #[cfg(feature = "threading")] - init_thread_slot_if_needed(vm); - - #[cfg(feature = "threading")] - if was_outermost { - attach_thread(vm); - } - - scopeguard::defer! { - // Outermost exit: transition ATTACHED → DETACHED - #[cfg(feature = "threading")] - if was_outermost { - detach_thread(); - } - } - + // Attach/detach is handled by `set_current_vm`, which pairs it with the + // VM_STACK push so that switching interpreters mid-stack stays consistent. set_current_vm(vm, f) } @@ -188,29 +218,19 @@ pub fn enter_vm(vm: &VirtualMachine, f: impl FnOnce() -> R) -> R { #[must_use] pub(crate) struct VmBootstrapGuard { #[cfg(feature = "threading")] - was_outermost: bool, + switched: bool, } impl VmBootstrapGuard { pub(crate) fn new(vm: &VirtualMachine) -> Self { - // Outermost: transition DETACHED → ATTACHED - #[cfg(feature = "threading")] - let was_outermost = !current_vm_is_set(); - - // Initialize thread slot for this thread if not already done #[cfg(feature = "threading")] - init_thread_slot_if_needed(vm); - - #[cfg(feature = "threading")] - if was_outermost { - attach_thread(vm); - } + let switched = begin_interpreter_section(vm); VM_STACK.with(|vms| vms.borrow_mut().push(vm.into())); Self { #[cfg(feature = "threading")] - was_outermost, + switched, } } } @@ -221,11 +241,8 @@ impl Drop for VmBootstrapGuard { vms.borrow_mut().pop(); }); - // Outermost exit: transition ATTACHED → DETACHED #[cfg(feature = "threading")] - if self.was_outermost { - detach_thread(); - } + end_interpreter_section(self.switched); } } @@ -287,7 +304,13 @@ pub fn restore_current_thread(state: SavedThreadState) { // SAFETY: borrowed VMs remain alive for the dynamic save/restore scope, // while an owned GILState VM was restored above before this dereference. - attach_thread(unsafe { vm.as_ref() }); + let vm = unsafe { vm.as_ref() }; + // Point CURRENT_THREAD_SLOT at the restored interpreter before attach. + // After subinterpreter bootstrap, CURRENT may still refer to the temporary + // subinterpreter slot (DETACHED); attaching that would leave the parent + // slot detached and later confuse outermost detach. + init_thread_slot_if_needed(vm); + attach_thread(vm); VM_STACK.with(|vms| *vms.borrow_mut() = vm_stack); } @@ -340,43 +363,130 @@ pub fn release_current_thread(state: CurrentVmAttachState) { detach_thread(); } -/// Initialize thread slot for current thread if not already initialized. -/// Called automatically by enter_vm(). +/// Ensure this OS thread has a [`ThreadSlot`] registered with `vm`'s interpreter +/// and make it the current slot. +/// +/// Called automatically by `enter_vm()` / `VmBootstrapGuard` whenever a VM +/// becomes current. Switching between interpreters on the same OS thread swaps +/// `CURRENT_THREAD_SLOT` to that interpreter's slot (creating one if needed). #[cfg(feature = "threading")] fn init_thread_slot_if_needed(vm: &VirtualMachine) { - CURRENT_THREAD_SLOT.with(|slot| { - if slot.borrow().is_none() { - let thread_id = crate::stdlib::_thread::get_ident(); - let mut registry = vm.state.thread_frames.lock(); - let new_slot = Arc::new(ThreadSlot { - #[cfg(unix)] - top_frame: AtomicPtr::new(core::ptr::null_mut()), - top_iframe: AtomicUsize::new(0), - #[cfg(not(unix))] - frames: parking_lot::Mutex::new(Vec::new()), - exception: crate::PyAtomicRef::from(None::), - state: core::sync::atomic::AtomicI32::new( - if vm.state.stop_the_world.requested.load(Ordering::Acquire) { - // Match init_threadstate(): new thread-state starts - // suspended while stop-the-world is active. - THREAD_SUSPENDED - } else { - THREAD_DETACHED - }, - ), - stop_requested: core::sync::atomic::AtomicBool::new(false), - thread: std::thread::current(), - qsbr: crate::object::qsbr::QSBR.register(), - }); - registry.insert(thread_id, new_slot.clone()); - drop(registry); - #[cfg(all(unix, feature = "threading"))] - CURRENT_TOP_FRAME_SLOT.with(|c| c.set(&new_slot.top_frame)); - *slot.borrow_mut() = Some(new_slot); + let slot = ensure_thread_slot(vm); + set_current_thread_slot(slot); +} + +/// Look up (creating if needed) this thread's [`ThreadSlot`] for `vm`'s +/// interpreter, without making it the current slot. +#[cfg(feature = "threading")] +fn ensure_thread_slot(vm: &VirtualMachine) -> CurrentFrameSlot { + let interp_id = vm.state.interpreter_id; + INTERP_THREAD_SLOTS.with(|slots| { + let mut slots = slots.borrow_mut(); + if let Some(existing) = slots.get(&interp_id) { + return existing.clone(); } + + let thread_id = crate::stdlib::_thread::get_ident(); + let mut registry = vm.state.thread_frames.lock(); + let new_slot = Arc::new(ThreadSlot { + #[cfg(unix)] + top_frame: AtomicPtr::new(core::ptr::null_mut()), + top_iframe: AtomicUsize::new(0), + #[cfg(not(unix))] + frames: parking_lot::Mutex::new(Vec::new()), + exception: crate::PyAtomicRef::from(None::), + state: core::sync::atomic::AtomicI32::new( + if vm.state.stop_the_world.requested.load(Ordering::Acquire) { + // Match init_threadstate(): new thread-state starts + // suspended while stop-the-world is active. + THREAD_SUSPENDED + } else { + THREAD_DETACHED + }, + ), + stop_requested: core::sync::atomic::AtomicBool::new(false), + thread: std::thread::current(), + qsbr: crate::object::qsbr::QSBR.register(), + }); + registry.insert(thread_id, new_slot.clone()); + drop(registry); + slots.insert(interp_id, new_slot.clone()); + new_slot + }) +} + +/// Make `slot` the current thread slot (and the cached top-frame pointer). +#[cfg(feature = "threading")] +fn set_current_thread_slot(slot: CurrentFrameSlot) { + #[cfg(unix)] + CURRENT_TOP_FRAME_SLOT.with(|c| c.set(&slot.top_frame)); + CURRENT_TOP_IFRAME_SLOT.with(|c| c.set(&slot.top_iframe)); + CURRENT_STOP_REQUESTED.with(|c| c.set(&slot.stop_requested)); + CURRENT_THREAD_SLOT.with(|current| { + *current.borrow_mut() = Some(slot); }); } +/// Whether the current thread slot is ATTACHED. +#[cfg(feature = "threading")] +fn current_slot_is_attached() -> bool { + CURRENT_THREAD_SLOT.with(|slot| { + slot.borrow() + .as_ref() + .is_some_and(|s| s.state.load(Ordering::Acquire) == THREAD_ATTACHED) + }) +} + +/// Attach this thread to `vm`'s interpreter for the duration of a section, +/// detaching whichever interpreter it was attached to (≈ `_PyThreadState_Swap`). +/// +/// A thread must never be ATTACHED to two interpreters at once: stop-the-world +/// treats an ATTACHED slot as "running this interpreter's bytecode" and a +/// DETACHED slot as parkable without cooperation, so running interpreter B's +/// code while B's slot is DETACHED would let a collector conclude B is stopped +/// while this thread keeps mutating the (process-global) object graph. +/// +/// Returns whether the attachment changed, i.e. whether the matching +/// [`end_interpreter_section`] must undo it. +#[cfg(feature = "threading")] +fn begin_interpreter_section(vm: &VirtualMachine) -> bool { + let target = ensure_thread_slot(vm); + let already_current = CURRENT_THREAD_SLOT.with(|slot| { + slot.borrow() + .as_ref() + .is_some_and(|s| Arc::ptr_eq(s, &target)) + }); + if already_current && current_slot_is_attached() { + // Nested section in the same interpreter: already attached. + return false; + } + if !already_current && current_slot_is_attached() { + detach_thread(); + } + set_current_thread_slot(target); + attach_thread(vm); + true +} + +/// Undo [`begin_interpreter_section`]: detach this interpreter and re-attach the +/// enclosing one, if any. Call after the VM has been popped from `VM_STACK`. +#[cfg(feature = "threading")] +fn end_interpreter_section(switched: bool) { + if !switched { + return; + } + if current_slot_is_attached() { + detach_thread(); + } + // The enclosing section, if any, is the VM now on top of the stack. + if let Some(vm_ptr) = VM_STACK.with(|vms| vms.borrow().last().copied()) { + // SAFETY: entries on VM_STACK are valid for their enter/set_current_vm scope. + let vm = unsafe { vm_ptr.as_ref() }; + set_current_thread_slot(ensure_thread_slot(vm)); + attach_thread(vm); + } +} + /// Transition DETACHED → ATTACHED. Blocks if the thread was SUSPENDED by /// a stop-the-world request (like `_PyThreadState_Attach` + `tstate_wait_attach`). #[cfg(feature = "threading")] @@ -598,10 +708,12 @@ fn do_suspend(stw: &super::StopTheWorldState) { #[inline] #[must_use] pub fn stop_requested_for_current_thread() -> bool { - CURRENT_THREAD_SLOT.with(|slot| { - slot.borrow() - .as_ref() - .is_some_and(|s| s.stop_requested.load(Ordering::Relaxed)) + CURRENT_STOP_REQUESTED.with(|cached| { + let flag = cached.get(); + // SAFETY: the pointer is non-null only while `CURRENT_THREAD_SLOT` + // holds the `Arc` that owns the flag; both are cleared + // together in `cleanup_current_thread_frames`. + !flag.is_null() && unsafe { &*flag }.load(Ordering::Relaxed) }) } @@ -692,27 +804,28 @@ pub fn set_current_frame(frame: *const InterpreterFrame) -> *const InterpreterFr // sys._current_frames). #[cfg(feature = "threading")] { - CURRENT_THREAD_SLOT.with(|slot| { - if let Some(s) = slot.borrow().as_ref() { - if !frame.is_null() { - #[cfg(unix)] - { - let frame_obj = unsafe { (*frame).frame_obj() }; - let fo_ptr = match frame_obj { - Some(py) => { - py as *const Py as *const FrameObject - as *mut FrameObject - } - None => core::ptr::null_mut(), - }; - s.top_frame.store(fo_ptr, Ordering::Relaxed); - } - s.top_iframe.store(frame as usize, Ordering::Relaxed); + CURRENT_TOP_IFRAME_SLOT.with(|slot| { + let slot = slot.get(); + if !slot.is_null() { + unsafe { &*slot }.store(frame as usize, Ordering::Relaxed); + } + }); + #[cfg(unix)] + CURRENT_TOP_FRAME_SLOT.with(|slot| { + let slot = slot.get(); + if !slot.is_null() { + let fo_ptr = if frame.is_null() { + core::ptr::null_mut() } else { - #[cfg(unix)] - s.top_frame.store(core::ptr::null_mut(), Ordering::Relaxed); - s.top_iframe.store(0, Ordering::Relaxed); - } + let frame_obj = unsafe { (*frame).frame_obj() }; + // The payload address, which is what the cross-thread + // reader hands to `Py::from_payload_ptr`. The `Py` address + // would be off by the object header. + frame_obj.map_or(core::ptr::null_mut(), |py| { + core::ptr::from_ref::(py).cast_mut() + }) + }; + unsafe { &*slot }.store(fo_ptr, Ordering::Relaxed); } }); } @@ -759,16 +872,21 @@ pub fn get_all_current_exceptions(vm: &VirtualMachine) -> Vec<(u64, Option registry.remove(&thread_id), @@ -789,7 +907,6 @@ pub fn cleanup_current_thread_frames(vm: &VirtualMachine) { None }; - #[cfg(feature = "threading")] if let Some(slot) = &_removed && vm.state.stop_the_world.requested.load(Ordering::Acquire) && thread_id != vm.state.stop_the_world.requester_ident() @@ -799,12 +916,23 @@ pub fn cleanup_current_thread_frames(vm: &VirtualMachine) { // Unblock requester countdown progress. vm.state.stop_the_world.notify_thread_gone(); } - // Clear the cached top-frame pointer before dropping the slot Arc so no - // later `set_current_frame` dereferences freed slot memory. - #[cfg(all(unix, feature = "threading"))] - CURRENT_TOP_FRAME_SLOT.with(|c| c.set(core::ptr::null())); + + // If CURRENT pointed at the cleaned slot, clear it (and top-frame cache). CURRENT_THREAD_SLOT.with(|s| { - *s.borrow_mut() = None; + let clear = match (s.borrow().as_ref(), slot_to_clean.as_ref()) { + (Some(cur), Some(cleaned)) => Arc::ptr_eq(cur, cleaned), + (Some(_), None) => false, + (None, _) => false, + }; + if clear { + *s.borrow_mut() = None; + #[cfg(all(unix, feature = "threading"))] + CURRENT_TOP_FRAME_SLOT.with(|c| c.set(core::ptr::null())); + #[cfg(feature = "threading")] + CURRENT_TOP_IFRAME_SLOT.with(|c| c.set(core::ptr::null())); + #[cfg(feature = "threading")] + CURRENT_STOP_REQUESTED.with(|c| c.set(core::ptr::null())); + } }); } @@ -843,7 +971,7 @@ pub fn reinit_frame_slot_after_fork(vm: &VirtualMachine) { core::ptr::null_mut() } else { match unsafe { (*top_iframe).frame_obj() } { - Some(fo) => fo as *const Py as *const FrameObject as *mut FrameObject, + Some(fo) => core::ptr::from_ref::(fo).cast_mut(), None => core::ptr::null_mut(), } } @@ -865,6 +993,10 @@ pub fn reinit_frame_slot_after_fork(vm: &VirtualMachine) { }); #[cfg(all(unix, feature = "threading"))] CURRENT_TOP_FRAME_SLOT.with(|c| c.set(&new_slot.top_frame)); + #[cfg(feature = "threading")] + CURRENT_TOP_IFRAME_SLOT.with(|c| c.set(&new_slot.top_iframe)); + #[cfg(feature = "threading")] + CURRENT_STOP_REQUESTED.with(|c| c.set(&new_slot.stop_requested)); // Lock is safe: reinit_locks_after_fork() already reset it to unlocked. let mut registry = vm.state.thread_frames.lock(); @@ -873,7 +1005,23 @@ pub fn reinit_frame_slot_after_fork(vm: &VirtualMachine) { drop(registry); CURRENT_THREAD_SLOT.with(|s| { - *s.borrow_mut() = Some(new_slot); + *s.borrow_mut() = Some(new_slot.clone()); + }); + INTERP_THREAD_SLOTS.with(|slots| { + slots.borrow_mut().insert(vm.state.interpreter_id, new_slot); + }); +} + +/// Drop this thread's cached slots for every interpreter except `keep_id`. +/// +/// After `fork()` only the calling thread survives, and the other +/// interpreters' registries are cleared; a cached slot would otherwise stay +/// current for an interpreter that no longer lists it, hiding the thread from +/// that interpreter's stop-the-world. The next enter builds a fresh slot. +#[cfg(feature = "threading")] +pub fn purge_other_interpreter_slots_after_fork(keep_id: i64) { + INTERP_THREAD_SLOTS.with(|slots| { + slots.borrow_mut().retain(|&id, _| id == keep_id); }); } @@ -1023,7 +1171,7 @@ impl VirtualMachine { callable_cache: self.callable_cache.clone(), audit_hooks: RefCell::new(vec![]), pending_tailcall_frame: Cell::new(None), - pending_tailcall_refs: core::cell::UnsafeCell::new(Vec::with_capacity(2)), + pending_tailcall_owner: core::cell::UnsafeCell::new(None), }; ThreadedVirtualMachine { vm } } From f24b2570a92aa8de5c1077185cdc5572e7162cc5 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:32:29 +0900 Subject: [PATCH 312/351] Implement PEP 688 and rework the buffer protocol around managed exports (#8523) * Implement PEP 688 __buffer__ and __release_buffer__ A Python class could not export a buffer: the slot machinery had no bf_getbuffer or bf_releasebuffer, and every consumer acquired buffers as PyBUF_FULL_RO through a module of PyBUF_* constants. Add both slots. PyBuffer::release now runs a Python __release_buffer__ before the exporter's own release, once per acquisition, which PyBuffer tracks with an `acquired` flag that clones do not inherit. An export made by a Python __buffer__ is held by a _buffer_wrapper payload that counts its exports and drops the returned memoryview with the last one, and the view handed to __release_buffer__ is a _buffer_window that owns no export, so releasing it inside the hook is inert instead of re-entering it. Replace the PyBUF_* constants with a BufferFlags bitflags type whose composite requests are supersets of the simpler ones, so `contains` answers the REQ_* questions, and pass the request to PyBuffer::from_object. Each consumer now asks for what its counterpart asks for: y* arguments for SIMPLE, w* for WRITABLE, BytesIO.write for CONTIG_RO, bytes(), bytearray() and memoryview() for FULL_RO. memoryview checks the request in memory_getbuf, and array.array and mmap.mmap expose __release_buffer__. Test buffer support with PyObject::check_buffer (PyObject_CheckBuffer) instead of attempting an acquisition, so an exception raised by __buffer__ is no longer reported as the object not being bytes-like, and a __buffer__ with side effects runs once. PyBytesInner becomes a y* conversion as a result: bytes and bytearray methods no longer accept iterables of ints, and find, index, count and __contains__ take the arguments parse_args_finds_byte and bytes_contains describe. A view exports its start offset in the descriptor rather than in its window, which fixes a panic when collecting from a negative-stride view. BytesIO.write rechecks closed after acquiring its buffer, which __buffer__ can close in between. Assisted-by: Claude Code:claude-opus-5 * Rework the buffer protocol around managed exports and view offsets Give `PyBuffer` the `_PyManagedBufferObject` shape: one `bf_getbuffer` acquisition is shared by every handle taken from it, cloning takes another share instead of re-acquiring, and the exporter's release runs once when the last share goes away. Remove `retain`, the unsafe `drop_without_release`, the three `impl Drop`s and the `ManuallyDrop` that stood in for this. Add `abort_acquisition` so a failed request does not run `bf_releasebuffer`. Move the view start into `BufferDescriptor::offset`, the `Py_buffer.buf` analogue, and drop the separate `start` fields on `PyMemoryView` and `PyBufferWrapper`. Slicing goes through `SaturatedSlice::adjust_indices_start`, which reproduces `PySlice_AdjustIndices` and keeps the adjusted start. Fix `zip_eq` to take its contiguous fast path only when both last dimensions are contiguous, and make `for_each_segment` and `zip_eq` handle zero-length and zero-dimensional views. Add `BufferDescriptor::projected` so a request without `PyBUF_ND`, `PyBUF_STRIDES` or `PyBUF_FORMAT` receives a correspondingly reduced descriptor, and reject a request without `PyBUF_INDIRECT` against an exporter that has suboffsets. Copy the source first in `memoryview` slice assignment when both sides reach the same root exporter. Hold the export across the resize in `bytearray.extend`, take `y*` in `marshal.loads`, stop probing the buffer protocol in `FsPath`, rewrite `ord` over the concrete string types, fold `array`'s buffer slot into one `slot_as_buffer`, take `w*`/`y*` in `_overlapped`, and thread the new `offset` field through the `_ctypes` descriptors. Assisted-by: Claude * Re-check a memoryview after a conversion and tighten cast and release `pack_single` and `unpack_single` addressed the buffer with a position taken before `__index__` ran, so releasing the view from that conversion read or wrote outside the exporter's storage, panicking when it had also shrunk. Check the released flag again once the conversion is done, as `CHECK_RELEASED_AGAIN` does. Reject a cast to a format that is not a single native format character with an optional `@` in front of it. An empty format reached a division by its item size of zero. get_native_fmtchar Report a second `__release_buffer__` on the same view as a `ValueError` rather than accepting it, and check that the view belongs to the object first; the silent case is a view that exports nothing. wrap_releasebuffer Compare against another memoryview by reading its view where it lies instead of acquiring a buffer from it, so the restricted view handed to `__release_buffer__` compares equal rather than unequal in one direction only. memory_richcompare Name the type in the unraisable an exception from `__release_buffer__` reports, as `releasebuffer_call_python` does, instead of reporting the exporter object. Assisted-by: Claude * Re-check the destination of a memoryview slice assignment Acquiring the source runs `__buffer__`, which can release the destination view, so check the released flag again once the source is in hand and before the structures are compared, as `copy_single` does. Build the sliced destination as a view that counts as no export, the way a `Py_buffer dest = *view` copy does. Holding one kept the exporter unresizable for the length of the assignment, so a source that released the view and then resized the exporter met a `BufferError` instead of the assignment reporting the released view. Assisted-by: Claude * Answer the memoryview contiguity getsets with the layout `f_contiguous` reported the row-major answer for one dimension and False for any more, so a view laid out both ways, such as one of shape (1, 8), reported False while `check_buffer_request` accepted a `F_CONTIGUOUS` request on it. `contiguous` answered row-major order alone. Move the Fortran-order test from `PyMemoryView` to `BufferDescriptor`, next to the row-major one it differs from only in iteration order, and answer all three getsets with it. Assisted-by: Claude --- .cspell.json | 1 + Lib/test/test_buffer.py | 11 - Lib/test/test_collections.py | 1 - Lib/test/test_memoryio.py | 8 - Lib/test/test_memoryview.py | 1 - Lib/test/test_struct.py | 2 - crates/derive-impl/src/pyclass.rs | 9 +- crates/stdlib/src/array.rs | 52 +- crates/stdlib/src/mmap.rs | 2 + crates/stdlib/src/overlapped.rs | 26 +- crates/stdlib/src/ssl.rs | 36 +- crates/vm/src/anystr.rs | 30 +- crates/vm/src/builtins/bytearray.rs | 68 +- crates/vm/src/builtins/bytes.rs | 38 +- crates/vm/src/builtins/descriptor.rs | 37 ++ crates/vm/src/builtins/int.rs | 16 +- crates/vm/src/builtins/memory.rs | 725 +++++++++++++++------ crates/vm/src/builtins/str.rs | 29 +- crates/vm/src/builtins/type.rs | 13 - crates/vm/src/byte.rs | 11 +- crates/vm/src/bytes_inner.rs | 79 ++- crates/vm/src/cformat.rs | 37 +- crates/vm/src/function/buffer.rs | 47 +- crates/vm/src/function/fspath.rs | 12 +- crates/vm/src/function/mod.rs | 4 +- crates/vm/src/protocol/buffer.rs | 409 +++++++++++- crates/vm/src/protocol/mod.rs | 4 +- crates/vm/src/sliceable.rs | 44 ++ crates/vm/src/stdlib/_ctypes/array.rs | 1 + crates/vm/src/stdlib/_ctypes/base.rs | 4 +- crates/vm/src/stdlib/_ctypes/function.rs | 1 + crates/vm/src/stdlib/_ctypes/pointer.rs | 4 +- crates/vm/src/stdlib/_ctypes/simple.rs | 1 + crates/vm/src/stdlib/_ctypes/structure.rs | 1 + crates/vm/src/stdlib/_ctypes/union.rs | 1 + crates/vm/src/stdlib/_imp.rs | 11 +- crates/vm/src/stdlib/_io.rs | 10 +- crates/vm/src/stdlib/_sre.rs | 8 +- crates/vm/src/stdlib/builtins.rs | 40 +- crates/vm/src/stdlib/marshal.rs | 13 +- crates/vm/src/stdlib/winsound.rs | 6 +- crates/vm/src/types/slot.rs | 94 ++- crates/vm/src/types/slot_defs.rs | 64 +- crates/vm/src/vm/context.rs | 2 + extra_tests/snippets/builtin_memoryview.py | 424 ++++++++++++ 45 files changed, 1971 insertions(+), 466 deletions(-) diff --git a/.cspell.json b/.cspell.json index 2889a518409..57f7baab3f7 100644 --- a/.cspell.json +++ b/.cspell.json @@ -59,6 +59,7 @@ "alnum", "csock", "coro", + "contig", "Crnl", "dedentations", "dedents", diff --git a/Lib/test/test_buffer.py b/Lib/test/test_buffer.py index bc09329e6de..19582e75716 100644 --- a/Lib/test/test_buffer.py +++ b/Lib/test/test_buffer.py @@ -4471,7 +4471,6 @@ def test_flags_overflow(self): class TestPythonBufferProtocol(unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON def test_basic(self): class MyBuffer: def __buffer__(self, flags): @@ -4500,7 +4499,6 @@ def __buffer__(self): self.assertRaises(TypeError, memoryview, WrongArity()) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_release_buffer(self): class WhatToRelease: def __init__(self): @@ -4523,7 +4521,6 @@ def __release_buffer__(self, buffer): self.assertEqual(mv.tobytes(), b"hello") self.assertFalse(wr.held) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_same_buffer_returned(self): class WhatToRelease: def __init__(self): @@ -4549,7 +4546,6 @@ def __release_buffer__(self, buffer): self.assertEqual(mv.tobytes(), b"hello") self.assertFalse(wr.held) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_buffer_flags(self): class PossiblyMutable: def __init__(self, data, mutable) -> None: @@ -4589,7 +4585,6 @@ def __buffer__(self, flags): mv[0] = ord(b'x') self.assertEqual(mv.tobytes(), b"hello") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_call_builtins(self): ba = bytearray(b"hello") mv = ba.__buffer__(0) @@ -4651,7 +4646,6 @@ def __buffer__(self, flags): mv = memoryview(a) self.assertEqual(mv.tobytes(), b"hello") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_inheritance_releasebuffer(self): rb_call_count = 0 class B(bytearray): @@ -4668,7 +4662,6 @@ def __release_buffer__(self, view): self.assertEqual(rb_call_count, 0) self.assertEqual(rb_call_count, 1) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_inherit_but_return_something_else(self): class A(bytearray): def __buffer__(self, flags): @@ -4708,7 +4701,6 @@ def __release_buffer__(self, buffer): with memoryview(c) as mv: self.assertEqual(mv.tobytes(), b"hello") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_release_saves_reference(self): smuggled_buffer = None @@ -4736,7 +4728,6 @@ def __release_buffer__(s, buffer: memoryview): with self.assertRaises(ValueError): smuggled_buffer.tobytes() - @unittest.expectedFailure # TODO: RUSTPYTHON def test_release_saves_reference_no_subclassing(self): ba = bytearray(b"hello") @@ -4757,7 +4748,6 @@ def __release_buffer__(self, buffer): c.buffer.release() ba.clear() - @unittest.expectedFailure # TODO: RUSTPYTHON def test_multiple_inheritance_buffer_last(self): class A: def __buffer__(self, flags): @@ -4817,7 +4807,6 @@ def __buffer__(self, flags): c.clear() self.assertIs(c.buffer, None) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_release_buffer_with_exception_set(self): class A: def __buffer__(self, flags): diff --git a/Lib/test/test_collections.py b/Lib/test/test_collections.py index b5d3411c71a..c1dadc4e274 100644 --- a/Lib/test/test_collections.py +++ b/Lib/test/test_collections.py @@ -1956,7 +1956,6 @@ class X(ByteString): pass # No metaclass conflict class Z(ByteString, Awaitable): pass - @unittest.expectedFailure # TODO: RUSTPYTHON; Need to implement __buffer__ and __release_buffer__ (https://docs.python.org/3.13/reference/datamodel.html#emulating-buffer-types) def test_Buffer(self): for sample in [bytes, bytearray, memoryview]: self.assertIsInstance(sample(b"x"), Buffer) diff --git a/Lib/test/test_memoryio.py b/Lib/test/test_memoryio.py index 1683a71fc88..7f57d20f205 100644 --- a/Lib/test/test_memoryio.py +++ b/Lib/test/test_memoryio.py @@ -587,7 +587,6 @@ def test_issue5449(self): self.ioclass(initial_bytes=buf) self.assertRaises(TypeError, self.ioclass, buf, foo=None) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: a bytes-like object is required, not 'B' def test_write_concurrent_close(self): class B: def __buffer__(self, flags): @@ -601,7 +600,6 @@ def __buffer__(self, flags): # concurrently mutates (e.g., closes or exports) 'memio'. # See: https://github.com/python/cpython/issues/143378. - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: a bytes-like object is required, not 'B' def test_writelines_concurrent_close(self): class B: def __buffer__(self, flags): @@ -611,7 +609,6 @@ def __buffer__(self, flags): memio = self.ioclass() self.assertRaises(ValueError, memio.writelines, [B()]) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: a bytes-like object is required, not 'B' def test_write_concurrent_export(self): class B: buf = None @@ -622,7 +619,6 @@ def __buffer__(self, flags): memio = self.ioclass() self.assertRaises(BufferError, memio.write, B()) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: a bytes-like object is required, not 'B' def test_writelines_concurrent_export(self): class B: buf = None @@ -633,7 +629,6 @@ def __buffer__(self, flags): memio = self.ioclass() self.assertRaises(BufferError, memio.writelines, [B()]) - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: a bytes-like object is required, not 'B' def test_write_mutating_buffer(self): # Test that buffer is exported only once during write(). # See: https://github.com/python/cpython/issues/143602. @@ -930,9 +925,6 @@ def test_cow_mutable(self): def test_flags(self): return super().test_flags() - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: ValueError not raised by write - def test_write(self): - return super().test_write() class CStringIOTest(PyStringIOTest): ioclass = io.StringIO diff --git a/Lib/test/test_memoryview.py b/Lib/test/test_memoryview.py index 12e3504e42e..707540f299d 100644 --- a/Lib/test/test_memoryview.py +++ b/Lib/test/test_memoryview.py @@ -797,7 +797,6 @@ def __bool__(self): m[0] = MyBool() self.assertEqual(ba[:8], b'\0'*8) - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'memoryview' object has no attribute '__buffer__' def test_buffer_reference_loop(self): m = memoryview(b'abc').__buffer__(0) o = MyObject() diff --git a/Lib/test/test_struct.py b/Lib/test/test_struct.py index 31d2e58b108..c7663980939 100644 --- a/Lib/test/test_struct.py +++ b/Lib/test/test_struct.py @@ -498,12 +498,10 @@ def _test_pack_into(self, pack_into): with self.assertRaises((IndexError, OverflowError)): pack_into(writable_buf, -2**1000, test_string) - @unittest.expectedFailure # TODO: RUSTPYTHON; BufferError: non-contiguous buffer is not a bytes-like object def test_pack_into(self): s = struct.Struct('21s') self._test_pack_into(s.pack_into) - @unittest.expectedFailure # TODO: RUSTPYTHON; BufferError: non-contiguous buffer is not a bytes-like object def test_pack_into_fn(self): pack_into = lambda *args: struct.pack_into('21s', *args) self._test_pack_into(pack_into) diff --git a/crates/derive-impl/src/pyclass.rs b/crates/derive-impl/src/pyclass.rs index 809d3164b4a..94bb445fec6 100644 --- a/crates/derive-impl/src/pyclass.rs +++ b/crates/derive-impl/src/pyclass.rs @@ -1162,13 +1162,16 @@ where let slot_ident = Ident::new(&slot_ident.to_string().to_lowercase(), slot_ident.span()); let slot_name = slot_ident.to_string(); let tokens = { - const NON_ATOMIC_SLOTS: &[&str] = &["as_buffer"]; const POINTER_SLOTS: &[&str] = &["as_sequence", "as_mapping"]; const STATIC_GEN_SLOTS: &[&str] = &["as_number"]; - if NON_ATOMIC_SLOTS.contains(&slot_name.as_str()) { + if slot_name == "as_buffer" { + // bf_releasebuffer is not a separate function in RustPython; the + // exporter's BufferMethods already release. Only its presence is + // observable, and AsBuffer declares that. quote_spanned! { span => - slots.#slot_ident = Some(Self::#ident as _); + slots.#slot_ident.store(Some(Self::#ident as _)); + slots.has_release_buffer.store(Self::RELEASE_BUFFER); } } else if POINTER_SLOTS.contains(&slot_name.as_str()) { quote_spanned! { span => diff --git a/crates/stdlib/src/array.rs b/crates/stdlib/src/array.rs index 094e690665f..68e7aab2566 100644 --- a/crates/stdlib/src/array.rs +++ b/crates/stdlib/src/array.rs @@ -27,8 +27,8 @@ pub mod array { ArgBytesLike, ArgIntoFloat, ArgIterable, KwArgs, OptionalArg, PyComparisonValue, }, protocol::{ - BufferDescriptor, BufferMethods, BufferResizeGuard, PyBuffer, PyIterReturn, - PyMappingMethods, PySequenceMethods, + BufferDescriptor, BufferFlags, BufferMethods, BufferResizeGuard, PyBuffer, + PyIterReturn, PyMappingMethods, PySequenceMethods, }, sequence::{OptionalRangeArgs, SequenceExt, SequenceMutExt}, sliceable::{ @@ -732,12 +732,12 @@ pub mod array { } } else if init.downcastable::() || init.downcastable::() { init.try_bytes_like(vm, |x| array.frombytes(x))?; - } else if let Ok(iter) = ArgIterable::try_from_object(vm, init.clone()) { + } else { + // Everything else is taken item by item, buffer or not. + let iter = ArgIterable::try_from_object(vm, init)?; for obj in iter.iter(vm)? { array.push(obj?, vm)?; } - } else { - init.try_bytes_like(vm, |x| array.frombytes(x))?; } } @@ -1291,20 +1291,42 @@ pub mod array { } } + impl PyArray { + fn buffer_desc(&self) -> BufferDescriptor { + let array = self.read(); + BufferDescriptor::format( + array.len() * array.itemsize(), + false, + array.itemsize(), + array.typecode_str().into(), + ) + } + } + impl AsBuffer for PyArray { + const RELEASE_BUFFER: bool = true; + + // array_buffer_getbuf, which reports the type code only when the request + // asked for a format. + fn slot_as_buffer( + zelf: &PyObject, + flags: BufferFlags, + vm: &VirtualMachine, + ) -> PyResult { + let zelf = zelf + .downcast_ref::() + .ok_or_else(|| vm.new_type_error("unexpected payload for as_buffer"))?; + let desc = zelf.buffer_desc().projected(flags); + flags.check_writable(desc.readonly, "Object is not writable.", vm)?; + Ok(PyBuffer::new(zelf.to_owned().into(), desc, &BUFFER_METHODS)) + } + fn as_buffer(zelf: &Py, _vm: &VirtualMachine) -> PyResult { - let array = zelf.read(); - let buf = PyBuffer::new( + Ok(PyBuffer::new( zelf.to_owned().into(), - BufferDescriptor::format( - array.len() * array.itemsize(), - false, - array.itemsize(), - array.typecode_str().into(), - ), + zelf.buffer_desc(), &BUFFER_METHODS, - ); - Ok(buf) + )) } } diff --git a/crates/stdlib/src/mmap.rs b/crates/stdlib/src/mmap.rs index 91d4058a706..b5dec976594 100644 --- a/crates/stdlib/src/mmap.rs +++ b/crates/stdlib/src/mmap.rs @@ -611,6 +611,8 @@ mod mmap { }; impl AsBuffer for PyMmap { + const RELEASE_BUFFER: bool = true; + fn as_buffer(zelf: &Py, _vm: &VirtualMachine) -> PyResult { let readonly = matches!(zelf.access, AccessMode::Read); let buf = PyBuffer::new( diff --git a/crates/stdlib/src/overlapped.rs b/crates/stdlib/src/overlapped.rs index 86ac24e3a0f..6cdd0014604 100644 --- a/crates/stdlib/src/overlapped.rs +++ b/crates/stdlib/src/overlapped.rs @@ -12,7 +12,7 @@ mod _overlapped { builtins::{PyBaseExceptionRef, PyBytesRef, PyModule, PyStrRef, PyTupleRef, PyType}, common::lock::PyMutex, convert::{ToPyException, ToPyObject}, - function::OptionalArg, + function::{ArgBytesLike, ArgMemoryBuffer, OptionalArg}, object::{Traverse, TraverseFn}, protocol::PyBuffer, types::{Constructor, Destructor}, @@ -428,12 +428,14 @@ mod _overlapped { fn ReadFileInto( zelf: &Py, handle: isize, - buf: PyBuffer, + // w*, as _overlapped.Overlapped.ReadFileInto takes + buf: ArgMemoryBuffer, vm: &VirtualMachine, ) -> PyResult { use host_winapi::{ ERROR_BROKEN_PIPE, ERROR_IO_PENDING, ERROR_MORE_DATA, ERROR_SUCCESS, }; + let buf: PyBuffer = buf.into(); let mut inner = zelf.inner.lock(); if !matches!(inner.data, OverlappedData::None) { @@ -530,13 +532,15 @@ mod _overlapped { fn WSARecvInto( zelf: &Py, handle: isize, - buf: PyBuffer, + // w*, as _overlapped.Overlapped.WSARecvInto takes + buf: ArgMemoryBuffer, flags: u32, vm: &VirtualMachine, ) -> PyResult { use host_winapi::{ ERROR_BROKEN_PIPE, ERROR_IO_PENDING, ERROR_MORE_DATA, ERROR_SUCCESS, }; + let buf: PyBuffer = buf.into(); let mut inner = zelf.inner.lock(); if !matches!(inner.data, OverlappedData::None) { @@ -583,10 +587,12 @@ mod _overlapped { fn WriteFile( zelf: &Py, handle: isize, - buf: PyBuffer, + // y*, as _overlapped.Overlapped.WriteFile takes + buf: ArgBytesLike, vm: &VirtualMachine, ) -> PyResult { use host_winapi::{ERROR_IO_PENDING, ERROR_SUCCESS}; + let buf: PyBuffer = buf.into(); let mut inner = zelf.inner.lock(); if !matches!(inner.data, OverlappedData::None) { @@ -629,11 +635,13 @@ mod _overlapped { fn WSASend( zelf: &Py, handle: isize, - buf: PyBuffer, + // y*, as _overlapped.Overlapped.WSASend takes + buf: ArgBytesLike, flags: u32, vm: &VirtualMachine, ) -> PyResult { use host_winapi::{ERROR_IO_PENDING, ERROR_SUCCESS}; + let buf: PyBuffer = buf.into(); let mut inner = zelf.inner.lock(); if !matches!(inner.data, OverlappedData::None) { @@ -870,12 +878,14 @@ mod _overlapped { fn WSASendTo( zelf: &Py, handle: isize, - buf: PyBuffer, + // y*, as _overlapped.Overlapped.WSASendTo takes + buf: ArgBytesLike, flags: u32, address: PyTupleRef, vm: &VirtualMachine, ) -> PyResult { use host_winapi::{ERROR_IO_PENDING, ERROR_SUCCESS}; + let buf: PyBuffer = buf.into(); let mut inner = zelf.inner.lock(); if !matches!(inner.data, OverlappedData::None) { @@ -1001,7 +1011,8 @@ mod _overlapped { fn WSARecvFromInto( zelf: &Py, handle: isize, - buf: PyBuffer, + // w*, as _overlapped.Overlapped.WSARecvFromInto takes + buf: ArgMemoryBuffer, size: u32, flags: OptionalArg, vm: &VirtualMachine, @@ -1009,6 +1020,7 @@ mod _overlapped { use host_winapi::{ ERROR_BROKEN_PIPE, ERROR_IO_PENDING, ERROR_MORE_DATA, ERROR_SUCCESS, }; + let buf: PyBuffer = buf.into(); let mut inner = zelf.inner.lock(); if !matches!(inner.data, OverlappedData::None) { diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 18d171a8583..b942e27fc69 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -1158,19 +1158,19 @@ mod _ssl { let pwd_result = callable.call((), vm)?; // Convert callable result to string - let password_from_callable = if let Ok(pwd_str) = - PyUtf8StrRef::try_from_object(vm, pwd_result.clone()) - { - pwd_str.as_str().to_owned() - } else if let Ok(pwd_bytes_like) = ArgBytesLike::try_from_object(vm, pwd_result) { - String::from_utf8(pwd_bytes_like.borrow_buf().to_vec()).map_err(|_| { - vm.new_type_error("password callback returned invalid UTF-8 bytes") - })? - } else { - return Err( - vm.new_type_error("password callback must return a string or bytes") - ); - }; + let password_from_callable = + if let Ok(pwd_str) = PyUtf8StrRef::try_from_object(vm, pwd_result.clone()) { + pwd_str.as_str().to_owned() + } else if pwd_result.check_buffer() { + let pwd_bytes_like = ArgBytesLike::try_from_object(vm, pwd_result)?; + String::from_utf8(pwd_bytes_like.borrow_buf().to_vec()).map_err(|_| { + vm.new_type_error("password callback returned invalid UTF-8 bytes") + })? + } else { + return Err( + vm.new_type_error("password callback must return a string or bytes") + ); + }; // Validate callable password length if password_from_callable.len() > PEM_BUFSIZE { @@ -1808,7 +1808,8 @@ mod _ssl { // Validate filepath is str or bytes let path_str = if let Ok(s) = PyUtf8StrRef::try_from_object(vm, filepath.clone()) { s.as_str().to_owned() - } else if let Ok(b) = ArgBytesLike::try_from_object(vm, filepath) { + } else if filepath.check_buffer() { + let b = ArgBytesLike::try_from_object(vm, filepath)?; String::from_utf8(b.borrow_buf().to_vec()) .map_err(|_| vm.new_value_error("Invalid path encoding"))? } else { @@ -1863,7 +1864,8 @@ mod _ssl { // Validate name is str or bytes let curve_name = if let Ok(s) = PyUtf8StrRef::try_from_object(vm, name.clone()) { s.as_str().to_owned() - } else if let Ok(b) = ArgBytesLike::try_from_object(vm, name) { + } else if name.check_buffer() { + let b = ArgBytesLike::try_from_object(vm, name)?; String::from_utf8(b.borrow_buf().to_vec()) .map_err(|_| vm.new_value_error("Invalid curve name encoding"))? } else { @@ -2106,8 +2108,8 @@ mod _ssl { Ok((Some(pwd_str.as_str().to_owned()), None)) } // Try bytes-like - else if let Ok(pwd_bytes_like) = ArgBytesLike::try_from_object(vm, p.clone()) - { + else if p.check_buffer() { + let pwd_bytes_like = ArgBytesLike::try_from_object(vm, p.clone())?; let pwd = String::from_utf8(pwd_bytes_like.borrow_buf().to_vec()) .map_err(|_| vm.new_type_error("password bytes must be valid UTF-8"))?; Ok((Some(pwd), None)) diff --git a/crates/vm/src/anystr.rs b/crates/vm/src/anystr.rs index 45a69dbfe2f..0f187f6d476 100644 --- a/crates/vm/src/anystr.rs +++ b/crates/vm/src/anystr.rs @@ -4,7 +4,7 @@ use num_traits::{cast::ToPrimitive, sign::Signed}; use rustpython_unicode::case; use crate::{ - Py, PyObject, PyObjectRef, PyResult, TryFromObject, VirtualMachine, + AsObject, PyObject, PyObjectRef, PyResult, TryFromObject, VirtualMachine, builtins::{PyIntRef, PyTuple}, convert::TryFromBorrowedObject, function::OptionalOption, @@ -485,19 +485,25 @@ where F: Fn(T) -> PyResult, M: Fn(&PyObject) -> String, { - if let Ok(single) = obj.try_to_value::(vm) { - (predicate)(single) - } else { - let tuple: &Py = obj - .try_to_value(vm) - .map_err(|_| vm.new_type_error((message)(obj)))?; - - for obj in tuple { - if single_or_tuple_any(obj, predicate, message, vm)? { + // _Py_bytes_tailmatch: a tuple is taken apart before anything is converted, and + // each item is converted on its own terms, so a tuple of tuples is not an affix. + if let Some(tuple) = obj.downcast_ref::() { + for item in tuple { + if (predicate)(item.try_to_value::(vm)?)? { return Ok(true); } } - - Ok(false) + return Ok(false); } + + // Only the argument simply being the wrong kind of object is reported as such; + // whatever the conversion itself raised belongs to the caller. + let single = obj.try_to_value::(vm).map_err(|exc| { + if exc.fast_isinstance(vm.ctx.exceptions.type_error) { + vm.new_type_error((message)(obj)) + } else { + exc + } + })?; + (predicate)(single) } diff --git a/crates/vm/src/builtins/bytearray.rs b/crates/vm/src/builtins/bytearray.rs index 793b269d100..9be51a37012 100644 --- a/crates/vm/src/builtins/bytearray.rs +++ b/crates/vm/src/builtins/bytearray.rs @@ -1,7 +1,7 @@ //! Implementation of the python bytearray object. use super::{ - PositionIterInternal, PyBytes, PyDictRef, PyGenericAlias, PyIntRef, PyStrRef, PyTuple, - PyTupleRef, PyType, PyTypeRef, iter::builtins_iter, + PositionIterInternal, PyBytes, PyDictRef, PyGenericAlias, PyStrRef, PyTuple, PyTupleRef, + PyType, PyTypeRef, iter::builtins_iter, }; use crate::{ AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, @@ -11,7 +11,8 @@ use crate::{ byte::{bytes_from_object, value_from_object}, bytes_inner::{ ByteInnerFindOptions, ByteInnerHexOptions, ByteInnerNewOptions, ByteInnerPaddingOptions, - ByteInnerSplitOptions, ByteInnerTranslateOptions, DecodeArgs, PyBytesInner, bytes_decode, + ByteInnerSplitOptions, ByteInnerSub, ByteInnerTranslateOptions, DecodeArgs, PyBytesInner, + bytes_decode, }, class::PyClassImpl, common::{ @@ -23,10 +24,10 @@ use crate::{ }, convert::{ToPyObject, ToPyResult}, function::{ - ArgBytesLike, ArgIterable, ArgSize, Either, OptionalArg, OptionalOption, PyComparisonValue, + ArgBytesLike, ArgIterable, ArgSize, OptionalArg, OptionalOption, PyComparisonValue, }, protocol::{ - BufferDescriptor, BufferMethods, BufferResizeGuard, PyBuffer, PyIterReturn, + BufferDescriptor, BufferFlags, BufferMethods, BufferResizeGuard, PyBuffer, PyIterReturn, PyMappingMethods, PyNumberMethods, PySequenceMethods, }, sliceable::{SequenceIndex, SliceableSequenceMutOp, SliceableSequenceOp}, @@ -228,11 +229,8 @@ impl PyByteArray { self.inner().add(&other.borrow_buf()).into() } - fn __contains__( - &self, - needle: Either, - vm: &VirtualMachine, - ) -> PyResult { + fn __contains__(&self, needle: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let needle = ByteInnerSub::from_contains_arg(needle, vm)?; self.inner().contains(needle, vm) } @@ -613,12 +611,34 @@ impl Py { #[pymethod] fn extend(&self, object: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { if self.is(&object) { - PyByteArray::irepeat(self, 2, vm) - } else { - let items = bytes_from_object(vm, &object)?; - self.try_resizable(vm)?.elements.extend(items); - Ok(()) + return PyByteArray::irepeat(self, 2, vm); } + // bytearray_setslice keeps the export alive across the resize, so a value + // looking at this bytearray is what stops it from growing. + let buffer = object + .check_buffer() + .then(|| { + PyBuffer::from_object(vm, &object, BufferFlags::SIMPLE).map_err(|_| { + // What an exporter refuses to hand out leaves the value simply + // not usable here, whatever the exporter's own complaint was. + vm.new_type_error(format!( + "can't set bytearray slice from {}", + object.class().name() + )) + }) + }) + .transpose()?; + let items = match &buffer { + Some(buffer) => buffer + .as_contiguous() + .ok_or_else(|| { + vm.new_buffer_error("non-contiguous buffer is not a bytes-like object") + })? + .to_vec(), + None => bytes_from_object(vm, &object)?, + }; + self.try_resizable(vm)?.elements.extend(items); + Ok(()) } #[pymethod] @@ -731,6 +751,20 @@ static BUFFER_METHODS: BufferMethods = BufferMethods { }; impl AsBuffer for PyByteArray { + const RELEASE_BUFFER: bool = true; + + fn slot_as_buffer( + zelf: &PyObject, + flags: BufferFlags, + vm: &VirtualMachine, + ) -> PyResult { + let zelf = zelf + .downcast_ref::() + .ok_or_else(|| vm.new_type_error("unexpected payload for as_buffer"))?; + flags.fill_info_check(false, vm)?; + Self::as_buffer(zelf, vm) + } + fn as_buffer(zelf: &Py, _vm: &VirtualMachine) -> PyResult { Ok(PyBuffer::new( zelf.to_owned().into(), @@ -801,9 +835,7 @@ impl AsSequence for PyByteArray { } }), contains: atomic_func!(|seq, other, vm| { - let other = - >::try_from_object(vm, other.to_owned())?; - PyByteArray::sequence_downcast(seq).__contains__(other, vm) + PyByteArray::sequence_downcast(seq).__contains__(other.to_owned(), vm) }), inplace_concat: atomic_func!(|seq, other, vm| { let other = ArgBytesLike::try_from_object(vm, other.to_owned())?; diff --git a/crates/vm/src/builtins/bytes.rs b/crates/vm/src/builtins/bytes.rs index bb514b84ce1..d62b873bca7 100644 --- a/crates/vm/src/builtins/bytes.rs +++ b/crates/vm/src/builtins/bytes.rs @@ -1,27 +1,28 @@ use super::{ - PositionIterInternal, PyDictRef, PyGenericAlias, PyIntRef, PyStrRef, PyTuple, PyTupleRef, - PyType, PyTypeRef, iter::builtins_iter, + PositionIterInternal, PyDictRef, PyGenericAlias, PyStrRef, PyTuple, PyTupleRef, PyType, + PyTypeRef, iter::builtins_iter, }; use crate::common::lock::LazyLock; use crate::{ AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, - TryFromBorrowedObject, TryFromObject, VirtualMachine, + TryFromBorrowedObject, VirtualMachine, anystr::{self, AnyStr}, atomic_func, bytes_inner::{ ByteInnerFindOptions, ByteInnerHexOptions, ByteInnerNewOptions, ByteInnerPaddingOptions, - ByteInnerSplitOptions, ByteInnerTranslateOptions, DecodeArgs, PyBytesInner, bytes_decode, + ByteInnerSplitOptions, ByteInnerSub, ByteInnerTranslateOptions, DecodeArgs, PyBytesInner, + bytes_decode, }, class::PyClassImpl, common::{hash::PyHash, lock::PyMutex}, convert::{ToPyObject, ToPyResult}, function::{ - ArgBytesLike, ArgIndex, ArgIterable, Either, FuncArgs, OptionalArg, OptionalOption, + ArgBytesLike, ArgIndex, ArgIterable, FuncArgs, OptionalArg, OptionalOption, PyComparisonValue, }, protocol::{ - BufferDescriptor, BufferMethods, PyBuffer, PyIterReturn, PyMappingMethods, PyNumberMethods, - PySequenceMethods, + BufferDescriptor, BufferFlags, BufferMethods, PyBuffer, PyIterReturn, PyMappingMethods, + PyNumberMethods, PySequenceMethods, }, sliceable::{SequenceIndex, SliceableSequenceOp}, types::{ @@ -246,11 +247,8 @@ impl PyBytes { self.inner.add(&other.borrow_buf()) } - fn __contains__( - &self, - needle: Either, - vm: &VirtualMachine, - ) -> PyResult { + fn __contains__(&self, needle: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let needle = ByteInnerSub::from_contains_arg(needle, vm)?; self.inner.contains(needle, vm) } @@ -627,6 +625,18 @@ static BUFFER_METHODS: BufferMethods = BufferMethods { }; impl AsBuffer for PyBytes { + fn slot_as_buffer( + zelf: &PyObject, + flags: BufferFlags, + vm: &VirtualMachine, + ) -> PyResult { + let zelf = zelf + .downcast_ref::() + .ok_or_else(|| vm.new_type_error("unexpected payload for as_buffer"))?; + flags.fill_info_check(true, vm)?; + Self::as_buffer(zelf, vm) + } + fn as_buffer(zelf: &Py, _vm: &VirtualMachine) -> PyResult { let buf = PyBuffer::new( zelf.to_owned().into(), @@ -673,9 +683,7 @@ impl AsSequence for PyBytes { .map(|x| vm.ctx.new_bytes(vec![x]).into()) }), contains: atomic_func!(|seq, other, vm| { - let other = - >::try_from_object(vm, other.to_owned())?; - PyBytes::sequence_downcast(seq).__contains__(other, vm) + PyBytes::sequence_downcast(seq).__contains__(other.to_owned(), vm) }), ..PySequenceMethods::NOT_IMPLEMENTED }); diff --git a/crates/vm/src/builtins/descriptor.rs b/crates/vm/src/builtins/descriptor.rs index 5c0662e9fef..50bdc841abf 100644 --- a/crates/vm/src/builtins/descriptor.rs +++ b/crates/vm/src/builtins/descriptor.rs @@ -542,6 +542,10 @@ pub enum SlotFunc { NumBinaryRight(PyNumberBinaryFunc), // __radd__, __rsub__, etc. (swapped args) NumTernary(PyNumberTernaryFunc), // __pow__ NumTernaryRight(PyNumberTernaryFunc), // __rpow__ (swapped first two args) + + // Buffer protocol + GetBuffer(crate::types::AsBufferFunc), // __buffer__ + ReleaseBuffer, // __release_buffer__ } impl core::fmt::Debug for SlotFunc { @@ -582,6 +586,8 @@ impl core::fmt::Debug for SlotFunc { Self::NumBinaryRight(_) => write!(f, "SlotFunc::NumBinaryRight(...)"), Self::NumTernary(_) => write!(f, "SlotFunc::NumTernary(...)"), Self::NumTernaryRight(_) => write!(f, "SlotFunc::NumTernaryRight(...)"), + Self::GetBuffer(_) => write!(f, "SlotFunc::GetBuffer(...)"), + Self::ReleaseBuffer => write!(f, "SlotFunc::ReleaseBuffer"), } } } @@ -758,10 +764,41 @@ impl SlotFunc { let z = z.unwrap_or_else(|| vm.ctx.none()); func(&y, &obj, &z, vm) // Swapped: y ** obj % z } + // Buffer protocol + Self::GetBuffer(func) => { + let (flags_obj,): (PyObjectRef,) = args.bind(vm)?; + let buffer = func(&obj, parse_buffer_flags(flags_obj, vm)?, vm)?; + crate::builtins::PyMemoryView::from_buffer(buffer, vm) + .map(|mv| mv.into_pyobject(vm)) + } + Self::ReleaseBuffer => { + let (mv_obj,): (PyObjectRef,) = args.bind(vm)?; + let mv = mv_obj + .downcast::() + .map_err(|_| vm.new_type_error("expected a memoryview object"))?; + crate::builtins::memory::release_buffer_from_python(&obj, mv, vm)?; + Ok(vm.ctx.none()) + } } } } +/// Parse the `flags` argument of `__buffer__`. wrap_buffer +fn parse_buffer_flags( + arg: PyObjectRef, + vm: &VirtualMachine, +) -> PyResult { + use num_traits::ToPrimitive; + let idx = arg.try_index(vm)?; + let flags = idx + .as_bigint() + .to_isize() + .ok_or_else(|| vm.new_overflow_error("cannot fit 'int' into an index-sized integer"))?; + let flags = + i32::try_from(flags).map_err(|_| vm.new_overflow_error("buffer flags out of range"))?; + Ok(crate::protocol::BufferFlags::from_bits_retain(flags as u32)) +} + /// wrapper_descriptor: wraps a slot function as a Python method // = PyWrapperDescrObject #[pyclass(name = "wrapper_descriptor", module = false)] diff --git a/crates/vm/src/builtins/int.rs b/crates/vm/src/builtins/int.rs index 134617ab7ea..60463ed0d58 100644 --- a/crates/vm/src/builtins/int.rs +++ b/crates/vm/src/builtins/int.rs @@ -3,7 +3,7 @@ use crate::{ AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyRefExact, PyResult, TryFromBorrowedObject, VirtualMachine, builtins::PyUtf8StrRef, - bytes_inner::PyBytesInner, + byte::bytes_from_object, class::PyClassImpl, common::{ format::FormatSpec, @@ -572,13 +572,13 @@ impl PyInt { vm: &VirtualMachine, ) -> PyResult> { let signed = args.signed.map_or(false, Into::into); + // PyObject_Bytes, so an iterable of ints is as good as a buffer + let bytes = bytes_from_object(vm, &args.bytes)?; let value = match (args.byteorder, signed) { - (ArgByteOrder::Big, true) => BigInt::from_signed_bytes_be(args.bytes.as_bytes()), - (ArgByteOrder::Big, false) => BigInt::from_bytes_be(Sign::Plus, args.bytes.as_bytes()), - (ArgByteOrder::Little, true) => BigInt::from_signed_bytes_le(args.bytes.as_bytes()), - (ArgByteOrder::Little, false) => { - BigInt::from_bytes_le(Sign::Plus, args.bytes.as_bytes()) - } + (ArgByteOrder::Big, true) => BigInt::from_signed_bytes_be(&bytes), + (ArgByteOrder::Big, false) => BigInt::from_bytes_be(Sign::Plus, &bytes), + (ArgByteOrder::Little, true) => BigInt::from_signed_bytes_le(&bytes), + (ArgByteOrder::Little, false) => BigInt::from_bytes_le(Sign::Plus, &bytes), }; Self::with_value(cls, value, vm) } @@ -802,7 +802,7 @@ pub(crate) struct IntOptions { #[derive(FromArgs)] struct IntFromByteArgs { - bytes: PyBytesInner, + bytes: PyObjectRef, #[pyarg(any, default = ArgByteOrder::Big)] byteorder: ArgByteOrder, #[pyarg(named, optional)] diff --git a/crates/vm/src/builtins/memory.rs b/crates/vm/src/builtins/memory.rs index 9f8312a0704..31f99715742 100644 --- a/crates/vm/src/builtins/memory.rs +++ b/crates/vm/src/builtins/memory.rs @@ -8,7 +8,7 @@ use crate::{ TryFromBorrowedObject, TryFromObject, VirtualMachine, atomic_func, buffer::FormatSpec, bytes_inner::{ByteInnerHexOptions, bytes_to_hex}, - class::PyClassImpl, + class::{PyClassImpl, StaticType}, common::{ borrow::{BorrowedValue, BorrowedValueMut}, hash::PyHash, @@ -16,9 +16,9 @@ use crate::{ }, convert::ToPyObject, function::Either, - function::{FuncArgs, OptionalArg, PyComparisonValue}, + function::{ArgIndex, FuncArgs, OptionalArg, PyComparisonValue}, protocol::{ - BufferDescriptor, BufferMethods, PyBuffer, PyIterReturn, PyMappingMethods, + BufferDescriptor, BufferFlags, BufferMethods, PyBuffer, PyIterReturn, PyMappingMethods, PySequenceMethods, VecBuffer, }, sliceable::SequenceIndexOp, @@ -27,7 +27,7 @@ use crate::{ PyComparisonOp, Representable, SelfIter, }, }; -use core::{cmp::Ordering, fmt::Debug, mem::ManuallyDrop, ops::Range}; +use core::{cmp::Ordering, fmt::Debug, ops::Range}; use crossbeam_utils::atomic::AtomicCell; use itertools::Itertools; use rustpython_common::lock::PyMutex; @@ -37,18 +37,24 @@ pub struct PyMemoryViewNewArgs { object: PyObjectRef, } +#[derive(FromArgs)] +struct PyMemoryViewFromFlagsArgs { + object: PyObjectRef, + flags: ArgIndex, +} + #[pyclass(module = false, name = "memoryview")] #[derive(Debug)] pub struct PyMemoryView { - // avoid double release when memoryview had released the buffer before drop - buffer: ManuallyDrop, + /// One share of the acquisition this view is looking at, given up when the + /// view is released or dropped. + buffer: PyBuffer, // the released memoryview does not mean the buffer is destroyed // because the possible another memoryview is viewing from it released: AtomicCell, - // start does NOT mean the bytes before start will not be visited, - // it means the point we starting to get the absolute position via - // the needle - start: usize, + /// Forbids handing out anything that outlives this view, for the window + /// passed to `__release_buffer__`. + restricted: AtomicCell, format_spec: FormatSpec, // memoryview's options could be different from buffer's options desc: BufferDescriptor, @@ -71,13 +77,54 @@ impl PyMemoryView { FormatSpec::parse(format.as_bytes(), vm) } + /// The single native format character a cast is allowed to name, with an + /// optional `@` in front of it. get_native_fmtchar + fn native_fmtchar(format: &str) -> Option { + let format = format.strip_prefix('@').unwrap_or(format); + let [c] = *format.as_bytes() else { + return None; + }; + matches!( + c, + b'c' | b'b' + | b'B' + | b'h' + | b'H' + | b'i' + | b'I' + | b'l' + | b'L' + | b'q' + | b'Q' + | b'n' + | b'N' + | b'f' + | b'd' + | b'e' + | b'?' + | b'P' + ) + .then_some(c) + } + /// this should be the main entrance to create the memoryview /// to avoid the chained memoryview pub fn from_object(obj: &PyObject, vm: &VirtualMachine) -> PyResult { + Self::from_object_with_flags(obj, BufferFlags::FULL_RO, vm) + } + + // PyMemoryView_FromObjectAndFlags + pub fn from_object_with_flags( + obj: &PyObject, + flags: BufferFlags, + vm: &VirtualMachine, + ) -> PyResult { if let Some(other) = obj.downcast_ref::() { + other.try_not_released(vm)?; + other.try_not_restricted(vm)?; Ok(other.new_view()) } else { - let buffer = PyBuffer::try_from_borrowed_object(vm, obj)?; + let buffer = PyBuffer::from_object(vm, obj, flags)?; Self::from_buffer(buffer, vm) } } @@ -93,9 +140,9 @@ impl PyMemoryView { let desc = buffer.desc.clone(); Ok(Self { - buffer: ManuallyDrop::new(buffer), + buffer, released: AtomicCell::new(false), - start: 0, + restricted: AtomicCell::new(false), format_spec, desc, hash: OnceCell::new(), @@ -120,16 +167,29 @@ impl PyMemoryView { /// this should be the only way to create a memoryview from another memoryview. #[must_use] pub fn new_view(&self) -> Self { - let zelf = Self { + Self { buffer: self.buffer.clone(), released: AtomicCell::new(false), - start: self.start, + restricted: AtomicCell::new(false), format_spec: self.format_spec.clone(), desc: self.desc.clone(), hash: OnceCell::new(), - }; - zelf.buffer.retain(); - zelf + } + } + + /// A view for a temporary that never reaches Python. It counts as no export, + /// so the exporter stays exactly as resizable as it already was, the way a + /// `Py_buffer dest = *view` copy does. + #[must_use] + fn borrowed_view(&self) -> Self { + Self { + buffer: self.buffer.detached(), + released: AtomicCell::new(false), + restricted: AtomicCell::new(false), + format_spec: self.format_spec.clone(), + desc: self.desc.clone(), + hash: OnceCell::new(), + } } fn try_not_released(&self, vm: &VirtualMachine) -> PyResult<()> { @@ -140,22 +200,83 @@ impl PyMemoryView { } } + fn try_not_restricted(&self, vm: &VirtualMachine) -> PyResult<()> { + if self.restricted.load() { + Err(vm.new_value_error("cannot create new view on restricted memoryview")) + } else { + Ok(()) + } + } + + fn try_usable(&self, vm: &VirtualMachine) -> PyResult<()> { + self.try_not_released(vm)?; + self.try_not_restricted(vm) + } + + /// Reject a request this view cannot serve. memory_getbuf + fn check_buffer_request(&self, flags: BufferFlags, vm: &VirtualMachine) -> PyResult<()> { + let c_contiguous = self.desc.is_contiguous(); + flags.check_writable( + self.desc.readonly, + "memoryview: underlying buffer is not writable", + vm, + )?; + if flags.contains(BufferFlags::C_CONTIGUOUS) && !c_contiguous { + return Err(vm.new_buffer_error("memoryview: underlying buffer is not C-contiguous")); + } + if flags.contains(BufferFlags::F_CONTIGUOUS) && !self.desc.is_fortran_contiguous() { + return Err( + vm.new_buffer_error("memoryview: underlying buffer is not Fortran contiguous") + ); + } + if flags.contains(BufferFlags::ANY_CONTIGUOUS) + && !c_contiguous + && !self.desc.is_fortran_contiguous() + { + return Err(vm.new_buffer_error("memoryview: underlying buffer is not contiguous")); + } + // No exporter here produces a suboffset, so this is a guard rather than a + // reachable rejection. + if !flags.contains(BufferFlags::INDIRECT) && self.desc.has_suboffsets() { + return Err(vm.new_buffer_error("memoryview: underlying buffer requires suboffsets")); + } + if !flags.contains(BufferFlags::STRIDES) && !c_contiguous { + return Err(vm.new_buffer_error("memoryview: underlying buffer is not C-contiguous")); + } + if !flags.contains(BufferFlags::ND) && flags.intersects(BufferFlags::FORMAT) { + return Err(vm.new_buffer_error( + "memoryview: cannot cast to unsigned bytes if the format flag is present", + )); + } + Ok(()) + } + + /// The descriptor this view exports for `flags`, or an error if it cannot + /// serve the request. memory_getbuf + fn requested_desc( + &self, + flags: BufferFlags, + vm: &VirtualMachine, + ) -> PyResult { + self.check_buffer_request(flags, vm)?; + Ok(self.desc.projected(flags)) + } + fn getitem_by_idx(&self, i: isize, vm: &VirtualMachine) -> PyResult { if self.desc.ndim() != 1 { return Err( vm.new_not_implemented_error("multi-dimensional sub-views are not implemented") ); } - let (shape, stride, suboffset) = self.desc.dim_desc[0]; + let (shape, _, _) = self.desc.dim_desc[0]; let index = i .wrapped_at(shape) .ok_or_else(|| vm.new_index_error("index out of range"))?; - let index = index as isize * stride + suboffset; - let pos = (index + self.start as isize) as usize; - self.unpack_single(pos, vm) + self.unpack_single(self.desc.fast_position(&[index]) as usize, vm) } fn getitem_by_slice(&self, slice: &PySlice, vm: &VirtualMachine) -> PyResult { + self.try_not_restricted(vm)?; let mut other = self.new_view(); other.init_slice(slice, 0, vm)?; other.init_len(); @@ -166,20 +287,22 @@ impl PyMemoryView { fn getitem_by_multi_idx(&self, indexes: &[isize], vm: &VirtualMachine) -> PyResult { let pos = self.pos_from_multi_index(indexes, vm)?; let bytes = self.buffer.obj_bytes(); - format_unpack(&self.format_spec, &bytes[pos..pos + self.desc.itemsize], vm) + format_unpack( + &self.format_spec, + &bytes[pos..pos + self.format_spec.size()], + vm, + ) } fn setitem_by_idx(&self, i: isize, value: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { if self.desc.ndim() != 1 { return Err(vm.new_not_implemented_error("sub-views are not implemented")); } - let (shape, stride, suboffset) = self.desc.dim_desc[0]; + let (shape, _, _) = self.desc.dim_desc[0]; let index = i .wrapped_at(shape) .ok_or_else(|| vm.new_index_error("index out of range"))?; - let index = index as isize * stride + suboffset; - let pos = (index + self.start as isize) as usize; - self.pack_single(pos, value, vm) + self.pack_single(self.desc.fast_position(&[index]) as usize, value, vm) } fn setitem_by_multi_idx( @@ -193,7 +316,9 @@ impl PyMemoryView { } fn pack_single(&self, pos: usize, value: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { - let mut bytes = self.buffer.obj_bytes_mut(); + // The value is converted before the destination is borrowed, because the + // conversion runs `__index__` or `__float__`, which can read or write the + // same buffer. // TODO: Optimize let data = self.format_spec.pack(vec![value], vm).map_err(|_| { vm.new_type_error(format!( @@ -201,15 +326,23 @@ impl PyMemoryView { self.desc.format )) })?; - bytes[pos..pos + self.desc.itemsize].copy_from_slice(&data); + // The conversion, and the index that produced `pos`, could have released + // the view; `pos` addresses a buffer that is no longer there. + // CHECK_RELEASED_INT_AGAIN + self.try_not_released(vm)?; + let mut bytes = self.buffer.obj_bytes_mut(); + bytes[pos..pos + self.format_spec.size()].copy_from_slice(&data); Ok(()) } fn unpack_single(&self, pos: usize, vm: &VirtualMachine) -> PyResult { + // The index that produced `pos` could have released the view. + // CHECK_RELEASED_AGAIN + self.try_not_released(vm)?; let bytes = self.buffer.obj_bytes(); // TODO: Optimize self.format_spec - .unpack(&bytes[pos..pos + self.desc.itemsize], vm) + .unpack(&bytes[pos..pos + self.format_spec.size()], vm) .map(|x| { if x.len() == 1 { x[0].to_owned() @@ -234,9 +367,7 @@ impl PyMemoryView { Ordering::Equal => (), } - let pos = self.desc.position(indexes, vm)?; - let pos = (pos + self.start as isize) as usize; - Ok(pos) + Ok(self.desc.position(indexes, vm)? as usize) } fn init_len(&mut self) { @@ -244,50 +375,38 @@ impl PyMemoryView { self.desc.len = product * self.desc.itemsize; } + /// Move this view by `delta` bytes. The offset moves, unless a dimension + /// outside `dim` is reached through a pointer, in which case its suboffset + /// does. + fn adjust_position(&mut self, dim: usize, delta: isize) { + match self.desc.dim_desc[..dim] + .iter() + .rposition(|&(_, _, suboffset)| suboffset != 0) + { + Some(n) => self.desc.dim_desc[n].2 += delta, + None => self.desc.offset += delta, + } + } + fn init_range(&mut self, range: Range, dim: usize) { let (shape, stride, _) = self.desc.dim_desc[dim]; debug_assert!(shape >= range.len()); - let mut is_adjusted = false; - for (_, _, suboffset) in self.desc.dim_desc.iter_mut().rev() { - if *suboffset != 0 { - *suboffset += stride * range.start as isize; - is_adjusted = true; - break; - } - } - if !is_adjusted { - // no suboffset set, stride must be positive - self.start += stride as usize * range.start; - } - let new_len = range.len(); - self.desc.dim_desc[dim].0 = new_len; + self.adjust_position(dim, stride * range.start as isize); + self.desc.dim_desc[dim].0 = range.len(); } + // init_slice fn init_slice(&mut self, slice: &PySlice, dim: usize, vm: &VirtualMachine) -> PyResult<()> { let (shape, stride, _) = self.desc.dim_desc[dim]; let slice = slice.to_saturated(vm)?; - let (range, step, slice_len) = slice.adjust_indices(shape); - - let mut is_adjusted_suboffset = false; - for (_, _, suboffset) in self.desc.dim_desc.iter_mut().rev() { - if *suboffset != 0 { - *suboffset += stride * range.start as isize; - is_adjusted_suboffset = true; - break; - } - } - if !is_adjusted_suboffset { - // no suboffset set, stride must be positive - self.start += stride as usize - * if step.is_negative() { - range.end - 1 - } else { - range.start - }; - } + let (start, slice_len) = slice.adjust_indices_start(shape); + + // Repeated slicing multiplies the stride by the step every time, which + // overflows after about twenty rounds; C wraps there and so does this. + self.adjust_position(dim, stride.wrapping_mul(start)); self.desc.dim_desc[dim].0 = slice_len; - self.desc.dim_desc[dim].1 *= step; + self.desc.dim_desc[dim].1 = stride.wrapping_mul(slice.step()); Ok(()) } @@ -303,10 +422,12 @@ impl PyMemoryView { if dim + 1 == self.desc.ndim() { let mut v = Vec::with_capacity(shape); for _ in 0..shape { - let pos = index + suboffset; - let pos = (pos + self.start as isize) as usize; - let obj = - format_unpack(&self.format_spec, &bytes[pos..pos + self.desc.itemsize], vm)?; + let pos = (index + suboffset) as usize; + let obj = format_unpack( + &self.format_spec, + &bytes[pos..pos + self.format_spec.size()], + vm, + )?; v.push(obj); index += stride; } @@ -330,29 +451,42 @@ impl PyMemoryView { return Ok(false); } - if let Some(other) = other.downcast_ref::() - && other.released.load() - { - return Ok(false); - } - - let other = match PyBuffer::try_from_borrowed_object(vm, other) { - Ok(buf) => buf, - Err(_) => return Ok(false), + let other = if let Some(mv) = other.downcast_ref::() { + if mv.released.load() { + return Ok(false); + } + // Another view's buffer is read where it lies rather than acquired, + // so that a restricted view still compares. memory_richcompare + let mut view = mv.buffer.detached(); + view.desc = mv.desc.clone(); + view + } else { + match PyBuffer::try_from_borrowed_object(vm, other) { + Ok(buf) => buf, + Err(_) => return Ok(false), + } }; if !is_equiv_shape(&zelf.desc, &other.desc) { return Ok(false); } - let a_itemsize = zelf.desc.itemsize; - let b_itemsize = other.desc.itemsize; let a_format_spec = &zelf.format_spec; let b_format_spec = &Self::parse_format(&other.desc.format, vm)?; + // An element is as wide as its format, which a projected descriptor can + // make narrower than the item size it steps by. + let a_itemsize = a_format_spec.size(); + let b_itemsize = b_format_spec.size(); if zelf.desc.ndim() == 0 { - let a_val = format_unpack(a_format_spec, &zelf.buffer.obj_bytes()[..a_itemsize], vm)?; - let b_val = format_unpack(b_format_spec, &other.obj_bytes()[..b_itemsize], vm)?; + let a_pos = zelf.desc.offset as usize; + let b_pos = other.desc.offset as usize; + let a_bytes = zelf.buffer.obj_bytes(); + let a_val = format_unpack(a_format_spec, &a_bytes[a_pos..a_pos + a_itemsize], vm)?; + drop(a_bytes); + let b_bytes = other.obj_bytes(); + let b_val = format_unpack(b_format_spec, &b_bytes[b_pos..b_pos + b_itemsize], vm)?; + drop(b_bytes); return vm.bool_eq(&a_val, &b_val); } @@ -361,9 +495,8 @@ impl PyMemoryView { let a_bytes = zelf.buffer.obj_bytes(); let b_bytes = other.obj_bytes(); zelf.desc.zip_eq(&other.desc, false, |a_range, b_range| { - let a_range = (a_range.start + zelf.start as isize) as usize - ..(a_range.end + zelf.start as isize) as usize; - let b_range = b_range.start as usize..b_range.end as usize; + let a_range = a_range.start as usize..a_range.start as usize + a_itemsize; + let b_range = b_range.start as usize..b_range.start as usize + b_itemsize; let a_val = match format_unpack(a_format_spec, &a_bytes[a_range], vm) { Ok(val) => val, Err(e) => { @@ -384,39 +517,17 @@ impl PyMemoryView { ret } - fn obj_bytes(&self) -> BorrowedValue<'_, [u8]> { - if self.desc.is_contiguous() { - BorrowedValue::map(self.buffer.obj_bytes(), |x| { - &x[self.start..self.start + self.desc.len] - }) - } else { - BorrowedValue::map(self.buffer.obj_bytes(), |x| &x[self.start..]) - } - } - - fn obj_bytes_mut(&self) -> BorrowedValueMut<'_, [u8]> { - if self.desc.is_contiguous() { - BorrowedValueMut::map(self.buffer.obj_bytes_mut(), |x| { - &mut x[self.start..self.start + self.desc.len] - }) - } else { - BorrowedValueMut::map(self.buffer.obj_bytes_mut(), |x| &mut x[self.start..]) - } - } - fn as_contiguous(&self) -> Option> { self.desc.is_contiguous().then(|| { - BorrowedValue::map(self.buffer.obj_bytes(), |x| { - &x[self.start..self.start + self.desc.len] - }) + let range = self.desc.contiguous_range(); + BorrowedValue::map(self.buffer.obj_bytes(), |x| &x[range]) }) } fn _as_contiguous_mut(&self) -> Option> { self.desc.is_contiguous().then(|| { - BorrowedValueMut::map(self.buffer.obj_bytes_mut(), |x| { - &mut x[self.start..self.start + self.desc.len] - }) + let range = self.desc.contiguous_range(); + BorrowedValueMut::map(self.buffer.obj_bytes_mut(), |x| &mut x[range]) }) } @@ -427,9 +538,7 @@ impl PyMemoryView { buf.reserve(self.desc.len); let bytes = &*self.buffer.obj_bytes(); self.desc.for_each_segment(true, |range| { - let start = (range.start + self.start as isize) as usize; - let end = (range.end + self.start as isize) as usize; - buf.extend_from_slice(&bytes[start..end]); + buf.extend_from_slice(&bytes[range.start as usize..range.end as usize]); }) } } @@ -454,27 +563,7 @@ impl PyMemoryView { let mut data = vec![]; self.append_to(&mut data); - if self.desc.ndim() == 0 { - return VecBuffer::from(data) - .into_ref(&vm.ctx) - .into_pybuffer_with_descriptor(self.desc.clone()); - } - - let mut dim_desc = self.desc.dim_desc.clone(); - dim_desc.last_mut().unwrap().1 = self.desc.itemsize as isize; - dim_desc.last_mut().unwrap().2 = 0; - for i in (0..dim_desc.len() - 1).rev() { - dim_desc[i].1 = dim_desc[i + 1].1 * dim_desc[i + 1].0 as isize; - dim_desc[i].2 = 0; - } - - let desc = BufferDescriptor { - len: self.desc.len, - readonly: self.desc.readonly, - itemsize: self.desc.itemsize, - format: self.desc.format.clone(), - dim_desc, - }; + let desc = self.desc.contiguous(); VecBuffer::from(data) .into_ref(&vm.ctx) @@ -493,7 +582,7 @@ impl Py { return Err(vm.new_not_implemented_error("sub-view are not implemented")); } - let mut dest = self.new_view(); + let mut dest = self.borrowed_view(); dest.init_slice(slice, 0, vm)?; dest.init_len(); @@ -508,15 +597,11 @@ impl Py { }; }; - let src = if let Some(src) = src.downcast_ref::() { - if self.buffer.obj.is(&src.buffer.obj) { - src.to_contiguous(vm) - } else { - AsBuffer::as_buffer(src, vm)? - } - } else { - PyBuffer::try_from_object(vm, src)? - }; + // PyObject_GetBuffer(value, &src, PyBUF_FULL_RO) + let src = PyBuffer::try_from_object(vm, src)?; + // Acquiring the source ran `__buffer__`, which can release this view. + // copy_single: CHECK_RELEASED_INT_AGAIN + self.try_not_released(vm)?; if !is_equiv_structure(&src.desc, &dest.desc) { return Err(vm.new_value_error( @@ -524,11 +609,21 @@ impl Py { )); } + // copy_buffer reads the source as it stood before the copy began, which an + // overlapping assignment depends on and which also keeps the two borrows + // below off the same storage. + let src = if root_exporter(&src).is(&root_exporter(&dest.buffer)) { + let owned = src.to_contiguous(vm); + drop(src); + owned + } else { + src + }; + let mut bytes_mut = dest.buffer.obj_bytes_mut(); let src_bytes = src.obj_bytes(); dest.desc.zip_eq(&src.desc, true, |a_range, b_range| { - let a_range = (a_range.start + dest.start as isize) as usize - ..(a_range.end + dest.start as isize) as usize; + let a_range = a_range.start as usize..a_range.end as usize; let b_range = b_range.start as usize..b_range.end as usize; bytes_mut[a_range].copy_from_slice(&src_bytes[b_range]); false @@ -562,6 +657,17 @@ impl PyMemoryView { PyGenericAlias::from_args(cls, args, vm) } + #[pyclassmethod] + fn _from_flags( + _cls: PyTypeRef, + args: PyMemoryViewFromFlagsArgs, + vm: &VirtualMachine, + ) -> PyResult> { + let flags = + BufferFlags::from_bits_retain(args.flags.as_ref().try_to_primitive::(vm)? as u32); + Self::from_object_with_flags(&args.object, flags, vm).map(|mv| mv.into_ref(&vm.ctx)) + } + #[pymethod] pub fn release(&self) { if self.released.compare_exchange(false, true).is_ok() { @@ -571,7 +677,14 @@ impl PyMemoryView { #[pygetset] fn obj(&self, vm: &VirtualMachine) -> PyResult { - self.try_not_released(vm).map(|_| self.buffer.obj.clone()) + self.try_not_released(vm)?; + // A window over a buffer being released exposes no exporter, like a + // Py_buffer whose obj is NULL. + Ok(if self.buffer.obj.downcastable::() { + vm.ctx.none() + } else { + self.buffer.obj.clone() + }) } #[pygetset] @@ -647,7 +760,8 @@ impl PyMemoryView { #[pygetset] fn contiguous(&self, vm: &VirtualMachine) -> PyResult { - self.try_not_released(vm).map(|_| self.desc.is_contiguous()) + self.try_not_released(vm) + .map(|_| self.desc.is_contiguous() || self.desc.is_fortran_contiguous()) } #[pygetset] @@ -657,9 +771,8 @@ impl PyMemoryView { #[pygetset] fn f_contiguous(&self, vm: &VirtualMachine) -> PyResult { - // TODO: column-major order self.try_not_released(vm) - .map(|_| self.desc.ndim() <= 1 && self.desc.is_contiguous()) + .map(|_| self.desc.is_fortran_contiguous()) } #[pymethod] @@ -682,7 +795,7 @@ impl PyMemoryView { if let Some(tuple) = needle.downcast_ref::() && tuple.is_empty() { - return zelf.unpack_single(0, vm); + return zelf.unpack_single(zelf.desc.offset as usize, vm); } return Err(vm.new_type_error("invalid indexing of 0-dim memory")); } @@ -721,22 +834,26 @@ impl PyMemoryView { } #[pymethod] - fn tolist(&self, vm: &VirtualMachine) -> PyResult { + // memory_tolist + fn tolist(&self, vm: &VirtualMachine) -> PyResult { self.try_not_released(vm)?; let bytes = self.buffer.obj_bytes(); if self.desc.ndim() == 0 { - return Ok(vm.ctx.new_list(vec![format_unpack( + // A 0-dim view holds one element, which is what it unpacks to. + let pos = self.desc.offset as usize; + return format_unpack( &self.format_spec, - &bytes[..self.desc.itemsize], + &bytes[pos..pos + self.format_spec.size()], vm, - )?])); + ); } - self._to_list(&bytes, 0, 0, vm) + self._to_list(&bytes, self.desc.offset, 0, vm) + .map(Into::into) } #[pymethod] fn toreadonly(&self, vm: &VirtualMachine) -> PyResult> { - self.try_not_released(vm)?; + self.try_usable(vm)?; let mut other = self.new_view(); other.desc.readonly = true; Ok(other.into_ref(&vm.ctx)) @@ -808,31 +925,38 @@ impl PyMemoryView { fn cast_to_1d(&self, format: PyUtf8StrRef, vm: &VirtualMachine) -> PyResult { let format_str = format.as_str(); + if Self::native_fmtchar(format_str).is_none() { + return Err(vm.new_value_error( + "memoryview: destination format must be a native single character format prefixed with an optional '@'", + )); + } let format_spec = Self::parse_format(format_str, vm)?; let itemsize = format_spec.size(); if !self.desc.len.is_multiple_of(itemsize) { return Err(vm.new_type_error("memoryview: length is not a multiple of itemsize")); } - Ok(Self { + let zelf = Self { buffer: self.buffer.clone(), released: AtomicCell::new(false), - start: self.start, + restricted: AtomicCell::new(false), format_spec, desc: BufferDescriptor { len: self.desc.len, + offset: self.desc.offset, readonly: self.desc.readonly, itemsize, format: format_str.to_owned().into(), dim_desc: vec![(self.desc.len / itemsize, itemsize as isize, 0)], }, hash: OnceCell::new(), - }) + }; + Ok(zelf) } #[pymethod] fn cast(&self, args: CastArgs, vm: &VirtualMachine) -> PyResult> { - self.try_not_released(vm)?; + self.try_usable(vm)?; if !self.desc.is_contiguous() { return Err(vm.new_type_error("memoryview: casts are restricted to C-contiguous views")); } @@ -872,6 +996,11 @@ impl PyMemoryView { // 0 ndim is single item if shape_ndim == 0 { + if itemsize != other.desc.len { + return Err( + vm.new_type_error("memoryview: product(shape) * itemsize != buffer size") + ); + } other.desc.dim_desc = vec![]; other.desc.len = itemsize; return Ok(other.into_ref(&vm.ctx)); @@ -929,11 +1058,11 @@ impl Py { if self.desc.ndim() == 0 { // TODO: merge branches when we got conditional if let if needle.is(&vm.ctx.ellipsis) { - return self.pack_single(0, value, vm); + return self.pack_single(self.desc.offset as usize, value, vm); } else if let Some(tuple) = needle.downcast_ref::() && tuple.is_empty() { - return self.pack_single(0, value, vm); + return self.pack_single(self.desc.offset as usize, value, vm); } return Err(vm.new_type_error("invalid indexing of 0-dim memory")); } @@ -1002,33 +1131,43 @@ impl TryFromObject for SubscriptNeedle { } static BUFFER_METHODS: BufferMethods = BufferMethods { - obj_bytes: |buffer| buffer.obj_as::().obj_bytes(), - obj_bytes_mut: |buffer| buffer.obj_as::().obj_bytes_mut(), - release: |buffer| buffer.obj_as::().buffer.release(), - retain: |buffer| buffer.obj_as::().buffer.retain(), + obj_bytes: |buffer| buffer.obj_as::().buffer.obj_bytes(), + obj_bytes_mut: |buffer| buffer.obj_as::().buffer.obj_bytes_mut(), + // memory_releasebuf / memory_getbuf: a consumer's export of this view is a + // share of the acquisition the view is looking at. + release: |buffer| buffer.obj_as::().buffer.release_share(), + retain: |buffer| buffer.obj_as::().buffer.retain_share(), }; impl AsBuffer for PyMemoryView { - fn as_buffer(zelf: &Py, vm: &VirtualMachine) -> PyResult { - if zelf.released.load() { - Err(vm.new_value_error("operation forbidden on released memoryview object")) - } else { - Ok(PyBuffer::new( - zelf.to_owned().into(), - zelf.desc.clone(), - &BUFFER_METHODS, - )) - } + const RELEASE_BUFFER: bool = true; + + // memory_getbuf + fn slot_as_buffer( + zelf: &PyObject, + flags: BufferFlags, + vm: &VirtualMachine, + ) -> PyResult { + let zelf = zelf + .downcast_ref::() + .ok_or_else(|| vm.new_type_error("unexpected payload for as_buffer"))?; + zelf.try_usable(vm)?; + Ok(PyBuffer::new( + zelf.to_owned().into(), + zelf.requested_desc(flags, vm)?, + &BUFFER_METHODS, + )) } -} -impl Drop for PyMemoryView { - fn drop(&mut self) { - if self.released.load() { - unsafe { self.buffer.drop_without_release() }; - } else { - unsafe { ManuallyDrop::drop(&mut self.buffer) }; - } + fn as_buffer(zelf: &Py, vm: &VirtualMachine) -> PyResult { + zelf.try_usable(vm)?; + // memory_getbuf: *view = *base — the descriptor already says where the + // view starts. + Ok(PyBuffer::new( + zelf.to_owned().into(), + zelf.desc.clone(), + &BUFFER_METHODS, + )) } } @@ -1103,6 +1242,11 @@ impl Hashable for PyMemoryView { if !zelf.desc.readonly { return Err(vm.new_value_error("cannot hash writable memoryview object")); } + if !matches!(&*zelf.desc.format, "B" | "b" | "c") { + return Err( + vm.new_value_error("memoryview: hashing is restricted to formats 'B', 'b' or 'c'") + ); + } let val = zelf.contiguous_or_collect(|bytes| vm.state.hash_secret.hash_bytes(bytes)); let _ = zelf.hash.set(val); Ok(*zelf.hash.get().unwrap()) @@ -1131,6 +1275,211 @@ impl Representable for PyMemoryView { pub(crate) fn init(ctx: &'static Context) { PyMemoryView::extend_class(ctx, ctx.types.memoryview_type); PyMemoryViewIterator::extend_class(ctx, ctx.types.memoryviewiterator_type); + let wrapper_type = PyBufferWrapper::init_builtin_type(); + // bufferwrapper_as_buffer: bf_releasebuffer and no bf_getbuffer, so the type + // has `__release_buffer__` but no `__buffer__`. + wrapper_type.slots.has_release_buffer.store(true); + PyBufferWrapper::extend_class(ctx, wrapper_type); + PyBufferWindow::extend_class(ctx, PyBufferWindow::init_builtin_type()); +} + +#[pyclass(module = false, name = "_buffer_wrapper")] +#[derive(Debug)] +struct PyBufferWrapper { + // bw->obj: the object whose `__buffer__` produced the view + exporter: PyObjectRef, + // bw->mv: the memoryview `__buffer__` returned, dropped with the last export + returned_mv: PyMutex>>, + /// Memory of `returned_mv`, held on behalf of every live export. The wrapper + /// forwards shares of it rather than owning one. + view: PyBuffer, + /// Exports handed out for this wrapper; the wrapper is spent at zero. + exports: AtomicCell, +} + +impl PyPayload for PyBufferWrapper { + fn class(_ctx: &Context) -> &'static Py { + Self::static_type() + } +} + +#[pyclass(flags(DISALLOW_INSTANTIATION))] +impl PyBufferWrapper {} + +static BUFFER_WRAPPER_METHODS: BufferMethods = BufferMethods { + obj_bytes: |buffer| buffer.obj_as::().view.obj_bytes(), + obj_bytes_mut: |buffer| buffer.obj_as::().view.obj_bytes_mut(), + retain: |buffer| { + let wrapper = buffer.obj_as::(); + wrapper.exports.fetch_add(1); + wrapper.view.retain_share(); + }, + // bufferwrapper_releasebuf + release: |buffer| { + let wrapper = buffer.obj_as::(); + wrapper.view.release_share(); + if wrapper.exports.fetch_sub(1) != 1 { + return; + } + let Some(mv) = wrapper.returned_mv.lock().take() else { + return; + }; + // A native release runs when the memoryview itself is torn down; only a + // Python-level hook on a foreign exporter has to be called here. + if !mv.buffer.obj.is(&wrapper.exporter) + && wrapper.exporter.class().slots.python_release_buffer.load() + { + call_python_release_buffer(&wrapper.exporter, mv.clone()); + } + // Py_CLEAR(bw->mv): the view outlives this only if user code kept it. + drop(mv); + }, +}; + +// Read-only window over an exporter, handed to `__release_buffer__`. It owns no +// export, like a `Py_buffer` whose `obj` is NULL, so releasing it is inert and +// cannot recurse back into the hook. +#[pyclass(module = false, name = "_buffer_window")] +#[derive(Debug)] +struct PyBufferWindow { + source: PyBuffer, +} + +impl PyPayload for PyBufferWindow { + fn class(_ctx: &Context) -> &'static Py { + Self::static_type() + } +} + +#[pyclass(flags(DISALLOW_INSTANTIATION))] +impl PyBufferWindow {} + +static BUFFER_WINDOW_METHODS: BufferMethods = BufferMethods { + obj_bytes: |buffer| buffer.obj_as::().source.obj_bytes(), + obj_bytes_mut: |buffer| buffer.obj_as::().source.obj_bytes_mut(), + retain: |_buffer| {}, + release: |_buffer| {}, +}; + +/// The object that ultimately owns the bytes a buffer reads, seen through the +/// payloads that only forward to another export: a view, the wrapper holding what +/// a `__buffer__` returned, and the window handed to `__release_buffer__`. +/// +/// Two buffers that resolve to the same object address the same storage, so +/// borrowing one for writing while the other is borrowed for reading would +/// deadlock on it. +fn root_exporter(buffer: &PyBuffer) -> PyObjectRef { + let mut obj = buffer.obj.clone(); + loop { + let next = if let Some(view) = obj.downcast_ref::() { + view.buffer.obj.clone() + } else if let Some(wrapper) = obj.downcast_ref::() { + wrapper.view.obj.clone() + } else if let Some(window) = obj.downcast_ref::() { + window.source.obj.clone() + } else { + return obj; + }; + obj = next; + } +} + +// slot_bf_getbuffer +pub(crate) fn buffer_from_python_getbuffer( + obj: &PyObject, + flags: BufferFlags, + vm: &VirtualMachine, +) -> PyResult { + let flags_obj = vm.ctx.new_int(flags.bits() as i32); + let ret = vm.call_special_method(obj, identifier!(vm, __buffer__), (flags_obj,))?; + let mv = ret + .downcast::() + .map_err(|_| vm.new_type_error("__buffer__ returned non-memoryview object"))?; + + // PyObject_GetBuffer(ret, buffer, flags): the returned view has to satisfy + // the request in its own right. + mv.try_usable(vm)?; + let desc = mv.requested_desc(flags, vm)?; + let wrapper = PyBufferWrapper { + exporter: obj.to_owned(), + view: mv.buffer.detached(), + returned_mv: PyMutex::new(Some(mv)), + exports: AtomicCell::new(0), + } + .into_pyobject(vm); + + // PyBuffer::new retains once through BUFFER_WRAPPER_METHODS. + Ok(PyBuffer::new(wrapper, desc, &BUFFER_WRAPPER_METHODS)) +} + +// wrap_releasebuffer +pub(crate) fn release_buffer_from_python( + obj: &PyObject, + mv: PyRef, + vm: &VirtualMachine, +) -> PyResult<()> { + let view_obj = &mv.buffer.obj; + if view_obj.downcastable::() { + // A window exports nothing, so there is nothing left to release, as for + // a `Py_buffer` whose `obj` is NULL. + return Ok(()); + } + let exports_obj = view_obj.is(obj) + || view_obj + .downcast_ref::() + .is_some_and(|wrapper| wrapper.exporter.is(obj)); + if !exports_obj { + return Err(vm.new_value_error("memoryview's buffer is not this object")); + } + if mv.released.load() { + return Err(vm.new_value_error("memoryview's buffer has already been released")); + } + mv.release(); + Ok(()) +} + +// releasebuffer_call_python, for a buffer acquired from a native exporter +pub(crate) fn release_buffer_call_python(buffer: &PyBuffer) { + crate::vm::thread::try_with_current_vm(|vm| { + let exporter = buffer.obj.clone(); + let window = PyBufferWindow { + source: buffer.detached(), + } + .into_pyobject(vm); + let window = PyBuffer::new(window, buffer.desc.clone(), &BUFFER_WINDOW_METHODS); + let mv = match PyMemoryView::from_buffer(window, vm) { + Ok(mv) => mv, + Err(exc) => { + let msg = format!( + "Exception ignored in bf_releasebuffer of {}", + exporter.class().name() + ); + return vm.run_unraisable(exc, Some(msg), vm.ctx.none()); + } + }; + // Restricted, so user code cannot keep anything addressing the memory + // that is about to go away. + mv.restricted.store(true); + let mv = mv.into_ref(&vm.ctx); + call_python_release_buffer(&exporter, mv.clone()); + // The window does not outlive the release it was made for. + mv.release(); + }); +} + +fn call_python_release_buffer(exporter: &PyObject, mv: PyRef) { + crate::vm::thread::try_with_current_vm(|vm| { + let method = vm.get_special_method(exporter, identifier!(vm, __release_buffer__)); + if let Ok(Some(method)) = method + && let Err(exc) = method.invoke((mv,), vm) + { + let msg = format!( + "Exception ignored in __release_buffer__ of {}", + exporter.class().name() + ); + vm.run_unraisable(exc, Some(msg), vm.ctx.none()); + } + }); } fn format_unpack( diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index f3655d892ea..8a95dcb648c 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -23,7 +23,9 @@ use crate::{ function::{ArgIterable, ArgSize, FuncArgs, OptionalArg, OptionalOption, PyComparisonValue}, intern::PyInterned, object::{MaybeTraverse, Traverse, TraverseFn}, - protocol::{PyIterReturn, PyMappingMethods, PyNumberMethods, PySequenceMethods}, + protocol::{ + BufferFlags, PyBuffer, PyIterReturn, PyMappingMethods, PyNumberMethods, PySequenceMethods, + }, sequence::SequenceExt, sliceable::{SequenceIndex, SliceableSequenceOp}, types::{ @@ -441,15 +443,24 @@ impl Constructor for PyStr { if input.fast_isinstance(vm.ctx.types.str_type) { return Err(vm.new_type_error("decoding str is not supported")); } - if !input.fast_isinstance(vm.ctx.types.bytes_type) - && !input.fast_isinstance(vm.ctx.types.bytearray_type) - && crate::protocol::PyBuffer::try_from_borrowed_object(vm, &input).is_err() + let input = if input.fast_isinstance(vm.ctx.types.bytes_type) + || input.fast_isinstance(vm.ctx.types.bytearray_type) { - return Err(vm.new_type_error(format!( - "decoding to str: need a bytes-like object, {} found", - input.class().name() - ))); - } + input + } else { + // PyUnicode_FromEncodedObject: whatever an exporter + // complains about, the argument is simply not bytes-like. + let buffer = PyBuffer::from_object(vm, &input, BufferFlags::SIMPLE) + .map_err(|_| { + vm.new_type_error(format!( + "decoding to str: need a bytes-like object, {} found", + input.class().name() + )) + })?; + vm.ctx + .new_bytes(buffer.contiguous_or_collect(<[u8]>::to_vec)) + .into() + }; let enc_str = encoding.as_ref().map_or("utf-8", |e| e.as_str()); let s = vm .state diff --git a/crates/vm/src/builtins/type.rs b/crates/vm/src/builtins/type.rs index 5a7169983af..46e2e4ebfc6 100644 --- a/crates/vm/src/builtins/type.rs +++ b/crates/vm/src/builtins/type.rs @@ -821,8 +821,6 @@ impl PyType { slots.basicsize = base.slots.basicsize; } - Self::inherit_readonly_slots(&mut slots, &base); - // Normalize: any type with HAS_WEAKREF gets MANAGED_WEAKREF if slots.flags.has_feature(PyTypeFlags::HAS_WEAKREF) { slots.flags |= PyTypeFlags::MANAGED_WEAKREF; @@ -891,8 +889,6 @@ impl PyType { slots.basicsize = base.slots.basicsize; } - Self::inherit_readonly_slots(&mut slots, &base); - // Normalize: any type with HAS_WEAKREF gets MANAGED_WEAKREF if slots.flags.has_feature(PyTypeFlags::HAS_WEAKREF) { slots.flags |= PyTypeFlags::MANAGED_WEAKREF; @@ -1018,18 +1014,9 @@ impl PyType { } } - /// Inherit readonly slots from base type at creation time. - /// These slots are not AtomicCell and must be set before the type is used. - fn inherit_readonly_slots(slots: &mut PyTypeSlots, base: &Self) { - if slots.as_buffer.is_none() { - slots.as_buffer = base.slots.as_buffer; - } - } - /// Inherit slots from base type. inherit_slots pub(crate) fn inherit_slots(&self, base: &Self) { // Use SLOT_DEFS to iterate all slots - // Note: as_buffer is handled in inherit_readonly_slots (not AtomicCell) for def in SLOT_DEFS { def.accessor.copyslot_if_none(self, base); } diff --git a/crates/vm/src/byte.rs b/crates/vm/src/byte.rs index 933ddead4b9..0e90f296ac9 100644 --- a/crates/vm/src/byte.rs +++ b/crates/vm/src/byte.rs @@ -2,11 +2,16 @@ use num_traits::ToPrimitive; -use crate::{AsObject, PyObject, PyResult, VirtualMachine}; +use crate::{ + AsObject, PyObject, PyResult, VirtualMachine, + protocol::{BufferFlags, PyBuffer}, +}; +// PyBytes_FromObject pub fn bytes_from_object(vm: &VirtualMachine, obj: &PyObject) -> PyResult> { - if let Ok(elements) = obj.try_bytes_like(vm, |bytes| bytes.to_vec()) { - return Ok(elements); + if obj.check_buffer() { + let buffer = PyBuffer::from_object(vm, obj, BufferFlags::FULL_RO)?; + return Ok(buffer.contiguous_or_collect(|bytes| bytes.to_vec())); } if !obj.fast_isinstance(vm.ctx.types.str_type) diff --git a/crates/vm/src/bytes_inner.rs b/crates/vm/src/bytes_inner.rs index 6ceebb70d07..3c79ed3295d 100644 --- a/crates/vm/src/bytes_inner.rs +++ b/crates/vm/src/bytes_inner.rs @@ -1,6 +1,7 @@ // spell-checker:ignore unchunked use crate::{ - AsObject, PyObject, PyObjectRef, PyResult, TryFromBorrowedObject, VirtualMachine, + AsObject, PyObject, PyObjectRef, PyResult, TryFromBorrowedObject, TryFromObject, + VirtualMachine, anystr::{self, AnyStr, AnyStrContainer, AnyStrWrapper}, builtins::{ PyBaseExceptionRef, PyByteArray, PyBytes, PyBytesRef, PyInt, PyIntRef, PyStr, PyStrRef, @@ -12,7 +13,7 @@ use crate::{ common::wtf8::is_py_ascii_whitespace, function::{ArgIterable, Either, OptionalArg, OptionalOption, PyComparisonValue}, literal::escape::Escape, - protocol::PyBuffer, + protocol::{BufferFlags, PyBuffer}, sequence::{SequenceExt, SequenceMutExt}, types::PyComparisonOp, }; @@ -35,9 +36,10 @@ impl From> for PyBytesInner { } } +/// "y*": any bytes-like object, and nothing else. impl<'a> TryFromBorrowedObject<'a> for PyBytesInner { fn try_from_borrowed_object(vm: &VirtualMachine, obj: &'a PyObject) -> PyResult { - bytes_from_object(vm, obj).map(Self::from) + obj.try_bytes_like(vm, <[u8]>::to_vec).map(Self::from) } } @@ -137,10 +139,50 @@ impl ByteInnerNewOptions { } } +/// What is searched for: a bytes-like object, or a single byte given as an +/// integer. parse_args_finds_byte +pub enum ByteInnerSub { + Buffer(PyBytesInner), + Byte(PyIntRef), +} + +impl TryFromObject for ByteInnerSub { + fn try_from_object(vm: &VirtualMachine, obj: PyObjectRef) -> PyResult { + if obj.check_buffer() { + return PyBytesInner::try_from_object(vm, obj).map(Self::Buffer); + } + match obj.try_index_opt(vm) { + Some(int) => int.map(Self::Byte), + None => Err(vm.new_type_error(format!( + "argument should be integer or bytes-like object, not '{}'", + obj.class().name() + ))), + } + } +} + +impl ByteInnerSub { + /// The needle of a containment test, which is an integer if it is one at + /// all and a bytes-like object otherwise. bytes_contains + pub fn from_contains_arg(obj: PyObjectRef, vm: &VirtualMachine) -> PyResult { + match obj.try_index_opt(vm) { + Some(int) => int.map(Self::Byte), + None => PyBytesInner::try_from_object(vm, obj).map(Self::Buffer), + } + } + + fn into_vec(self, vm: &VirtualMachine) -> PyResult> { + Ok(match self { + Self::Buffer(buffer) => buffer.elements, + Self::Byte(int) => vec![int.as_bigint().byte_or(vm)?], + }) + } +} + #[derive(FromArgs)] pub struct ByteInnerFindOptions { #[pyarg(positional)] - sub: Either, + sub: ByteInnerSub, #[pyarg(positional, default)] start: Option, #[pyarg(positional, default)] @@ -153,10 +195,7 @@ impl ByteInnerFindOptions { len: usize, vm: &VirtualMachine, ) -> PyResult<(Vec, core::ops::Range)> { - let sub = match self.sub { - Either::A(v) => v.elements.to_vec(), - Either::B(int) => vec![int.as_bigint().byte_or(vm)?], - }; + let sub = self.sub.into_vec(vm)?; let range = anystr::adjust_indices(self.start, self.end, len); Ok((sub, range)) } @@ -203,14 +242,11 @@ impl ByteInnerTranslateOptions { let table = self.table.map_or_else( || Ok((0..=u8::MAX).collect::>()), |v| { - let bytes = v - .try_into_value::(vm) - .ok() - .filter(|v| v.elements.len() == 256) - .ok_or_else(|| { - vm.new_value_error("translation table must be 256 characters long") - })?; - Ok(bytes.elements.to_vec()) + let bytes: PyBytesInner = v.try_into_value(vm)?; + if bytes.elements.len() != 256 { + return Err(vm.new_value_error("translation table must be 256 characters long")); + } + Ok(bytes.elements) }, )?; @@ -363,10 +399,10 @@ impl PyBytesInner { self.elements.py_add(other) } - pub fn contains(&self, needle: Either, vm: &VirtualMachine) -> PyResult { + pub fn contains(&self, needle: ByteInnerSub, vm: &VirtualMachine) -> PyResult { Ok(match needle { - Either::A(byte) => self.elements.contains_str(byte.elements.as_slice()), - Either::B(int) => self.elements.contains(&int.as_bigint().byte_or(vm)?), + ByteInnerSub::Buffer(sub) => self.elements.contains_str(sub.elements.as_slice()), + ByteInnerSub::Byte(int) => self.elements.contains(&int.as_bigint().byte_or(vm)?), }) } @@ -522,7 +558,8 @@ impl PyBytesInner { pub fn fromhex_object(string: PyObjectRef, vm: &VirtualMachine) -> PyResult> { if let Some(s) = string.downcast_ref::() { Self::fromhex(s.as_bytes(), vm) - } else if let Ok(buffer) = PyBuffer::try_from_borrowed_object(vm, &string) { + } else if string.check_buffer() { + let buffer = PyBuffer::from_object(vm, &string, BufferFlags::SIMPLE)?; let borrowed = buffer .as_contiguous() .ok_or_else(|| vm.new_buffer_error("fromhex() requires a contiguous buffer"))?; @@ -974,7 +1011,7 @@ impl PyBytesInner { } pub fn concat(&self, other: &PyObject, vm: &VirtualMachine) -> PyResult> { - let buffer = PyBuffer::try_from_borrowed_object(vm, other)?; + let buffer = PyBuffer::from_object(vm, other, BufferFlags::SIMPLE)?; let borrowed = buffer.as_contiguous(); if let Some(other) = borrowed { let mut v = Vec::with_capacity(self.elements.len() + other.len()); diff --git a/crates/vm/src/cformat.rs b/crates/vm/src/cformat.rs index 3bba0e5f8e7..7d47da39928 100644 --- a/crates/vm/src/cformat.rs +++ b/crates/vm/src/cformat.rs @@ -22,7 +22,7 @@ use crate::{ wtf8::{CodePoint, Wtf8, Wtf8Buf}, }, function::ArgIntoFloat, - protocol::PyBuffer, + protocol::{BufferFlags, PyBuffer}, stdlib::builtins, }; @@ -39,24 +39,29 @@ fn spec_format_bytes( let b = builtins::ascii(obj, vm)?.as_bytes().to_vec(); Ok(b) } + // format_obj CFormatConversion::Str | CFormatConversion::Bytes => { - if let Ok(buffer) = PyBuffer::try_from_borrowed_object(vm, &obj) { - Ok(buffer.contiguous_or_collect(|bytes| spec.format_bytes(bytes))) - } else { - let bytes = vm - .get_special_method(&obj, identifier!(vm, __bytes__))? - .ok_or_else(|| { - let msg = format!( - "%b requires a bytes-like object, or an object that \ - implements __bytes__, not '{}'", - obj.class().name() - ); - vm.new_type_error(msg) - })? - .invoke((), vm)?; + if let Some(bytes) = obj.downcast_ref::() { + return Ok(spec.format_bytes(bytes.as_bytes())); + } + if let Some(bytearray) = obj.downcast_ref::() { + return Ok(spec.format_bytes(&bytearray.borrow_buf())); + } + if let Some(method) = vm.get_special_method(&obj, identifier!(vm, __bytes__))? { + let bytes = method.invoke((), vm)?; let bytes = PyBytes::try_from_borrowed_object(vm, &bytes)?; - Ok(spec.format_bytes(bytes.as_bytes())) + return Ok(spec.format_bytes(bytes.as_bytes())); } + if obj.check_buffer() { + let buffer = PyBuffer::from_object(vm, &obj, BufferFlags::FULL_RO)?; + return Ok(buffer.contiguous_or_collect(|bytes| spec.format_bytes(bytes))); + } + let msg = format!( + "%b requires a bytes-like object, or an object that \ + implements __bytes__, not '{}'", + obj.class().name() + ); + Err(vm.new_type_error(msg)) } }, CFormatType::Number(number_type) => match number_type { diff --git a/crates/vm/src/function/buffer.rs b/crates/vm/src/function/buffer.rs index 213193bb9c8..c73f27c041d 100644 --- a/crates/vm/src/function/buffer.rs +++ b/crates/vm/src/function/buffer.rs @@ -3,7 +3,7 @@ use crate::{ VirtualMachine, builtins::{PyStr, PyStrRef}, common::borrow::{BorrowedValue, BorrowedValueMut}, - protocol::PyBuffer, + protocol::{BufferFlags, PyBuffer}, }; // Python/getargs.c @@ -17,7 +17,7 @@ impl PyObject { where F: FnOnce(&[u8]) -> R, { - let buffer = PyBuffer::try_from_borrowed_object(vm, self)?; + let buffer = PyBuffer::from_object(vm, self, BufferFlags::SIMPLE)?; buffer .as_contiguous() .map(|x| f(&x)) @@ -28,7 +28,7 @@ impl PyObject { where F: FnOnce(&mut [u8]) -> R, { - let buffer = PyBuffer::try_from_borrowed_object(vm, self)?; + let buffer = PyBuffer::from_object(vm, self, BufferFlags::WRITABLE)?; buffer .as_contiguous_mut() .map(|mut x| f(&mut x)) @@ -77,9 +77,9 @@ impl From for PyObjectRef { } } -impl<'a> TryFromBorrowedObject<'a> for ArgBytesLike { - fn try_from_borrowed_object(vm: &VirtualMachine, obj: &'a PyObject) -> PyResult { - let buffer = PyBuffer::try_from_borrowed_object(vm, obj)?; +impl ArgBytesLike { + fn from_request(vm: &VirtualMachine, obj: &PyObject, flags: BufferFlags) -> PyResult { + let buffer = PyBuffer::from_object(vm, obj, flags)?; if buffer.desc.is_contiguous() { Ok(Self(buffer)) } else { @@ -88,6 +88,31 @@ impl<'a> TryFromBorrowedObject<'a> for ArgBytesLike { } } +impl<'a> TryFromBorrowedObject<'a> for ArgBytesLike { + fn try_from_borrowed_object(vm: &VirtualMachine, obj: &'a PyObject) -> PyResult { + Self::from_request(vm, obj, BufferFlags::SIMPLE) + } +} + +/// A bytes-like object asked for as `PyBUF_CONTIG_RO`, which is what a shape is +/// requested with rather than assumed. +#[derive(Debug, Traverse)] +pub struct ArgContiguousBytesLike(ArgBytesLike); + +impl core::ops::Deref for ArgContiguousBytesLike { + type Target = ArgBytesLike; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl<'a> TryFromBorrowedObject<'a> for ArgContiguousBytesLike { + fn try_from_borrowed_object(vm: &VirtualMachine, obj: &'a PyObject) -> PyResult { + ArgBytesLike::from_request(vm, obj, BufferFlags::CONTIG_RO).map(Self) + } +} + /// A memory buffer, read-write access. Like the `w*` format code for `PyArg_Parse` in CPython. #[derive(Debug, Traverse)] pub struct ArgMemoryBuffer(PyBuffer); @@ -124,7 +149,15 @@ impl From for PyBuffer { impl<'a> TryFromBorrowedObject<'a> for ArgMemoryBuffer { fn try_from_borrowed_object(vm: &VirtualMachine, obj: &'a PyObject) -> PyResult { - let buffer = PyBuffer::try_from_borrowed_object(vm, obj)?; + let buffer = PyBuffer::from_object(vm, obj, BufferFlags::WRITABLE).map_err(|exc| { + if obj.check_buffer() { + // An exporter that cannot serve the request leaves the argument + // simply the wrong kind of object, as `PyArg_Parse` reports it. + vm.new_type_error("buffer is not a read-write bytes-like object") + } else { + exc + } + })?; if !buffer.desc.is_contiguous() { Err(vm.new_buffer_error("non-contiguous buffer is not a bytes-like object")) } else if buffer.desc.readonly { diff --git a/crates/vm/src/function/fspath.rs b/crates/vm/src/function/fspath.rs index 50feef86dd0..954c82cb737 100644 --- a/crates/vm/src/function/fspath.rs +++ b/crates/vm/src/function/fspath.rs @@ -3,7 +3,6 @@ use crate::{ builtins::{PyBytes, PyBytesRef, PyStrRef}, convert::{IntoPyException, ToPyObject}, function::PyStr, - protocol::PyBuffer, }; use alloc::borrow::Cow; use core::hint::cold_path; @@ -147,16 +146,9 @@ impl ToPyObject for FsPath { } impl TryFromObject for FsPath { - // PyUnicode_FSDecoder in CPython + // PyUnicode_FSDecoder, which takes what PyOS_FSPath takes: str, bytes, or an + // object with __fspath__, and nothing that merely exports a buffer. fn try_from_object(vm: &VirtualMachine, obj: PyObjectRef) -> PyResult { - let obj = match obj.try_to_value::(vm) { - Ok(buffer) => { - let mut bytes = vec![]; - buffer.append_to(&mut bytes); - vm.ctx.new_bytes(bytes).into() - } - Err(_) => obj, - }; Self::try_from_path_like(obj, true, vm) } } diff --git a/crates/vm/src/function/mod.rs b/crates/vm/src/function/mod.rs index 2ec1d09e8ef..2b37c9fc8de 100644 --- a/crates/vm/src/function/mod.rs +++ b/crates/vm/src/function/mod.rs @@ -15,7 +15,9 @@ pub use argument::{ OptionalArg, OptionalOption, PosArgs, }; pub use arithmetic::{PyArithmeticValue, PyComparisonValue}; -pub use buffer::{ArgAsciiBuffer, ArgBytesLike, ArgMemoryBuffer, ArgStrOrBytesLike}; +pub use buffer::{ + ArgAsciiBuffer, ArgBytesLike, ArgContiguousBytesLike, ArgMemoryBuffer, ArgStrOrBytesLike, +}; pub use builtin::{IntoPyNativeFn, PyNativeFn, static_func, static_raw_func}; pub use either::Either; pub use fspath::FsPath; diff --git a/crates/vm/src/protocol/buffer.rs b/crates/vm/src/protocol/buffer.rs index d79c5e9933d..cf60775f76f 100644 --- a/crates/vm/src/protocol/buffer.rs +++ b/crates/vm/src/protocol/buffer.rs @@ -6,14 +6,101 @@ use crate::{ common::{ borrow::{BorrowedValue, BorrowedValueMut}, lock::{MapImmutable, PyMutex, PyMutexGuard}, + rc::PyRc, }, object::PyObjectPayload, sliceable::SequenceIndexOp, }; use alloc::borrow::Cow; +use bitflags::bitflags; use core::{fmt::Debug, ops::Range}; +use crossbeam_utils::atomic::AtomicCell; use itertools::Itertools; +bitflags! { + /// Capabilities a consumer asks a buffer exporter for, the `flags` argument of + /// `bf_getbuffer` and of `__buffer__` (`PyBUF_*`). + /// + /// The composite requests are supersets of the simpler ones, so + /// [`contains`](Self::contains) answers the `REQ_*` questions an exporter asks: + /// `flags.contains(BufferFlags::C_CONTIGUOUS)` is `REQ_C_CONTIGUOUS(flags)`. + #[derive(Copy, Clone, Debug, PartialEq, Eq)] + pub struct BufferFlags: u32 { + const WRITABLE = 0x0001; + const FORMAT = 0x0004; + const ND = 0x0008; + const STRIDES = 0x0010 | Self::ND.bits(); + const C_CONTIGUOUS = 0x0020 | Self::STRIDES.bits(); + const F_CONTIGUOUS = 0x0040 | Self::STRIDES.bits(); + const ANY_CONTIGUOUS = 0x0080 | Self::STRIDES.bits(); + const INDIRECT = 0x0100 | Self::STRIDES.bits(); + } +} + +impl BufferFlags { + /// `PyBUF_SIMPLE`: a plain read-only block of bytes. + pub const SIMPLE: Self = Self::empty(); + /// `PyBUF_CONTIG` + pub const CONTIG: Self = Self::ND.union(Self::WRITABLE); + /// `PyBUF_CONTIG_RO` + pub const CONTIG_RO: Self = Self::ND; + /// `PyBUF_STRIDED` + pub const STRIDED: Self = Self::STRIDES.union(Self::WRITABLE); + /// `PyBUF_STRIDED_RO` + pub const STRIDED_RO: Self = Self::STRIDES; + /// `PyBUF_RECORDS` + pub const RECORDS: Self = Self::STRIDED.union(Self::FORMAT); + /// `PyBUF_RECORDS_RO` + pub const RECORDS_RO: Self = Self::STRIDED_RO.union(Self::FORMAT); + /// `PyBUF_FULL`: everything an exporter can describe, writable. + pub const FULL: Self = Self::INDIRECT.union(Self::WRITABLE).union(Self::FORMAT); + /// `PyBUF_FULL_RO`: everything an exporter can describe, read-only. + pub const FULL_RO: Self = Self::INDIRECT.union(Self::FORMAT); + + /// `PyBUF_READ`. Belongs to `PyMemoryView_FromMemory`, not to `bf_getbuffer`. + const MEMORY_READ: Self = Self::from_bits_retain(0x100); + /// `PyBUF_WRITE`. Belongs to `PyMemoryView_FromMemory`, not to `bf_getbuffer`. + const MEMORY_WRITE: Self = Self::from_bits_retain(0x200); + + /// Whether this request is really a `PyMemoryView_FromMemory` access mode, + /// which no exporter can serve. + #[must_use] + pub const fn is_memory_access_mode(self) -> bool { + self.bits() == Self::MEMORY_READ.bits() || self.bits() == Self::MEMORY_WRITE.bits() + } + + /// Whether the consumer demands a writable buffer. + #[must_use] + pub const fn is_writable(self) -> bool { + self.intersects(Self::WRITABLE) + } + + /// The argument checks `PyBuffer_FillInfo` performs, for exporters that hand + /// out a flat block of bytes. + pub fn fill_info_check(self, readonly: bool, vm: &VirtualMachine) -> PyResult<()> { + if self == Self::SIMPLE { + return Ok(()); + } + if self.is_memory_access_mode() { + return Err(vm.new_system_error("bad argument to internal function")); + } + self.check_writable(readonly, "Object is not writable.", vm) + } + + /// Reject a writable request against a read-only export. + pub fn check_writable( + self, + readonly: bool, + message: &str, + vm: &VirtualMachine, + ) -> PyResult<()> { + if self.is_writable() && readonly { + return Err(vm.new_buffer_error(message.to_owned())); + } + Ok(()) + } +} + pub struct BufferMethods { pub obj_bytes: fn(&PyBuffer) -> BorrowedValue<'_, [u8]>, pub obj_bytes_mut: fn(&PyBuffer) -> BorrowedValueMut<'_, [u8]>, @@ -32,13 +119,46 @@ impl Debug for BufferMethods { } } -#[derive(Debug, Clone, Traverse)] +/// One acquisition from an exporter: the state a single `bf_getbuffer` set up, +/// shared by every handle taken from it. _PyManagedBufferObject +#[derive(Debug)] +struct BufferExport { + /// Handles and raw shares that have not been given up yet. mbuf->exports + shares: AtomicCell, + /// Whether the exporter's release has already run. + /// _Py_MANAGED_BUFFER_RELEASED + released: AtomicCell, +} + +#[derive(Debug, Traverse)] pub struct PyBuffer { pub obj: PyObjectRef, #[pytraverse(skip)] pub desc: BufferDescriptor, #[pytraverse(skip)] methods: &'static BufferMethods, + #[pytraverse(skip)] + export: PyRc, + /// Whether this handle still holds its share of `export`. + #[pytraverse(skip)] + owns_share: AtomicCell, +} + +/// Cloning takes another share of the same acquisition rather than asking the +/// exporter for a new one, and the exporter's release waits for the last share. +/// mbuf_add_view +impl Clone for PyBuffer { + fn clone(&self) -> Self { + debug_assert!(!self.export.released.load()); + self.export.shares.fetch_add(1); + Self { + obj: self.obj.clone(), + desc: self.desc.clone(), + methods: self.methods, + export: self.export.clone(), + owns_share: AtomicCell::new(true), + } + } } impl PyBuffer { @@ -47,8 +167,17 @@ impl PyBuffer { #[cfg(debug_assertions)] let desc = desc.validate(); - let zelf = Self { obj, desc, methods }; - zelf.retain(); + let zelf = Self { + obj, + desc, + methods, + export: PyRc::new(BufferExport { + shares: AtomicCell::new(1), + released: AtomicCell::new(false), + }), + owns_share: AtomicCell::new(true), + }; + (zelf.methods.retain)(&zelf); zelf } @@ -78,14 +207,16 @@ impl PyBuffer { /// assume the buffer is contiguous #[must_use] pub unsafe fn contiguous_unchecked(&self) -> BorrowedValue<'_, [u8]> { - self.obj_bytes() + let range = self.desc.contiguous_range(); + BorrowedValue::map(self.obj_bytes(), |x| &x[range]) } /// # Safety /// assume the buffer is contiguous and writable #[must_use] pub unsafe fn contiguous_mut_unchecked(&self) -> BorrowedValueMut<'_, [u8]> { - self.obj_bytes_mut() + let range = self.desc.contiguous_range(); + BorrowedValueMut::map(self.obj_bytes_mut(), |x| &mut x[range]) } pub fn append_to(&self, buf: &mut Vec) { @@ -113,6 +244,18 @@ impl PyBuffer { f(v) } + /// A copy of these bytes in C order, keeping shape and format. The copy + /// borrows nothing from the exporter, so it can be read while the exporter is + /// borrowed for writing. + #[must_use] + pub fn to_contiguous(&self, vm: &VirtualMachine) -> Self { + let mut data = vec![]; + self.append_to(&mut data); + VecBuffer::from(data) + .into_ref(&vm.ctx) + .into_pybuffer_with_descriptor(self.desc.contiguous()) + } + #[must_use] pub fn obj_as(&self) -> &Py { unsafe { self.obj.downcast_unchecked_ref() } @@ -128,31 +271,87 @@ impl PyBuffer { (self.methods.obj_bytes_mut)(self) } + /// Give up this handle's share of the acquisition. PyBuffer_Release + /// + /// Idempotent: a handle that has already been released owns nothing, so + /// dropping it afterwards does nothing, like a `Py_buffer` whose `obj` was + /// cleared. + /// + /// This can run arbitrary Python through `__release_buffer__`, so no borrow + /// of the exporter may be held while a buffer is released or dropped. pub fn release(&self) { + if self.owns_share.swap(false) { + self.drop_share(); + } + } + + /// Take a share of this acquisition that no handle owns. An exporter that + /// forwards a consumer's export onto a buffer it holds itself keeps the + /// acquisition alive this way. memory_getbuf + pub(crate) fn retain_share(&self) { + self.export.shares.fetch_add(1); + } + + /// Give back a share taken by [`Self::retain_share`]. memory_releasebuf + pub(crate) fn release_share(&self) { + self.drop_share(); + } + + fn drop_share(&self) { + if self.export.shares.fetch_sub(1) == 1 { + self.finalize(); + } + } + + /// The exporter learns its export is gone, once per acquisition. mbuf_release + fn finalize(&self) { + // Latched before the hook runs, so a release re-entered from Python is + // inert. + if self.export.released.swap(true) { + return; + } + // slot_bf_releasebuffer: a Python-level `__release_buffer__` runs first, + // then the exporter's own release so export counts stay balanced. + if self.obj.class().slots.python_release_buffer.load() { + crate::builtins::memory::release_buffer_call_python(self); + } (self.methods.release)(self) } - pub fn retain(&self) { - (self.methods.retain)(self) + /// Undo an acquisition the exporter had already handed out but that could not + /// be served, without telling Python: `bf_releasebuffer` does not run when + /// `bf_getbuffer` fails. + pub(crate) fn abort_acquisition(self) { + debug_assert_eq!(self.export.shares.load(), 1); + self.owns_share.store(false); + self.export.released.store(true); + (self.methods.release)(&self); } - // drop PyBuffer without calling release - // after this function, the owner should use forget() - // or wrap PyBuffer in the ManuallyDrop to prevent drop() - pub(crate) unsafe fn drop_without_release(&mut self) { - // SAFETY: requirements forwarded from caller - unsafe { - core::ptr::drop_in_place(&mut self.obj); - core::ptr::drop_in_place(&mut self.desc); + /// A copy that owns no share: it reads the same memory, but releasing it is + /// inert and it never finalizes the acquisition. A `Py_buffer` whose `obj` is + /// NULL. + #[must_use] + pub fn detached(&self) -> Self { + Self { + obj: self.obj.clone(), + desc: self.desc.clone(), + methods: self.methods, + export: self.export.clone(), + owns_share: AtomicCell::new(false), } } } -impl<'a> TryFromBorrowedObject<'a> for PyBuffer { - fn try_from_borrowed_object(vm: &VirtualMachine, obj: &'a PyObject) -> PyResult { +impl PyBuffer { + /// Acquire a buffer from `obj`. PyObject_GetBuffer + pub fn from_object(vm: &VirtualMachine, obj: &PyObject, flags: BufferFlags) -> PyResult { + if flags.is_memory_access_mode() { + return Err(vm.new_system_error("bad argument to internal function")); + } let cls = obj.class(); - if let Some(f) = cls.slots.as_buffer { - return f(obj, vm); + if let Some(f) = cls.slots.as_buffer.load() { + return f(obj, flags, vm); } Err(vm.new_type_error(format!( "a bytes-like object is required, not '{}'", @@ -161,6 +360,26 @@ impl<'a> TryFromBorrowedObject<'a> for PyBuffer { } } +impl PyObject { + /// Whether this object's type exports the buffer protocol. PyObject_CheckBuffer + /// + /// A consumer that falls back to something else for non-buffer objects asks + /// this instead of attempting an acquisition, so that an error raised by + /// `__buffer__` is not mistaken for "not a buffer". + #[must_use] + pub fn check_buffer(&self) -> bool { + self.class().slots.as_buffer.load().is_some() + } +} + +/// The request a conversion makes when the consumer has no say in it: describe +/// the export as fully as possible, read-only. +impl<'a> TryFromBorrowedObject<'a> for PyBuffer { + fn try_from_borrowed_object(vm: &VirtualMachine, obj: &'a PyObject) -> PyResult { + Self::from_object(vm, obj, BufferFlags::FULL_RO) + } +} + impl Drop for PyBuffer { fn drop(&mut self) { self.release(); @@ -172,10 +391,20 @@ pub struct BufferDescriptor { /// product(shape) * itemsize /// bytes length, but not the length for obj_bytes() even is contiguous pub len: usize, + /// Byte position of the element at index `[0, .., 0]` within + /// [`PyBuffer::obj_bytes`], which always yields the exporter's whole memory. + /// `Py_buffer.buf` + /// + /// A view that walks backwards begins at the far end of its data, so this is + /// where addressing starts rather than a lower bound. A view with no elements + /// addresses nothing and may sit outside the exporter altogether, which is why + /// this is signed. + pub offset: isize, pub readonly: bool, pub itemsize: usize, pub format: Cow<'static, str>, - /// (shape, stride, suboffset) for each dimension + /// (shape, stride, suboffset) for each dimension. A non-zero suboffset means + /// the dimension is reached through a pointer; slicing never introduces one. pub dim_desc: Vec<(usize, isize, isize)>, // TODO: flags } @@ -185,6 +414,7 @@ impl BufferDescriptor { pub fn simple(bytes_len: usize, readonly: bool) -> Self { Self { len: bytes_len, + offset: 0, readonly, itemsize: 1, format: Cow::Borrowed("B"), @@ -201,6 +431,7 @@ impl BufferDescriptor { ) -> Self { Self { len: bytes_len, + offset: 0, readonly, itemsize, format, @@ -208,9 +439,48 @@ impl BufferDescriptor { } } + /// The descriptor an exporter hands to a consumer that asked for `flags`: + /// everything the request did not ask for is dropped. + /// + /// A `Py_buffer` drops a field by setting it to NULL and leaves the consumer to + /// reconstruct it. A descriptor has no NULL, so a dropped field is filled in + /// here with what that reconstruction would produce: `"B"` for a format, C-order + /// strides for strides, and a single dimension of `len / itemsize` items for a + /// shape. `itemsize` is never touched, so `calcsize(format)` and `itemsize` can + /// disagree on a projected descriptor — the format governs an element's width, + /// the item size governs the step — and `product(shape) * itemsize == len` + /// continues to hold. + #[must_use] + pub fn projected(&self, flags: BufferFlags) -> Self { + let mut desc = self.clone(); + if !flags.contains(BufferFlags::FORMAT) { + desc.format = Cow::Borrowed("B"); + } + if !flags.contains(BufferFlags::ND) { + // A request this flat is refused unless the layout is C-contiguous, so + // one dimension addresses the same bytes. + let shape = desc.len.checked_div(desc.itemsize).unwrap_or(0); + desc.dim_desc = vec![(shape, desc.itemsize as isize, 0)]; + } else if !flags.contains(BufferFlags::STRIDES) { + // Shape survives but strides do not, which means C order. + let mut stride = desc.itemsize as isize; + for (shape, dim_stride, suboffset) in desc.dim_desc.iter_mut().rev() { + *dim_stride = stride; + *suboffset = 0; + stride *= *shape as isize; + } + } + desc + } + #[cfg(debug_assertions)] #[must_use] pub fn validate(self) -> Self { + // Only a view with nothing to address is allowed to start outside the + // exporter. + if self.len != 0 { + debug_assert!(self.offset >= 0); + } // ndim=0 is valid for scalar types (e.g., ctypes Structure) if self.ndim() == 0 { // Empty structures (len=0) can have itemsize=0 @@ -239,6 +509,7 @@ impl BufferDescriptor { self.dim_desc.len() } + /// Whether the elements are laid out in row-major order. _IsCContiguous #[must_use] pub fn is_contiguous(&self) -> bool { if self.len == 0 { @@ -254,11 +525,76 @@ impl BufferDescriptor { true } + /// Whether the elements are laid out in column-major order. A view whose + /// dimensions are all but one of length 1 is laid out both ways at once. + /// _IsFortranContiguous + #[must_use] + pub fn is_fortran_contiguous(&self) -> bool { + if self.len == 0 { + return true; + } + let mut sd = self.itemsize; + for (shape, stride, _) in self.dim_desc.iter().copied() { + if shape > 1 && stride != sd as isize { + return false; + } + sd *= shape; + } + true + } + + /// The byte range this view occupies in [`PyBuffer::obj_bytes`], for a + /// contiguous view. + /// + /// A view with no bytes maps to the empty range at zero: its offset is + /// wherever slicing left it and need not be a position that exists. + #[must_use] + pub fn contiguous_range(&self) -> Range { + if self.len == 0 { + return 0..0; + } + debug_assert!(self.offset >= 0); + let start = self.offset as usize; + start..start + self.len + } + + /// The same shape, format and item size, laid out in C order from byte zero. + #[must_use] + pub fn contiguous(&self) -> Self { + let itemsize = self.itemsize; + let mut dim_desc = self.dim_desc.clone(); + if let Some((_, stride, suboffset)) = dim_desc.last_mut() { + *stride = itemsize as isize; + *suboffset = 0; + } + for i in (1..dim_desc.len()).rev() { + dim_desc[i - 1].1 = dim_desc[i].1 * dim_desc[i].0 as isize; + dim_desc[i - 1].2 = 0; + } + Self { + len: self.len, + offset: 0, + readonly: self.readonly, + itemsize: self.itemsize, + format: self.format.clone(), + dim_desc, + } + } + + /// Whether any dimension is reached through a pointer rather than by + /// stepping, the layout `PyBUF_INDIRECT` describes. + #[must_use] + pub fn has_suboffsets(&self) -> bool { + self.dim_desc + .iter() + .any(|(_, _, suboffset)| *suboffset != 0) + } + /// this function do not check the bound /// panic if indices.len() != ndim #[must_use] pub fn fast_position(&self, indices: &[usize]) -> isize { - let mut pos = 0; + let mut pos = self.offset; for (i, (_, stride, suboffset)) in indices .iter() .copied() @@ -271,7 +607,7 @@ impl BufferDescriptor { /// panic if indices.len() != ndim pub fn position(&self, indices: &[isize], vm: &VirtualMachine) -> PyResult { - let mut pos = 0; + let mut pos = self.offset; for (i, (shape, stride, suboffset)) in indices .iter() .copied() @@ -289,14 +625,19 @@ impl BufferDescriptor { where F: FnMut(Range), { + // A view with no bytes reaches nothing, and its offset need not be a + // position that exists, so it yields no segment at all. + if self.len == 0 { + return; + } if self.ndim() == 0 { - f(0..self.itemsize as isize); + f(self.offset..self.offset + self.itemsize as isize); return; } if try_contiguous && self.is_last_dim_contiguous() { - self._for_each_segment::<_, true>(0, 0, &mut f); + self._for_each_segment::<_, true>(self.offset, 0, &mut f); } else { - self._for_each_segment::<_, false>(0, 0, &mut f); + self._for_each_segment::<_, false>(self.offset, 0, &mut f); } } @@ -328,14 +669,24 @@ impl BufferDescriptor { where F: FnMut(Range, Range) -> bool, { + if self.len == 0 { + return; + } if self.ndim() == 0 { - f(0..self.itemsize as isize, 0..other.itemsize as isize); + f( + self.offset..self.offset + self.itemsize as isize, + other.offset..other.offset + other.itemsize as isize, + ); return; } - if try_contiguous && self.is_last_dim_contiguous() { - self._zip_eq::<_, true>(other, 0, 0, 0, &mut f); + // last_dim_is_contiguous: the whole-run path walks both sides at once, so + // both have to be laid out that way. + let run_at_once = + try_contiguous && self.is_last_dim_contiguous() && other.is_last_dim_contiguous(); + if run_at_once { + self._zip_eq::<_, true>(other, self.offset, other.offset, 0, &mut f); } else { - self._zip_eq::<_, false>(other, 0, 0, 0, &mut f); + self._zip_eq::<_, false>(other, self.offset, other.offset, 0, &mut f); } } diff --git a/crates/vm/src/protocol/mod.rs b/crates/vm/src/protocol/mod.rs index 411aa4dfad3..4061e06458a 100644 --- a/crates/vm/src/protocol/mod.rs +++ b/crates/vm/src/protocol/mod.rs @@ -6,7 +6,9 @@ mod number; mod object; mod sequence; -pub use buffer::{BufferDescriptor, BufferMethods, BufferResizeGuard, PyBuffer, VecBuffer}; +pub use buffer::{ + BufferDescriptor, BufferFlags, BufferMethods, BufferResizeGuard, PyBuffer, VecBuffer, +}; pub use callable::PyCallable; pub(crate) use callable::TraceEvent; pub use iter::{PyIter, PyIterIter, PyIterReturn}; diff --git a/crates/vm/src/sliceable.rs b/crates/vm/src/sliceable.rs index b0f4c7808ff..ef78614efd5 100644 --- a/crates/vm/src/sliceable.rs +++ b/crates/vm/src/sliceable.rs @@ -419,6 +419,50 @@ impl SaturatedSlice { (range, self.step, slice_len) } + // PySlice_AdjustIndices, keeping the adjusted start rather than a range. + /// The index the slice begins at, clamped into `0..=len` for a positive step + /// and into `-1..=len-1` for a negative one, together with its length. + /// + /// Unlike [`Self::adjust_indices`] this stays meaningful for an empty slice, + /// where it is still the position a strided view moves to. + #[must_use] + pub fn adjust_indices_start(&self, len: usize) -> (isize, usize) { + let len = len as isize; + let clamp = |i: isize| { + if i < 0 { + let i = i.saturating_add(len); + if i < 0 { + if self.step.is_negative() { -1 } else { 0 } + } else { + i + } + } else if i >= len { + if self.step.is_negative() { + len - 1 + } else { + len + } + } else { + i + } + }; + let start = clamp(self.start); + let stop = clamp(self.stop); + let step = self.step.unsigned_abs(); + let slice_len = if self.step.is_negative() { + if stop < start { + (start - stop - 1) as usize / step + 1 + } else { + 0 + } + } else if start < stop { + (stop - start - 1) as usize / step + 1 + } else { + 0 + }; + (start, slice_len) + } + #[must_use] pub fn iter(&self, len: usize) -> SaturatedSliceIter { SaturatedSliceIter::new(self, len) diff --git a/crates/vm/src/stdlib/_ctypes/array.rs b/crates/vm/src/stdlib/_ctypes/array.rs index c65f9748caf..d4674f33b07 100644 --- a/crates/vm/src/stdlib/_ctypes/array.rs +++ b/crates/vm/src/stdlib/_ctypes/array.rs @@ -1061,6 +1061,7 @@ impl AsBuffer for PyCArray { dim_desc.reverse(); BufferDescriptor { + offset: 0, len: buffer_len, readonly: false, itemsize, diff --git a/crates/vm/src/stdlib/_ctypes/base.rs b/crates/vm/src/stdlib/_ctypes/base.rs index e86fdbc7a42..6067fc61bf0 100644 --- a/crates/vm/src/stdlib/_ctypes/base.rs +++ b/crates/vm/src/stdlib/_ctypes/base.rs @@ -624,7 +624,9 @@ impl PyCData { // Get buffer pointer - the memory is owned by source let ptr = { - let bytes = buffer.obj_bytes(); + // Contiguity is checked above, so this is the view's own bytes rather + // than the whole exporter's. + let bytes = unsafe { buffer.contiguous_unchecked() }; bytes.as_ptr().wrapping_add(offset) }; diff --git a/crates/vm/src/stdlib/_ctypes/function.rs b/crates/vm/src/stdlib/_ctypes/function.rs index 90b41a4e66a..afbe0ae76ea 100644 --- a/crates/vm/src/stdlib/_ctypes/function.rs +++ b/crates/vm/src/stdlib/_ctypes/function.rs @@ -1655,6 +1655,7 @@ impl AsBuffer for PyCFuncPtr { (Cow::Borrowed(pointer_format()), pointer_size()) }; let desc = BufferDescriptor { + offset: 0, len: itemsize, readonly: false, itemsize, diff --git a/crates/vm/src/stdlib/_ctypes/pointer.rs b/crates/vm/src/stdlib/_ctypes/pointer.rs index a401fde6fc0..bcc39fd5745 100644 --- a/crates/vm/src/stdlib/_ctypes/pointer.rs +++ b/crates/vm/src/stdlib/_ctypes/pointer.rs @@ -712,7 +712,8 @@ impl PyCPointer { } // Try bytes - if let Ok(bytes) = value.try_bytes_like(vm, |b| b.to_vec()) { + if value.check_buffer() { + let bytes = value.try_bytes_like(vm, |b| b.to_vec())?; rustpython_host_env::ctypes::write_value_to_address( addr, size, @@ -776,6 +777,7 @@ impl AsBuffer for PyCPointer { let itemsize = stg_info.size; // Pointer types are scalars with ndim=0, shape=() let desc = BufferDescriptor { + offset: 0, len: itemsize, readonly: false, itemsize, diff --git a/crates/vm/src/stdlib/_ctypes/simple.rs b/crates/vm/src/stdlib/_ctypes/simple.rs index 5577fb8d25d..9699cef984b 100644 --- a/crates/vm/src/stdlib/_ctypes/simple.rs +++ b/crates/vm/src/stdlib/_ctypes/simple.rs @@ -1283,6 +1283,7 @@ impl AsBuffer for PyCSimple { let itemsize = stg_info.size; // Simple types are scalars with ndim=0, shape=() let desc = BufferDescriptor { + offset: 0, len: itemsize, readonly: false, itemsize, diff --git a/crates/vm/src/stdlib/_ctypes/structure.rs b/crates/vm/src/stdlib/_ctypes/structure.rs index 1632d745dc6..12ddf8b5dee 100644 --- a/crates/vm/src/stdlib/_ctypes/structure.rs +++ b/crates/vm/src/stdlib/_ctypes/structure.rs @@ -822,6 +822,7 @@ impl AsBuffer for PyCStructure { let buf = PyBuffer::new( zelf.to_owned().into(), BufferDescriptor { + offset: 0, len: buffer_len, readonly: false, itemsize: buffer_len, diff --git a/crates/vm/src/stdlib/_ctypes/union.rs b/crates/vm/src/stdlib/_ctypes/union.rs index 8fe2e8348a5..326e1fbd704 100644 --- a/crates/vm/src/stdlib/_ctypes/union.rs +++ b/crates/vm/src/stdlib/_ctypes/union.rs @@ -685,6 +685,7 @@ impl AsBuffer for PyCUnion { let buf = PyBuffer::new( zelf.to_owned().into(), BufferDescriptor { + offset: 0, len: buffer_len, readonly: false, itemsize: buffer_len, diff --git a/crates/vm/src/stdlib/_imp.rs b/crates/vm/src/stdlib/_imp.rs index 50f0b0be8ab..838012a1d0a 100644 --- a/crates/vm/src/stdlib/_imp.rs +++ b/crates/vm/src/stdlib/_imp.rs @@ -178,7 +178,6 @@ mod _imp { use crate::{ PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, builtins::{PyBytesRef, PyCode, PyMemoryView, PyModule, PyStrRef, PyUtf8StrRef}, - convert::TryFromBorrowedObject, function::OptionalArg, import, version, }; @@ -270,8 +269,14 @@ mod _imp { name.clone().into_wtf8(), ) }; - // A non-buffer is a TypeError, not invalid frozen data. - crate::protocol::PyBuffer::try_from_borrowed_object(vm, &data)?; + // A non-buffer is a TypeError, not invalid frozen data. The request + // is the one marshal.loads() makes, so that what passes here is + // exactly what it accepts. + crate::protocol::PyBuffer::from_object( + vm, + &data, + crate::protocol::BufferFlags::SIMPLE, + )?; // The data is a marshalled code object: a whole marshal value, which // deserialize_code() does not read — it takes the code body alone, // without the type byte the writer puts in front of it. diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index 80085a82290..f32fce314c4 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -135,8 +135,8 @@ mod _io { convert::ToPyObject, exceptions::nul_char_error, function::{ - ArgBytesLike, ArgIterable, ArgMemoryBuffer, ArgSize, Either, FsPath, FuncArgs, - IntoFuncArgs, OptionalArg, OptionalOption, PySetterValue, + ArgBytesLike, ArgContiguousBytesLike, ArgIterable, ArgMemoryBuffer, ArgSize, Either, + FsPath, FuncArgs, IntoFuncArgs, OptionalArg, OptionalOption, PySetterValue, }, protocol::{ BufferDescriptor, BufferMethods, BufferResizeGuard, PyBuffer, PyIterReturn, VecBuffer, @@ -4785,8 +4785,12 @@ mod _io { } #[pymethod] - fn write(&self, data: ArgBytesLike, vm: &VirtualMachine) -> PyResult { + fn write(&self, data: ArgContiguousBytesLike, vm: &VirtualMachine) -> PyResult { let mut buffer = self.try_resizable(vm)?; + // Acquiring the buffer can run `__buffer__`, which may have closed us. + if self.closed.load() { + return Err(io_closed_error(vm)); + } data.with_ref(|b| buffer.write(b)) .ok_or_else(|| vm.new_type_error("Error Writing Bytes")) } diff --git a/crates/vm/src/stdlib/_sre.rs b/crates/vm/src/stdlib/_sre.rs index 168363103e4..a9f98ca7015 100644 --- a/crates/vm/src/stdlib/_sre.rs +++ b/crates/vm/src/stdlib/_sre.rs @@ -3,8 +3,8 @@ pub(crate) use _sre::module_def; #[pymodule] mod _sre { use crate::{ - Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, TryFromBorrowedObject, - TryFromObject, VirtualMachine, atomic_func, + Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, VirtualMachine, + atomic_func, builtins::{ PyCallableIterator, PyDictRef, PyGenericAlias, PyInt, PyList, PyListRef, PyStr, PyStrRef, PyTuple, PyTupleRef, PyTypeRef, @@ -13,7 +13,7 @@ mod _sre { common::{ascii, hash::PyHash}, convert::ToPyObject, function::{ArgCallable, OptionalArg, PosArgs, PyComparisonValue}, - protocol::{PyBuffer, PyCallable, PyMappingMethods}, + protocol::{BufferFlags, PyBuffer, PyCallable, PyMappingMethods}, stdlib::sys, types::{AsMapping, Comparable, Hashable, Representable}, }; @@ -386,7 +386,7 @@ mod _sre { where F: FnOnce(&[u8]) -> PyResult, { - PyBuffer::try_from_borrowed_object(vm, string)?.contiguous_or_collect(f) + PyBuffer::from_object(vm, string, BufferFlags::SIMPLE)?.contiguous_or_collect(f) } #[pymethod(name = "match")] diff --git a/crates/vm/src/stdlib/builtins.rs b/crates/vm/src/stdlib/builtins.rs index 95feea65620..19e33110a5b 100644 --- a/crates/vm/src/stdlib/builtins.rs +++ b/crates/vm/src/stdlib/builtins.rs @@ -21,9 +21,9 @@ mod builtins { bytecode, common::hash::PyHash, function::{ - ArgBytesLike, ArgCallable, ArgIndex, ArgIntoBool, ArgIterable, ArgMapping, - ArgPrimitiveIndex, ArgStrOrBytesLike, Either, FsPath, FuncArgs, KwArgs, OptionalArg, - OptionalOption, PosArgs, + ArgCallable, ArgIndex, ArgIntoBool, ArgIterable, ArgMapping, ArgPrimitiveIndex, + ArgStrOrBytesLike, Either, FsPath, FuncArgs, KwArgs, OptionalArg, OptionalOption, + PosArgs, }, protocol::{PyIter, PyIterReturn}, py_io, @@ -997,18 +997,10 @@ mod builtins { } #[pyfunction] - fn ord(string: Either, vm: &VirtualMachine) -> PyResult { - match string { - Either::A(bytes) => bytes.with_ref(|bytes| { - let bytes_len = bytes.len(); - if bytes_len != 1 { - return Err(vm.new_type_error(format!( - "ord() expected a character, but string of length {bytes_len} found" - ))); - } - Ok(u32::from(bytes[0])) - }), - Either::B(string) => match string.as_wtf8().code_points().exactly_one() { + // builtin_ord + fn ord(c: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let bytes = if let Some(string) = c.downcast_ref::() { + return match string.as_wtf8().code_points().exactly_one() { Ok(character) => Ok(character.to_u32()), Err(_) => { let string_len = string.char_len(); @@ -1016,8 +1008,24 @@ mod builtins { "ord() expected a character, but string of length {string_len} found" ))) } - }, + }; + } else if let Some(bytes) = c.downcast_ref::() { + bytes.as_bytes().to_vec() + } else if let Some(bytearray) = c.downcast_ref::() { + bytearray.borrow_buf().to_vec() + } else { + return Err(vm.new_type_error(format!( + "ord() expected string of length 1, but {} found", + c.class().name() + ))); + }; + let bytes_len = bytes.len(); + if bytes_len != 1 { + return Err(vm.new_type_error(format!( + "ord() expected a character, but string of length {bytes_len} found" + ))); } + Ok(u32::from(bytes[0])) } #[derive(FromArgs)] diff --git a/crates/vm/src/stdlib/marshal.rs b/crates/vm/src/stdlib/marshal.rs index 38891200b05..ca92b444a4c 100644 --- a/crates/vm/src/stdlib/marshal.rs +++ b/crates/vm/src/stdlib/marshal.rs @@ -16,7 +16,6 @@ mod decl { convert::ToPyObject, function::{ArgBytesLike, OptionalArg}, object::{AsObject, PyPayload}, - protocol::PyBuffer, }; use core::cell::RefCell; use malachite_bigint::BigInt; @@ -651,20 +650,16 @@ mod decl { #[derive(FromArgs)] struct LoadsArgs { #[pyarg(any)] - data: PyBuffer, + // marshal_loads_impl takes `bytes: Py_buffer`, a y* argument. + data: ArgBytesLike, #[pyarg(named, default = true)] allow_code: bool, } #[pyfunction] fn loads(args: LoadsArgs, vm: &VirtualMachine) -> PyResult { - let LoadsArgs { - data: pybuffer, - allow_code, - } = args; - let buf = pybuffer.as_contiguous().ok_or_else(|| { - vm.new_buffer_error("Buffer provided to marshal.loads() is not contiguous") - })?; + let LoadsArgs { data, allow_code } = args; + let buf = data.borrow_buf(); let result = deserialize_value(&mut &buf[..], vm)?; if !allow_code { diff --git a/crates/vm/src/stdlib/winsound.rs b/crates/vm/src/stdlib/winsound.rs index 95032ad8970..091a3f801aa 100644 --- a/crates/vm/src/stdlib/winsound.rs +++ b/crates/vm/src/stdlib/winsound.rs @@ -6,10 +6,10 @@ pub(crate) use winsound::module_def; #[pymodule] mod winsound { use crate::builtins::{PyBaseExceptionRef, PyBytes, PyStr}; - use crate::convert::{IntoPyException, ToPyException, TryFromBorrowedObject}; + use crate::convert::{IntoPyException, ToPyException}; use crate::exceptions; use crate::host_env::windows::ToWideString; - use crate::protocol::PyBuffer; + use crate::protocol::{BufferFlags, PyBuffer}; use crate::{AsObject, PyObjectRef, PyResult, VirtualMachine}; use rustpython_host_env::winsound::{PlaySoundError, PlaySoundSource, play_sound}; @@ -90,7 +90,7 @@ mod winsound { } if flags & SND_MEMORY != 0 { - let buffer = PyBuffer::try_from_borrowed_object(vm, &sound)?; + let buffer = PyBuffer::from_object(vm, &sound, BufferFlags::SIMPLE)?; let buf = buffer .as_contiguous() .ok_or_else(|| vm.new_type_error("a bytes-like object is required, not 'str'"))?; diff --git a/crates/vm/src/types/slot.rs b/crates/vm/src/types/slot.rs index c039e6b5b59..fc9d1b04885 100644 --- a/crates/vm/src/types/slot.rs +++ b/crates/vm/src/types/slot.rs @@ -9,7 +9,7 @@ use crate::{ convert::ToPyObject, function::{Either, FromArgs, FuncArgs, PyComparisonValue, PyMethodDef, PySetterValue}, protocol::{ - PyBuffer, PyIterReturn, PyMapping, PyMappingMethods, PyMappingSlots, PyNumber, + BufferFlags, PyBuffer, PyIterReturn, PyMapping, PyMappingMethods, PyMappingSlots, PyNumber, PyNumberMethods, PyNumberSlots, PySequence, PySequenceMethods, PySequenceSlots, }, types::slot_defs::{SlotAccessor, find_slot_defs_by_name}, @@ -149,7 +149,12 @@ pub struct PyTypeSlots { pub setattro: AtomicCell>, // Functions to access object as input/output buffer - pub as_buffer: Option, + pub as_buffer: AtomicCell>, + /// bf_releasebuffer: releasing an export of this type is observable, so the + /// type exposes `__release_buffer__`. + pub has_release_buffer: AtomicCell, + /// True when a Python-level `__release_buffer__` must be invoked on release. + pub python_release_buffer: AtomicCell, // Assigned meaning in release 2.1 // rich comparisons @@ -296,7 +301,8 @@ pub(crate) type StringifyFunc = fn(&PyObject, &VirtualMachine) -> PyResult, &VirtualMachine) -> PyResult; pub(crate) type SetattroFunc = fn(&PyObject, &Py, PySetterValue, &VirtualMachine) -> PyResult<()>; -pub(crate) type AsBufferFunc = fn(&PyObject, &VirtualMachine) -> PyResult; +/// bf_getbuffer +pub(crate) type AsBufferFunc = fn(&PyObject, BufferFlags, &VirtualMachine) -> PyResult; pub(crate) type RichCompareFunc = fn( &PyObject, &PyObject, @@ -329,6 +335,15 @@ pub(crate) type MapSubscriptFunc = fn(PyMapping<'_>, &PyObject, &VirtualMachine) pub(crate) type MapAssSubscriptFunc = fn(PyMapping<'_>, &PyObject, Option, &VirtualMachine) -> PyResult<()>; +// slot_bf_getbuffer +pub(crate) fn python_as_buffer( + obj: &PyObject, + flags: BufferFlags, + vm: &VirtualMachine, +) -> PyResult { + crate::builtins::memory::buffer_from_python_getbuffer(obj, flags, vm) +} + // slot_sq_length pub(crate) fn len_wrapper(obj: &PyObject, vm: &VirtualMachine) -> PyResult { let ret = vm.call_special_method(obj, identifier!(vm, __len__), ())?; @@ -1579,6 +1594,58 @@ impl PyType { } } + // === Buffer protocol === + SlotAccessor::BfGetBuffer => { + if ADD { + match self.lookup_slot_in_mro(name, ctx, |sf| { + if let SlotFunc::GetBuffer(f) = sf { + Some(*f) + } else { + None + } + }) { + SlotLookupResult::NativeSlot(func) => { + self.slots.as_buffer.store(Some(func)); + } + SlotLookupResult::PythonMethod => { + self.slots.as_buffer.store(Some(python_as_buffer)); + } + SlotLookupResult::NotFound => { + accessor.inherit_from_mro(self); + } + } + } else { + accessor.inherit_from_mro(self); + } + } + SlotAccessor::BfReleaseBuffer => { + // Which of the two implementations `__release_buffer__` resolves to + // decides whether buffer release has to call back into Python. + if ADD { + match self.lookup_slot_in_mro(name, ctx, |sf| { + if matches!(sf, SlotFunc::ReleaseBuffer) { + Some(()) + } else { + None + } + }) { + SlotLookupResult::NativeSlot(()) => { + self.slots.python_release_buffer.store(false); + self.slots.has_release_buffer.store(true); + } + SlotLookupResult::PythonMethod => { + self.slots.python_release_buffer.store(true); + self.slots.has_release_buffer.store(true); + } + SlotLookupResult::NotFound => { + accessor.inherit_from_mro(self); + } + } + } else { + accessor.inherit_from_mro(self); + } + } + // Reserved slots - no-op _ => {} } @@ -2070,14 +2137,29 @@ pub trait SetAttr: PyPayload { #[pyclass] pub trait AsBuffer: PyPayload { - // TODO: `flags` parameter + /// bf_releasebuffer: set when releasing an export of this type is observable, + /// i.e. the exporter counts exports. Such types expose `__release_buffer__`. + const RELEASE_BUFFER: bool = false; + #[inline] #[pyslot] - fn slot_as_buffer(zelf: &PyObject, vm: &VirtualMachine) -> PyResult { + fn slot_as_buffer( + zelf: &PyObject, + flags: BufferFlags, + vm: &VirtualMachine, + ) -> PyResult { let zelf = zelf .downcast_ref() .ok_or_else(|| vm.new_type_error("unexpected payload for as_buffer"))?; - Self::as_buffer(zelf, vm) + let buffer = Self::as_buffer(zelf, vm)?; + if let Err(exc) = flags.check_writable(buffer.desc.readonly, "Object is not writable.", vm) + { + // An acquisition that cannot be served never happened, so the + // exporter's release is undone without running the Python hook. + buffer.abort_acquisition(); + return Err(exc); + } + Ok(buffer) } fn as_buffer(zelf: &Py, vm: &VirtualMachine) -> PyResult; diff --git a/crates/vm/src/types/slot_defs.rs b/crates/vm/src/types/slot_defs.rs index 69c7bb61045..300ee319907 100644 --- a/crates/vm/src/types/slot_defs.rs +++ b/crates/vm/src/types/slot_defs.rs @@ -71,7 +71,7 @@ pub struct SlotDef { #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u8)] pub enum SlotAccessor { - // Buffer protocol (1-2) - Reserved, not used in RustPython + // Buffer protocol (1-2) BfGetBuffer = 1, BfReleaseBuffer = 2, @@ -173,9 +173,7 @@ impl SlotAccessor { pub fn is_reserved(&self) -> bool { matches!( self, - Self::BfGetBuffer - | Self::BfReleaseBuffer - | Self::TpAlloc + Self::TpAlloc | Self::TpBase | Self::TpBases | Self::TpClear @@ -411,6 +409,10 @@ impl SlotAccessor { ) } + // Buffer protocol + Self::BfGetBuffer => matches!(slot_func, SlotFunc::GetBuffer(_)), + Self::BfReleaseBuffer => matches!(slot_func, SlotFunc::ReleaseBuffer), + // New and reserved slots Self::TpNew => false, _ => false, // Reserved slots @@ -539,6 +541,18 @@ impl SlotAccessor { Self::MpSubscript => inherit_mapping!(subscript), Self::MpAssSubscript => inherit_mapping!(ass_subscript), + // Buffer protocol + Self::BfGetBuffer => { + let inherited = mro.iter().find_map(|cls| cls.slots.as_buffer.load()); + typ.slots.as_buffer.store(inherited); + } + Self::BfReleaseBuffer => { + let has_release = mro.iter().any(|cls| cls.slots.has_release_buffer.load()); + typ.slots.has_release_buffer.store(has_release); + let py_release = mro.iter().any(|cls| cls.slots.python_release_buffer.load()); + typ.slots.python_release_buffer.store(py_release); + } + // Reserved slots - no-op _ => {} } @@ -677,6 +691,25 @@ impl SlotAccessor { Self::MpSubscript => copy_mapping!(subscript), Self::MpAssSubscript => copy_mapping!(ass_subscript), + // Buffer protocol + Self::BfGetBuffer => { + if typ.slots.as_buffer.load().is_none() + && let Some(base_val) = base.slots.as_buffer.load() + { + typ.slots.as_buffer.store(Some(base_val)); + } + } + Self::BfReleaseBuffer => { + if !typ.slots.has_release_buffer.load() && base.slots.has_release_buffer.load() { + typ.slots.has_release_buffer.store(true); + } + if !typ.slots.python_release_buffer.load() + && base.slots.python_release_buffer.load() + { + typ.slots.python_release_buffer.store(true); + } + } + // Reserved slots - no-op _ => {} } @@ -816,6 +849,16 @@ impl SlotAccessor { .load() .map(SlotFunc::MapSetSubscript), + // Buffer protocol + Self::BfGetBuffer => slots.as_buffer.load().map(SlotFunc::GetBuffer), + Self::BfReleaseBuffer => { + if slots.has_release_buffer.load() || slots.python_release_buffer.load() { + Some(SlotFunc::ReleaseBuffer) + } else { + None + } + } + // Reserved slots _ => None, } @@ -973,6 +1016,19 @@ pub const SLOT_DEFS_COUNT: usize = SLOT_DEFS.len(); /// All slot definitions pub static SLOT_DEFS: &[SlotDef] = &[ + // Buffer protocol (bf_*) + SlotDef { + name: "__buffer__", + accessor: SlotAccessor::BfGetBuffer, + op: None, + doc: "Return a buffer object that exposes the underlying memory of the object.", + }, + SlotDef { + name: "__release_buffer__", + accessor: SlotAccessor::BfReleaseBuffer, + op: None, + doc: "Release the buffer object that exposes the underlying memory of the object.", + }, // Type slots (tp_*) SlotDef { name: "__init__", diff --git a/crates/vm/src/vm/context.rs b/crates/vm/src/vm/context.rs index 71d017c6d1c..5deaffb3f6a 100644 --- a/crates/vm/src/vm/context.rs +++ b/crates/vm/src/vm/context.rs @@ -103,6 +103,7 @@ declare_const_name! { __await__, __bases__, __bool__, + __buffer__, __build_class__, __builtins__, __bytes__, @@ -205,6 +206,7 @@ declare_const_name! { __rdivmod__, __reduce__, __reduce_ex__, + __release_buffer__, __repr__, __reversed__, __rfloordiv__, diff --git a/extra_tests/snippets/builtin_memoryview.py b/extra_tests/snippets/builtin_memoryview.py index f206056ebfd..8a3a194d96d 100644 --- a/extra_tests/snippets/builtin_memoryview.py +++ b/extra_tests/snippets/builtin_memoryview.py @@ -90,3 +90,427 @@ def test_delitem(): test_delitem() + + +def test_empty_view_offset(): + # An empty view keeps the offset slicing left it, which can sit outside the + # exporter, and reaches no byte through it. + ba = bytearray(range(17)) + assert bytes(memoryview(ba)[::-9][-30::-9]) == b"" + assert bytes(memoryview(ba)[-30::-1]) == b"" + v = memoryview(ba)[::-9][-30::-9] + assert v.shape == (0,) + assert v.strides == (81,) + assert v.suboffsets == () + b24 = bytearray(range(24)) + assert bytes(memoryview(b24).cast("B", [4, 6])[-30::-1]) == b"" + + +test_empty_view_offset() + + +def test_exported_suboffsets(): + mv = memoryview(bytearray(b"abcdef"))[::-1] + exported = mv.__buffer__(284) + assert exported.suboffsets == () + assert bytes(exported) == b"fedcba" + assert ( + bytes(memoryview(memoryview(bytearray(b"abcdefg"))[::2].__buffer__(284))) + == b"aceg" + ) + + +test_exported_suboffsets() + + +def test_setitem_slice_strided_source(): + src = bytearray(b"abcdef") + dst = bytearray(b"......") + memoryview(dst)[:] = memoryview(src)[::-1] + assert bytes(dst) == b"fedcba" + dst = bytearray(b"...") + memoryview(dst)[:] = memoryview(src)[::2] + assert bytes(dst) == b"ace" + + +test_setitem_slice_strided_source() + + +def test_zero_dim_position(): + z = memoryview(bytearray(range(8)))[4:5].cast("B", []) + assert z[()] == 4 + assert z.tolist() == 4 + w = bytearray(range(8)) + memoryview(w)[4:5].cast("B", [])[()] = 99 + assert w[4] == 99 + assert w[0] == 0 + + +test_zero_dim_position() + + +def test_cast_zero_dim_size(): + assert_raises(TypeError, lambda: memoryview(bytearray(range(8))).cast("B", [])) + assert memoryview(bytearray(b"a")).cast("B", []).nbytes == 1 + + +test_cast_zero_dim_size() + + +def test_hash_format(): + assert_raises(ValueError, lambda: hash(memoryview(b"abcd").cast("I"))) + hash(memoryview(b"abcd").cast("b")) + hash(memoryview(b"abcdef")[::2]) + hash(memoryview(b"a").cast("B", [])) + + +test_hash_format() + + +def test_cast_keeps_exports(): + ba = bytearray(b"abc") + mv = memoryview(ba) + cast = mv.cast("B") + mv.release() + assert_raises(BufferError, lambda: ba.clear()) + cast.release() + ba.clear() + assert bytes(ba) == b"" + + +test_cast_keeps_exports() + + +def test_setitem_converts_before_writing(): + ba = bytearray(b"abc") + mv = memoryview(ba) + + class Idx: + def __index__(self): + return len(bytes(ba)) + + mv[0] = Idx() + assert bytes(ba) == b"\x03bc" + + +test_setitem_converts_before_writing() + + +def test_pep688_exporter_aliasing(): + def exporter(view_factory): + class C: + def __buffer__(self, flags): + return view_factory() + + def __release_buffer__(self, view): + pass + + return C() + + ba = bytearray(b"abc") + memoryview(ba)[:] = exporter(lambda: memoryview(ba)) + assert bytes(ba) == b"abc" + + ba = bytearray(b"abcdef") + memoryview(ba)[0:3] = exporter(lambda: memoryview(ba)[3:6]) + assert bytes(ba) == b"defdef" + + ba = bytearray(b"abcdef") + memoryview(ba)[3:6] = exporter(lambda: memoryview(ba)[0:3]) + assert bytes(ba) == b"abcabc" + + ba = bytearray(b"abcdef") + memoryview(ba)[:] = exporter(lambda: memoryview(ba)[::-1]) + assert bytes(ba) == b"fedcba" + + ba = bytearray(b"abcdef") + memoryview(ba)[::2] = exporter(lambda: memoryview(ba)[0:3]) + assert bytes(ba) == b"abbdcf" + + ba = bytearray(b"abcdef") + mv = memoryview(exporter(lambda: memoryview(ba))) + mv[:] = exporter(lambda: memoryview(ba)) + assert bytes(ba) == b"abcdef" + mv[:] = ba + assert bytes(ba) == b"abcdef" + + +test_pep688_exporter_aliasing() + + +def test_release_buffer_waits_for_last_view(): + class C(bytearray): + calls = 0 + + def __release_buffer__(self, view): + type(self).calls += 1 + super().__release_buffer__(view) + + c = C(b"abcdef") + a = memoryview(c) + b = memoryview(a) + a.release() + assert C.calls == 0 + assert b.tobytes() == b"abcdef" + b.release() + assert C.calls == 1 + + class D: + n = 0 + + def __init__(self): + self.b = bytearray(b"abcdef") + + def __buffer__(self, flags): + return memoryview(self.b) + + def __release_buffer__(self, view): + type(self).n += 1 + + d = D() + m = memoryview(d) + m2 = memoryview(m) + m3 = m.cast("B") + m.release() + m2.release() + assert D.n == 0 + m3.release() + assert D.n == 1 + + # Two acquisitions are two exports, each released on its own. + D.n = 0 + d = D() + a1 = memoryview(d) + a2 = memoryview(d) + a1.release() + assert D.n == 1 + a2.release() + assert D.n == 2 + + +test_release_buffer_waits_for_last_view() + + +def test_failed_request_does_not_release(): + import inspect + import mmap + + class M(mmap.mmap): + calls = 0 + + def __release_buffer__(self, view): + type(self).calls += 1 + super().__release_buffer__(view) + + m = M(-1, 10, access=mmap.ACCESS_READ) + assert_raises(BufferError, lambda: m.__buffer__(inspect.BufferFlags.WRITABLE)) + assert M.calls == 0 + + +test_failed_request_does_not_release() + + +def test_request_shapes_exported_descriptor(): + import array + + a = array.array("I", [1, 2, 3]) + assert a.__buffer__(0).format == "B" + assert a.__buffer__(28).format == "I" + + m = memoryview(a) + b = m.__buffer__(0) + assert (b.format, b.itemsize, b.ndim, b.shape, b.strides) == ("B", 4, 1, (3,), (4,)) + assert m.__buffer__(28).format == "I" + + b = a.__buffer__(0) + assert b[0] == 1 + assert b.tolist() == [1, 2, 3] + assert len(b.tobytes()) == 12 + b[0] = 9 + assert a[0] == 9 + + n = memoryview(bytearray(b"abcdef" * 4)).cast("I", (2, 3)) + assert n.__buffer__(0).ndim == 1 + assert n.__buffer__(0).shape == (6,) + assert n.__buffer__(8).ndim == 2 + assert n.__buffer__(8).format == "B" + + +test_request_shapes_exported_descriptor() + + +def test_release_during_index_conversion(): + # CHECK_RELEASED_AGAIN: the conversion that produces the value, and the one + # that produced the index, both run Python that can release the view. + ba = bytearray(b"abcdefgh") + mv = memoryview(ba) + + class Writer: + def __index__(self): + mv.release() + ba.clear() + return 7 + + try: + mv[7] = Writer() + raise AssertionError("write into a released view") + except ValueError as e: + assert "released memoryview" in str(e), e + + ba = bytearray(b"abcdefgh") + mv = memoryview(ba) + + class Reader: + def __index__(self): + mv.release() + ba.clear() + return 7 + + try: + mv[Reader()] + raise AssertionError("read from a released view") + except ValueError as e: + assert "released memoryview" in str(e), e + + # A release that does not resize still forbids the write. + ba = bytearray(b"abcd") + mv = memoryview(ba) + + class Quiet: + def __index__(self): + mv.release() + return 65 + + try: + mv[0] = Quiet() + raise AssertionError("write into a released view") + except ValueError as e: + assert "released memoryview" in str(e), e + assert bytes(ba) == b"abcd" + + +test_release_during_index_conversion() + + +def test_cast_rejects_non_native_format(): + # get_native_fmtchar + for fmt in ["", "ii", " Date: Mon, 17 Aug 2026 00:33:27 +0900 Subject: [PATCH 313/351] Fix crashes found hunting the last open fuzzing record (#8524) * specialize: check the member descriptor's type before caching its slot offset The LOAD_ATTR/STORE_ATTR specializations cached the slot offset of any member descriptor found on the owner's type and then guarded the specialized instruction on the type version alone, while descr_get()/descr_set() check on every access that the instance belongs to the type the descriptor was defined for. A descriptor taken from a wider class and bound to a narrower one read past the instance's slot array once the cache warmed up: class Big: __slots__ = ("a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7") class Narrow: __slots__ = ("z",) Narrow.x = Big.__dict__["a7"] o = Narrow() for _ in range(1000): try: o.x except TypeError: pass # index out of bounds: the len is 1 but the index is 7 (object/core.rs) A class with no slots at all reached the ext_ref().unwrap() on the same line. Assisted-by: Claude * socket: reserve recv()'s buffer fallibly recv() and recvfrom() handed the caller's bufsize straight to Vec::with_capacity, so an unreachable size aborted the process through handle_alloc_error before any syscall was made: socket.socket().recv(2**62) # memory allocation of 4611686018427387904 bytes failed -> SIGABRT try_reserve_exact reports MemoryError instead, which is what CPython raises. Assisted-by: Claude * types: count the __call__ and __get__ slot dispatches as recursion Both wrappers re-enter Python without pushing a frame, so nothing counted the nesting when the special method named the object it was looked up on: class C: pass c = C(); C.__call__ = c c() # native stack overflow, SIGSEGV class D: pass d = D(); D.__get__ = d; D.x = d d.x # the same, through descr_get with_recursion around the two dispatches raises RecursionError instead, the way Py_EnterRecursiveCall bounds a tp_call dispatch. It costs about 5% on a __call__ dispatch and 3% on a __get__ dispatch through these wrappers. Assisted-by: Claude * typevar: show a ParamSpecArgs origin by its repr ParamSpecArgs and ParamSpecKwargs fell back to a Rust `{:?}` of __origin__ when it had no __name__. That walks the object graph natively through Debug for PyInner, where no recursion guard sits, so a single repr() of a deeply nested chain overflowed the native stack: a = object() for _ in range(30000): a = typing.ParamSpecArgs(a) repr(a) # SIGSEGV The origin is formatted with its repr now, which is guarded, and a ParamSpec origin is recognized by its type rather than by carrying a __name__. Assisted-by: Claude * Do not hold a lock across a call back into Python Three places kept a lock while running code that can reach the same object, so a callback that touched it wedged the process: _asyncio.future_add_to_awaited_by(fut, waiter) # waiter.__hash__ adds again select.select(elements, [], [], 0) # fileno() clears `elements` select.poll().poll(1000) # SIGALRM handler registers The future's awaited-by field is read and written under its lock but the set is built outside it, the list extraction re-reads the list on each step the way map_iterable_object() does, and poll() waits on a copy of its descriptors. All three ran forever before and now finish the way they do on CPython. Assisted-by: Claude * Validate memoryview.cast() arguments and export negative strides correctly cast() accepted any struct format and any shape element. A zero-size format ('0s') and a 0 in the shape both reached a division by zero; cast() now takes only a native single character format, optionally '@'-prefixed, and shape elements that are ints greater than zero. A view with a negative stride starts at its last item, so the bytes it exported began there and its own offsets walked off the front of them. Such a view now exports the whole underlying buffer with `start` folded into the descriptor's offsets, and zip_eq() hands over a whole run only when both sides are contiguous in the last dimension. Assisted-by: Claude Assisted-by: Codex:GPT-5 * Charge native recursion to the stack, not to the frame limit with_recursion() checked the limit sys.setrecursionlimit() sets and incremented the same counter that pushing a frame does, so a guard on a native dispatch spent what Python code had left to call with, and did so where sys._getframe() cannot see it: test.support.get_recursion_available() reported frames that were no longer there. Py_EnterRecursiveCall bounds the native stack instead, which is a separate budget, and the C stack check with_recursion already performs is that bound. The snippets pinning the guarded paths nest deep enough to reach the stack rather than the frame limit. Assisted-by: Claude * Report a size that cannot be allocated instead of aborting on it A size taken from Python went straight into an infallible allocation in several places, so the process aborted through handle_alloc_error before any exception could be raised: - str/bytes/bytearray center(), ljust(), rjust() and zfill() reserved the padded result for the caller's width - expandtabs() built its runs of spaces from a tabsize of any width; the argument is a C int, and a wider one does not fit - Buffered{Reader,Writer,Random} allocated buffer_size, and read(), read1() and FileIO.read() their read size - bytes(n) and bytearray(n) allocated n - pbkdf2_hmac() allocated the derived key length, which is a C int Each of these now reports MemoryError, or OverflowError where the argument does not fit the type it is declared with. new_zeroed_bytes() leaves the zeroing to the allocator, so a large request costs the pages that are written to rather than all of them. Assisted-by: Claude * marshal: answer allow_code where a code object is written or read allow_code was answered by walking the whole result a second time, with no depth counter and no record of what it had already seen, so a value that referred back to itself or nested deeply enough ran off the native stack. w_object() and r_object() answer it where the code object is, inside the walk that already bounds its depth and resolves references. A container length is read the way r_long() reads one: it is signed, so a length with the top bit set is out of range rather than four billion items to reserve room for. load() no longer holds a borrow of the buffer read() returned across the seek() it makes afterwards. Assisted-by: Claude * Do not lock an object while running code that can reach it Several places held a lock or a borrow of an object across a call back into Python, so a callback that touched the same object waited on a lock its own caller was holding: - memoryview slice assignment read a source overlapping the destination, and __setitem__ converted the value while holding the write borrow - BytesIO.readinto() read into a buffer viewing the same BytesIO - array.__setitem__ converted the value under the array's write lock, and mmap.write() read a source viewing the same map - bytearray.join() and bytearray.__mod__ drove Python with the bytearray borrowed - array and bytearray answered "is this resizable" after taking the write lock, though an export is exactly a borrow someone else holds A TextIOWrapper cookie now has to name a position inside what was decoded in characters as well as in bytes; only the byte offset was checked, and the character count is what read() and tell() index with. Assisted-by: Claude Assisted-by: Codex:GPT-5 * Stop asserting a pbkdf2 message that depends on the width of a C long The snippet asserted "key length is too great.", which pbkdf2_hmac() only reaches once the length has been converted; where a C long is narrower than the length asked for, the conversion fails first and says so instead. Both are OverflowError, which is what the case is about. test_support.test_get_recursion_depth passes now that a native recursion guard no longer spends frames get_recursion_depth() cannot see. Assisted-by: Claude * Publish and read the same pointer for a thread's top frame set_current_frame() casts the `Py` it publishes straight to `*mut FrameObject`, so ThreadSlot::top_frame holds the object's base. sys._current_frames() read it back through Py::from_payload_ptr(), which subtracts the payload offset from what it is given. The reference it took therefore incremented, and later decremented, a word 48 bytes ahead of the frame -- inside the object allocated before it, whose OnceLock state word sits exactly there for two frames adjacent in the size class. The neighbour then read an initialized-looking cold pointer that had never been written and locked whatever the uninitialized word addressed, so the thread that owned it crashed rather than the one that read. The slot now holds `*mut Py`, which is what both sides mean. A thread parked in a call has no FrameObject for its topmost frame, so top_frame is null there and the reader takes the materialize path instead: test_sys.test_current_frames never reaches the branch. The snippet takes _current_frames() against threads that are running. Assisted-by: Claude Assisted-by: Codex:GPT-5 * Decide stop-the-world parking under the thread registry lock do_suspend() published SUSPENDED first and only then re-read `requested`, restoring itself to ATTACHED if the stop had ended in the meantime. That made a thread the second writer able to leave SUSPENDED, so a stop whose completion check had already observed the thread parked could be undone behind the requester's back: worker CAS ATTACHED -> SUSPENDED requester all_non_requester_suspended() -> true, world_stopped = true requester start_the_world(): requested = false, then walks the registry worker reads requested == false, stores ATTACHED With the store landing inside that walk the debug assertion in start_the_world fires; with the walk already past the slot, a following stop force-parks the thread DETACHED -> SUSPENDED, counts it as stopped, and the store then puts it back to ATTACHED with the world declared stopped and the thread running bytecode. `requested` is set in init_thread_countdown() and cleared in start_the_world() with the registry held, and start_the_world() keeps holding it while releasing every SUSPENDED thread. Taking the registry around the check and the transition therefore makes the two orders the only ones possible: park before that release pass and be woken by it, or find the request already withdrawn and stay ATTACHED. The requester is left as the only writer that takes a thread out of SUSPENDED, and the self-restore is gone. suspend_if_needed() takes the VirtualMachine to reach the registry. Assisted-by: Claude Assisted-by: Codex:GPT-5 * Keep an atexit callback alive while it is being compared atexit.unregister() releases the callback list around each __eq__ call and identified the entry it had compared by the address of its Box. __eq__ can call atexit._clear(), which drops that Box, and atexit.register(), whose new Box lands on the freed allocation; the identity search then matched the freshly registered callback and removed it. atexit.register(a); atexit.register(b); atexit.register(c) # __eq__ runs _clear() then register(d), returns True atexit.unregister(probe) left no callbacks registered where CPython leaves d. Entries are Arc-shared now, so unregister() holds the one it is comparing and matches it with Arc::ptr_eq: an address cannot be reused while the comparison that named it is still running. Assisted-by: Claude * Hold atexit entries in PyRc rather than Arc PyObjectRef is Send and Sync only under the threading feature, so an Arc over a callback entry trips clippy::arc_with_non_send_sync in builds without it, such as the wasm package. PyRc is Arc there and Rc otherwise. Assisted-by: Claude * Do not hold a buffer's storage while waiting for a peer FileIO.readinto() and socket.recv_into()/recvfrom_into() took the target buffer's write borrow and kept it for the whole call, including the wait for data a pipe, socket or terminal may never deliver. What CPython holds across that wait is the export, which only forbids resizing; the borrow is a lock every other thread touching the same object waits on, so threading.Thread(target=lambda: sock.recv_into(buf)).start() len(buf) did not answer until the peer sent. A thread parked on that lock is ATTACHED and never reaches a safepoint, so gc.collect() in a third thread waited for the peer as well: one incidental read of the buffer stopped the world from being stopped at all. The wait now runs against storage of its own and the bytes are copied over once they arrive, with the export held throughout so the target still cannot be resized meanwhile. A seekable file answers from itself rather than from a peer, so FileIO.readinto() writes into the target directly there and the buffered read path is unchanged. Assisted-by: Claude * Do not hold a buffer's storage while waiting to hand it over socket.send()/sendall()/sendto()/sendmsg() and FileIO.write() kept the source buffer's read borrow for the whole call, including the wait for a peer that may never make room. That borrow is a lock every other thread writing to the same object waits on, so threading.Thread(target=lambda: sock.sendall(buf)).start() buf[0] = 1 did not return until the peer read; and a thread parked there is ATTACHED and never reaches a safepoint, so gc.collect() in a third thread waited for the peer too -- the same wedge readinto() had on the receiving side. ArgBytesLike::borrow_buf_unlocked() answers with bytes that survive the borrow being dropped. An immutable object hands out a plain reference and locks nothing, so those are sent where they lie and bytes and memoryviews over them cost nothing; only bytes reached through a lock are copied out first. The export is held throughout either way, so the source still cannot be resized while it is being sent. The regression snippet covers both directions now and is renamed for it. Assisted-by: Claude Assisted-by: Codex:GPT-5 * Check select()'s descriptor limit while the sequence is walked seq2set() collected the whole sequence and compared the result's length against FD_SETSIZE afterwards. Selectable::try_from_object() calls fileno(), which runs Python and can append to the list being walked, and the walk re-reads the list on every step, so the collection had no end to reach and the comparison was never made. seq2set in Modules/selectmodule.c checks the count per element instead. stdlib_select.py gains a fileno() that appends to its own list, and releases its sockets with close() rather than by dropping the name. Assisted-by: Claude * Bound native recursion where the C stack cannot be measured check_c_stack_overflow() answers no unconditionally under miri and on musl, where the stack pointer is not read. Since 9c9905aff that check is all with_recursion() does, so every guard placed on native recursion -- __call__ and __get__ dispatch among them -- was a no-op on those targets and the nesting ran until the stack ran out. with_recursion() now counts its own depth on those targets and refuses past NATIVE_RECURSION_LIMIT_UNMEASURED. The count is separate from the frame limit sys.setrecursionlimit() sets, and compiles away where the stack pointer can be read. Assisted-by: Claude * Report the failure to allocate pbkdf2's key and Take.readinto's scratch Both buffers are sized from an argument -- pbkdf2_hmac's dklen accepts up to i32::MAX, and readinto's from the length of the destination -- and were built with vec![0u8; n], which aborts the process on allocation failure. new_zeroed_bytes() raises MemoryError instead. Assisted-by: Claude * Read the frozen-code tuple length as a signed length The '(' branches in read_marshal_str_vec() and read_marshal_const_tuple() took the length with read_u32() as usize, so a value with the top bit set read as four billion items rather than as out of range. read_len() is what every other length in this file goes through, and it reinterprets as i32. Both readers serve deserialize_code(), which reads only the frozen modules baked in at build time, so this changes no reachable behavior; marshal.loads() already went through read_len(). Assisted-by: Claude * Make the snippets from the fuzzer sweep assert what they check builtin_str.py expanded a tab to 2**31-1 columns, allocating 2 GiB to observe that the width is accepted; a string with no tab observes the same acceptance without laying anything out. stdlib_array.py caught the refusal of frombytes() on its own exported buffer and passed silently when nothing was raised. stdlib_hashlib.py used a bare `assert False` as its failure branch. Both now say so through the same shapes the other snippets use. stdlib_socket.py also accepts OverflowError from recv() with a size that does not fit the platform's C int. stdlib_threading_current_frames.py indexed the frame chain for "f123" without first asserting it is there. Assisted-by: Claude * Ask for a marshal container's room instead of assuming it A flagged tuple or list is published in the reference table before its children are read, and the placeholder was built with vec![none; len]. The length is the input's to choose and read_len() lets it reach i32::MAX, so marshal.loads(b"\xa8\xff\xff\xff\x7f") -- five bytes -- asks for 17 GB of element slots and aborts the process where the allocator cannot serve it. r_object() allocates the container up front too, but PyTuple_New() reports what it cannot get. The elements are now reserved with try_reserve_exact() and a refusal is raised as MemoryError through the decoder's pending-error channel. PyTuple::new_marshal_placeholder() held nothing but that allocation and is gone; the caller builds the elements and uses new_ref(). Assisted-by: Claude * Compare the blocking-buffer snippet against something measure() asserted len(buf) == len(buf), which holds whatever the length is; it now takes the length the caller expects. The pipe case compared the drained total against the whole source, while an unbuffered write() reports only what it transferred and a signal can cut that short; it now compares against what write() returned. Assisted-by: Claude * Refuse array.frombytes() a source whose items are not bytes frombytes() reads its argument as bytes, but accepted any contiguous buffer, so array("i").frombytes(memoryview(array("d", [1.0]))) appended a double's bytes read as ints instead of raising. array_array_frombytes_impl requires an itemsize of 1; ArgBytesLike now reports the itemsize so the same check can be made here. The BufferError that a resize meets while the array is exported also names the array rather than repeating bytearray's wording. stdlib_array.py's frombytes-of-itself case used typecode "i", where the new check answers before the resize guard is reached; it uses "b" so the guard is what refuses, and asserts the wider case separately. Assisted-by: Claude * Tell apart the ways marshal data can be bad A type byte no reader knows, a back reference that names nothing, and TYPE_NULL all came out as a bare "bad marshal data" ValueError. r_object() answers the first two with "bad marshal data (unknown type code)" and "bad marshal data (invalid reference)", and read_object() answers the third with a TypeError, "NULL object in marshal data for object", since TYPE_NULL stands for no object rather than for a value. MarshalError gains the three cases and deserialize_value() maps them. The container-specific wording r_object() uses for a NULL read inside a tuple or list is not reproduced; the exception type is. Assisted-by: Claude * Hash the collector's tables by address rather than by SipHash A collection keys three sets and two maps by object address, and it visits every tracked object and every edge between them, so the hashing is a per-edge cost. Those tables used the default RandomState, whose SipHash buys resistance against a caller choosing colliding keys -- and nothing chooses these keys: they are addresses this process handed out into tables that live and die inside one collection. A profile of gc.collect() over a 423k-object heap spent 45% of its samples in SipHash. They now hash with a splitmix64 finalizer. The shifts matter: a table picks its bucket from the low bits and an address arrives with those bits zeroed by alignment, so a plain multiply leaves every object in a handful of buckets and is slower than SipHash was. The reachability walk also copied each object's referent vector out of the map it was cached in, a second pass over every edge; it reads them in place, and reference subtraction hands its vector to the map instead of cloning it. Measured over 423k live objects: 0.93s to 0.15s. Over 843k dead ones: 3.00s to 0.79s. extra_tests/snippets/stdlib_threading_gc_import.py, whose collector thread calls gc.collect() in a loop, ran anywhere from 2.7s to 28s and now runs in 3.1-3.5s: a collection that takes longer leaves more garbage for the next one to walk, so the cost fed back on itself. Assisted-by: Claude Assisted-by: Codex:GPT-5 * Keep the collection's candidates and their counts in one table A collection built a set of candidates and, beside it, a map from the same addresses to their reference counts. Both were probed for every edge in the heap -- membership from the set, the count from the map -- so each edge paid to hash the same address twice, and each candidate paid to be inserted twice. The map alone answers both questions. The candidates also keep a walkable order now, which the reference subtraction pass needs since it writes the counts while reading the candidates, and which the unreachable set is built from instead of a set difference. Over the 423k-object heap measured in the previous commit: 0.15s to 0.13s live, and 0.79s to 0.49s dead. Assisted-by: Claude Assisted-by: Codex:GPT-5 * gc: collect referents into one buffer instead of a vector per object Step 3 allocated a `Vec` for every tracked object to hold its referents and kept them all in a map until step 4 read them back. The referents now go into a single growing buffer, with the map holding each object's range into it. Adds `PyObject::gc_extend_referent_ptrs`, which appends to a caller's buffer; `gc_get_referent_ptrs` calls it with a fresh one. Assisted-by: Claude * memoryview and struct: match the checks and errors of the reference memoryview: - `cast()` accepted a source and destination that are both item types, which reinterprets the items rather than re-dividing the bytes; one side now has to be a byte format. - A cast to `shape=()` returned without checking that the buffer holds exactly the one item that shape describes. - `hash()` hashes the bytes, so it now raises ValueError for a view whose items are not bytes, rather than returning a hash that disagrees with the value the view compares equal to. - `tobytes()` takes the `order` argument, with 'F' walking a multidimensional view down its columns; `BufferDescriptor` gained `for_each_segment_fortran` for that walk. struct: - A value the format has no room for reported "argument out of range" instead of naming the format and its range. The format character is now passed to the packing functions to report it. - `Struct.__new__` no longer reads the format; `__init__` does, so `__init__` can be called again and a subclass can pass the format up. Methods raise RuntimeError until it has run, and `Struct` is a base type. Removes the expectedFailure from test_Struct_reinitialization and test_struct_subclass_instantiation. Assisted-by: Claude Assisted-by: Codex:GPT-5 * io: decide the readinto path by file type, not by seekability FileIO.readinto wrote straight into the caller's buffer, holding its write borrow, when the fd was seekable; otherwise it read aside into scratch and copied. Seekability stood in for "this read answers without waiting on a peer", which a pipe on Windows breaks: lseek on one succeeds, so the pipe took the borrow-holding path and every other thread touching that bytearray waited for the peer. host_io::reads_without_waiting answers it directly -- seekability elsewhere, GetFileType() == FILE_TYPE_DISK on Windows. The regression snippet times each operation separately, so a failure names the one that waited; it asserts the transfer is still in flight before checking the export; and the socket case fills the connection until it refuses rather than assuming a size that outruns it, which SO_SNDBUF on an already-connected pair does not settle. Assisted-by: Claude --- Lib/test/test_struct.py | 2 - Lib/test/test_support.py | 1 - crates/common/src/borrow.rs | 11 ++ crates/common/src/str.rs | 25 +-- crates/compiler-core/src/marshal.rs | 82 +++++--- crates/host_env/src/io.rs | 23 +++ crates/host_env/src/io_unsupported.rs | 4 + crates/stdlib/src/_asyncio.rs | 71 ++++--- crates/stdlib/src/array.rs | 59 ++++-- crates/stdlib/src/hashlib.rs | 6 +- crates/stdlib/src/mmap.rs | 27 ++- crates/stdlib/src/pystruct.rs | 92 ++++++--- crates/stdlib/src/select.rs | 33 +++- crates/stdlib/src/socket.rs | 71 ++++--- crates/vm/src/anystr.rs | 27 ++- crates/vm/src/buffer.rs | 67 +++++-- crates/vm/src/builtins/bytearray.rs | 19 +- crates/vm/src/builtins/bytes.rs | 4 +- crates/vm/src/builtins/memory.rs | 81 +++++++- crates/vm/src/builtins/str.rs | 42 ++-- crates/vm/src/builtins/tuple.rs | 4 - crates/vm/src/bytes_inner.rs | 29 ++- crates/vm/src/frame.rs | 8 + crates/vm/src/function/buffer.rs | 68 +++++++ crates/vm/src/gc_state.rs | 155 +++++++++------ crates/vm/src/object/core.rs | 13 +- crates/vm/src/protocol/buffer.rs | 41 ++++ crates/vm/src/stdlib/_io.rs | 102 +++++++--- crates/vm/src/stdlib/_thread.rs | 6 +- crates/vm/src/stdlib/atexit.rs | 20 +- crates/vm/src/stdlib/marshal.rs | 141 ++++++++------ crates/vm/src/stdlib/typevar.rs | 18 +- crates/vm/src/types/slot.rs | 12 +- crates/vm/src/vm/mod.rs | 82 ++++++-- crates/vm/src/vm/thread.rs | 183 ++++++++++-------- crates/vm/src/vm/vm_ops.rs | 21 ++ extra_tests/snippets/builtin_bytes.py | 19 ++ extra_tests/snippets/builtin_hash.py | 5 +- extra_tests/snippets/builtin_memoryview.py | 161 +++++++++++++++ extra_tests/snippets/builtin_str.py | 15 ++ extra_tests/snippets/builtin_type.py | 30 +++ extra_tests/snippets/recursion.py | 33 ++++ extra_tests/snippets/stdlib_array.py | 33 ++++ extra_tests/snippets/stdlib_asyncio.py | 27 +++ extra_tests/snippets/stdlib_atexit.py | 101 ++++++++++ extra_tests/snippets/stdlib_hashlib.py | 8 + extra_tests/snippets/stdlib_io.py | 45 +++++ .../snippets/stdlib_io_blocking_buffer.py | 176 +++++++++++++++++ extra_tests/snippets/stdlib_io_bytesio.py | 8 + extra_tests/snippets/stdlib_marshal.py | 58 ++++++ extra_tests/snippets/stdlib_select.py | 73 ++++++- extra_tests/snippets/stdlib_socket.py | 15 ++ extra_tests/snippets/stdlib_struct.py | 64 ++++++ .../stdlib_threading_current_frames.py | 100 ++++++++++ extra_tests/snippets/stdlib_types.py | 4 +- extra_tests/snippets/stdlib_typing.py | 18 ++ 56 files changed, 2144 insertions(+), 499 deletions(-) create mode 100644 extra_tests/snippets/stdlib_atexit.py create mode 100644 extra_tests/snippets/stdlib_io_blocking_buffer.py create mode 100644 extra_tests/snippets/stdlib_threading_current_frames.py diff --git a/Lib/test/test_struct.py b/Lib/test/test_struct.py index c7663980939..f828b778659 100644 --- a/Lib/test/test_struct.py +++ b/Lib/test/test_struct.py @@ -601,7 +601,6 @@ def test_trailing_counter(self): 'spam and eggs') self.assertRaises(struct.error, struct.unpack_from, '14s42', store, 0) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: '>h' != '>hh' def test_Struct_reinitialization(self): # Issue 9422: there was a memory leak when reinitializing a # Struct instance. This test can be used to detect the leak @@ -826,7 +825,6 @@ def test_error_propagation(fmt_str): test_error_propagation('N') test_error_propagation('n') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_struct_subclass_instantiation(self): # Regression test for https://github.com/python/cpython/issues/112358 class MyStruct(struct.Struct): diff --git a/Lib/test/test_support.py b/Lib/test/test_support.py index 19ea6fafcf7..42aa7e3d9bb 100644 --- a/Lib/test/test_support.py +++ b/Lib/test/test_support.py @@ -631,7 +631,6 @@ def test_has_strftime_extensions(self): else: self.assertTrue(support.has_strftime_extensions) - @unittest.expectedFailure # TODO: RUSTPYTHON; - _testinternalcapi module not available def test_get_recursion_depth(self): # test support.get_recursion_depth() code = textwrap.dedent(""" diff --git a/crates/common/src/borrow.rs b/crates/common/src/borrow.rs index 2be5f8275c8..70d755ff155 100644 --- a/crates/common/src/borrow.rs +++ b/crates/common/src/borrow.rs @@ -34,6 +34,17 @@ impl_from!('a, T, BorrowedValue<'a, T>, ); impl<'a, T: ?Sized> BorrowedValue<'a, T> { + /// Whether reaching the value holds a lock that other threads wait on. + /// + /// An immutable object hands out a plain reference and answers `false`; + /// one whose storage can change hands out a guard. A caller about to wait + /// for something unrelated -- a peer, a file, a signal -- can use this to + /// decide whether it may keep the borrow for the duration. + #[must_use] + pub const fn is_locked(&self) -> bool { + !matches!(self, Self::Ref(_)) + } + pub fn map(s: Self, f: F) -> BorrowedValue<'a, U> where F: FnOnce(&T) -> &U, diff --git a/crates/common/src/str.rs b/crates/common/src/str.rs index 326f76c43cb..39ec7da1de5 100644 --- a/crates/common/src/str.rs +++ b/crates/common/src/str.rs @@ -592,20 +592,21 @@ pub fn codepoint_range_end(s: &Wtf8, n_chars: usize) -> Option { } #[must_use] -pub fn zfill(bytes: &[u8], width: usize) -> Vec { +/// Returns `None` for a width whose result cannot be allocated. +pub fn zfill(bytes: &[u8], width: usize) -> Option> { if width <= bytes.len() { - bytes.to_vec() - } else { - let (sign, s) = match bytes.first() { - Some(_sign @ (b'+' | b'-')) => (unsafe { bytes.get_unchecked(..1) }, &bytes[1..]), - _ => (&b""[..], bytes), - }; - let mut filled = Vec::new(); - filled.extend_from_slice(sign); - filled.extend(core::iter::repeat_n(b'0', width - bytes.len())); - filled.extend_from_slice(s); - filled + return Some(bytes.to_vec()); } + let (sign, s) = match bytes.first() { + Some(_sign @ (b'+' | b'-')) => (unsafe { bytes.get_unchecked(..1) }, &bytes[1..]), + _ => (&b""[..], bytes), + }; + let mut filled = Vec::new(); + filled.try_reserve_exact(width).ok()?; + filled.extend_from_slice(sign); + filled.extend(core::iter::repeat_n(b'0', width - bytes.len())); + filled.extend_from_slice(s); + Some(filled) } /// Convert a string to ascii compatible, escaping unicode-s into escape diff --git a/crates/compiler-core/src/marshal.rs b/crates/compiler-core/src/marshal.rs index 46e0047941c..754854e7cba 100644 --- a/crates/compiler-core/src/marshal.rs +++ b/crates/compiler-core/src/marshal.rs @@ -19,6 +19,14 @@ pub enum MarshalError { InvalidLocation, /// Bad type marker BadType, + /// A type marker no reader knows + UnknownType, + /// A back reference that names nothing + InvalidRef, + /// A marker that stands for no object at all + NullObject, + /// A container length that is negative or does not fit, named by what it counts + BadSize(&'static str), } impl core::fmt::Display for MarshalError { @@ -29,6 +37,10 @@ impl core::fmt::Display for MarshalError { Self::InvalidUtf8 => f.write_str("invalid utf8"), Self::InvalidLocation => f.write_str("invalid source location"), Self::BadType => f.write_str("bad type marker"), + Self::UnknownType => f.write_str("unknown type code"), + Self::InvalidRef => f.write_str("invalid reference"), + Self::NullObject => f.write_str("NULL object in marshal data for object"), + Self::BadSize(what) => write!(f, "{what} size out of range"), } } } @@ -111,7 +123,7 @@ impl TryFrom for Type { b'A' => Self::AsciiInterned, b'z' => Self::ShortAscii, b'Z' => Self::ShortAsciiInterned, - _ => return Err(MarshalError::BadType), + _ => return Err(MarshalError::UnknownType), }) } } @@ -146,6 +158,13 @@ pub trait Read { fn read_u64(&mut self) -> Result { Ok(u64::from_le_bytes(*self.read_array()?)) } + + /// A length, read the way `r_long` reads one: it is signed, so a value + /// with the top bit set is out of range rather than four billion items. + fn read_len(&mut self, what: &'static str) -> Result { + let len = self.read_u32()? as i32; + usize::try_from(len).map_err(|_| MarshalError::BadSize(what)) + } } pub(crate) trait ReadBorrowed<'a>: Read { @@ -305,7 +324,7 @@ fn reserve_ref_slot(has_flag: bool, refs: &mut Vec>) -> Option(idx: usize, refs: &[Option]) -> Result { refs.get(idx) .and_then(|v| v.clone()) - .ok_or(MarshalError::InvalidBytecode) + .ok_or(MarshalError::InvalidRef) } /// Read a marshal bytes object (TYPE_STRING = b's'), resolving TYPE_REF @@ -408,7 +427,7 @@ fn read_marshal_str_vec( } let n = match type_byte { - b'(' => rdr.read_u32()? as usize, + b'(' => rdr.read_len("tuple")?, b')' => rdr.read_u8()? as usize, _ => return Err(MarshalError::BadType), }; @@ -471,7 +490,7 @@ fn read_marshal_const_tuple( } let n = match type_byte { - b'(' => rdr.read_u32()? as usize, + b'(' => rdr.read_len("tuple")?, b')' => rdr.read_u8()? as usize, _ => return Err(MarshalError::BadType), }; @@ -553,7 +572,7 @@ pub trait MarshalBag: Copy { fn make_code( &self, code: CodeObject<::Constant>, - ) -> Self::Value; + ) -> Result; /// Construct a runtime code object while retaining the exact values read /// from ``co_consts``. Compiler bags ignore this second channel; runtime @@ -563,7 +582,7 @@ pub trait MarshalBag: Copy { &self, code: CodeObject<::Constant>, _constants: Vec, - ) -> Self::Value { + ) -> Result { self.make_code(code) } @@ -583,8 +602,12 @@ pub trait MarshalBag: Copy { /// Install partially-built containers in the marshal reference table /// before reading their children, as CPython's `r_object()` does. /// Runtime bags can opt in; constant bags retain collect-then-construct. - fn make_tuple_placeholder(&self, _len: usize) -> Option { - None + /// + /// `len` comes straight from the input and is only bounded by what a + /// length can hold, so a bag that opts in reports the room it cannot get + /// rather than taking it for granted. + fn make_tuple_placeholder(&self, _len: usize) -> Result> { + Ok(None) } fn set_tuple_item( @@ -596,8 +619,8 @@ pub trait MarshalBag: Copy { Err(MarshalError::BadType) } - fn make_list_placeholder(&self, _len: usize) -> Option { - None + fn make_list_placeholder(&self, _len: usize) -> Result> { + Ok(None) } fn set_list_item(&self, _list: &Self::Value, _index: usize, _value: Self::Value) -> Result<()> { @@ -725,8 +748,8 @@ impl MarshalBag for Bag { fn make_code( &self, code: CodeObject<::Constant>, - ) -> Self::Value { - self.make_code(code) + ) -> Result { + Ok(self.make_code(code)) } fn make_stop_iter(&self) -> Result { @@ -830,10 +853,7 @@ fn deserialize_value_after_header( // TYPE_REF: return previously stored object if type_code == Type::Ref as u8 { let idx = rdr.read_u32()? as usize; - return refs - .get(idx) - .and_then(|v| v.clone()) - .ok_or(MarshalError::InvalidBytecode); + return resolve_ref(idx, refs); } // Reserve ref slot before reading (matches write order) @@ -986,7 +1006,7 @@ fn deserialize_code_value_inner( linetable, exceptiontable, }; - Ok(bag.make_code_with_constants(code, constant_values)) + bag.make_code_with_constants(code, constant_values) } fn deserialize_value_typed( @@ -1033,13 +1053,13 @@ fn deserialize_value_typed( bag.make_complex(value) } Type::Ascii | Type::Unicode => { - let len = rdr.read_u32()?; - let value = rdr.read_wtf8(len)?; + let len = rdr.read_len("string")?; + let value = rdr.read_wtf8(len as u32)?; bag.make_str(value) } Type::AsciiInterned | Type::Interned => { - let len = rdr.read_u32()?; - let value = rdr.read_wtf8(len)?; + let len = rdr.read_len("string")?; + let value = rdr.read_wtf8(len as u32)?; bag.make_interned_str(value) } Type::ShortAscii => { @@ -1056,7 +1076,7 @@ fn deserialize_value_typed( let len = rdr.read_u8()? as usize; let d = depth - 1; if let Some(index) = slot - && let Some(tuple) = bag.make_tuple_placeholder(len) + && let Some(tuple) = bag.make_tuple_placeholder(len)? { refs[index] = Some(tuple.clone()); for item_index in 0..len { @@ -1070,17 +1090,17 @@ fn deserialize_value_typed( } } Type::Null => { - return Err(MarshalError::BadType); + return Err(MarshalError::NullObject); } Type::Ref => { // Handled in deserialize_value_depth before calling this function return Err(MarshalError::BadType); } Type::Tuple => { - let len = rdr.read_u32()? as usize; + let len = rdr.read_len("tuple")?; let d = depth - 1; if let Some(index) = slot - && let Some(tuple) = bag.make_tuple_placeholder(len) + && let Some(tuple) = bag.make_tuple_placeholder(len)? { refs[index] = Some(tuple.clone()); for item_index in 0..len { @@ -1094,10 +1114,10 @@ fn deserialize_value_typed( } } Type::List => { - let len = rdr.read_u32()? as usize; + let len = rdr.read_len("list")?; let d = depth - 1; if let Some(index) = slot - && let Some(list) = bag.make_list_placeholder(len) + && let Some(list) = bag.make_list_placeholder(len)? { refs[index] = Some(list.clone()); for item_index in 0..len { @@ -1111,7 +1131,7 @@ fn deserialize_value_typed( } } Type::Set => { - let len = rdr.read_u32()? as usize; + let len = rdr.read_len("set")?; let d = depth - 1; if let Some(index) = slot && let Some(set) = bag.make_set_placeholder() @@ -1128,7 +1148,7 @@ fn deserialize_value_typed( } } Type::FrozenSet => { - let len = rdr.read_u32()?; + let len = rdr.read_len("set")?; let d = depth - 1; let it = (0..len).map(|_| deserialize_value_depth(rdr, bag, d, refs)); itertools::process_results(it, |it| bag.make_frozenset(it))?? @@ -1165,8 +1185,8 @@ fn deserialize_value_typed( } Type::Bytes => { // After marshaling, byte arrays are converted into bytes. - let len = rdr.read_u32()?; - let value = rdr.read_slice(len)?; + let len = rdr.read_len("bytes object")?; + let value = rdr.read_slice(len as u32)?; bag.make_bytes(value) } Type::Code => return Err(MarshalError::BadType), diff --git a/crates/host_env/src/io.rs b/crates/host_env/src/io.rs index 6df29bcd6bc..f32ef2f6944 100644 --- a/crates/host_env/src/io.rs +++ b/crates/host_env/src/io.rs @@ -199,6 +199,29 @@ pub fn is_seekable(fd: crt_fd::Borrowed<'_>) -> bool { os::seek_fd(fd, 0, libc::SEEK_CUR).is_ok() } +/// Whether a read from `fd` answers from data the file already holds, rather +/// than waiting for whoever writes the other end. +/// +/// Seeking answers this everywhere but Windows, where a pipe seeks too -- +/// `lseek` on one succeeds and reports a position, so a reader that took +/// seekability for an answer would wait on a peer while holding whatever it +/// holds for the length of the call. +#[cfg(not(windows))] +pub fn reads_without_waiting(fd: crt_fd::Borrowed<'_>) -> bool { + is_seekable(fd) +} + +#[cfg(windows)] +pub fn reads_without_waiting(fd: crt_fd::Borrowed<'_>) -> bool { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, GetFileType}; + + let Ok(handle) = crt_fd::as_handle(fd) else { + return false; + }; + unsafe { GetFileType(handle.as_raw_handle() as _) == FILE_TYPE_DISK } +} + pub fn validate_whence(whence: i32) -> bool { let standard = (0..=2).contains(&whence); #[cfg(any(target_os = "dragonfly", target_os = "freebsd", target_os = "linux"))] diff --git a/crates/host_env/src/io_unsupported.rs b/crates/host_env/src/io_unsupported.rs index e46f05af900..d9fdc5d0d23 100644 --- a/crates/host_env/src/io_unsupported.rs +++ b/crates/host_env/src/io_unsupported.rs @@ -176,6 +176,10 @@ pub fn is_seekable(_fd: crt_fd::Borrowed<'_>) -> bool { false } +pub fn reads_without_waiting(_fd: crt_fd::Borrowed<'_>) -> bool { + false +} + pub fn validate_whence(whence: i32) -> bool { (0..=2).contains(&whence) } diff --git a/crates/stdlib/src/_asyncio.rs b/crates/stdlib/src/_asyncio.rs index 9ad75fb8d69..c3f28590e6a 100644 --- a/crates/stdlib/src/_asyncio.rs +++ b/crates/stdlib/src/_asyncio.rs @@ -724,47 +724,56 @@ pub(crate) mod _asyncio { /// Add waiter to fut_awaited_by with single-object optimization fn awaited_by_add(&self, waiter: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { - let mut awaited_by = self.fut_awaited_by.write(); - if awaited_by.is_none() { - // First waiter - store directly - *awaited_by = Some(waiter); - return Ok(()); - } + // Storing a waiter in the set runs its __hash__ and __eq__, which can + // come back to this future, so the field is locked only while it is + // read or written. + let existing = { + let mut awaited_by = self.fut_awaited_by.write(); + match awaited_by.as_ref() { + // First waiter - store directly + None => { + *awaited_by = Some(waiter); + return Ok(()); + } + Some(existing) => existing.clone(), + } + }; if self.fut_awaited_by_is_set.load(Ordering::Relaxed) { // Already a Set - add to it - let set = awaited_by.as_ref().unwrap(); - vm.call_method(set, "add", (waiter,))?; - } else { - // Single object - convert to Set - let existing = awaited_by.take().unwrap(); - let new_set = PySet::default().into_ref(&vm.ctx); - new_set.add(existing, vm)?; - new_set.add(waiter, vm)?; - *awaited_by = Some(new_set.into()); - self.fut_awaited_by_is_set.store(true, Ordering::Relaxed); + return vm.call_method(&existing, "add", (waiter,)).map(drop); } + + // Single object - convert to Set + let new_set = PySet::default().into_ref(&vm.ctx); + new_set.add(existing, vm)?; + new_set.add(waiter, vm)?; + *self.fut_awaited_by.write() = Some(new_set.into()); + self.fut_awaited_by_is_set.store(true, Ordering::Relaxed); Ok(()) } /// Discard waiter from fut_awaited_by with single-object optimization fn awaited_by_discard(&self, waiter: &PyObject, vm: &VirtualMachine) -> PyResult<()> { - let mut awaited_by = self.fut_awaited_by.write(); - if awaited_by.is_none() { - return Ok(()); - } - - let obj = awaited_by.as_ref().unwrap(); - if !self.fut_awaited_by_is_set.load(Ordering::Relaxed) { - // Single object - check if it matches - if obj.is(waiter) { - *awaited_by = None; + // As in awaited_by_add, discarding from the set runs Python. + let set = { + let mut awaited_by = self.fut_awaited_by.write(); + let Some(obj) = awaited_by.as_ref() else { + return Ok(()); + }; + if !self.fut_awaited_by_is_set.load(Ordering::Relaxed) { + // Single object - check if it matches + if obj.is(waiter) { + *awaited_by = None; + } + return Ok(()); } - } else { - // It's a Set - use discard - vm.call_method(obj, "discard", (waiter.to_owned(),))?; - } - Ok(()) + obj.clone() + }; + + // It's a Set - use discard + vm.call_method(&set, "discard", (waiter.to_owned(),)) + .map(drop) } #[pymethod] diff --git a/crates/stdlib/src/array.rs b/crates/stdlib/src/array.rs index 68e7aab2566..0f652efd35c 100644 --- a/crates/stdlib/src/array.rs +++ b/crates/stdlib/src/array.rs @@ -55,6 +55,11 @@ pub mod array { $($n(Vec<$t>),)* } + /// One item, already converted to the array's element type. + enum ArrayItem { + $($n($t),)* + } + impl ArrayContentType { fn from_char(c: char) -> Result { match c { @@ -303,17 +308,31 @@ pub mod array { } } - fn setitem_by_index( + /// Convert an object to the element type of the array with + /// this typecode. This runs the object's conversion methods, + /// which can reach the array, so it takes the typecode by + /// value and holds no lock on it. + fn item_from_object( + typecode: char, + value: PyObjectRef, + vm: &VirtualMachine + ) -> PyResult { + match typecode { + $($c => Ok(ArrayItem::$n(<$t>::try_into_from_object(vm, value)?)),)* + _ => unreachable!("array has a typecode"), + } + } + + fn setitem_by_item( &mut self, i: isize, - value: PyObjectRef, + item: ArrayItem, vm: &VirtualMachine ) -> PyResult<()> { - match self { - $(ArrayContentType::$n(v) => { - let value = <$t>::try_into_from_object(vm, value)?; - v.setitem_by_index(vm, i, value) - })* + match (self, item) { + $((ArrayContentType::$n(v), ArrayItem::$n(value)) => + v.setitem_by_index(vm, i, value),)* + _ => unreachable!("item was converted for this array"), } } @@ -906,6 +925,11 @@ pub mod array { #[pymethod] fn frombytes(&self, b: ArgBytesLike, vm: &VirtualMachine) -> PyResult<()> { + // The source is read as bytes, so items of any other width would + // be reinterpreted rather than appended. + if b.itemsize() != 1 { + return Err(vm.new_type_error("a bytes-like object is required")); + } let b = b.borrow_buf(); let itemsize = self.read().itemsize(); self._from_bytes(&b, itemsize, vm) @@ -1047,7 +1071,11 @@ pub mod array { vm: &VirtualMachine, ) -> PyResult<()> { match SequenceIndex::try_from_borrowed_object(vm, needle, "array")? { - SequenceIndex::Int(i) => zelf.write().setitem_by_index(i, value, vm), + SequenceIndex::Int(i) => { + let typecode = zelf.read().typecode(); + let item = ArrayContentType::item_from_object(typecode, value, vm)?; + zelf.write().setitem_by_item(i, item, vm) + } SequenceIndex::Slice(slice) => { let cloned; let guard; @@ -1408,7 +1436,9 @@ pub mod array { ass_item: atomic_func!(|seq, i, value, vm| { let zelf = PyArray::sequence_downcast(seq); if let Some(value) = value { - zelf.write().setitem_by_index(i, value, vm) + let typecode = zelf.read().typecode(); + let item = ArrayContentType::item_from_object(typecode, value, vm)?; + zelf.write().setitem_by_item(i, item, vm) } else { zelf.write().delitem_by_index(i, vm) } @@ -1443,8 +1473,15 @@ pub mod array { type Resizable<'a> = PyRwLockWriteGuard<'a, ArrayContentType>; fn try_resizable_opt(&self) -> Option> { - let w = self.write(); - (self.exports.load(atomic::Ordering::SeqCst) == 0).then_some(w) + // An export is a borrow someone else still holds, so it is + // answered before the lock rather than by waiting on it. + (self.exports.load(atomic::Ordering::SeqCst) == 0).then(|| self.write()) + } + + fn try_resizable(&self, vm: &VirtualMachine) -> PyResult> { + self.try_resizable_opt().ok_or_else(|| { + vm.new_buffer_error("cannot resize an array that is exporting buffers") + }) } } diff --git a/crates/stdlib/src/hashlib.rs b/crates/stdlib/src/hashlib.rs index c2153b08a59..d7f94cc2796 100644 --- a/crates/stdlib/src/hashlib.rs +++ b/crates/stdlib/src/hashlib.rs @@ -847,15 +847,15 @@ pub(crate) mod _hashlib { if len < 1 { return Err(vm.new_value_error("key length must be greater than 0.")); } - usize::try_from(len) - .map_err(|_| vm.new_overflow_error("key length is too great."))? + i32::try_from(len).map_err(|_| vm.new_overflow_error("key length is too great."))? + as usize } None => hash_digest_size(&name).ok_or_else(|| unsupported_hash(&name, vm))?, }; let password_buf = args.password.borrow_buf(); let salt_buf = args.salt.borrow_buf(); - let mut dk = vec![0u8; dklen]; + let mut dk = vm.new_zeroed_bytes(dklen)?; macro_rules! do_pbkdf2 { ($hash_ty:ty) => {{ diff --git a/crates/stdlib/src/mmap.rs b/crates/stdlib/src/mmap.rs index b5dec976594..14957ad904e 100644 --- a/crates/stdlib/src/mmap.rs +++ b/crates/stdlib/src/mmap.rs @@ -1152,24 +1152,35 @@ mod mmap { } #[pymethod] - fn write(&self, bytes: ArgBytesLike, vm: &VirtualMachine) -> PyResult { - let pos = self.pos(); - let size = self.__len__(); - - let data = bytes.borrow_buf(); + fn write(zelf: &Py, bytes: ArgBytesLike, vm: &VirtualMachine) -> PyResult { + let self_ = &**zelf; + let pos = self_.pos(); + let size = self_.__len__(); + + // Writing locks the map, and reading a source that views this same + // map locks it too, so such a source is copied out first. + let copied; + let borrowed; + let data: &[u8] = if bytes.source_object().is(zelf.as_object()) { + copied = bytes.borrow_buf().to_vec(); + &copied + } else { + borrowed = bytes.borrow_buf(); + &borrowed + }; if pos > size || size - pos < data.len() { return Err(vm.new_value_error("data out of range")); } - let len = self.try_writable(vm, |mmap| { + let len = self_.try_writable(vm, |mmap| { (&mut mmap[pos..(pos + data.len())]) - .write(&data) + .write(data) .map_err(|err| err.to_pyexception(vm))?; Ok(data.len()) })??; - self.advance_pos(len); + self_.advance_pos(len); Ok(PyInt::from(len).into_ref(&vm.ctx)) } diff --git a/crates/stdlib/src/pystruct.rs b/crates/stdlib/src/pystruct.rs index c525942e35e..496b448e5e8 100644 --- a/crates/stdlib/src/pystruct.rs +++ b/crates/stdlib/src/pystruct.rs @@ -10,13 +10,14 @@ pub(crate) use _struct::module_def; #[pymodule] pub(crate) mod _struct { use crate::vm::{ - AsObject, Py, PyObjectRef, PyPayload, PyResult, TryFromObject, VirtualMachine, + AsObject, Py, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, VirtualMachine, buffer::{FormatSpec, new_struct_error, struct_error_type}, builtins::{PyBytes, PyStr, PyStrRef, PyTupleRef, PyType, PyTypeRef}, - function::{ArgBytesLike, ArgMemoryBuffer, PosArgs}, + common::lock::{PyMappedRwLockReadGuard, PyRwLock, PyRwLockReadGuard}, + function::{ArgBytesLike, ArgMemoryBuffer, FuncArgs, PosArgs}, match_class, protocol::PyIterReturn, - types::{Constructor, IterNext, Iterable, Representable, SelfIter}, + types::{Constructor, Initializer, IterNext, Iterable, Representable, SelfIter}, }; use crossbeam_utils::atomic::AtomicCell; use rustpython_common::wtf8::{Wtf8Buf, wtf8_concat}; @@ -251,41 +252,76 @@ pub(crate) mod _struct { Ok(fmt.format_spec(vm)?.size) } + /// What a `Struct` is once a format has been read into it. Held apart + /// from the object because `__new__` hands out a `Struct` that `__init__` + /// has not filled in yet, and `__init__` may be called again on one that + /// already holds a format. + #[derive(Debug)] + struct StructSpec { + spec: FormatSpec, + format: PyStrRef, + } + #[pyattr] #[pyclass(name = "Struct", traverse)] #[derive(Debug, PyPayload)] struct PyStruct { #[pytraverse(skip)] - spec: FormatSpec, - format: PyStrRef, + inner: PyRwLock>, } impl Constructor for PyStruct { + type Args = FuncArgs; + + fn py_new(_cls: &Py, _args: Self::Args, _vm: &VirtualMachine) -> PyResult { + Ok(Self { + inner: PyRwLock::new(None), + }) + } + } + + impl Initializer for PyStruct { type Args = IntoStructFormatBytes; - fn py_new(_cls: &Py, fmt: Self::Args, vm: &VirtualMachine) -> PyResult { + fn init(zelf: PyRef, fmt: Self::Args, vm: &VirtualMachine) -> PyResult<()> { + // The format is read before anything is replaced, so a format that + // cannot be read leaves the object as it was. let spec = fmt.format_spec(vm)?; - let format = fmt.0; - Ok(Self { spec, format }) + *zelf.inner.write() = Some(StructSpec { + spec, + format: fmt.0, + }); + Ok(()) } } - #[pyclass(with(Constructor, Representable))] + #[pyclass(with(Constructor, Initializer, Representable), flags(BASETYPE))] impl PyStruct { + /// The format this was initialized with, or an error if `__init__` + /// never ran. + fn ready(&self, vm: &VirtualMachine) -> PyResult> { + PyRwLockReadGuard::try_map(self.inner.read(), Option::as_ref) + .map_err(|_| vm.new_runtime_error("Struct object is not initialized")) + } + #[pygetset] - fn format(&self) -> PyStrRef { - self.format.clone() + fn format(&self, vm: &VirtualMachine) -> PyResult { + Ok(self.ready(vm)?.format.clone()) } + /// The size an uninitialized `Struct` reports, which no format has + /// yet given a value. #[pygetset] - #[inline] - const fn size(&self) -> usize { - self.spec.size + fn size(&self) -> isize { + self.inner + .read() + .as_ref() + .map_or(-1, |inner| inner.spec.size as isize) } #[pymethod] fn pack(&self, args: PosArgs, vm: &VirtualMachine) -> PyResult> { - self.spec.pack(args.into_vec(), vm) + self.ready(vm)?.spec.pack(args.into_vec(), vm) } #[pymethod] @@ -296,23 +332,28 @@ pub(crate) mod _struct { args: PosArgs, vm: &VirtualMachine, ) -> PyResult<()> { - let offset = get_buffer_offset(buffer.len(), offset, self.size(), true, vm)?; + let inner = self.ready(vm)?; + let offset = get_buffer_offset(buffer.len(), offset, inner.spec.size, true, vm)?; buffer.with_ref(|data| { - self.spec + inner + .spec .pack_into(&mut data[offset..], args.into_vec(), vm) }) } #[pymethod] fn unpack(&self, data: ArgBytesLike, vm: &VirtualMachine) -> PyResult { - data.with_ref(|buf| self.spec.unpack(buf, vm)) + let inner = self.ready(vm)?; + data.with_ref(|buf| inner.spec.unpack(buf, vm)) } #[pymethod] fn unpack_from(&self, args: UpdateFromArgs, vm: &VirtualMachine) -> PyResult { - let offset = get_buffer_offset(args.buffer.len(), args.offset, self.size(), false, vm)?; + let inner = self.ready(vm)?; + let size = inner.spec.size; + let offset = get_buffer_offset(args.buffer.len(), args.offset, size, false, vm)?; args.buffer - .with_ref(|buf| self.spec.unpack(&buf[offset..][..self.size()], vm)) + .with_ref(|buf| inner.spec.unpack(&buf[offset..][..size], vm)) } #[pymethod] @@ -321,14 +362,19 @@ pub(crate) mod _struct { buffer: ArgBytesLike, vm: &VirtualMachine, ) -> PyResult { - UnpackIterator::with_buffer(vm, self.spec.clone(), buffer) + let spec = self.ready(vm)?.spec.clone(); + UnpackIterator::with_buffer(vm, spec, buffer) } } impl Representable for PyStruct { #[inline] - fn repr_wtf8(zelf: &Py, _vm: &VirtualMachine) -> PyResult { - Ok(wtf8_concat!("Struct('", zelf.format.as_wtf8(), "')")) + fn repr_wtf8(zelf: &Py, vm: &VirtualMachine) -> PyResult { + Ok(wtf8_concat!( + "Struct('", + zelf.ready(vm)?.format.as_wtf8(), + "')" + )) } } diff --git a/crates/stdlib/src/select.rs b/crates/stdlib/src/select.rs index c1f10f3ecc2..84ec92927e8 100644 --- a/crates/stdlib/src/select.rs +++ b/crates/stdlib/src/select.rs @@ -79,16 +79,26 @@ mod decl { } let deadline = timeout.map(|s| time::time(vm).unwrap() + s); + let max_fds: usize = cfg_select! { + windows => FD_SETSIZE as usize, + _ => FD_SETSIZE, + }; + let seq2set = |list: &PyObject| -> PyResult<(Vec, FdSet)> { - let v: Vec = list.try_to_value(vm)?; - - let too_many_fds = cfg_select! { - windows => v.len() > FD_SETSIZE as usize, - _ => v.len() > FD_SETSIZE, - }; - if too_many_fds { - return Err(vm.new_value_error("too many file descriptors in select()")); - } + // The limit is answered while the sequence is walked rather than + // from the length of the result. fileno() runs Python and can + // append to the very list being walked, and a walk that re-reads + // the list each step -- which is what `seq2set` does -- then never + // reaches a length to check. + let seen = core::cell::Cell::new(0usize); + let v: Vec = vm.extract_elements_with(list, |obj| { + let selectable = Selectable::try_from_object(vm, obj)?; + seen.set(seen.get() + 1); + if seen.get() > max_fds { + return Err(vm.new_value_error("too many file descriptors in select()")); + } + Ok(selectable) + })?; let mut fds = FdSet::new(); for fd in &v { @@ -304,7 +314,10 @@ mod decl { timeout: OptionalArg>, vm: &VirtualMachine, ) -> PyResult> { - let mut fds = self.fds.lock(); + // Poll a copy: the wait releases the GIL-equivalent and runs + // signal handlers, which can register or unregister on the same + // object, and a held lock would deadlock them. + let mut fds = self.fds.lock().clone(); let TimeoutArg(timeout) = timeout.unwrap_or_default(); let timeout_ms = match timeout { Some(d) => i32::try_from(d.as_millis()) diff --git a/crates/stdlib/src/socket.rs b/crates/stdlib/src/socket.rs index f78bec69dc5..4e02dca451c 100644 --- a/crates/stdlib/src/socket.rs +++ b/crates/stdlib/src/socket.rs @@ -40,7 +40,6 @@ mod _socket { } use core::{ - mem::MaybeUninit, net::{Ipv4Addr, Ipv6Addr, SocketAddr}, time::Duration, }; @@ -1589,7 +1588,10 @@ mod _socket { vm: &VirtualMachine, ) -> Result, IoOrPyException> { let flags = flags.unwrap_or(0); - let mut buffer = Vec::with_capacity(bufsize); + let mut buffer = Vec::new(); + buffer + .try_reserve_exact(bufsize) + .map_err(|_| vm.new_memory_error(""))?; let sock = self.sock()?; let n = self.sock_op(vm, SockWaitKind::Read, || { sock.recv_with_flags(buffer.spare_capacity_mut(), flags) @@ -1608,8 +1610,6 @@ mod _socket { ) -> Result { let flags = flags.unwrap_or(0); let sock = self.sock()?; - let mut buf = buf.borrow_buf_mut(); - let buf = &mut *buf; // Handle nbytes parameter let read_len = if let OptionalArg::Present(nbytes) = nbytes { @@ -1621,10 +1621,13 @@ mod _socket { buf.len() }; - let buf = &mut buf[..read_len]; - self.sock_op(vm, SockWaitKind::Read, || { - sock.recv_with_flags(unsafe { slice_as_uninit(buf) }, flags) - }) + let mut scratch = alloc_recv_scratch(read_len, vm)?; + let n = self.sock_op(vm, SockWaitKind::Read, || { + sock.recv_with_flags(&mut scratch.spare_capacity_mut()[..read_len], flags) + })?; + unsafe { scratch.set_len(n) }; + buf.borrow_buf_mut()[..n].copy_from_slice(&scratch); + Ok(n) } #[pymethod] @@ -1638,7 +1641,10 @@ mod _socket { let bufsize = bufsize .to_usize() .ok_or_else(|| vm.new_value_error("negative buffersize in recvfrom"))?; - let mut buffer = Vec::with_capacity(bufsize); + let mut buffer = Vec::new(); + buffer + .try_reserve_exact(bufsize) + .map_err(|_| vm.new_memory_error(""))?; let (n, addr) = self.sock_op(vm, SockWaitKind::Read, || { self.sock()? .recv_from_with_flags(buffer.spare_capacity_mut(), flags) @@ -1655,24 +1661,28 @@ mod _socket { flags: OptionalArg, vm: &VirtualMachine, ) -> Result<(usize, PyObjectRef), IoOrPyException> { - let mut buf = buf.borrow_buf_mut(); - let buf = &mut *buf; - let buf = match nbytes { + let read_len = match nbytes { OptionalArg::Present(i) => { let i = i.to_usize().ok_or_else(|| { vm.new_value_error("negative buffersize in recvfrom_into") })?; - buf.get_mut(..i).ok_or_else(|| { - vm.new_value_error("nbytes is greater than the length of the buffer") - })? + if i > buf.len() { + return Err(vm + .new_value_error("nbytes is greater than the length of the buffer") + .into()); + } + i } - OptionalArg::Missing => buf, + OptionalArg::Missing => buf.len(), }; let flags = flags.unwrap_or(0); let sock = self.sock()?; + let mut scratch = alloc_recv_scratch(read_len, vm)?; let (n, addr) = self.sock_op(vm, SockWaitKind::Read, || { - sock.recv_from_with_flags(unsafe { slice_as_uninit(buf) }, flags) + sock.recv_from_with_flags(&mut scratch.spare_capacity_mut()[..read_len], flags) })?; + unsafe { scratch.set_len(n) }; + buf.borrow_buf_mut()[..n].copy_from_slice(&scratch); Ok((n, get_addr_tuple(&addr, vm))) } @@ -1684,7 +1694,7 @@ mod _socket { vm: &VirtualMachine, ) -> Result { let flags = flags.unwrap_or(0); - let buf = bytes.borrow_buf(); + let buf = bytes.borrow_buf_unlocked(vm)?; let buf = &*buf; self.sock_op(vm, SockWaitKind::Write, || { self.sock()?.send_with_flags(buf, flags) @@ -1704,7 +1714,7 @@ mod _socket { let deadline = timeout.map(Deadline::new); - let buf = bytes.borrow_buf(); + let buf = bytes.borrow_buf_unlocked(vm)?; let buf = &*buf; let mut buf_offset = 0; // now we have like 3 layers of interrupt loop :) @@ -1741,7 +1751,7 @@ mod _socket { OptionalArg::Missing => (0, arg2), }; let addr = self.extract_address(address, "sendto", vm)?; - let buf = bytes.borrow_buf(); + let buf = bytes.borrow_buf_unlocked(vm)?; let buf = &*buf; self.sock_op(vm, SockWaitKind::Write, || { self.sock()?.send_to_with_flags(buf, &addr, flags) @@ -1771,8 +1781,8 @@ mod _socket { let buffers = buffers .iter() - .map(|buf| buf.borrow_buf()) - .collect::>(); + .map(|buf| buf.borrow_buf_unlocked(vm)) + .collect::>>()?; let buffers = buffers .iter() .map(|buf| io::IoSlice::new(buf)) @@ -2380,8 +2390,21 @@ mod _socket { Ok(s.to_string_lossy().into_owned()) } - unsafe fn slice_as_uninit(v: &mut [T]) -> &mut [MaybeUninit] { - unsafe { &mut *(v as *mut [T] as *mut [MaybeUninit]) } + /// Room to receive into that belongs to no Python object. + /// + /// A peer may never send, so the wait for it is unbounded. The export of + /// the caller's buffer is held for the whole call, which is what keeps it + /// from being resized, but the borrow that reaches its bytes is a lock + /// every other thread touching that object waits on, and a thread waiting + /// on a lock never reaches a safepoint — holding it across the wait stops + /// the world from being stopped at all. The bytes are copied over once + /// they have arrived. + fn alloc_recv_scratch(len: usize, vm: &VirtualMachine) -> PyResult> { + let mut scratch = Vec::new(); + scratch + .try_reserve_exact(len) + .map_err(|_| vm.new_memory_error(""))?; + Ok(scratch) } enum IoOrPyException { diff --git a/crates/vm/src/anystr.rs b/crates/vm/src/anystr.rs index 0f187f6d476..4896f2789bd 100644 --- a/crates/vm/src/anystr.rs +++ b/crates/vm/src/anystr.rs @@ -27,7 +27,7 @@ pub struct SplitLinesArgs { #[derive(FromArgs)] pub struct ExpandTabsArgs { #[pyarg(any, default = 8)] - tabsize: isize, + tabsize: i32, } impl ExpandTabsArgs { @@ -132,6 +132,11 @@ where { fn new() -> Self; fn with_capacity(capacity: usize) -> Self; + /// `with_capacity`, reporting a capacity that cannot be allocated instead + /// of aborting the process on it. + fn try_with_capacity(capacity: usize) -> Option + where + Self: Sized; fn push_str(&mut self, s: &S); } @@ -285,27 +290,29 @@ pub(crate) trait AnyStr { } } - fn py_pad(&self, left: usize, right: usize, fillchar: Self::Char) -> Self::Container { - let mut u = Self::Container::with_capacity( - (left + right) * fillchar.bytes_len() + self.bytes_len(), - ); + fn py_pad(&self, left: usize, right: usize, fillchar: Self::Char) -> Option { + let capacity = left + .checked_add(right)? + .checked_mul(fillchar.bytes_len())? + .checked_add(self.bytes_len())?; + let mut u = Self::Container::try_with_capacity(capacity)?; u.extend(core::iter::repeat_n(fillchar, left)); u.push_str(self); u.extend(core::iter::repeat_n(fillchar, right)); - u + Some(u) } - fn py_center(&self, width: usize, fillchar: Self::Char, len: usize) -> Self::Container { + fn py_center(&self, width: usize, fillchar: Self::Char, len: usize) -> Option { let marg = width - len; let left = marg / 2 + (marg & width & 1); self.py_pad(left, marg - left, fillchar) } - fn py_ljust(&self, width: usize, fillchar: Self::Char, len: usize) -> Self::Container { + fn py_ljust(&self, width: usize, fillchar: Self::Char, len: usize) -> Option { self.py_pad(0, width - len, fillchar) } - fn py_rjust(&self, width: usize, fillchar: Self::Char, len: usize) -> Self::Container { + fn py_rjust(&self, width: usize, fillchar: Self::Char, len: usize) -> Option { self.py_pad(width - len, 0, fillchar) } @@ -402,7 +409,7 @@ pub(crate) trait AnyStr { elements } - fn py_zfill(&self, width: isize) -> Vec { + fn py_zfill(&self, width: isize) -> Option> { let width = width.to_usize().unwrap_or(0); let char_len = self.elements().count(); let width = self diff --git a/crates/vm/src/buffer.rs b/crates/vm/src/buffer.rs index dc3691b5421..038e7cae9f3 100644 --- a/crates/vm/src/buffer.rs +++ b/crates/vm/src/buffer.rs @@ -16,7 +16,7 @@ use malachite_bigint::BigInt; use num_traits::{PrimInt, ToPrimitive}; use std::os::raw; -type PackFunc = fn(&VirtualMachine, PyObjectRef, &mut [u8]) -> PyResult<()>; +type PackFunc = fn(&VirtualMachine, FormatType, PyObjectRef, &mut [u8]) -> PyResult<()>; type UnpackFunc = fn(&VirtualMachine, &[u8]) -> PyObjectRef; static OVERFLOW_MSG: &str = "total struct size too long"; // not a const to reduce code size @@ -490,7 +490,7 @@ impl FormatSpec { let pack = code.info.pack.unwrap(); for arg in args.by_ref().take(code.repeat) { let (item_buf, rest) = buffer.split_at_mut(code.info.size); - pack(vm, arg, item_buf)?; + pack(vm, code.code, arg, item_buf)?; buffer = rest; } } @@ -549,7 +549,12 @@ impl FormatSpec { } trait Packable { - fn pack(vm: &VirtualMachine, arg: PyObjectRef, data: &mut [u8]) -> PyResult<()>; + fn pack( + vm: &VirtualMachine, + code: FormatType, + arg: PyObjectRef, + data: &mut [u8], + ) -> PyResult<()>; fn unpack(vm: &VirtualMachine, data: &[u8]) -> PyObjectRef; } @@ -576,10 +581,11 @@ macro_rules! make_pack_prim_int { impl Packable for $T { fn pack( vm: &VirtualMachine, + code: FormatType, arg: PyObjectRef, data: &mut [u8], ) -> PyResult<()> { - let i: $T = get_int_or_index(vm, arg)?; + let i: $T = get_int_or_index(vm, code, arg)?; i.pack_int::(data); Ok(()) } @@ -592,16 +598,28 @@ macro_rules! make_pack_prim_int { }; } -fn get_int_or_index(vm: &VirtualMachine, arg: PyObjectRef) -> PyResult +fn get_int_or_index(vm: &VirtualMachine, code: FormatType, arg: PyObjectRef) -> PyResult where - T: PrimInt + for<'a> TryFrom<&'a BigInt>, + T: PrimInt + fmt::Display + for<'a> TryFrom<&'a BigInt>, { let index = arg .try_index_opt(vm) .unwrap_or_else(|| Err(new_struct_error(vm, "required argument is not an integer")))?; - index - .try_to_primitive(vm) - .map_err(|_| new_struct_error(vm, "argument out of range")) + index.try_to_primitive(vm).map_err(|_| { + // A pointer is converted rather than checked against the range of a + // named format, so what it reports is the conversion failing. + let msg = if code == FormatType::VoidP { + "int too large to convert".to_owned() + } else { + format!( + "'{}' format requires {} <= number <= {}", + code as u8 as char, + T::min_value(), + T::max_value() + ) + }; + new_struct_error(vm, msg) + }) } make_pack_prim_int!(i8); @@ -620,6 +638,7 @@ macro_rules! make_pack_float { impl Packable for $T { fn pack( vm: &VirtualMachine, + _code: FormatType, arg: PyObjectRef, data: &mut [u8], ) -> PyResult<()> { @@ -648,7 +667,12 @@ make_pack_float!(f32, "f"); make_pack_float!(f64, "d"); impl Packable for f16 { - fn pack(vm: &VirtualMachine, arg: PyObjectRef, data: &mut [u8]) -> PyResult<()> { + fn pack( + vm: &VirtualMachine, + _code: FormatType, + arg: PyObjectRef, + data: &mut [u8], + ) -> PyResult<()> { let f_64 = ArgIntoFloat::try_from_object(vm, arg)?.into_float(); // "from_f64 should be preferred in any non-`const` context" except it gives the wrong result :/ let f_16 = Self::from_f64_const(f_64); @@ -666,8 +690,13 @@ impl Packable for f16 { } impl Packable for *mut raw::c_void { - fn pack(vm: &VirtualMachine, arg: PyObjectRef, data: &mut [u8]) -> PyResult<()> { - usize::pack::(vm, arg, data) + fn pack( + vm: &VirtualMachine, + code: FormatType, + arg: PyObjectRef, + data: &mut [u8], + ) -> PyResult<()> { + usize::pack::(vm, code, arg, data) } fn unpack(vm: &VirtualMachine, rdr: &[u8]) -> PyObjectRef { @@ -676,7 +705,12 @@ impl Packable for *mut raw::c_void { } impl Packable for bool { - fn pack(vm: &VirtualMachine, arg: PyObjectRef, data: &mut [u8]) -> PyResult<()> { + fn pack( + vm: &VirtualMachine, + _code: FormatType, + arg: PyObjectRef, + data: &mut [u8], + ) -> PyResult<()> { let v = ArgIntoBool::try_from_object(vm, arg)?.into_bool() as u8; v.pack_int::(data); Ok(()) @@ -688,7 +722,12 @@ impl Packable for bool { } } -fn pack_char(vm: &VirtualMachine, arg: PyObjectRef, data: &mut [u8]) -> PyResult<()> { +fn pack_char( + vm: &VirtualMachine, + _code: FormatType, + arg: PyObjectRef, + data: &mut [u8], +) -> PyResult<()> { let v = PyBytesRef::try_from_object(vm, arg)?; let ch = *v .as_bytes() diff --git a/crates/vm/src/builtins/bytearray.rs b/crates/vm/src/builtins/bytearray.rs index 9be51a37012..93046b4932e 100644 --- a/crates/vm/src/builtins/bytearray.rs +++ b/crates/vm/src/builtins/bytearray.rs @@ -354,7 +354,10 @@ impl PyByteArray { #[pymethod] fn join(&self, iter: ArgIterable, vm: &VirtualMachine) -> PyResult { - Ok(self.inner().join(iter, vm)?.into()) + // Driving the iterable runs Python, which can reach this bytearray, + // so the separator is taken by value rather than left borrowed. + let separator = self.inner().clone(); + Ok(separator.join(iter, vm)?.into()) } #[pymethod] @@ -497,8 +500,8 @@ impl PyByteArray { } #[pymethod] - fn zfill(&self, width: isize) -> Self { - self.inner().zfill(width).into() + fn zfill(&self, width: isize, vm: &VirtualMachine) -> PyResult { + Ok(self.inner().zfill(width, vm)?.into()) } #[pymethod] @@ -532,7 +535,10 @@ impl PyByteArray { } fn __mod__(&self, values: PyObjectRef, vm: &VirtualMachine) -> PyResult { - let formatted = self.inner().cformat(values, vm)?; + // Formatting calls the values' conversion methods, which can reach + // this bytearray, so the format is taken by value. + let format = self.inner().clone(); + let formatted = format.cformat(values, vm)?; Ok(formatted.into()) } @@ -778,8 +784,9 @@ impl BufferResizeGuard for PyByteArray { type Resizable<'a> = PyRwLockWriteGuard<'a, PyBytesInner>; fn try_resizable_opt(&self) -> Option> { - let w = self.inner.write(); - (self.exports.load(Ordering::SeqCst) == 0).then_some(w) + // An export is a borrow someone else still holds, so it is answered + // before the lock rather than by waiting on it. + (self.exports.load(Ordering::SeqCst) == 0).then(|| self.inner.write()) } } diff --git a/crates/vm/src/builtins/bytes.rs b/crates/vm/src/builtins/bytes.rs index d62b873bca7..48c0e431229 100644 --- a/crates/vm/src/builtins/bytes.rs +++ b/crates/vm/src/builtins/bytes.rs @@ -505,8 +505,8 @@ impl PyBytes { } #[pymethod] - fn zfill(&self, width: isize) -> Self { - self.inner.zfill(width).into() + fn zfill(&self, width: isize, vm: &VirtualMachine) -> PyResult { + Ok(self.inner.zfill(width, vm)?.into()) } #[pymethod] diff --git a/crates/vm/src/builtins/memory.rs b/crates/vm/src/builtins/memory.rs index 31f99715742..a4c29fe443c 100644 --- a/crates/vm/src/builtins/memory.rs +++ b/crates/vm/src/builtins/memory.rs @@ -1,6 +1,6 @@ use super::{ PositionIterInternal, PyBytes, PyBytesRef, PyGenericAlias, PyInt, PyListRef, PySlice, PyStr, - PyTuple, PyTupleRef, PyType, PyTypeRef, PyUtf8StrRef, iter::builtins_iter, + PyStrRef, PyTuple, PyTupleRef, PyType, PyTypeRef, PyUtf8StrRef, iter::builtins_iter, }; use crate::common::lock::LazyLock; use crate::{ @@ -192,6 +192,11 @@ impl PyMemoryView { } } + /// The object this view looks at, whose storage it borrows. + pub fn viewed_object(&self) -> &PyObject { + &self.buffer.obj + } + fn try_not_released(&self, vm: &VirtualMachine) -> PyResult<()> { if self.released.load() { Err(vm.new_value_error("operation forbidden on released memoryview object")) @@ -826,10 +831,32 @@ impl PyMemoryView { } #[pymethod] - fn tobytes(&self, vm: &VirtualMachine) -> PyResult { + fn tobytes(&self, args: ToBytesArgs, vm: &VirtualMachine) -> PyResult { self.try_not_released(vm)?; + let order = match &args.order { + None => Order::C, + Some(order) => match order.to_str() { + Some("C") => Order::C, + Some("F") => Order::Fortran, + Some("A") => Order::Any, + _ => return Err(vm.new_value_error("order must be 'C', 'F' or 'A'")), + }, + }; + let mut v = vec![]; - self.append_to(&mut v); + // 'A' asks for the memory as it is laid out, which is what appending a + // contiguous view does. Only a Fortran walk of a view that is not + // already Fortran-contiguous reorders anything, and a view of fewer + // than two dimensions has one layout under either name. + if order == Order::Fortran && self.desc.ndim() > 1 { + v.reserve(self.desc.len); + let bytes = &*self.buffer.obj_bytes(); + self.desc.for_each_segment_fortran(|range| { + v.extend_from_slice(&bytes[range.start as usize..range.end as usize]); + }); + } else { + self.append_to(&mut v); + } Ok(PyBytes::from(v).into_ref(&vm.ctx)) } @@ -925,10 +952,17 @@ impl PyMemoryView { fn cast_to_1d(&self, format: PyUtf8StrRef, vm: &VirtualMachine) -> PyResult { let format_str = format.as_str(); - if Self::native_fmtchar(format_str).is_none() { + let Some(dest_char) = Self::native_fmtchar(format_str) else { return Err(vm.new_value_error( "memoryview: destination format must be a native single character format prefixed with an optional '@'", )); + }; + // One side has to be bytes. Casting between two item types would + // reinterpret the items rather than re-divide the memory, and the + // source items were written by something that chose their type. + let source_is_bytes = Self::native_fmtchar(&self.desc.format).is_some_and(is_byte_fmtchar); + if !source_is_bytes && !is_byte_fmtchar(dest_char) { + return Err(vm.new_type_error("memoryview: cannot cast between two non-byte formats")); } let format_spec = Self::parse_format(format_str, vm)?; let itemsize = format_spec.size(); @@ -994,7 +1028,7 @@ impl PyMemoryView { let mut other = self.cast_to_1d(format, vm)?; let itemsize = other.desc.itemsize; - // 0 ndim is single item + // 0 ndim is single item, so the buffer has to be that one item if shape_ndim == 0 { if itemsize != other.desc.len { return Err( @@ -1002,7 +1036,6 @@ impl PyMemoryView { ); } other.desc.dim_desc = vec![]; - other.desc.len = itemsize; return Ok(other.into_ref(&vm.ctx)); } @@ -1010,7 +1043,19 @@ impl PyMemoryView { let mut dim_descriptor = Vec::with_capacity(shape_ndim); for x in shape { - let x = usize::try_from_borrowed_object(vm, x)?; + let x = x + .downcast_ref::() + .ok_or_else(|| { + vm.new_type_error("memoryview.cast(): elements of shape must be integers") + })? + .try_to_primitive::(vm) + .ok() + .filter(|x| *x > 0) + .ok_or_else(|| { + vm.new_value_error( + "memoryview.cast(): elements of shape must be integers > 0", + ) + })?; if x > isize::MAX as usize / product_shape { return Err(vm.new_value_error("memoryview.cast(): product(shape) > SSIZE_MAX")); @@ -1084,6 +1129,20 @@ impl Py { } } +#[derive(FromArgs)] +struct ToBytesArgs { + #[pyarg(any, default)] + order: Option, +} + +/// The layout a copy of a view is written in. +#[derive(PartialEq, Eq)] +enum Order { + C, + Fortran, + Any, +} + #[derive(FromArgs)] struct CastArgs { #[pyarg(any)] @@ -1242,7 +1301,9 @@ impl Hashable for PyMemoryView { if !zelf.desc.readonly { return Err(vm.new_value_error("cannot hash writable memoryview object")); } - if !matches!(&*zelf.desc.format, "B" | "b" | "c") { + // The hash is over the bytes, so it agrees with the hash of the same + // bytes only where an item is a byte. + if !Self::native_fmtchar(&zelf.desc.format).is_some_and(is_byte_fmtchar) { return Err( vm.new_value_error("memoryview: hashing is restricted to formats 'B', 'b' or 'c'") ); @@ -1496,6 +1557,10 @@ fn format_unpack( }) } +/// Whether `ch` names a format whose items are single bytes. +const fn is_byte_fmtchar(ch: u8) -> bool { + matches!(ch, b'c' | b'b' | b'B') +} fn is_equiv_shape(a: &BufferDescriptor, b: &BufferDescriptor) -> bool { if a.ndim() != b.ndim() { return false; diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index 8a95dcb648c..6e774f7e652 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -1320,11 +1320,13 @@ impl PyStr { } #[pymethod] - fn zfill(&self, width: isize) -> Wtf8Buf { - unsafe { - // SAFETY: this is safe-guaranteed because the original self.as_wtf8() is valid wtf8 - Wtf8Buf::from_bytes_unchecked(self.as_wtf8().py_zfill(width)) - } + fn zfill(&self, width: isize, vm: &VirtualMachine) -> PyResult { + let filled = self + .as_wtf8() + .py_zfill(width) + .ok_or_else(|| vm.new_memory_error(""))?; + // SAFETY: this is safe-guaranteed because the original self.as_wtf8() is valid wtf8 + Ok(unsafe { Wtf8Buf::from_bytes_unchecked(filled) }) } #[inline] @@ -1332,7 +1334,7 @@ impl PyStr { &self, width: isize, fillchar: OptionalArg, - pad: fn(&Wtf8, usize, CodePoint, usize) -> Wtf8Buf, + pad: fn(&Wtf8, usize, CodePoint, usize) -> Option, vm: &VirtualMachine, ) -> PyResult { let fillchar = fillchar.map_or(Ok(' '.into()), |ref s| { @@ -1340,11 +1342,11 @@ impl PyStr { vm.new_type_error("The fill character must be exactly one character long") }) })?; - Ok(if self.len() as isize >= width { - self.as_wtf8().to_owned() - } else { - pad(self.as_wtf8(), width as usize, fillchar, self.len()) - }) + if self.len() as isize >= width { + return Ok(self.as_wtf8().to_owned()); + } + pad(self.as_wtf8(), width as usize, fillchar, self.len()) + .ok_or_else(|| vm.new_memory_error("")) } #[pymethod] @@ -2214,6 +2216,12 @@ impl AnyStrContainer for String { Self::with_capacity(capacity) } + fn try_with_capacity(capacity: usize) -> Option { + let mut s = Self::new(); + s.try_reserve_exact(capacity).ok()?; + Some(s) + } + fn push_str(&mut self, other: &str) { Self::push_str(self, other) } @@ -2327,6 +2335,12 @@ impl AnyStrContainer for Wtf8Buf { Self::with_capacity(capacity) } + fn try_with_capacity(capacity: usize) -> Option { + let mut s = Self::new(); + s.try_reserve_exact(capacity).ok()?; + Some(s) + } + fn push_str(&mut self, other: &Wtf8) { self.push_wtf8(other) } @@ -2447,6 +2461,12 @@ impl AnyStrContainer for AsciiString { Self::with_capacity(capacity) } + fn try_with_capacity(capacity: usize) -> Option { + let mut v = Vec::new(); + v.try_reserve_exact(capacity).ok()?; + Some(Self::from(v)) + } + fn push_str(&mut self, other: &AsciiStr) { Self::push_str(self, other) } diff --git a/crates/vm/src/builtins/tuple.rs b/crates/vm/src/builtins/tuple.rs index 7af176840b7..d510e35326f 100644 --- a/crates/vm/src/builtins/tuple.rs +++ b/crates/vm/src/builtins/tuple.rs @@ -342,10 +342,6 @@ impl PyTuple { } } - pub(crate) fn new_marshal_placeholder(len: usize, ctx: &Context) -> PyRef { - Self::new_ref(vec![ctx.none(); len], ctx) - } - /// # Safety /// This tuple must be a marshal placeholder which has not escaped the /// decoder, and `index` must not have been replaced previously. diff --git a/crates/vm/src/bytes_inner.rs b/crates/vm/src/bytes_inner.rs index 3c79ed3295d..65a9dc0a01c 100644 --- a/crates/vm/src/bytes_inner.rs +++ b/crates/vm/src/bytes_inner.rs @@ -78,7 +78,7 @@ impl ByteInnerNewOptions { } else { size as usize }; - Ok(vec![0; size].into()) + Ok(vm.new_zeroed_bytes(size)?.into()) } fn handle_object_fallback(obj: PyObjectRef, vm: &VirtualMachine) -> PyResult { @@ -576,16 +576,15 @@ impl PyBytesInner { fn _pad( &self, options: ByteInnerPaddingOptions, - pad: fn(&[u8], usize, u8, usize) -> Vec, + pad: PadFn, vm: &VirtualMachine, ) -> PyResult> { let (width, fillchar) = options.get_value("center", vm)?; let len = self.len(); - Ok(if len as isize >= width { - Vec::from(&self.elements[..]) - } else { - pad(&self.elements, width as usize, fillchar, len) - }) + if len as isize >= width { + return Ok(Vec::from(&self.elements[..])); + } + pad(&self.elements, width as usize, fillchar, len).ok_or_else(|| vm.new_memory_error("")) } pub fn center( @@ -821,8 +820,10 @@ impl PyBytesInner { self.elements.py_bytes_splitlines(options, into_wrapper) } - pub fn zfill(&self, width: isize) -> Vec { - self.elements.py_zfill(width) + pub fn zfill(&self, width: isize, vm: &VirtualMachine) -> PyResult> { + self.elements + .py_zfill(width) + .ok_or_else(|| vm.new_memory_error("")) } // len(self)>=1, from="", len(to)>=1, max_count>=1 @@ -1077,11 +1078,21 @@ impl AnyStrContainer<[u8]> for Vec { Self::with_capacity(capacity) } + fn try_with_capacity(capacity: usize) -> Option { + let mut v = Self::new(); + v.try_reserve_exact(capacity).ok()?; + Some(v) + } + fn push_str(&mut self, other: &[u8]) { self.extend(other) } } +/// A padding function from `AnyStr`, returning `None` for a width whose result +/// cannot be allocated. +type PadFn = fn(&[u8], usize, u8, usize) -> Option>; + const ASCII_WHITESPACES: [u8; 6] = [0x20, 0x09, 0x0a, 0x0c, 0x0d, 0x0b]; impl anystr::AnyChar for u8 { diff --git a/crates/vm/src/frame.rs b/crates/vm/src/frame.rs index 69417ee61b7..d102b9a6d8e 100644 --- a/crates/vm/src/frame.rs +++ b/crates/vm/src/frame.rs @@ -9362,9 +9362,14 @@ impl ExecutingFrame<'_> { if has_data_descr { // Check for member descriptor (slot access) + // The slot offset only means anything on the layout the + // descriptor was defined for; the specialized instruction + // guards on the type version alone, so what descr_get() + // checks on every access has to be checked here instead. if let Some(ref descr) = cls_attr && let Some(member_descr) = descr.downcast_ref::() && let MemberGetter::Offset(offset) = member_descr.member.getter + && cls.fast_issubclass(&member_descr.common.typ) { unsafe { self.code @@ -11099,9 +11104,12 @@ impl ExecutingFrame<'_> { if has_data_descr { // Check for member descriptor (slot access) + // As in the load specialization, the offset is only valid for + // instances of the type the descriptor belongs to. if let Some(ref descr) = cls_attr && let Some(member_descr) = descr.downcast_ref::() && let MemberGetter::Offset(offset) = member_descr.member.getter + && cls.fast_issubclass(&member_descr.common.typ) { unsafe { self.code diff --git a/crates/vm/src/function/buffer.rs b/crates/vm/src/function/buffer.rs index c73f27c041d..dba97e9c77f 100644 --- a/crates/vm/src/function/buffer.rs +++ b/crates/vm/src/function/buffer.rs @@ -49,11 +49,41 @@ impl ArgBytesLike { f(&self.borrow_buf()) } + /// The bytes to hand to an operation that may wait, and whatever keeps + /// them readable while it does. + /// + /// `borrow_buf` may answer with a lock that every other thread writing to + /// the same object waits on, and a thread waiting on a lock never reaches + /// a safepoint, so keeping one across a wait for a peer, a pipe or a + /// signal stops the world from being stopped at all. Bytes reached that + /// way are copied out first. Bytes that lock nothing -- an immutable + /// object's -- are borrowed where they lie, which is all CPython holds in + /// either case. + pub fn borrow_buf_unlocked(&self, vm: &VirtualMachine) -> PyResult> { + let borrowed = self.borrow_buf(); + if !borrowed.is_locked() { + return Ok(UnlockedBuf::Borrowed(borrowed)); + } + let mut copy = Vec::new(); + copy.try_reserve_exact(borrowed.len()) + .map_err(|_| vm.new_memory_error(""))?; + copy.extend_from_slice(&borrowed); + Ok(UnlockedBuf::Copied(copy)) + } + #[must_use] pub const fn len(&self) -> usize { self.0.desc.len } + /// The width of one item. Callers that read the buffer as bytes rather + /// than as whatever it holds have to ask, since a contiguous buffer of + /// wider items is contiguous all the same. + #[must_use] + pub const fn itemsize(&self) -> usize { + self.0.desc.itemsize + } + #[must_use] pub const fn is_empty(&self) -> bool { self.len() == 0 @@ -63,6 +93,16 @@ impl ArgBytesLike { pub fn as_object(&self) -> &PyObject { &self.0.obj } + + /// The object whose storage is borrowed while this buffer is read: a view + /// borrows the object it looks at, not itself. + #[must_use] + pub fn source_object(&self) -> &PyObject { + self.0 + .obj + .downcast_ref::() + .map_or(&self.0.obj, |view| view.viewed_object()) + } } impl From for PyBuffer { @@ -113,6 +153,24 @@ impl<'a> TryFromBorrowedObject<'a> for ArgContiguousBytesLike { } } +/// Bytes that stay readable across a wait, from [`ArgBytesLike::borrow_buf_unlocked`]. +#[derive(Debug)] +pub enum UnlockedBuf<'a> { + Borrowed(BorrowedValue<'a, [u8]>), + Copied(Vec), +} + +impl core::ops::Deref for UnlockedBuf<'_> { + type Target = [u8]; + + fn deref(&self) -> &[u8] { + match self { + Self::Borrowed(b) => b, + Self::Copied(v) => v, + } + } +} + /// A memory buffer, read-write access. Like the `w*` format code for `PyArg_Parse` in CPython. #[derive(Debug, Traverse)] pub struct ArgMemoryBuffer(PyBuffer); @@ -139,6 +197,16 @@ impl ArgMemoryBuffer { pub const fn is_empty(&self) -> bool { self.len() == 0 } + + /// The object whose storage is borrowed while this buffer is written: a + /// view borrows the object it looks at, not itself. + #[must_use] + pub fn source_object(&self) -> &PyObject { + self.0 + .obj + .downcast_ref::() + .map_or(&self.0.obj, |view| view.viewed_object()) + } } impl From for PyBuffer { diff --git a/crates/vm/src/gc_state.rs b/crates/vm/src/gc_state.rs index a9b8c7be171..e5eb3758950 100644 --- a/crates/vm/src/gc_state.rs +++ b/crates/vm/src/gc_state.rs @@ -8,7 +8,6 @@ use crate::object::{GC_NO_OWNER, GC_PERMANENT, GC_UNTRACKED, GcLink, GcOwner}; use crate::{AsObject, PyObject, PyObjectRef}; use core::ptr::NonNull; use core::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, AtomicUsize, Ordering}; -use std::collections::HashSet; fn elapsed_secs( #[cfg(target_arch = "wasm32")] _start: (), @@ -155,6 +154,45 @@ fn is_owned_by(obj: &PyObject, owner: GcOwner) -> bool { #[derive(Clone, Copy, PartialEq, Eq, Hash)] struct GcPtr(NonNull); +/// Hashing for the tables a collection keys by an object's address. +/// +/// The default hasher is SipHash, which buys resistance against a caller +/// choosing keys that collide. Nothing chooses these keys: they are addresses +/// this process handed out, and the tables live and die inside one collection. +/// What a collection needs from them is speed -- it hashes every tracked +/// object and every edge between them -- so this runs the address through a +/// handful of multiplies and shifts instead. The shifts are what earns the +/// speed: a table picks its bucket from the low bits, and an address arrives +/// with its low bits zeroed by alignment, so entropy has to be carried +/// downward or every object lands in the same few buckets. +#[derive(Default)] +struct GcPtrHasher(u64); + +impl core::hash::Hasher for GcPtrHasher { + fn finish(&self) -> u64 { + self.0 + } + + fn write_usize(&mut self, value: usize) { + let mut z = (value as u64).wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + self.0 = z ^ (z >> 31); + } + + fn write(&mut self, bytes: &[u8]) { + // Addresses reach this hasher through `write_usize`; a key hashed any + // other way still has to land somewhere sensible. + for &byte in bytes { + self.0 = (self.0 ^ u64::from(byte)).wrapping_mul(0x0100_0000_01B3); + } + } +} + +type GcBuildHasher = core::hash::BuildHasherDefault; +type GcSet = std::collections::HashSet; +type GcMap = std::collections::HashMap; + /// RAII barrier that parks every other thread for the pointer-reading phases /// of a collection and lets them run again before finalizers execute. /// @@ -556,14 +594,25 @@ impl GcState { retired.sort_unstable(); retired }; - let mut collecting: HashSet = HashSet::new(); + // The candidates and their reference counts go in one table, not a set + // beside a map: every edge in the heap is looked up here, and the two + // held the same keys, so a second table only bought a second hash of + // the same address. `candidate_ptrs` keeps them in a walkable order, + // since the counts are written while the candidates are read. + let mut gc_refs: GcMap = GcMap::default(); + let mut candidate_ptrs: Vec = Vec::new(); for gen_list in &gen_locks { for obj in gen_list.iter() { if retired.binary_search(&obj.gc_owner()).is_ok() { obj.set_gc_owner(GC_NO_OWNER); } - if obj.strong_count() > 0 && is_owned_by(obj, owner) { - collecting.insert(GcPtr(NonNull::from(obj))); + let strong_count = obj.strong_count(); + let ptr = GcPtr(NonNull::from(obj)); + if strong_count > 0 + && is_owned_by(obj, owner) + && gc_refs.insert(ptr, strong_count).is_none() + { + candidate_ptrs.push(ptr); } } } @@ -583,7 +632,7 @@ impl GcState { .retain(|tag| retired.binary_search(tag).is_err()); } - if collecting.is_empty() { + if candidate_ptrs.is_empty() { // Reset counts for generations whose objects were promoted away. // For gen2 (oldest), survivors stay in-place so don't reset gen2 count. let reset_end = if generation >= 2 { 2 } else { generation + 1 }; @@ -602,26 +651,10 @@ impl GcState { }; } - let candidates = collecting.len(); + let candidates = candidate_ptrs.len(); if debug.contains(GcDebugFlags::STATS) { - eprintln!( - "gc: collecting {} objects from generations 0..={}", - collecting.len(), - generation - ); - } - - // Step 2: Build gc_refs map (copy reference counts) - let mut gc_refs: std::collections::HashMap = std::collections::HashMap::new(); - - #[expect( - clippy::iter_over_hash_type, - reason = "Iteration order doesn't matter here" - )] - for &ptr in &collecting { - let obj = unsafe { ptr.0.as_ref() }; - gc_refs.insert(ptr, obj.strong_count()); + eprintln!("gc: collecting {candidates} objects from generations 0..={generation}"); } // Step 3: Subtract internal references @@ -630,32 +663,31 @@ impl GcState { // of each object's children. Without this, a dict whose write lock is // held during one traversal but not the other can yield inconsistent // results, causing live objects to be incorrectly collected. - let mut referents_map: std::collections::HashMap>> = - std::collections::HashMap::new(); + // + // Every object's referents go in one buffer, with each object holding + // the range that is its own: a vector each would be an allocation per + // tracked object, and the collection wants them all at once anyway. + let mut referent_ptrs: Vec> = Vec::new(); + let mut referent_ranges: GcMap = GcMap::default(); - #[expect( - clippy::iter_over_hash_type, - reason = "Iteration order doesn't matter here" - )] - for &ptr in &collecting { + for &ptr in &candidate_ptrs { let obj = unsafe { ptr.0.as_ref() }; if obj.strong_count() == 0 { continue; } - let referent_ptrs = unsafe { obj.gc_get_referent_ptrs() }; - referents_map.insert(ptr, referent_ptrs.clone()); - for child_ptr in referent_ptrs { - let gc_ptr = GcPtr(child_ptr); - if collecting.contains(&gc_ptr) - && let Some(refs) = gc_refs.get_mut(&gc_ptr) - { + let start = referent_ptrs.len(); + unsafe { obj.gc_extend_referent_ptrs(&mut referent_ptrs) }; + let end = referent_ptrs.len(); + for &child_ptr in &referent_ptrs[start..end] { + if let Some(refs) = gc_refs.get_mut(&GcPtr(child_ptr)) { *refs = refs.saturating_sub(1); } } + referent_ranges.insert(ptr, (start, end)); } // Step 4: Find reachable objects (gc_refs > 0) and traverse from them - let mut reachable: HashSet = HashSet::new(); + let mut reachable: GcSet = GcSet::default(); let mut worklist: Vec = Vec::new(); #[expect( @@ -672,16 +704,21 @@ impl GcState { while let Some(ptr) = worklist.pop() { let obj = unsafe { ptr.0.as_ref() }; if obj.is_gc_tracked() { - // Reuse the pre-computed referent pointers from step 3. - // For objects that were skipped in step 3 (strong_count was 0), - // compute them now as a fallback. - let referent_ptrs = referents_map - .get(&ptr) - .cloned() - .unwrap_or_else(|| unsafe { obj.gc_get_referent_ptrs() }); - for child_ptr in referent_ptrs { + // Reuse the pre-computed referent pointers from step 3, in + // place: copying them out again costs a second pass over every + // edge in the heap. Objects skipped in step 3 (strong_count was + // 0) have none stored and are traversed here instead. + let computed; + let children: &[NonNull] = match referent_ranges.get(&ptr) { + Some(&(start, end)) => &referent_ptrs[start..end], + None => { + computed = unsafe { obj.gc_get_referent_ptrs() }; + &computed + } + }; + for &child_ptr in children { let gc_ptr = GcPtr(child_ptr); - if collecting.contains(&gc_ptr) && reachable.insert(gc_ptr) { + if gc_refs.contains_key(&gc_ptr) && reachable.insert(gc_ptr) { worklist.push(gc_ptr); } } @@ -689,7 +726,11 @@ impl GcState { } // Step 5: Find unreachable objects - let unreachable: Vec = collecting.difference(&reachable).copied().collect(); + let unreachable: Vec = candidate_ptrs + .iter() + .filter(|ptr| !reachable.contains(ptr)) + .copied() + .collect(); // With the world stopped, every frame on any thread's call stack is a // live root that is externally referenced and must have been @@ -702,7 +743,7 @@ impl GcState { // set_current_frame_nosave), not top_frame. #[cfg(all(unix, feature = "threading", debug_assertions))] if stw.is_stopped() { - let unreachable_set: HashSet = unreachable.iter().copied().collect(); + let unreachable_set: GcSet = unreachable.iter().copied().collect(); let mut cur = crate::vm::thread::get_current_frame(); while !cur.is_null() { let iframe = unsafe { &*cur }; @@ -802,7 +843,7 @@ impl GcState { } // 6b: Record initial strong counts (for resurrection detection) - let initial_counts: std::collections::HashMap = unreachable_refs + let initial_counts: GcMap = unreachable_refs .iter() .map(|obj| { let ptr = GcPtr(core::ptr::NonNull::from(obj.as_ref())); @@ -833,8 +874,8 @@ impl GcState { } // Detect resurrection - let mut resurrected_set: HashSet = HashSet::new(); - let unreachable_set: HashSet = unreachable.iter().copied().collect(); + let mut resurrected_set: GcSet = GcSet::default(); + let unreachable_set: GcSet = unreachable.iter().copied().collect(); for obj in &unreachable_refs { let ptr = GcPtr(core::ptr::NonNull::from(obj.as_ref())); @@ -874,7 +915,7 @@ impl GcState { // Compute collected count (exclude instance dicts in truly_dead) let collected = { - let dead_ptrs: HashSet = truly_dead + let dead_ptrs: GcSet = truly_dead .iter() .map(|obj| obj.as_ref() as *const PyObject as usize) .collect(); @@ -932,10 +973,9 @@ impl GcState { // never be observable through the generation lists, or another // thread could obtain a strong reference via gc.get_objects() // and access the cleared payload. - let mut late_resurrected: HashSet = HashSet::new(); + let mut late_resurrected: GcSet = GcSet::default(); if !save_all { - let mut expected_counts: std::collections::HashMap = - std::collections::HashMap::new(); + let mut expected_counts: GcMap = GcMap::default(); for obj_ref in &truly_dead { let obj = obj_ref.as_ref(); if obj.is_gc_tracked() { @@ -949,8 +989,7 @@ impl GcState { // the dead set; any surplus in strong_count means another thread // grabbed a reference before untracking (late resurrection) and // the object must not be cleared. - let mut referents: std::collections::HashMap>> = - std::collections::HashMap::new(); + let mut referents: GcMap>> = GcMap::default(); for obj_ref in &truly_dead { let referent_ptrs = unsafe { obj_ref.gc_get_referent_ptrs() }; for child_ptr in &referent_ptrs { diff --git a/crates/vm/src/object/core.rs b/crates/vm/src/object/core.rs index 1e36d57ab31..bdacb7c5b83 100644 --- a/crates/vm/src/object/core.rs +++ b/crates/vm/src/object/core.rs @@ -1957,11 +1957,20 @@ impl PyObject { /// and its contents haven't been modified. pub unsafe fn gc_get_referent_ptrs(&self) -> Vec> { let mut result = Vec::new(); + unsafe { self.gc_extend_referent_ptrs(&mut result) }; + result + } + + /// Append this object's referents to `out`, for a caller that holds many + /// objects' referents in one buffer rather than one buffer each. + /// + /// # Safety + /// Same as [`Self::gc_get_referent_ptrs`]. + pub unsafe fn gc_extend_referent_ptrs(&self, out: &mut Vec>) { // Traverse the entire object including dict and slots self.0.traverse(&mut |child: &Self| { - result.push(NonNull::from(child)); + out.push(NonNull::from(child)); }); - result } /// Pop edges from this object for cycle breaking. diff --git a/crates/vm/src/protocol/buffer.rs b/crates/vm/src/protocol/buffer.rs index cf60775f76f..050c568b7ac 100644 --- a/crates/vm/src/protocol/buffer.rs +++ b/crates/vm/src/protocol/buffer.rs @@ -641,6 +641,47 @@ impl BufferDescriptor { } } + /// Visit each item's byte range with the *first* dimension varying + /// fastest, which is the order a Fortran-ordered copy is written in. + /// `for_each_segment` visits in the opposite order and can hand over whole + /// rows at once; here every item is its own range, since consecutive items + /// in this order are a row apart. + pub fn for_each_segment_fortran(&self, mut f: F) + where + F: FnMut(Range), + { + if self.len == 0 { + return; + } + if self.ndim() == 0 { + f(self.offset..self.offset + self.itemsize as isize); + return; + } + let mut indices = vec![0usize; self.ndim()]; + loop { + let pos = self.offset + + indices + .iter() + .zip_eq(self.dim_desc.iter()) + .map(|(&i, &(_, stride, suboffset))| i as isize * stride + suboffset) + .sum::(); + f(pos..pos + self.itemsize as isize); + + let mut dim = 0; + loop { + indices[dim] += 1; + if indices[dim] < self.dim_desc[dim].0 { + break; + } + indices[dim] = 0; + dim += 1; + if dim == self.ndim() { + return; + } + } + } + } + fn _for_each_segment(&self, mut index: isize, dim: usize, f: &mut F) where F: FnMut(Range), diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index f32fce314c4..9aad883b0d4 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -1258,7 +1258,7 @@ mod _io { let current_size = self.readahead() as usize; - let mut out = vec![0u8; n]; + let mut out = vm.new_zeroed_bytes(n)?; let mut remaining = n; let mut written = 0; if current_size > 0 { @@ -1673,7 +1673,7 @@ mod _io { check_writable(&raw, vm)?; } - data.buffer = vec![0; buffer_size]; + data.buffer = vm.new_zeroed_bytes(buffer_size)?; if Self::READABLE { data.reset_read(); @@ -1938,7 +1938,7 @@ mod _io { if data.writable() { data.flush_rewind(vm)?; } - let mut v = vec![0; n]; + let mut v = vm.new_zeroed_bytes(n)?; data.reset_read(); let r = data .raw_read(Either::A(Some(&mut v)), 0..n, vm)? @@ -3364,14 +3364,17 @@ mod _io { *snapshot = Some((cookie.dec_flags, input_chunk.clone())); let decoded = vm.call_method(decoder, "decode", (input_chunk, cookie.need_eof))?; let decoded = check_decoded(decoded, vm)?; - let pos_is_valid = decoded - .as_wtf8() - .is_code_point_boundary(cookie.bytes_to_skip as usize); + // The position is stored both as a count of characters and as + // an offset in bytes, so both have to land inside what was + // just decoded: everything read back from here indexes it. + let num_to_skip = cookie.num_to_skip(); + let pos_is_valid = num_to_skip.chars <= decoded.char_len() + && decoded.as_wtf8().is_code_point_boundary(num_to_skip.bytes); textio.set_decoded_chars(Some(decoded)); if !pos_is_valid { return Err(vm.new_os_error("can't restore logical file position")); } - textio.decoded_chars_used = cookie.num_to_skip(); + textio.decoded_chars_used = num_to_skip; } else { textio.snapshot = Some((cookie.dec_flags, PyBytes::from(vec![]).into_ref(&vm.ctx))) } @@ -4813,8 +4816,20 @@ mod _io { } #[pymethod] - fn readinto(&self, obj: ArgMemoryBuffer, vm: &VirtualMachine) -> PyResult { - let mut buf = self.buffer(vm)?; + fn readinto(zelf: &Py, obj: ArgMemoryBuffer, vm: &VirtualMachine) -> PyResult { + // Reading locks this object, and a destination that views it locks + // it too, so such a destination is filled after the read is done. + if obj.source_object().is(zelf.as_object()) { + let mut data = vm.new_zeroed_bytes(obj.len())?; + let ret = zelf + .buffer(vm)? + .cursor + .read(&mut data) + .map_err(|_| vm.new_value_error("Error readinto from Take"))?; + obj.borrow_buf_mut()[..ret].copy_from_slice(&data[..ret]); + return Ok(ret); + } + let mut buf = zelf.buffer(vm)?; let ret = buf .cursor .read(&mut obj.borrow_buf_mut()) @@ -5767,7 +5782,7 @@ mod fileio { } let handle = zelf.get_fd(vm)?; let bytes = if let Some(read_byte) = read_byte.to_usize() { - let mut bytes = vec![0; read_byte]; + let mut bytes = vm.new_zeroed_bytes(read_byte)?; // Loop on EINTR (PEP 475) let n = loop { match vm.allow_threads(|| host_io::read_once(handle, &mut bytes)) { @@ -5811,6 +5826,26 @@ mod fileio { Ok(Some(bytes)) } + /// One `read()` into `buf`, retried on EINTR (PEP 475). `None` on EAGAIN. + fn read_once_into( + zelf: &Py, + handle: crt_fd::Borrowed<'_>, + buf: &mut [u8], + vm: &VirtualMachine, + ) -> PyResult> { + loop { + match vm.allow_threads(|| host_io::read_once(handle, buf)) { + Ok(n) => return Ok(Some(n)), + Err(e) if host_io::is_interrupted_error(&e) => { + vm.check_signals()?; + } + // Non-blocking mode: return None if EAGAIN + Err(e) if host_io::is_would_block_error(&e) => return Ok(None), + Err(e) => return Err(Self::io_error(zelf, e, vm)), + } + } + } + #[pymethod] fn readinto( zelf: &Py, @@ -5826,24 +5861,28 @@ mod fileio { let handle = zelf.get_fd(vm)?; - let mut buf = obj.borrow_buf_mut(); - // Loop on EINTR (PEP 475) - let ret = loop { - match vm.allow_threads(|| host_io::read_once(handle, &mut buf)) { - Ok(n) => break n, - Err(e) if host_io::is_interrupted_error(&e) => { - vm.check_signals()?; - continue; - } - // Non-blocking mode: return None if EAGAIN - Err(e) if host_io::is_would_block_error(&e) => { - return Ok(None); - } - Err(e) => return Err(Self::io_error(zelf, e, vm)), - } - }; - - Ok(Some(ret)) + if host_io::reads_without_waiting(handle) { + // The read answers from the file itself, so it returns without + // waiting on anyone; write where the caller asked directly. + // Seekability is not the question -- a pipe on Windows seeks. + let mut buf = obj.borrow_buf_mut(); + return Self::read_once_into(zelf, handle, &mut buf, vm); + } + + // A pipe, socket or terminal answers only when the other end + // writes, which may be never. Holding the export for the whole + // call is what keeps the target from being resized meanwhile, as a + // Py_buffer does; but reaching its bytes takes a lock that every + // other thread touching the same object waits on, and a thread + // waiting on a lock never reaches a safepoint, so holding that one + // across the wait stops the world from being stopped at all. Read + // aside and take the lock for the copy. + let mut scratch = vm.new_zeroed_bytes(obj.len())?; + let ret = Self::read_once_into(zelf, handle, &mut scratch, vm)?; + if let Some(n) = ret { + obj.borrow_buf_mut()[..n].copy_from_slice(&scratch[..n]); + } + Ok(ret) } #[pymethod] @@ -5861,9 +5900,14 @@ mod fileio { let handle = zelf.get_fd(vm)?; + // A pipe, socket or terminal takes the bytes only when the other + // end makes room, which may be never; see readinto above for what + // holding the source's lock across that wait costs. + let buf = obj.borrow_buf_unlocked(vm)?; + // Loop on EINTR (PEP 475) let len = loop { - match obj.with_ref(|b| vm.allow_threads(|| host_io::write_once(handle, b))) { + match vm.allow_threads(|| host_io::write_once(handle, &buf)) { Ok(n) => break n, Err(e) if host_io::is_interrupted_error(&e) => { vm.check_signals()?; diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index de942f947d3..79dce3d21ce 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -1207,9 +1207,9 @@ pub(crate) mod _thread { // fall back to top_iframe (may be a stack-allocated frame). let top = slot.top_frame.load(Ordering::Relaxed); if let Some(p) = core::ptr::NonNull::new(top) { - let py = unsafe { - &*Py::::from_payload_ptr(p.as_ptr()) - }; + // SAFETY: world stopped -> the owning thread is parked + // with this frame on its chain, so it is alive. + let py = unsafe { p.as_ref() }; Some((*id, py.to_owned())) } else { // Stack-allocated frame: materialize from top_iframe. diff --git a/crates/vm/src/stdlib/atexit.rs b/crates/vm/src/stdlib/atexit.rs index 891f8e5437b..0260b0f115d 100644 --- a/crates/vm/src/stdlib/atexit.rs +++ b/crates/vm/src/stdlib/atexit.rs @@ -3,7 +3,9 @@ pub(crate) use atexit::module_def; #[pymodule] mod atexit { - use crate::{AsObject, PyObjectRef, PyResult, VirtualMachine, function::FuncArgs}; + use crate::{ + AsObject, PyObjectRef, PyResult, VirtualMachine, common::rc::PyRc, function::FuncArgs, + }; #[pyfunction] fn register(func: PyObjectRef, args: FuncArgs, vm: &VirtualMachine) -> PyObjectRef { @@ -11,7 +13,7 @@ mod atexit { vm.state .atexit_funcs .lock() - .insert(0, Box::new((func.clone(), args))); + .insert(0, PyRc::new((func.clone(), args))); func } @@ -29,24 +31,26 @@ mod atexit { funcs.len() as isize - 1 }; while i >= 0 { - let (cb, entry_ptr) = { + let entry = { let funcs = vm.state.atexit_funcs.lock(); if i as usize >= funcs.len() { i = funcs.len() as isize; i -= 1; continue; } - let entry = &funcs[i as usize]; - (entry.0.clone(), &**entry as *const (PyObjectRef, FuncArgs)) + // Keep the entry alive for as long as it is being compared, so + // it cannot be dropped and have its address handed to a + // callback registered from within __eq__. + funcs[i as usize].clone() }; // Lock released: __eq__ can safely call atexit functions - let eq = vm.bool_eq(&func, &cb)?; + let eq = vm.bool_eq(&func, &entry.0)?; if eq { // The entry may have moved during __eq__. Search backward by identity. let mut funcs = vm.state.atexit_funcs.lock(); let mut j = (funcs.len() as isize - 1).min(i); while j >= 0 { - if core::ptr::eq(&**funcs.get(j as usize).unwrap(), entry_ptr) { + if PyRc::ptr_eq(funcs.get(j as usize).unwrap(), &entry) { funcs.remove(j as usize); i = j; break; @@ -70,7 +74,7 @@ mod atexit { let funcs: Vec<_> = core::mem::take(&mut *vm.state.atexit_funcs.lock()); // Callbacks stored in LIFO order, iterate forward for entry in funcs { - let (func, args) = *entry; + let (func, args) = PyRc::try_unwrap(entry).unwrap_or_else(|e| (*e).clone()); if let Err(e) = func.call(args, vm) { let exit = e.fast_isinstance(vm.ctx.exceptions.system_exit); let msg = func diff --git a/crates/vm/src/stdlib/marshal.rs b/crates/vm/src/stdlib/marshal.rs index ca92b444a4c..08a8f589a77 100644 --- a/crates/vm/src/stdlib/marshal.rs +++ b/crates/vm/src/stdlib/marshal.rs @@ -116,9 +116,6 @@ mod decl { )?; } - if !allow_code { - check_no_code(&value, vm)?; - } check_exact_type(&value, vm)?; let mut buf = Vec::new(); let mut refs = if version >= 3 { @@ -126,7 +123,7 @@ mod decl { } else { None }; - write_object(&mut buf, &value, &mut refs, version, vm)?; + write_object(&mut buf, &value, &mut refs, version, allow_code, vm)?; Ok(PyBytes::from(buf)) } @@ -185,6 +182,7 @@ mod decl { obj: &PyObjectRef, refs: &mut Option, version: i32, + allow_code: bool, vm: &VirtualMachine, ) -> PyResult<()> { write_object_depth( @@ -192,6 +190,7 @@ mod decl { obj, refs, version, + allow_code, vm, marshal::MAX_MARSHAL_STACK_DEPTH, ) @@ -202,6 +201,7 @@ mod decl { obj: &PyObjectRef, refs: &mut Option, version: i32, + allow_code: bool, vm: &VirtualMachine, depth: usize, ) -> PyResult<()> { @@ -322,20 +322,20 @@ mod decl { buf.write_u8(b'('); buf.write_u32(t.len() as u32); for elem in t.as_slice() { - write_object_depth(buf, elem, refs, version, vm, depth - 1)?; + write_object_depth(buf, elem, refs, version, allow_code, vm, depth - 1)?; } } else if let Some(l) = obj.downcast_ref::() { buf.write_u8(b'['); let items = l.borrow_vec(); buf.write_u32(items.len() as u32); for elem in items.iter() { - write_object_depth(buf, elem, refs, version, vm, depth - 1)?; + write_object_depth(buf, elem, refs, version, allow_code, vm, depth - 1)?; } } else if let Some(d) = obj.downcast_ref::() { buf.write_u8(b'{'); for (k, v) in d { - write_object_depth(buf, &k, refs, version, vm, depth - 1)?; - write_object_depth(buf, &v, refs, version, vm, depth - 1)?; + write_object_depth(buf, &k, refs, version, allow_code, vm, depth - 1)?; + write_object_depth(buf, &v, refs, version, allow_code, vm, depth - 1)?; } buf.write_u8(b'0'); // TYPE_NULL terminator } else if let Some(s) = obj.downcast_ref::() { @@ -343,16 +343,19 @@ mod decl { let elems = s.elements(); buf.write_u32(elems.len() as u32); for elem in &elems { - write_object_depth(buf, elem, refs, version, vm, depth - 1)?; + write_object_depth(buf, elem, refs, version, allow_code, vm, depth - 1)?; } } else if let Some(s) = obj.downcast_ref::() { buf.write_u8(b'>'); let elems = s.elements(); buf.write_u32(elems.len() as u32); for elem in &elems { - write_object_depth(buf, elem, refs, version, vm, depth - 1)?; + write_object_depth(buf, elem, refs, version, allow_code, vm, depth - 1)?; } } else if let Some(co) = obj.downcast_ref::() { + if !allow_code { + return Err(vm.new_value_error("marshalling code objects is disallowed")); + } buf.write_u8(b'c'); // `Literal` holds the exact object a constant was built from, so // route `co_consts` back through the object writer: it reaches the @@ -360,7 +363,7 @@ mod decl { // reference table the reader indexes against. marshal::serialize_code_with(buf, &co.code, |buf, constant| { let constant = PyObjectRef::from(constant.clone()); - write_object_depth(buf, &constant, refs, version, vm, depth - 1) + write_object_depth(buf, &constant, refs, version, allow_code, vm, depth - 1) })?; } else if let Some(sl) = obj.downcast_ref::() { if version < 5 { @@ -373,15 +376,17 @@ mod decl { sl.start.as_ref().unwrap_or(&none), refs, version, + allow_code, vm, depth - 1, )?; - write_object_depth(buf, &sl.stop, refs, version, vm, depth - 1)?; + write_object_depth(buf, &sl.stop, refs, version, allow_code, vm, depth - 1)?; write_object_depth( buf, sl.step.as_ref().unwrap_or(&none), refs, version, + allow_code, vm, depth - 1, )?; @@ -431,14 +436,36 @@ mod decl { struct PyMarshalBag<'a> { vm: &'a VirtualMachine, pending_error: &'a RefCell>, + allow_code: bool, } impl<'a> PyMarshalBag<'a> { fn new( vm: &'a VirtualMachine, pending_error: &'a RefCell>, + allow_code: bool, ) -> Self { - Self { vm, pending_error } + Self { + vm, + pending_error, + allow_code, + } + } + + /// Room for a container the decoder publishes before it reads what + /// goes in it. The length is the input's to choose, so the room is + /// asked for rather than assumed: a length no allocator can serve is + /// a MemoryError, not an aborted process. + fn placeholder_elements( + &self, + len: usize, + ) -> Result, marshal::MarshalError> { + let mut elements = Vec::new(); + elements + .try_reserve_exact(len) + .map_err(|_| self.remember_python_error(self.vm.new_memory_error("")))?; + elements.resize(len, self.vm.ctx.none()); + Ok(elements) } fn remember_python_error(&self, error: PyBaseExceptionRef) -> marshal::MarshalError { @@ -484,8 +511,12 @@ mod decl { fn make_tuple(&self, elements: impl Iterator) -> Self::Value { self.vm.ctx.new_tuple(elements.collect()).into() } - fn make_tuple_placeholder(&self, len: usize) -> Option { - Some(PyTuple::new_marshal_placeholder(len, &self.vm.ctx).into()) + fn make_tuple_placeholder( + &self, + len: usize, + ) -> Result, marshal::MarshalError> { + let elements = self.placeholder_elements(len)?; + Ok(Some(PyTuple::new_ref(elements, &self.vm.ctx).into())) } fn set_tuple_item( &self, @@ -501,8 +532,14 @@ mod decl { unsafe { tuple.set_marshal_item(index, value) }; Ok(()) } - fn make_code(&self, code: CodeObject) -> Self::Value { - crate::builtins::PyCode::new_ref_with_bag(self.vm, code).into() + fn make_code(&self, code: CodeObject) -> Result { + if !self.allow_code { + return Err(self.remember_python_error( + self.vm + .new_value_error("unmarshalling code objects is disallowed"), + )); + } + Ok(crate::builtins::PyCode::new_ref_with_bag(self.vm, code).into()) } fn make_stop_iter(&self) -> Result { Ok(self.vm.ctx.exceptions.stop_iteration.to_owned().into()) @@ -513,8 +550,12 @@ mod decl { ) -> Result { Ok(self.vm.ctx.new_list(it.collect()).into()) } - fn make_list_placeholder(&self, len: usize) -> Option { - Some(self.vm.ctx.new_list(vec![self.vm.ctx.none(); len]).into()) + fn make_list_placeholder( + &self, + len: usize, + ) -> Result, marshal::MarshalError> { + let elements = self.placeholder_elements(len)?; + Ok(Some(self.vm.ctx.new_list(elements).into())) } fn set_list_item( &self, @@ -635,13 +676,20 @@ mod decl { fn deserialize_value( rdr: &mut impl marshal::Read, + allow_code: bool, vm: &VirtualMachine, ) -> PyResult { let pending_error = RefCell::new(None); - match marshal::deserialize_value(rdr, PyMarshalBag::new(vm, &pending_error)) { + match marshal::deserialize_value(rdr, PyMarshalBag::new(vm, &pending_error, allow_code)) { Ok(value) => Ok(value), Err(error) => Err(pending_error.into_inner().unwrap_or_else(|| match error { marshal::MarshalError::Eof => vm.new_eof_error("marshal data too short"), + error @ marshal::MarshalError::NullObject => vm.new_type_error(error.to_string()), + error @ (marshal::MarshalError::BadSize(_) + | marshal::MarshalError::UnknownType + | marshal::MarshalError::InvalidRef) => { + vm.new_value_error(format!("bad marshal data ({error})")) + } _ => vm.new_value_error("bad marshal data"), })), } @@ -661,11 +709,7 @@ mod decl { let LoadsArgs { data, allow_code } = args; let buf = data.borrow_buf(); - let result = deserialize_value(&mut &buf[..], vm)?; - if !allow_code { - check_no_code(&result, vm)?; - } - Ok(result) + deserialize_value(&mut &buf[..], allow_code, vm) } #[derive(FromArgs)] @@ -685,54 +729,25 @@ mod decl { .try_into_value::(vm)?; let read_res = vm.call_method(&args.f, "read", ())?; let bytes = ArgBytesLike::try_from_object(vm, read_res)?; - let buf = bytes.borrow_buf(); - let mut rdr: &[u8] = &buf; - let len_before = rdr.len(); - let result = deserialize_value(&mut rdr, vm)?; - let consumed = len_before - rdr.len(); + // The borrow ends here: seek() below is the caller's, and reaching the + // same buffer from it would deadlock on a borrow still held. + let (result, consumed) = { + let buf = bytes.borrow_buf(); + let mut rdr: &[u8] = &buf; + let len_before = rdr.len(); + let result = deserialize_value(&mut rdr, args.allow_code, vm)?; + (result, len_before - rdr.len()) + }; // Seek file to just after the consumed bytes let new_pos = tell_before + consumed as i64; vm.call_method(&args.f, "seek", (new_pos,))?; - if !args.allow_code { - check_no_code(&result, vm)?; - } Ok(result) } /// Reject subclasses of marshallable types (int, float, complex, tuple, etc.). - /// Recursively check that no code objects are present. - fn check_no_code(obj: &PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { - if obj.downcast_ref::().is_some() { - return Err(vm.new_value_error("unmarshalling code objects is disallowed")); - } - if let Some(tup) = obj.downcast_ref::() { - for elem in tup.as_slice() { - check_no_code(elem, vm)?; - } - } else if let Some(list) = obj.downcast_ref::() { - for elem in list.borrow_vec().iter() { - check_no_code(elem, vm)?; - } - } else if let Some(set) = obj.downcast_ref::() { - for elem in set.elements() { - check_no_code(&elem, vm)?; - } - } else if let Some(fset) = obj.downcast_ref::() { - for elem in fset.elements() { - check_no_code(&elem, vm)?; - } - } else if let Some(dict) = obj.downcast_ref::() { - for (k, v) in dict { - check_no_code(&k, vm)?; - check_no_code(&v, vm)?; - } - } - Ok(()) - } - fn check_exact_type(obj: &PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { let cls = obj.class(); // bool is a subclass of int but is marshallable diff --git a/crates/vm/src/stdlib/typevar.rs b/crates/vm/src/stdlib/typevar.rs index 3e2581406e8..b784d8799f6 100644 --- a/crates/vm/src/stdlib/typevar.rs +++ b/crates/vm/src/stdlib/typevar.rs @@ -923,11 +923,12 @@ pub(crate) mod typevar { impl Representable for ParamSpecArgs { #[inline(always)] fn repr_str(zelf: &crate::Py, vm: &VirtualMachine) -> PyResult { - // Check if origin is a ParamSpec - if let Ok(name) = zelf.__origin__.get_attr("__name__", vm) { - return Ok(format!("{name}.args", name = name.str(vm)?)); + // A ParamSpec origin is named; anything else is shown by its repr, + // which carries the recursion guard a Rust `{:?}` walk does not. + if let Some(param_spec) = zelf.__origin__.downcast_ref::() { + return Ok(format!("{}.args", param_spec.__name__().str_utf8(vm)?)); } - Ok(format!("{:?}.args", zelf.__origin__)) + Ok(format!("{}.args", zelf.__origin__.repr(vm)?)) } } @@ -986,11 +987,12 @@ pub(crate) mod typevar { impl Representable for ParamSpecKwargs { #[inline(always)] fn repr_str(zelf: &crate::Py, vm: &VirtualMachine) -> PyResult { - // Check if origin is a ParamSpec - if let Ok(name) = zelf.__origin__.get_attr("__name__", vm) { - return Ok(format!("{name}.kwargs", name = name.str(vm)?)); + // A ParamSpec origin is named; anything else is shown by its repr, + // which carries the recursion guard a Rust `{:?}` walk does not. + if let Some(param_spec) = zelf.__origin__.downcast_ref::() { + return Ok(format!("{}.kwargs", param_spec.__name__().str_utf8(vm)?)); } - Ok(format!("{:?}.kwargs", zelf.__origin__)) + Ok(format!("{}.kwargs", zelf.__origin__.repr(vm)?)) } } diff --git a/crates/vm/src/types/slot.rs b/crates/vm/src/types/slot.rs index fc9d1b04885..c0b9142c780 100644 --- a/crates/vm/src/types/slot.rs +++ b/crates/vm/src/types/slot.rs @@ -527,7 +527,11 @@ pub fn hash_not_implemented(zelf: &PyObject, vm: &VirtualMachine) -> PyResult PyResult { - vm.call_special_method(zelf, identifier!(vm, __call__), args) + // `__call__` can name the object being called, and dispatching it pushes no + // Python frame, so nothing else counts the nesting. + vm.with_recursion("while calling a Python object", || { + vm.call_special_method(zelf, identifier!(vm, __call__), args) + }) } fn getattro_wrapper(zelf: &PyObject, name: &Py, vm: &VirtualMachine) -> PyResult { @@ -616,7 +620,11 @@ fn descr_get_wrapper( cls: Option, vm: &VirtualMachine, ) -> PyResult { - vm.call_special_method(&zelf, identifier!(vm, __get__), (obj, cls)) + // A descriptor whose `__get__` is the descriptor itself resolves it by + // fetching `__get__` again, and none of that pushes a Python frame. + vm.with_recursion("while calling a Python object", || { + vm.call_special_method(&zelf, identifier!(vm, __get__), (obj, cls)) + }) } fn descr_set_wrapper( diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index c3861797b24..54d3e813eec 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -100,6 +100,11 @@ pub struct VirtualMachine { pub state: PyRc, pub initialized: bool, recursion_depth: Cell, + /// Depth of native recursion that pushes no Python frame, counted only + /// where the stack pointer cannot be read. Everywhere else the native + /// stack itself answers, and nothing needs counting. + #[cfg(any(miri, target_env = "musl"))] + native_recursion_depth: Cell, /// C stack soft limit for detecting stack overflow (like c_stack_soft_limit) #[cfg_attr(any(miri, target_env = "musl"), allow(dead_code))] c_stack_soft_limit: Cell, @@ -384,7 +389,7 @@ impl StopTheWorldState { /// is only ever `try_lock`'d. The active requester therefore force-parks /// this thread, finishes its whole stop→start span, releases the exclusion, /// and only then does this thread resume and acquire it. - fn acquire_exclusion(&self) { + fn acquire_exclusion(&self, state: &PyGlobalState) { if self .exclusion .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) @@ -393,7 +398,7 @@ impl StopTheWorldState { return; } loop { - crate::vm::thread::suspend_if_needed(self); + crate::vm::thread::suspend_if_needed(state); std::thread::yield_now(); if self .exclusion @@ -421,7 +426,7 @@ impl StopTheWorldState { /// drives the stop→start span at a time; it is released by /// `start_the_world`/`reset_after_fork`. pub fn stop_the_world(&self, state: &PyGlobalState) { - self.acquire_exclusion(); + self.acquire_exclusion(state); let start = std::time::Instant::now(); let requester_ident = crate::stdlib::_thread::get_ident(); self.requester.store(requester_ident, Ordering::Relaxed); @@ -759,7 +764,10 @@ pub struct PyGlobalState { pub stacksize: AtomicCell, pub thread_count: AtomicCell, pub hash_secret: HashSecret, - pub atexit_funcs: PyMutex>>, + /// Registered `atexit` callbacks, newest first. Shared ownership so + /// `atexit.unregister` can keep the entry it is comparing alive while the + /// list is unlocked, and still recognize it afterwards by identity. + pub atexit_funcs: PyMutex>>, pub codec_registry: CodecsRegistry, pub finalizing: AtomicBool, pub warnings: WarningsState, @@ -991,6 +999,8 @@ impl VirtualMachine { state, initialized: false, recursion_depth: Cell::new(0), + #[cfg(any(miri, target_env = "musl"))] + native_recursion_depth: Cell::new(0), c_stack_soft_limit: Cell::new(Self::calculate_c_stack_soft_limit()), async_gen_firstiter: RefCell::new(None), async_gen_finalizer: RefCell::new(None), @@ -2004,6 +2014,14 @@ impl VirtualMachine { const STACK_MARGIN_BYTES: usize = (if cfg!(debug_assertions) { 16384 } else { 4096 }) * core::mem::size_of::(); + /// How deep native recursion may go where the stack cannot be measured + /// (`Py_C_RECURSION_LIMIT`). A native step costs far more stack than a + /// Python one and debug builds cost more again, so this sits well under + /// what a default stack holds rather than at what it would just fit. + #[cfg(any(miri, target_env = "musl"))] + const NATIVE_RECURSION_LIMIT_UNMEASURED: usize = + if cfg!(debug_assertions) { 500 } else { 1500 }; + /// Get the stack boundaries using platform-specific APIs. /// Returns (base, top) where base is the lowest address and top is the highest. #[cfg(all(not(miri), not(target_env = "musl"), windows))] @@ -2105,16 +2123,34 @@ impl VirtualMachine { /// Used to run the body of a (possibly) recursive function. It will raise a /// RecursionError if recursive functions are nested far too many times, /// preventing a stack overflow. + /// `Py_EnterRecursiveCall`: bounds native recursion that pushes no Python + /// frame, against the native stack. That is a separate budget from the + /// frame limit `sys.setrecursionlimit()` sets, so nesting counted here does + /// not come out of what Python code has left to call with. pub fn with_recursion PyResult>(&self, _where: &str, f: F) -> PyResult { - self.check_recursive_call(_where)?; - - // Native stack guard: check C stack like _Py_MakeRecCheck - if self.check_c_stack_overflow() { - return Err(self.new_recursion_error(_where.to_string())); + // `check_c_stack_overflow()` answers no unconditionally where the stack + // pointer cannot be read, which would leave this guard with nothing to + // stop. A count of the nesting stands in for the measurement there. + #[cfg(any(miri, target_env = "musl"))] + let counted_too_deep = + self.native_recursion_depth.get() >= Self::NATIVE_RECURSION_LIMIT_UNMEASURED; + #[cfg(not(any(miri, target_env = "musl")))] + let counted_too_deep = false; + + if counted_too_deep || self.check_c_stack_overflow() { + return Err( + self.new_recursion_error(format!("maximum recursion depth exceeded {_where}")) + ); } - self.recursion_depth.update(|d| d + 1); - scopeguard::defer! { self.recursion_depth.update(|d| d - 1) } + #[cfg(any(miri, target_env = "musl"))] + let _native_depth_guard = { + self.native_recursion_depth.update(|d| d + 1); + scopeguard::guard((), |()| { + self.native_recursion_depth.update(|d| d.saturating_sub(1)) + }) + }; + f() } @@ -2603,12 +2639,28 @@ impl VirtualMachine { // Objects/listobject.c. Each branch takes an atomic snapshot to avoid // race conditions from concurrent mutation (no GIL). let cls = value.class(); - let list_borrow; let slice = if cls.is(self.ctx.types.tuple_type) { value.downcast_ref::().unwrap().as_slice() } else if cls.is(self.ctx.types.list_type) { - list_borrow = value.downcast_ref::().unwrap().borrow_vec(); - &list_borrow + // The list is re-read on every step, the way map_iterable_object() + // does it: func() runs Python, which can mutate or even clear the + // same list, and a borrow held across that call deadlocks it. + let list = value.downcast_ref::().unwrap(); + let mut results = Vec::new(); + let mut i = 0; + loop { + let elem = { + let elements = list.borrow_vec(); + let Some(elem) = elements.get(i) else { + break; + }; + elem.clone() + // free the lock + }; + results.push(func(elem)?); + i += 1; + } + return Ok(results); } else if cls.is(self.ctx.types.dict_type) { let keys = value.downcast_ref::().unwrap().keys_vec(); return keys.into_iter().map(func).collect(); @@ -2797,7 +2849,7 @@ impl VirtualMachine { // Suspend this thread if stop-the-world is in progress #[cfg(feature = "threading")] - thread::suspend_if_needed(&self.state.stop_the_world); + thread::suspend_if_needed(&self.state); // Pass a QSBR checkpoint if requested (deferred memory reclamation). #[cfg(feature = "threading")] diff --git a/crates/vm/src/vm/thread.rs b/crates/vm/src/vm/thread.rs index 3b378acd6d7..3f83d88fe70 100644 --- a/crates/vm/src/vm/thread.rs +++ b/crates/vm/src/vm/thread.rs @@ -5,11 +5,13 @@ use crate::builtins::PyBaseExceptionRef; #[cfg(feature = "threading")] use alloc::sync::Arc; -#[cfg(all(unix, feature = "threading"))] -use crate::frame::FrameObject; use crate::frame::InterpreterFrame; +#[cfg(feature = "threading")] +use crate::vm::PyGlobalState; use crate::{AsObject, PyObject, VirtualMachine}; #[cfg(all(unix, feature = "threading"))] +use crate::{Py, frame::FrameObject}; +#[cfg(all(unix, feature = "threading"))] use core::sync::atomic::AtomicPtr; use core::{ cell::{Cell, RefCell}, @@ -44,7 +46,7 @@ pub struct ThreadSlot { /// thread at a safepoint and supplies the happens-before edge, so the /// pointer and the frames it reaches are quiescent and alive at read time. #[cfg(unix)] - pub top_frame: AtomicPtr, + pub top_frame: AtomicPtr>, /// Raw InterpreterFrame pointer, published alongside top_frame so /// cross-thread readers (sys._current_frames) can materialize /// stack-allocated frames that have no FrameObject. @@ -114,7 +116,7 @@ thread_local! { /// initialized; the `Arc` in `CURRENT_THREAD_SLOT` keeps the /// pointee alive until `cleanup_current_thread_frames` clears this. #[cfg(all(unix, feature = "threading"))] - static CURRENT_TOP_FRAME_SLOT: Cell<*const AtomicPtr> = + static CURRENT_TOP_FRAME_SLOT: Cell<*const AtomicPtr>> = const { Cell::new(core::ptr::null()) }; /// Cached pointer to this thread's `ThreadSlot::top_iframe` for the hot @@ -536,9 +538,10 @@ fn attach_thread(vm: &VirtualMachine) { // a thread doing rapid allow_threads calls from re-attaching and running // past the requester forever, which would stall stop-the-world. Done // outside the CURRENT_THREAD_SLOT borrow above because suspend re-borrows - // it. Safe against a concurrent start_the_world: suspend_if_needed only - // parks while the request is still live and self-recovers otherwise. - suspend_if_needed(&vm.state.stop_the_world); + // it. Safe against a concurrent start_the_world: suspend_if_needed decides + // whether to park under the registry lock, so it never parks after the + // request has been withdrawn. + suspend_if_needed(&vm.state); } /// Transition ATTACHED → DETACHED (like `_PyThreadState_Detach`). @@ -605,102 +608,111 @@ pub fn allow_threads(_vm: &VirtualMachine, f: impl FnOnce() -> R) -> R { /// Transitions ATTACHED → SUSPENDED and waits until released /// (like `_PyThreadState_Suspend` + `_PyThreadState_Attach`). #[cfg(feature = "threading")] -pub fn suspend_if_needed(stw: &super::StopTheWorldState) { +pub fn suspend_if_needed(state: &PyGlobalState) { let should_suspend = CURRENT_THREAD_SLOT.with(|slot| { slot.borrow() .as_ref() .is_some_and(|s| s.stop_requested.load(Ordering::Relaxed)) }); - if !should_suspend { - return; + if should_suspend { + do_suspend(state); } - - if !stw.requested.load(Ordering::Acquire) { - CURRENT_THREAD_SLOT.with(|slot| { - if let Some(s) = slot.borrow().as_ref() { - s.stop_requested.store(false, Ordering::Release); - } - }); - return; - } - - do_suspend(stw); } #[cfg(feature = "threading")] #[cold] -fn do_suspend(stw: &super::StopTheWorldState) { +fn do_suspend(state: &PyGlobalState) { + let stw = &state.stop_the_world; CURRENT_THREAD_SLOT.with(|slot| { - if let Some(s) = slot.borrow().as_ref() { - // ATTACHED → SUSPENDED - match s.state.compare_exchange( - THREAD_ATTACHED, - THREAD_SUSPENDED, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) => { - // Consumed this thread's stop request bit. - s.stop_requested.store(false, Ordering::Release); - } - Err(THREAD_DETACHED) => { - // Leaving VM; caller will re-check on next entry. - super::stw_trace(format_args!("suspend skip DETACHED")); - return; - } - Err(THREAD_SUSPENDED) => { - // Already parked by another path. - s.stop_requested.store(false, Ordering::Release); - super::stw_trace(format_args!("suspend skip already-suspended")); - return; - } - Err(state) => { - debug_assert!(false, "unexpected thread state in suspend: {state}"); - return; - } + let borrowed = slot.borrow(); + let Some(s) = borrowed.as_ref() else { + return; + }; + + // Decide whether to park while holding the thread registry. Both edges + // of `requested` are written under that lock: `init_thread_countdown` + // sets it, and `start_the_world` clears it and then releases every + // SUSPENDED thread without letting go. Publishing SUSPENDED here is + // therefore either seen by that release pass or never reached, which + // leaves the requester the only writer that takes a thread out of + // SUSPENDED. A completion check that observed this thread parked cannot + // then be invalidated by the thread resuming on its own. + let park = { + let _registry = state.thread_frames.lock(); + if stw.requested.load(Ordering::Acquire) { + Some(s.state.compare_exchange( + THREAD_ATTACHED, + THREAD_SUSPENDED, + Ordering::AcqRel, + Ordering::Acquire, + )) + } else { + // The stop already ended; this thread's request bit is stale. + s.stop_requested.store(false, Ordering::Release); + None } - super::stw_trace(format_args!("suspend ATTACHED->SUSPENDED")); + }; - // Re-check: if start_the_world already ran (cleared `requested`), - // no one will set us back to DETACHED — we must self-recover. - if !stw.requested.load(Ordering::Acquire) { - s.state.store(THREAD_ATTACHED, Ordering::Release); + match park { + None => { + super::stw_trace(format_args!("suspend skip not-requested")); + return; + } + Some(Ok(_)) => { + // Consumed this thread's stop request bit. + s.stop_requested.store(false, Ordering::Release); + } + Some(Err(THREAD_DETACHED)) => { + // Leaving VM; caller will re-check on next entry. + super::stw_trace(format_args!("suspend skip DETACHED")); + return; + } + Some(Err(THREAD_SUSPENDED)) => { + // Already parked by another path. s.stop_requested.store(false, Ordering::Release); - super::stw_trace(format_args!("suspend abort requested-cleared")); + super::stw_trace(format_args!("suspend skip already-suspended")); return; } + Some(Err(state)) => { + debug_assert!(false, "unexpected thread state in suspend: {state}"); + return; + } + } + super::stw_trace(format_args!("suspend ATTACHED->SUSPENDED")); - // Notify the stop-the-world requester that we've parked - stw.notify_suspended(); - super::stw_trace(format_args!("suspend notified-requester")); + // Notify the stop-the-world requester that we've parked. The registry + // is released first: the requester's wait loop takes the notify mutex + // and then the registry, so taking them the other way round here would + // invert the order. + stw.notify_suspended(); + super::stw_trace(format_args!("suspend notified-requester")); - // Wait until start_the_world sets us back to DETACHED - let wait_yields = wait_while_suspended(s); - stw.add_suspend_wait_yields(wait_yields); + // Wait until start_the_world sets us back to DETACHED + let wait_yields = wait_while_suspended(s); + stw.add_suspend_wait_yields(wait_yields); - // Re-attach (DETACHED → ATTACHED), tstate_wait_attach CAS loop. - loop { - match s.state.compare_exchange( - THREAD_DETACHED, - THREAD_ATTACHED, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) => break, - Err(THREAD_SUSPENDED) => { - let extra_wait = wait_while_suspended(s); - stw.add_suspend_wait_yields(extra_wait); - } - Err(THREAD_ATTACHED) => break, - Err(state) => { - debug_assert!(false, "unexpected post-suspend state: {state}"); - break; - } + // Re-attach (DETACHED → ATTACHED), tstate_wait_attach CAS loop. + loop { + match s.state.compare_exchange( + THREAD_DETACHED, + THREAD_ATTACHED, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => break, + Err(THREAD_SUSPENDED) => { + let extra_wait = wait_while_suspended(s); + stw.add_suspend_wait_yields(extra_wait); + } + Err(THREAD_ATTACHED) => break, + Err(state) => { + debug_assert!(false, "unexpected post-suspend state: {state}"); + break; } } - s.stop_requested.store(false, Ordering::Release); - super::stw_trace(format_args!("suspend resume -> ATTACHED")); } + s.stop_requested.store(false, Ordering::Release); + super::stw_trace(format_args!("suspend resume -> ATTACHED")); }); } @@ -818,11 +830,8 @@ pub fn set_current_frame(frame: *const InterpreterFrame) -> *const InterpreterFr core::ptr::null_mut() } else { let frame_obj = unsafe { (*frame).frame_obj() }; - // The payload address, which is what the cross-thread - // reader hands to `Py::from_payload_ptr`. The `Py` address - // would be off by the object header. frame_obj.map_or(core::ptr::null_mut(), |py| { - core::ptr::from_ref::(py).cast_mut() + py as *const Py as *mut Py }) }; unsafe { &*slot }.store(fo_ptr, Ordering::Relaxed); @@ -971,7 +980,7 @@ pub fn reinit_frame_slot_after_fork(vm: &VirtualMachine) { core::ptr::null_mut() } else { match unsafe { (*top_iframe).frame_obj() } { - Some(fo) => core::ptr::from_ref::(fo).cast_mut(), + Some(fo) => fo as *const Py as *mut Py, None => core::ptr::null_mut(), } } @@ -1163,6 +1172,8 @@ impl VirtualMachine { state: self.state.clone(), initialized: self.initialized, recursion_depth: Cell::new(0), + #[cfg(any(miri, target_env = "musl"))] + native_recursion_depth: Cell::new(0), c_stack_soft_limit: Cell::new(Self::calculate_c_stack_soft_limit()), async_gen_firstiter: RefCell::new(None), async_gen_finalizer: RefCell::new(None), diff --git a/crates/vm/src/vm/vm_ops.rs b/crates/vm/src/vm/vm_ops.rs index 692444fc7de..dc31e508218 100644 --- a/crates/vm/src/vm/vm_ops.rs +++ b/crates/vm/src/vm/vm_ops.rs @@ -168,6 +168,27 @@ impl VirtualMachine { } } + /// `vec![0; len]` for a length that came from Python, where a request too + /// large to satisfy is a `MemoryError` rather than an aborted process. + /// + /// The bytes are left for the allocator to zero, so a large request costs + /// no more than the pages that are actually written to. + pub fn new_zeroed_bytes(&self, len: usize) -> PyResult> { + if len == 0 { + return Ok(Vec::new()); + } + let layout = + core::alloc::Layout::array::(len).map_err(|_| self.new_memory_error(""))?; + // SAFETY: `len` is not zero, so neither is the layout's size. + let ptr = unsafe { alloc::alloc::alloc_zeroed(layout) }; + if ptr.is_null() { + return Err(self.new_memory_error("")); + } + // SAFETY: `ptr` was just allocated by the global allocator for exactly + // this many bytes, and every one of them is initialized to zero. + Ok(unsafe { Vec::from_raw_parts(ptr, len, len) }) + } + /// Calling scheme used for binary operations: /// /// Order operations are tried until either a valid result or error: diff --git a/extra_tests/snippets/builtin_bytes.py b/extra_tests/snippets/builtin_bytes.py index 4f861364488..3cbed79c069 100644 --- a/extra_tests/snippets/builtin_bytes.py +++ b/extra_tests/snippets/builtin_bytes.py @@ -747,3 +747,22 @@ def __new__(cls, value): assert "123A".istitle(), f"{s}" assert not "123a".istitle(), f"{s}" assert not "123A\ta".istitle(), f"{s}" + + +def test_huge_size(): + # sizes that cannot be allocated are MemoryError, not an aborted process + for factory in (bytes, bytearray): + assert_raises(MemoryError, lambda factory=factory: factory(2**62)) + for meth in ("center", "ljust", "rjust", "zfill"): + assert_raises( + MemoryError, + lambda factory=factory, meth=meth: getattr(factory(b"a"), meth)( + 1 << 62 + ), + ) + assert_raises( + OverflowError, lambda factory=factory: factory(b"\ta").expandtabs(2**31) + ) + + +test_huge_size() diff --git a/extra_tests/snippets/builtin_hash.py b/extra_tests/snippets/builtin_hash.py index b3128cecc5a..818ee523f30 100644 --- a/extra_tests/snippets/builtin_hash.py +++ b/extra_tests/snippets/builtin_hash.py @@ -35,9 +35,10 @@ def __hash__(self): # slot dispatch is what recurses, so that is where the depth is checked. if sys.implementation.name == "rustpython": - # CPython, which also runs this snippet, survives this depth unguarded. + # Deep enough to reach the native stack guard; CPython, which also runs + # this snippet, dies on the same value. deep_tuple = () - for _ in range(sys.getrecursionlimit() * 2): + for _ in range(100_000): deep_tuple = (deep_tuple,) with assert_raises(RecursionError): hash(deep_tuple) diff --git a/extra_tests/snippets/builtin_memoryview.py b/extra_tests/snippets/builtin_memoryview.py index 8a3a194d96d..34928041cd2 100644 --- a/extra_tests/snippets/builtin_memoryview.py +++ b/extra_tests/snippets/builtin_memoryview.py @@ -514,3 +514,164 @@ def test_fortran_contiguity(): test_fortran_contiguity() + + +def test_cast_arguments(): + # cast() takes a native single character format, optionally '@'-prefixed; + # a zero-size format used to reach a division by zero. + assert memoryview(b"abcd").cast("@i").itemsize == 4 + for fmt in ("0s", "4s", " 0; a 0 used to divide by zero while + # checking the product against SSIZE_MAX + for shape in ([0], [0, 4], [4, 0], [-1, 4], [0, 0]): + assert_raises( + ValueError, lambda shape=shape: memoryview(b"abcd").cast("B", shape) + ) + + class Index: + def __index__(self): + return 4 + + for shape in ([2.0, 2], [Index()], ["4"]): + assert_raises( + TypeError, lambda shape=shape: memoryview(b"abcd").cast("B", shape) + ) + + assert memoryview(b"abcd").cast("B", [True, 4]).tolist() == [[97, 98, 99, 100]] + + +test_cast_arguments() + + +def test_negative_stride(): + # A reversed view starts at its last byte, so walking it from there runs + # off the front of the exported slice. + assert memoryview(b"dcba") == memoryview(b"abcd")[::-1] + assert memoryview(b"abcd")[::-1] == memoryview(b"dcba") + assert not memoryview(b"abcd") == memoryview(b"abcd")[::-1] + + b = bytearray(b"____") + memoryview(b)[0:4] = memoryview(b"abcd")[::-1] + assert b == bytearray(b"dcba"), b + + a = array.array("i", [1, 2, 3]) + assert memoryview(array.array("i", [3, 2, 1])) == memoryview(a)[::-1] + assert memoryview(a)[::-1].tolist() == [3, 2, 1] + + +test_negative_stride() + + +def test_write_through_same_object(): + # Reading the source and writing the destination lock the same object + # when they overlap, and converting a value runs Python that can reach it. + b = bytearray(b"abcd") + memoryview(b)[0:4] = b + assert b == bytearray(b"abcd"), b + + b = bytearray(b"abcd") + memoryview(b)[0:4] = memoryview(b)[::-1] + assert b == bytearray(b"dcba"), b + + b = bytearray(b"abcd") + memoryview(b)[0:2] = memoryview(b)[2:4] + assert b == bytearray(b"cdcd"), b + + b = bytearray(b"abcd") + view = memoryview(b) + + class Index: + def __index__(self): + view[1] = 66 + return 65 + + view[0] = Index() + assert b == bytearray(b"ABcd"), b + + +test_write_through_same_object() + + +def test_cast_between_non_byte_formats(): + # A cast re-divides bytes into items; going from one item type straight to + # another would reinterpret what is already there. + view = memoryview(b"abcd").cast("i") + for fmt in ("h", "i", "f"): + try: + view.cast(fmt) + except TypeError as e: + assert "cannot cast between two non-byte formats" in str(e), e + else: + raise AssertionError(f"expected TypeError for cast to {fmt!r}") + + # Either side being bytes is allowed. + assert view.cast("B").tolist() == [97, 98, 99, 100] + assert view.cast("b").format == "b" + assert view.cast("c").tolist() == [b"a", b"b", b"c", b"d"] + assert memoryview(b"abcd").cast("c").cast("i").format == "i" + + +def test_cast_to_zero_dim(): + # A zero-dimensional view holds exactly one item, so the buffer has to be + # that one item and no more. + assert memoryview(b"abcd").cast("I", shape=()).tobytes() == b"abcd" + assert memoryview(b"a").cast("B", shape=()).tobytes() == b"a" + + for source, fmt in ((b"abcd", "B"), (b"abcdefgh", "I"), (b"ab", "b")): + try: + memoryview(source).cast(fmt, shape=()) + except TypeError as e: + assert "product(shape) * itemsize != buffer size" in str(e), e + else: + raise AssertionError(f"expected TypeError for {source!r} as {fmt!r}") + + +def test_hash_restricted_to_byte_formats(): + # The hash is over the bytes, so it agrees with the hash of those bytes + # only where an item is a byte. + data = b"abcdefgh" + assert hash(memoryview(data)) == hash(data) + assert hash(memoryview(data).cast("c")) == hash(data) + assert hash(memoryview(data).cast("b")) == hash(data) + + for fmt in ("I", "i", "h", "d"): + try: + hash(memoryview(data).cast(fmt)) + except ValueError as e: + assert "hashing is restricted to formats" in str(e), e + else: + raise AssertionError(f"expected ValueError for format {fmt!r}") + + +def test_tobytes_order(): + view = memoryview(b"abcdefgh") + for order in (None, "C", "F", "A"): + assert view.tobytes(order=order) == b"abcdefgh", order + + # A multidimensional view is laid out C-contiguously, so a Fortran-ordered + # copy walks it down the columns instead. + grid = memoryview(b"abcdefgh").cast("B", shape=(2, 4)) + assert grid.tolist() == [[97, 98, 99, 100], [101, 102, 103, 104]] + assert grid.tobytes() == b"abcdefgh" + assert grid.tobytes(order="C") == b"abcdefgh" + assert grid.tobytes(order="A") == b"abcdefgh" + assert grid.tobytes(order="F") == b"aebfcgdh" + + cube = memoryview(b"abcdefgh").cast("B", shape=(2, 2, 2)) + assert cube.tobytes(order="F") == b"aecgbfdh" + + for order in ("Z", "c", "f", ""): + try: + view.tobytes(order=order) + except ValueError as e: + assert str(e) == "order must be 'C', 'F' or 'A'", e + else: + raise AssertionError(f"expected ValueError for order {order!r}") + + +test_cast_between_non_byte_formats() +test_cast_to_zero_dim() +test_hash_restricted_to_byte_formats() +test_tobytes_order() diff --git a/extra_tests/snippets/builtin_str.py b/extra_tests/snippets/builtin_str.py index 6eead5ddbfb..684bd66a1ff 100644 --- a/extra_tests/snippets/builtin_str.py +++ b/extra_tests/snippets/builtin_str.py @@ -900,3 +900,18 @@ class MyString(str): assert id(b) != id(b * 1) assert id(b) != id(1 * b) assert id(b) != id(b * 2) + + +def test_huge_width(): + # A width that cannot be allocated is a MemoryError, not an aborted + # process, and a tabsize wider than a C int does not fit at all. + for meth in ("center", "ljust", "rjust", "zfill"): + assert_raises(MemoryError, lambda meth=meth: getattr("a", meth)(1 << 62)) + assert_raises(OverflowError, lambda: "\ta".expandtabs(1 << 62)) + assert_raises(OverflowError, lambda: "\ta".expandtabs(2**31)) + # The widest tabsize that still fits is accepted. With no tab to expand + # there is nothing to lay out, so the width is never allocated. + assert "a".expandtabs(2**31 - 1) == "a" + + +test_huge_width() diff --git a/extra_tests/snippets/builtin_type.py b/extra_tests/snippets/builtin_type.py index 8cb0a09a215..15a330aea19 100644 --- a/extra_tests/snippets/builtin_type.py +++ b/extra_tests/snippets/builtin_type.py @@ -687,3 +687,33 @@ def foo(): code = compile(stmts, "", "exec") assert code.co_names == ("blah", "foo") + + +# A slot descriptor carries the layout it was defined for. Reached from another +# class, it has to report that rather than read the slot at its own offset, +# whether the access is fresh or has been seen often enough to be specialized. + + +class WideSlots: + __slots__ = ("s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7") + + +class NarrowSlots: + __slots__ = ("only",) + + +class NoSlots: + __slots__ = () + + +NarrowSlots.borrowed = WideSlots.__dict__["s7"] +NoSlots.borrowed = WideSlots.__dict__["s7"] + +for owner in (NarrowSlots(), NoSlots()): + for _ in range(1000): + with assert_raises(TypeError): + owner.borrowed + with assert_raises(TypeError): + owner.borrowed = 1 + with assert_raises(TypeError): + del owner.borrowed diff --git a/extra_tests/snippets/recursion.py b/extra_tests/snippets/recursion.py index 2d3b2205d68..4b61a74b438 100644 --- a/extra_tests/snippets/recursion.py +++ b/extra_tests/snippets/recursion.py @@ -11,3 +11,36 @@ class Foo(object): # Since the default __str__ implementation calls __repr__ and __repr__ is # actually __str__, str(foo) should raise a RecursionError. assert_raises(RecursionError, str, foo) + + +# A __call__ that is the object being called dispatches through the call slot +# again, and none of that pushes a Python frame. + + +class Caller: + pass + + +caller = Caller() +Caller.__call__ = caller +assert_raises(RecursionError, caller) + + +# The same shape through the descriptor protocol: resolving the attribute +# fetches __get__, which is the descriptor itself. + + +class Descr: + pass + + +descr = Descr() +Descr.__get__ = descr +Descr.x = descr +try: + descr.x +except (RecursionError, TypeError): + # RecursionError here, TypeError from the call of a non-callable elsewhere + pass +else: + raise AssertionError("descr.x should not resolve") diff --git a/extra_tests/snippets/stdlib_array.py b/extra_tests/snippets/stdlib_array.py index ed2a8f22369..c2de6ac1ec8 100644 --- a/extra_tests/snippets/stdlib_array.py +++ b/extra_tests/snippets/stdlib_array.py @@ -143,3 +143,36 @@ def write(self, chunk): arr = array("b", range(128)) arr.tofile(_ReenteringWriter(arr)) assert len(arr) == 129 + + +def test_setitem_reentrant(): + # Converting the value runs Python, which can reach the array, so the + # array is not locked while it happens. + a = array("i", [1, 2, 3]) + + class Index: + def __index__(self): + a[1] = 9 + return 7 + + a[0] = Index() + assert a == array("i", [7, 9, 3]), a + + +test_setitem_reentrant() + + +def test_frombytes_of_itself(): + # Resizing is refused while a buffer is exported, before any lock is taken. + # The typecode is "b" so the view's items are bytes and the resize is what + # the call is refused for. + a = array("b", [1, 2, 3]) + m = memoryview(a) + with assert_raises(BufferError): + a.frombytes(m) + del m + + # A view of wider items is not a source of bytes at all. + wide = array("i", [1, 2, 3]) + with assert_raises(TypeError): + wide.frombytes(memoryview(wide)) diff --git a/extra_tests/snippets/stdlib_asyncio.py b/extra_tests/snippets/stdlib_asyncio.py index d54f84564a3..a6a55509036 100644 --- a/extra_tests/snippets/stdlib_asyncio.py +++ b/extra_tests/snippets/stdlib_asyncio.py @@ -72,4 +72,31 @@ def __new__(cls, *args): asyncio.InvalidStateError = saved_invalid_state_error asyncio.exceptions.InvalidStateError = saved_invalid_state_error +# The awaited-by set is built with the waiter's __hash__, which can come back +# to the same future; the field must not be locked while that runs. + + +class Reentrant: + def __hash__(self): + _asyncio.future_add_to_awaited_by(awaited, Reentrant()) + return 1 + + def __eq__(self, other): + return self is other + + +awaited = _asyncio.Future(loop=object()) +_asyncio.future_add_to_awaited_by(awaited, Reentrant()) +with assert_raises(RecursionError): + # converting the single waiter into a set hashes both of them + _asyncio.future_add_to_awaited_by(awaited, Reentrant()) + +plain = _asyncio.Future(loop=object()) +waiter = object() +_asyncio.future_add_to_awaited_by(plain, waiter) +_asyncio.future_add_to_awaited_by(plain, object()) +assert waiter in plain._asyncio_awaited_by +_asyncio.future_discard_from_awaited_by(plain, waiter) +assert waiter not in plain._asyncio_awaited_by + print("ok") diff --git a/extra_tests/snippets/stdlib_atexit.py b/extra_tests/snippets/stdlib_atexit.py new file mode 100644 index 00000000000..de490c569df --- /dev/null +++ b/extra_tests/snippets/stdlib_atexit.py @@ -0,0 +1,101 @@ +"""atexit.unregister() compares callbacks with arbitrary Python code. + +The comparison runs with the callback list unlocked, so __eq__ may clear it +and register something new. unregister() then has to tell whether the entry +it compared is still there, and must not mistake a later registration that +happens to occupy the same storage for that entry. +""" + +import atexit + + +def make(name): + def f(): + ran.append(name) + + f.tag = name + return f + + +ran = [] +a, b, c, d = (make(n) for n in "abcd") + + +class Probe: + def __init__(self, action=None, result=True): + self.action = action + self.result = result + self.seen = [] + + def __eq__(self, other): + self.seen.append(getattr(other, "tag", "?")) + if self.action is not None: + self.action() + return self.result + + +def remaining(): + del ran[:] + atexit._run_exitfuncs() + return list(ran) + + +# A callback the probe does not match is left alone. +atexit._clear() +atexit.register(a) +atexit.register(b) +probe = Probe(result=False) +atexit.unregister(probe) +assert probe.seen == ["a", "b"], probe.seen +assert remaining() == ["b", "a"], ran + +# Matching callbacks are dropped, oldest compared first. +atexit._clear() +atexit.register(a) +atexit.register(b) +atexit.register(c) +probe = Probe(result=True) +atexit.unregister(probe) +assert probe.seen == ["a", "b", "c"], probe.seen +assert atexit._ncallbacks() == 0 +assert remaining() == [], ran + +# __eq__ empties the list: there is nothing left to drop. +atexit._clear() +atexit.register(a) +atexit.register(b) +probe = Probe(action=atexit._clear, result=True) +atexit.unregister(probe) +assert probe.seen == ["a"], probe.seen +assert remaining() == [], ran + +# __eq__ empties the list and registers a replacement. The replacement is a +# different callback, so it survives however its storage was reused. +atexit._clear() +atexit.register(a) +atexit.register(b) +atexit.register(c) + + +def replace(): + atexit._clear() + atexit.register(d) + + +probe = Probe(action=replace, result=True) +atexit.unregister(probe) +assert probe.seen == ["a", "d"], probe.seen +assert remaining() == ["d"], ran + +# __eq__ registers without clearing: every entry the walk had already passed +# stays, and so does each newly registered one. +atexit._clear() +atexit.register(a) +atexit.register(b) +probe = Probe(action=lambda: atexit.register(c), result=True) +atexit.unregister(probe) +assert probe.seen == ["a", "c"], probe.seen +assert remaining() == ["c", "c", "b", "a"], ran + +atexit._clear() +print("ok") diff --git a/extra_tests/snippets/stdlib_hashlib.py b/extra_tests/snippets/stdlib_hashlib.py index a463941b29a..f3400aed57d 100644 --- a/extra_tests/snippets/stdlib_hashlib.py +++ b/extra_tests/snippets/stdlib_hashlib.py @@ -2,6 +2,8 @@ import _sha1 import hashlib +from testutils import assert_raises + # print(hashlib.md5) h = hashlib.md5() h.update(b"a") @@ -56,3 +58,9 @@ assert _md5.md5(b"").hexdigest() == "d41d8cd98f00b204e9800998ecf8427e" assert _sha1.sha1(b"").hexdigest() == "da39a3ee5e6b4b0d3255bfef95601890afd80709" + +# a derived key wider than a C int does not fit, and never gets allocated. +# Which OverflowError comes out depends on the width of a C long: where it is +# narrower than the length asked for, converting the argument fails first. +with assert_raises(OverflowError): + hashlib.pbkdf2_hmac("sha256", b"password", b"salt", 1, 2**62) diff --git a/extra_tests/snippets/stdlib_io.py b/extra_tests/snippets/stdlib_io.py index f17eae5b172..8346ddbb62d 100644 --- a/extra_tests/snippets/stdlib_io.py +++ b/extra_tests/snippets/stdlib_io.py @@ -197,3 +197,48 @@ def __index__(self): f"cannot fit '{truncated_non_ascii_type_name}' into an index-sized integer", lambda: setattr(textio, "_CHUNK_SIZE", NonAsciiNamedChunkSize()), ) + + +# A buffer size or read size that cannot be allocated is a MemoryError, not an +# aborted process. +assert_raises(MemoryError, lambda: BufferedReader(BytesIO(b"a"), buffer_size=2**62)) +assert_raises(MemoryError, lambda: BufferedReader(BytesIO(b"a")).read(2**62)) +assert_raises(MemoryError, lambda: BufferedReader(BytesIO(b"a")).read1(2**62)) + + +def _text_cookie( + start_pos=0, + dec_flags=0, + bytes_to_feed=0, + chars_to_skip=0, + need_eof=0, + bytes_to_skip=0, +): + packed = ( + start_pos.to_bytes(8, "little", signed=True) + + dec_flags.to_bytes(4, "little", signed=True) + + bytes_to_feed.to_bytes(4, "little", signed=True) + + chars_to_skip.to_bytes(4, "little", signed=True) + + bytes([need_eof]) + + bytes_to_skip.to_bytes(4, "little", signed=True) + ) + return int.from_bytes(packed, "little") + + +# A cookie names a position both in characters and in bytes, and everything +# read back from it indexes what was decoded, so a position past the end is +# refused rather than stored. +for _bad in ( + _text_cookie(bytes_to_feed=10, chars_to_skip=1000, bytes_to_skip=0), + _text_cookie(bytes_to_feed=10, chars_to_skip=100000, bytes_to_skip=3), + _text_cookie(bytes_to_feed=10, chars_to_skip=1, bytes_to_skip=1000), +): + _textio = TextIOWrapper(BytesIO(b"hello world " * 20), encoding="utf-8") + _textio.read(1) + try: + _textio.seek(_bad) + except (OSError, OverflowError): + pass + else: + assert _textio.read(50) is not None + _textio.tell() diff --git a/extra_tests/snippets/stdlib_io_blocking_buffer.py b/extra_tests/snippets/stdlib_io_blocking_buffer.py new file mode 100644 index 00000000000..2119111dc2e --- /dev/null +++ b/extra_tests/snippets/stdlib_io_blocking_buffer.py @@ -0,0 +1,176 @@ +"""Transfers that wait for a peer must not hold the buffer they were given. + +A pipe or a socket answers when the other end does, which may be never. The +buffer is exported for the whole call, so it cannot be resized meanwhile, but +nothing else about it changes: another thread can still read it, write to it, +and the interpreter can still stop the world. An implementation that holds the +buffer's storage for the duration of the wait takes all of that away, and a +thread parked on that storage never reaches a safepoint, so a collection that +wants every thread stopped ends up waiting for the peer too. +""" + +import gc +import os +import socket +import threading +import time + +# The peer acts after DELAY; the checks below have to finish well inside it. +DELAY = 1.0 +SLACK = DELAY / 2 + + +def measure(buf, expected_len, writable): + """Time each operation on `buf` that does not need the peer, separately, so + a failure names the one that waited rather than the group.""" + elapsed = {} + + def timed(name, operation): + start = time.monotonic() + value = operation() + elapsed[name] = time.monotonic() - start + return value + + assert timed("len", lambda: len(buf)) == expected_len, len(buf) + assert isinstance(timed("bytes", lambda: bytes(buf)), bytes) + if writable: + timed("setitem", lambda: buf.__setitem__(0, buf[0])) + timed("gc.collect", gc.collect) + return elapsed + + +def run(buf, blocking_call, release_peer, writable): + started = threading.Event() + expected_len = len(buf) + result = [] + + def transfer(): + started.set() + result.append(blocking_call(buf)) + + def peer(): + time.sleep(DELAY) + release_peer() + + threads = [threading.Thread(target=transfer), threading.Thread(target=peer)] + for t in threads: + t.start() + started.wait() + time.sleep(0.2) # the transfer is now waiting on its peer + + elapsed = measure(buf, expected_len, writable) + waited = ["%s %.2fs" % item for item in elapsed.items() if item[1] >= SLACK] + assert not waited, "waited on the peer: " + ", ".join(waited) + + # The transfer is still in flight, so its export is still held and the + # buffer cannot be resized. An operating system that took the whole + # transfer without a peer leaves nothing here to observe. + assert not result, "the transfer finished without its peer" + try: + buf.append(0) + except BufferError: + pass + else: + raise AssertionError("append during an export should raise BufferError") + + for t in threads: + t.join() + return result[0] + + +# --- reading: the buffer is written into, so nothing else may touch it at all + + +read_fd, write_fd = os.pipe() +pipe = open(read_fd, "rb", buffering=0) +try: + target = bytearray(16) + n = run(target, pipe.readinto, lambda: os.write(write_fd, b"pipe"), writable=False) + assert n == 4, n + assert bytes(target[:4]) == b"pipe", bytes(target) +finally: + pipe.close() + os.close(write_fd) + +if hasattr(socket, "socketpair"): + left, right = socket.socketpair() + try: + target = bytearray(16) + n = run(target, left.recv_into, lambda: right.send(b"socket"), writable=False) + assert n == 6, n + assert bytes(target[:6]) == b"socket", bytes(target) + finally: + left.close() + right.close() + + +# --- writing: the buffer is only read, so it stays writable meanwhile + + +read_fd, write_fd = os.pipe() +sink = open(write_fd, "wb", buffering=0) +try: + # More than any pipe will hold, so the write cannot finish on its own. + source = bytearray(4 * 1024 * 1024) + drained = [] + + def drain(): + with open(read_fd, "rb", buffering=0) as f: + while True: + chunk = f.read(1 << 16) + if not chunk: + break + drained.append(len(chunk)) + + reader = threading.Thread(target=drain, daemon=True) + # One unbuffered write() reports what it transferred, which a signal can + # cut short, so the reader is measured against that rather than the source. + written = run(source, sink.write, reader.start, writable=True) + sink.close() + reader.join() + assert sum(drained) == written, (sum(drained), written) +finally: + if not sink.closed: + sink.close() + +if hasattr(socket, "socketpair"): + left, right = socket.socketpair() + try: + # How much a connection holds before it makes the sender wait is the + # operating system's to decide, and asking for a small send buffer does + # not settle it -- a socketpair is already connected, and on Windows it + # is a loopback pair whose receiver has a window of its own. So fill it + # until it refuses rather than guess a size that outruns it. + left.setblocking(False) + filled = 0 + while True: + try: + filled += left.send(bytes(1 << 16)) + except (BlockingIOError, InterruptedError): + break + left.setblocking(True) + + source = bytearray(1 << 16) + received = [] + + def receive(): + wanted = filled + len(source) + while sum(received) < wanted: + chunk = right.recv(1 << 16) + if not chunk: + break + received.append(len(chunk)) + + reader = threading.Thread(target=receive, daemon=True) + run(source, left.sendall, reader.start, writable=True) + reader.join() + assert sum(received) == filled + len(source), ( + sum(received), + filled, + len(source), + ) + finally: + left.close() + right.close() + +print("ok") diff --git a/extra_tests/snippets/stdlib_io_bytesio.py b/extra_tests/snippets/stdlib_io_bytesio.py index ba8ae20015e..9344c50d947 100644 --- a/extra_tests/snippets/stdlib_io_bytesio.py +++ b/extra_tests/snippets/stdlib_io_bytesio.py @@ -106,3 +106,11 @@ def test_07(): test_05() test_06() test_07() + + +# Reading into a buffer that views this same object locks it twice unless the +# read finishes first. +_bio = BytesIO(b"x" * 60) +assert _bio.readinto(_bio.getbuffer()) == 60 +_bio = BytesIO(b"x" * 60) +assert _bio.readinto(memoryview(_bio.getbuffer())) == 60 diff --git a/extra_tests/snippets/stdlib_marshal.py b/extra_tests/snippets/stdlib_marshal.py index 8881d3e0a7b..4e224fb313f 100644 --- a/extra_tests/snippets/stdlib_marshal.py +++ b/extra_tests/snippets/stdlib_marshal.py @@ -96,5 +96,63 @@ def test_roundtrip_shared_co_const(self): self.assertIs(loaded_code.co_consts[0], loaded_shared) +class AllowCodeTests(unittest.TestCase): + """allow_code is answered where a code object is written or read, so a + graph that walks back on itself is not a second walk of its own.""" + + def test_recursive_value(self): + recursive = [] + recursive.append(recursive) + loaded = marshal.loads( + marshal.dumps(recursive, allow_code=False), allow_code=False + ) + self.assertIs(loaded[0], loaded) + + def test_too_deeply_nested(self): + nested = [] + for _ in range(100_000): + nested = [nested] + with self.assertRaises(ValueError): + marshal.dumps(nested, allow_code=False) + + def test_code_is_rejected(self): + code = compile("1", "", "exec") + for value in (code, [code], (code,), {0: code}): + with self.assertRaises(ValueError): + marshal.dumps(value, allow_code=False) + data = marshal.dumps(value) + with self.assertRaises(ValueError): + marshal.loads(data, allow_code=False) + + +class BadDataTests(unittest.TestCase): + def test_container_size_out_of_range(self): + import struct + + # a length is signed, so the top bit set is out of range rather than + # four billion items to reserve room for + for marker in b"([<>": + data = bytes([marker | 0x80]) + struct.pack("H", "'H' format requires 0 <= number <= 65535"), + (">i", "'i' format requires -2147483648 <= number <= 2147483647"), + ("N", "'N' format requires 0 <= number <= 18446744073709551615"), + ("P", "int too large to convert"), +): + try: + struct.pack(fmt, 10**30) + except struct.error as e: + assert str(e) == message, (fmt, str(e)) + else: + raise AssertionError(f"expected struct.error for {fmt!r}") + +try: + struct.pack("B", "x") +except struct.error as e: + assert str(e) == "required argument is not an integer", e +else: + raise AssertionError("expected struct.error") + + +# __init__ reads a new format into a Struct that already holds one. +s = struct.Struct(">h") +s.__init__(">hh") +assert s.format == ">hh" +assert s.size == 4 +assert s.pack(1, 2) == b"\x00\x01\x00\x02" +assert s.unpack(b"\x00\x01\x00\x02") == (1, 2) + +# A format that cannot be read leaves the Struct as it was. +for bad in ("\udc00", "$"): + with assert_raises((UnicodeEncodeError, struct.error)): + s.__init__(bad) + assert s.format == ">hh" + assert s.pack(1, 2) == b"\x00\x01\x00\x02" + + +# A subclass may do its own __init__ and pass the format up. +class BigShort(struct.Struct): + def __init__(self): + super().__init__(">h") + + +assert BigShort().pack(12345) == b"\x30\x39" + +# Until __init__ runs there is no format to answer with. +blank = struct.Struct.__new__(struct.Struct) +assert blank.size == -1 +for call in ( + lambda: blank.format, + lambda: blank.pack(1), + lambda: blank.unpack(b"aa"), + lambda: blank.unpack_from(b"aaaa"), + lambda: blank.pack_into(bytearray(4), 0, 1), + lambda: blank.iter_unpack(b"aa"), + lambda: repr(blank), +): + with assert_raises(RuntimeError): + call() diff --git a/extra_tests/snippets/stdlib_threading_current_frames.py b/extra_tests/snippets/stdlib_threading_current_frames.py new file mode 100644 index 00000000000..e93a222148e --- /dev/null +++ b/extra_tests/snippets/stdlib_threading_current_frames.py @@ -0,0 +1,100 @@ +"""Take sys._current_frames() while other threads are running Python. + +The frame each thread is executing is published for cross-thread readers, and +_current_frames() takes a reference to it with the world stopped. A reader that +disagrees with the publisher about what the published pointer addresses reads +and reference-counts the wrong memory, which corrupts a neighbouring object +rather than failing at the read: the damage surfaces later, in the thread that +owns it, as a crash or a wedge. + +Workers therefore run ordinary Python calls (which publish a frame) in a tight +loop while the main thread hammers _current_frames(). +""" + +import sys +import threading +import time + +DURATION = 1.5 + + +def leaf(): + return sum(range(8)) + + +def nest(n): + if n: + return nest(n - 1) + return leaf() + + +def worker(stop): + while not stop.is_set(): + nest(16) + + +def frames_are_sane(frames): + # Every key is a thread id, every value a frame of this process. + for tid, frame in frames.items(): + assert isinstance(tid, int), tid + assert tid > 0, tid + assert type(frame).__name__ == "frame", frame + assert isinstance(frame.f_lineno, int), frame + assert isinstance(frame.f_code.co_name, str), frame + + +# The main thread sees itself where it stands. +me = sys._current_frames()[threading.get_ident()] +assert me is sys._getframe(), me + +stop = threading.Event() +threads = [threading.Thread(target=worker, args=(stop,)) for _ in range(4)] +for t in threads: + t.start() + +deadline = time.time() + DURATION +calls = 0 +while time.time() < deadline: + frames_are_sane(sys._current_frames()) + calls += 1 +stop.set() +for t in threads: + t.join() + +assert calls > 0, calls + + +# A thread parked in a call the main thread can name is reported inside it, +# with its callers reachable through f_back. +entered = threading.Event() +leave = threading.Event() +seen = [] + + +def g456(): + seen.append(threading.get_ident()) + entered.set() + leave.wait() + + +def f123(): + g456() + + +t = threading.Thread(target=f123) +t.start() +entered.wait() +try: + chain = [] + frame = sys._current_frames()[seen[0]] + while frame is not None: + chain.append(frame.f_code.co_name) + frame = frame.f_back + assert "g456" in chain, chain + assert "f123" in chain, chain + assert chain.index("g456") < chain.index("f123"), chain +finally: + leave.set() + t.join() + +print("ok") diff --git a/extra_tests/snippets/stdlib_types.py b/extra_tests/snippets/stdlib_types.py index 335069811a8..4bccd2985bf 100644 --- a/extra_tests/snippets/stdlib_types.py +++ b/extra_tests/snippets/stdlib_types.py @@ -47,14 +47,14 @@ def _run_missing_type_params_regression(): list[self_referential] nested = [0] - for _ in range(sys.getrecursionlimit() * 2): + for _ in range(100_000): nested = [nested] with assert_raises(RecursionError): list[nested] # hashing an alias walks the same shape deep_alias = int - for _ in range(sys.getrecursionlimit() * 2): + for _ in range(100_000): deep_alias = list[deep_alias] with assert_raises(RecursionError): hash(deep_alias) diff --git a/extra_tests/snippets/stdlib_typing.py b/extra_tests/snippets/stdlib_typing.py index 98d368c02cd..4082d683f8d 100644 --- a/extra_tests/snippets/stdlib_typing.py +++ b/extra_tests/snippets/stdlib_typing.py @@ -45,3 +45,21 @@ def method(self, value: Union[int, float]) -> Union[str, bytes]: assert _typing._idfunc(1) == 1 with assert_raises(TypeError): _typing._idfunc() + + +# ParamSpecArgs shows a non-ParamSpec origin by its repr, which is where the +# recursion guard lives; nesting them deeply must not walk the native stack. + +from typing import ParamSpec, ParamSpecArgs + +spec = ParamSpec("spec") +assert repr(spec.args) == "spec.args" +assert repr(spec.kwargs) == "spec.kwargs" + +nested = object() +for _ in range(2000): + nested = ParamSpecArgs(nested) +try: + repr(nested) +except RecursionError: + pass From aaeab4f754b4f40efc0c8ab39cf7c4a3c35a8cfd Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:33:56 +0900 Subject: [PATCH 314/351] Fix malformed Unicode error constructors (#8497) * Fix malformed Unicode error constructors Replace message-only Unicode decode and encode errors with fully initialized exceptions, preserve source objects and failure ranges, and remove the obsolete constructors. Assisted-by: Codex:gpt-5 * Fix Windows Unicode CI regressions Assisted-by: Codex:gpt-5 --- Lib/test/test_codeccallbacks.py | 3 +- crates/capi/src/pyerrors.rs | 2 +- crates/host_env/src/posix.rs | 28 +++++++++--- crates/stdlib/src/array.rs | 34 +++++++++++++-- crates/stdlib/src/csv.rs | 2 +- crates/stdlib/src/socket.rs | 4 +- crates/stdlib/src/tkinter.rs | 15 +++++-- crates/vm/src/codecs.rs | 2 +- crates/vm/src/function/fspath.rs | 2 +- crates/vm/src/stdlib/_codecs.rs | 51 ++++++++++++---------- crates/vm/src/stdlib/nt.rs | 73 +++++++++++++++----------------- crates/vm/src/stdlib/os.rs | 2 +- crates/vm/src/stdlib/posix.rs | 18 ++++++-- crates/vm/src/vm/vm_new.rs | 8 +--- 14 files changed, 149 insertions(+), 95 deletions(-) diff --git a/Lib/test/test_codeccallbacks.py b/Lib/test/test_codeccallbacks.py index 763146c94fc..28ddf0a63b0 100644 --- a/Lib/test/test_codeccallbacks.py +++ b/Lib/test/test_codeccallbacks.py @@ -1067,8 +1067,7 @@ def test_decodehelper_bug36819(self): decoded = input.decode(enc, "test.bug36819") self.assertEqual(decoded, 'abcdx' * 51) - # TODO: RUSTPYTHON - @unittest.expectedFailure + @unittest.expectedFailureIf(sys.platform != "win32", "TODO: RUSTPYTHON") def test_encodehelper_bug36819(self): handler = RepeatedPosReturn() codecs.register_error("test.bug36819", handler.handle) diff --git a/crates/capi/src/pyerrors.rs b/crates/capi/src/pyerrors.rs index 25b76f33362..a4ead856be9 100644 --- a/crates/capi/src/pyerrors.rs +++ b/crates/capi/src/pyerrors.rs @@ -347,7 +347,7 @@ pub unsafe extern "C" fn PyUnicodeDecodeError_Create( unsafe { slice::from_raw_parts(object.cast::(), length) }.to_vec() }; - let exc = vm.new_unicode_decode_error_real( + let exc = vm.new_unicode_decode_error( vm.ctx.new_str(encoding), vm.ctx.new_bytes(bytes), start, diff --git a/crates/host_env/src/posix.rs b/crates/host_env/src/posix.rs index 50d3f52a674..1e8d4cabe1e 100644 --- a/crates/host_env/src/posix.rs +++ b/crates/host_env/src/posix.rs @@ -1,5 +1,4 @@ use alloc::ffi::CString; -#[cfg(all(unix, not(target_os = "redox")))] use alloc::vec::Vec; use core::ffi::CStr; #[cfg(all(unix, not(target_os = "redox")))] @@ -22,6 +21,12 @@ pub struct UnameInfo { pub machine: String, } +#[derive(Debug)] +pub struct UnameDecodeError { + pub bytes: Vec, + pub error: core::str::Utf8Error, +} + #[cfg(all(unix, not(target_os = "redox")))] #[derive(Clone, Copy, Debug)] pub struct StatVfsInfo { @@ -354,14 +359,23 @@ pub fn fchownat( .map_err(std::io::Error::from) } -pub fn uname_info() -> Result { +pub fn uname_info() -> Result { + fn decode(value: &CStr) -> Result { + core::str::from_utf8(value.to_bytes()) + .map(str::to_owned) + .map_err(|error| UnameDecodeError { + bytes: value.to_bytes().to_vec(), + error, + }) + } + let info = rustix::system::uname(); Ok(UnameInfo { - sysname: info.sysname().to_str()?.into(), - nodename: info.nodename().to_str()?.into(), - release: info.release().to_str()?.into(), - version: info.version().to_str()?.into(), - machine: info.machine().to_str()?.into(), + sysname: decode(info.sysname())?, + nodename: decode(info.nodename())?, + release: decode(info.release())?, + version: decode(info.version())?, + machine: decode(info.machine())?, }) } diff --git a/crates/stdlib/src/array.rs b/crates/stdlib/src/array.rs index 0f652efd35c..b1fa925e16d 100644 --- a/crates/stdlib/src/array.rs +++ b/crates/stdlib/src/array.rs @@ -657,7 +657,7 @@ pub mod array { impl ToPyResult for WideChar { fn to_pyresult(self, vm: &VirtualMachine) -> PyResult { Ok(CodePoint::try_from(self) - .map_err(|e| vm.new_unicode_encode_error(e))? + .map_err(|e| vm.new_value_error(e))? .to_pyobject(vm)) } } @@ -1755,8 +1755,17 @@ pub mod array { })?, MachineFormatCode::Utf16 { big_endian } => { let utf16: Vec<_> = chunks.map(|b| chunk_to_obj!(b, u16, big_endian)).collect(); - let s = String::from_utf16(&utf16) - .map_err(|_| vm.new_unicode_encode_error("items cannot decode as utf16"))?; + let s = String::from_utf16(&utf16).map_err(|_| { + let (index, reason) = invalid_utf16(&utf16).unwrap(); + vm.new_unicode_decode_error( + vm.ctx + .new_str(if big_endian { "utf-16-be" } else { "utf-16-le" }), + args.items.clone(), + index * 2, + index * 2 + 2, + vm.ctx.new_str(reason), + ) + })?; let bytes = PyArray::_unicode_to_wchar_bytes((*s).as_ref(), array.itemsize()); array.frombytes_move(bytes); } @@ -1772,6 +1781,25 @@ pub mod array { PyArray::from(array).into_ref_with_type(vm, cls) } + fn invalid_utf16(units: &[u16]) -> Option<(usize, &'static str)> { + let mut index = 0; + while index < units.len() { + let unit = units[index]; + if (0xd800..=0xdbff).contains(&unit) { + match units.get(index + 1) { + Some(next) if (0xdc00..=0xdfff).contains(next) => index += 2, + Some(_) => return Some((index, "illegal UTF-16 surrogate")), + None => return Some((index, "unexpected end of data")), + } + } else if (0xdc00..=0xdfff).contains(&unit) { + return Some((index, "illegal encoding")); + } else { + index += 1; + } + } + None + } + // Register array.array as collections.abc.MutableSequence pub(crate) fn module_exec( vm: &VirtualMachine, diff --git a/crates/stdlib/src/csv.rs b/crates/stdlib/src/csv.rs index 3471f7a28d8..5697689ae7d 100644 --- a/crates/stdlib/src/csv.rs +++ b/crates/stdlib/src/csv.rs @@ -64,7 +64,7 @@ mod _csv { bytes: &[u8], err: core::str::Utf8Error, ) -> PyBaseExceptionRef { - vm.new_unicode_decode_error_real( + vm.new_unicode_decode_error( vm.ctx.new_str("utf-8"), vm.ctx.new_bytes(bytes.to_vec()), err.valid_up_to(), diff --git a/crates/stdlib/src/socket.rs b/crates/stdlib/src/socket.rs index 4e02dca451c..a1998ba7c3b 100644 --- a/crates/stdlib/src/socket.rs +++ b/crates/stdlib/src/socket.rs @@ -2624,7 +2624,7 @@ mod _socket { Some(ArgStrOrBytesLike::Buf(b)) => { let bytes = b.borrow_buf(); let host_str = core::str::from_utf8(&bytes).map_err(|e| { - vm.new_unicode_decode_error_real( + vm.new_unicode_decode_error( vm.ctx.new_str("utf-8"), vm.ctx.new_bytes(bytes.to_vec()), e.valid_up_to(), @@ -2666,7 +2666,7 @@ mod _socket { let bytes = b.borrow_buf(); core::str::from_utf8(&bytes) .map_err(|e| { - vm.new_unicode_decode_error_real( + vm.new_unicode_decode_error( vm.ctx.new_str("utf-8"), vm.ctx.new_bytes(bytes.to_vec()), e.valid_up_to(), diff --git a/crates/stdlib/src/tkinter.rs b/crates/stdlib/src/tkinter.rs index 653d6edb71d..ca70561b3ab 100644 --- a/crates/stdlib/src/tkinter.rs +++ b/crates/stdlib/src/tkinter.rs @@ -162,9 +162,18 @@ mod _tkinter { if let Some(tcl_obj) = obj.downcast_ref::() { let c_str = unsafe { tk_sys::Tcl_GetString(tcl_obj.value) }; - let varname = unsafe { ffi::CStr::from_ptr(c_str as _) } - .to_str() - .map_err(|e| vm.new_unicode_decode_error(e.to_string()))? + let bytes = unsafe { ffi::CStr::from_ptr(c_str as _) }.to_bytes(); + let varname = core::str::from_utf8(bytes) + .map_err(|e| { + vm.new_unicode_decode_error( + vm.ctx.new_str("utf-8"), + vm.ctx.new_bytes(bytes.to_vec()), + e.valid_up_to(), + e.error_len() + .map_or(bytes.len(), |len| e.valid_up_to() + len), + vm.ctx.new_str(e.to_string()), + ) + })? .to_owned(); return Ok(varname); } diff --git a/crates/vm/src/codecs.rs b/crates/vm/src/codecs.rs index c06caefef51..073fd102f38 100644 --- a/crates/vm/src/codecs.rs +++ b/crates/vm/src/codecs.rs @@ -802,7 +802,7 @@ impl DecodeContext for PyDecodeContext<'_> { } else { vm.ctx.new_bytes(self.data.to_vec()) }; - vm.new_unicode_decode_error_real( + vm.new_unicode_decode_error( vm.ctx.new_str(self.encoding), data, byte_range.start, diff --git a/crates/vm/src/function/fspath.rs b/crates/vm/src/function/fspath.rs index 954c82cb737..f0ab2909059 100644 --- a/crates/vm/src/function/fspath.rs +++ b/crates/vm/src/function/fspath.rs @@ -125,7 +125,7 @@ impl FsPath { pub fn bytes_as_os_str<'a>(b: &'a [u8], vm: &VirtualMachine) -> PyResult<&'a std::ffi::OsStr> { rustpython_host_env::os::bytes_as_os_str(b).map_err(|e| { - vm.new_unicode_decode_error_real( + vm.new_unicode_decode_error( vm.ctx.new_str("utf-8"), vm.ctx.new_bytes(b.to_vec()), e.valid_up_to(), diff --git a/crates/vm/src/stdlib/_codecs.rs b/crates/vm/src/stdlib/_codecs.rs index 497d62fcc81..8f6ea5f1900 100644 --- a/crates/vm/src/stdlib/_codecs.rs +++ b/crates/vm/src/stdlib/_codecs.rs @@ -382,6 +382,23 @@ mod _codecs_windows { use crate::{builtins::PyStrRef, builtins::PyUtf8StrRef, function::ArgBytesLike}; use rustpython_host_env::windows as host_windows; + fn string_from_utf16( + encoding: &str, + data: &[u8], + wide: &[u16], + vm: &VirtualMachine, + ) -> PyResult { + String::from_utf16(wide).map_err(|err| { + vm.new_unicode_decode_error( + vm.ctx.new_str(encoding), + vm.ctx.new_bytes(data.to_vec()), + 0, + data.len(), + vm.ctx.new_str(format!("{encoding}_decode failed: {err}")), + ) + }) + } + #[derive(FromArgs)] struct MbcsEncodeArgs { #[pyarg(positional)] @@ -399,9 +416,7 @@ mod _codecs_windows { Some(s) => s, None => { // String contains surrogates - not encodable with mbcs - return Err(vm.new_unicode_encode_error( - "'mbcs' codec can't encode character: surrogates not allowed", - )); + return encode_code_page_errors(host_windows::CP_ACP, &args.s, errors, "mbcs", vm); } }; let char_len = args.s.char_len(); @@ -433,9 +448,7 @@ mod _codecs_windows { .map_err(|err| vm.new_os_error(format!("mbcs_encode failed: {err}")))?; if errors == "strict" && used_default_char { - return Err(vm.new_unicode_encode_error( - "'mbcs' codec can't encode characters: invalid character", - )); + return encode_code_page_errors(host_windows::CP_ACP, &args.s, errors, "mbcs", vm); } buffer.truncate(result); @@ -484,8 +497,7 @@ mod _codecs_windows { ) .map_err(|err| vm.new_os_error(format!("mbcs_decode failed: {err}")))?; buffer.truncate(result); - let s = String::from_utf16(&buffer) - .map_err(|e| vm.new_unicode_decode_error(format!("mbcs_decode failed: {e}")))?; + let s = string_from_utf16("mbcs", data.as_ref(), &buffer, vm)?; return Ok((s, len)); } @@ -500,8 +512,7 @@ mod _codecs_windows { ) .map_err(|err| vm.new_os_error(format!("mbcs_decode failed: {err}")))?; buffer.truncate(result); - let s = String::from_utf16(&buffer) - .map_err(|e| vm.new_unicode_decode_error(format!("mbcs_decode failed: {e}")))?; + let s = string_from_utf16("mbcs", data.as_ref(), &buffer, vm)?; Ok((s, len)) } @@ -523,9 +534,7 @@ mod _codecs_windows { Some(s) => s, None => { // String contains surrogates - not encodable with oem - return Err(vm.new_unicode_encode_error( - "'oem' codec can't encode character: surrogates not allowed", - )); + return encode_code_page_errors(host_windows::CP_OEMCP, &args.s, errors, "oem", vm); } }; let char_len = args.s.char_len(); @@ -557,9 +566,7 @@ mod _codecs_windows { .map_err(|err| vm.new_os_error(format!("oem_encode failed: {err}")))?; if errors == "strict" && used_default_char { - return Err(vm.new_unicode_encode_error( - "'oem' codec can't encode characters: invalid character", - )); + return encode_code_page_errors(host_windows::CP_OEMCP, &args.s, errors, "oem", vm); } buffer.truncate(result); @@ -609,8 +616,7 @@ mod _codecs_windows { ) .map_err(|err| vm.new_os_error(format!("oem_decode failed: {err}")))?; buffer.truncate(result); - let s = String::from_utf16(&buffer) - .map_err(|e| vm.new_unicode_decode_error(format!("oem_decode failed: {e}")))?; + let s = string_from_utf16("oem", data.as_ref(), &buffer, vm)?; return Ok((s, len)); } @@ -625,8 +631,7 @@ mod _codecs_windows { ) .map_err(|err| vm.new_os_error(format!("oem_decode failed: {err}")))?; buffer.truncate(result); - let s = String::from_utf16(&buffer) - .map_err(|e| vm.new_unicode_decode_error(format!("oem_decode failed: {e}")))?; + let s = string_from_utf16("oem", data.as_ref(), &buffer, vm)?; Ok((s, len)) } @@ -1024,7 +1029,7 @@ mod _codecs_windows { } } let object = vm.ctx.new_bytes(data.to_vec()); - return Err(vm.new_unicode_decode_error_real( + return Err(vm.new_unicode_decode_error( encoding_str, object, fail_pos, @@ -1115,7 +1120,7 @@ mod _codecs_windows { } "strict" => { let object = vm.ctx.new_bytes(data.to_vec()); - return Err(vm.new_unicode_decode_error_real( + return Err(vm.new_unicode_decode_error( encoding_str, object, pos, @@ -1126,7 +1131,7 @@ mod _codecs_windows { _ => { // Custom error handler let object = vm.ctx.new_bytes(data.to_vec()); - let exc = vm.new_unicode_decode_error_real( + let exc = vm.new_unicode_decode_error( encoding_str.clone(), object, pos, diff --git a/crates/vm/src/stdlib/nt.rs b/crates/vm/src/stdlib/nt.rs index 31a08195c58..26412e352ce 100644 --- a/crates/vm/src/stdlib/nt.rs +++ b/crates/vm/src/stdlib/nt.rs @@ -19,7 +19,7 @@ pub(crate) mod module { use libc::intptr_t; use rustpython_common::wtf8::Wtf8Buf; use rustpython_host_env::nt as host_nt; - use std::os::windows::ffi::OsStringExt; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; use std::os::windows::io::AsRawHandle; #[pyattr] @@ -49,6 +49,26 @@ pub(crate) mod module { #[pyattr] const TMP_MAX: i32 = i32::MAX; + fn utf8_from_bytes<'a>(bytes: &'a [u8], vm: &VirtualMachine) -> PyResult<&'a str> { + core::str::from_utf8(bytes).map_err(|err| { + let reason = match err.error_len() { + None => "unexpected end of data", + Some(_) => match bytes[err.valid_up_to()] { + 0xc2..=0xf4 => "invalid continuation byte", + _ => "invalid start byte", + }, + }; + vm.new_unicode_decode_error( + vm.ctx.new_str("utf-8"), + vm.ctx.new_bytes(bytes.to_vec()), + err.valid_up_to(), + err.error_len() + .map_or(bytes.len(), |len| err.valid_up_to() + len), + vm.ctx.new_str(reason), + ) + }) + } + #[pyattr] use host_nt::{ LOAD_LIBRARY_SEARCH_APPLICATION_DIR as _LOAD_LIBRARY_SEARCH_APPLICATION_DIR, @@ -214,11 +234,8 @@ pub(crate) mod module { fn _findfirstfile(path: OsPath, vm: &VirtualMachine) -> PyResult { let filename = host_nt::find_first_file_name(path.as_ref()) .map_err(|err| OSErrorBuilder::with_filename(&err, path.clone(), vm))?; - let filename_str = filename - .to_str() - .ok_or_else(|| vm.new_unicode_decode_error("filename contains invalid UTF-8"))?; - - Ok(vm.ctx.new_str(filename_str)) + let filename_wide: Vec<_> = filename.encode_wide().collect(); + Ok(vm.ctx.new_str(Wtf8Buf::from_wide(&filename_wide))) } #[derive(FromArgs)] @@ -689,17 +706,7 @@ pub(crate) mod module { (wide, false) } else if let Some(b) = path.downcast_ref::() { // On Windows, bytes must be valid UTF-8 - this raises UnicodeDecodeError if not - let s = core::str::from_utf8(b.as_bytes()).map_err(|e| { - vm.new_exception_msg( - vm.ctx.exceptions.unicode_decode_error.to_owned(), - format!( - "'utf-8' codec can't decode byte {:#x} in position {}: invalid start byte", - b.as_bytes().get(e.valid_up_to()).copied().unwrap_or(0), - e.valid_up_to() - ) - .into(), - ) - })?; + let s = utf8_from_bytes(b.as_bytes(), vm)?; let wide: Vec = s.encode_utf16().collect(); (wide, true) } else { @@ -720,16 +727,13 @@ pub(crate) mod module { // Return as bytes if input was bytes, preserving the original content if is_bytes { // Convert UTF-16 back to UTF-8 for bytes output - let drv = String::from_utf16(&wide[..drv_size]) - .map_err(|e| vm.new_unicode_decode_error(e.to_string()))?; - let root = String::from_utf16(&wide[drv_size..drv_size + root_size]) - .map_err(|e| vm.new_unicode_decode_error(e.to_string()))?; - let tail = String::from_utf16(&wide[drv_size + root_size..]) - .map_err(|e| vm.new_unicode_decode_error(e.to_string()))?; + let drv = Wtf8Buf::from_wide(&wide[..drv_size]).into_bytes(); + let root = Wtf8Buf::from_wide(&wide[drv_size..drv_size + root_size]).into_bytes(); + let tail = Wtf8Buf::from_wide(&wide[drv_size + root_size..]).into_bytes(); Ok(vm.ctx.new_tuple(vec![ - vm.ctx.new_bytes(drv.into_bytes()).into(), - vm.ctx.new_bytes(root.into_bytes()).into(), - vm.ctx.new_bytes(tail.into_bytes()).into(), + vm.ctx.new_bytes(drv).into(), + vm.ctx.new_bytes(root).into(), + vm.ctx.new_bytes(tail).into(), ])) } else { // For str output, use WTF-8 to handle surrogates @@ -913,17 +917,7 @@ pub(crate) mod module { let wide: Vec = s.as_wtf8().encode_wide().collect(); (wide, false) } else if let Some(b) = path.downcast_ref::() { - let s = core::str::from_utf8(b.as_bytes()).map_err(|e| { - vm.new_exception_msg( - vm.ctx.exceptions.unicode_decode_error.to_owned(), - format!( - "'utf-8' codec can't decode byte {:#x} in position {}: invalid start byte", - b.as_bytes().get(e.valid_up_to()).copied().unwrap_or(0), - e.valid_up_to() - ) - .into(), - ) - })?; + let s = utf8_from_bytes(b.as_bytes(), vm)?; let wide: Vec = s.encode_utf16().collect(); (wide, true) } else { @@ -936,9 +930,8 @@ pub(crate) mod module { let normalized = normpath_wide(&wide); if is_bytes { - let s = String::from_utf16(&normalized) - .map_err(|e| vm.new_unicode_decode_error(e.to_string()))?; - Ok(vm.ctx.new_bytes(s.into_bytes()).into()) + let bytes = Wtf8Buf::from_wide(&normalized).into_bytes(); + Ok(vm.ctx.new_bytes(bytes).into()) } else { let s = Wtf8Buf::from_wide(&normalized); Ok(vm.ctx.new_str(s).into()) diff --git a/crates/vm/src/stdlib/os.rs b/crates/vm/src/stdlib/os.rs index 9156c9fc0bf..dccc0ae7e47 100644 --- a/crates/vm/src/stdlib/os.rs +++ b/crates/vm/src/stdlib/os.rs @@ -131,7 +131,7 @@ pub(super) struct FollowSymlinks( #[cfg(not(windows))] fn bytes_as_os_str<'a>(b: &'a [u8], vm: &VirtualMachine) -> PyResult<&'a std::ffi::OsStr> { rustpython_host_env::os::bytes_as_os_str(b).map_err(|e| { - vm.new_unicode_decode_error_real( + vm.new_unicode_decode_error( vm.ctx.new_str("utf-8"), vm.ctx.new_bytes(b.to_vec()), e.valid_up_to(), diff --git a/crates/vm/src/stdlib/posix.rs b/crates/vm/src/stdlib/posix.rs index f4d5a70db34..9233af9fbe0 100644 --- a/crates/vm/src/stdlib/posix.rs +++ b/crates/vm/src/stdlib/posix.rs @@ -1340,8 +1340,20 @@ pub mod module { #[pyfunction] fn uname(vm: &VirtualMachine) -> PyResult<_os::UnameResultData> { - let info = rustpython_host_env::posix::uname_info() - .map_err(|err| vm.new_unicode_decode_error(err.to_string()))?; + let info = rustpython_host_env::posix::uname_info().map_err(|err| { + let start = err.error.valid_up_to(); + let end = err + .error + .error_len() + .map_or(err.bytes.len(), |len| start + len); + vm.new_unicode_decode_error( + vm.ctx.new_str("utf-8"), + vm.ctx.new_bytes(err.bytes), + start, + end, + vm.ctx.new_str(err.error.to_string()), + ) + })?; Ok(_os::UnameResultData { sysname: info.sysname, nodename: info.nodename, @@ -1791,7 +1803,7 @@ pub mod module { return Err(vm.new_os_error("unable to determine login name")); }; login.to_str().map(|s| s.to_owned()).map_err(|e| { - vm.new_unicode_decode_error_real( + vm.new_unicode_decode_error( vm.ctx.new_str("utf-8"), vm.ctx.new_bytes(login.as_bytes().to_vec()), e.valid_up_to(), diff --git a/crates/vm/src/vm/vm_new.rs b/crates/vm/src/vm/vm_new.rs index 6110a3d5b1a..82f382ca6d7 100644 --- a/crates/vm/src/vm/vm_new.rs +++ b/crates/vm/src/vm/vm_new.rs @@ -517,7 +517,7 @@ impl VirtualMachine { self.new_os_subtype_error(exc_type.to_owned(), Some(errno), msg) } - pub fn new_unicode_decode_error_real( + pub fn new_unicode_decode_error( &self, encoding: PyStrRef, object: PyBytesRef, @@ -996,12 +996,6 @@ impl VirtualMachine { define_exception_fn!(fn new_type_error, type_error, TypeError); define_exception_fn!(fn new_system_error, system_error, SystemError); - // TODO: remove & replace with new_unicode_decode_error_real - define_exception_fn!(fn new_unicode_decode_error, unicode_decode_error, UnicodeDecodeError); - - // TODO: remove & replace with new_unicode_encode_error_real - define_exception_fn!(fn new_unicode_encode_error, unicode_encode_error, UnicodeEncodeError); - define_exception_fn!(fn new_value_error, value_error, ValueError); define_exception_fn!(fn new_buffer_error, buffer_error, BufferError); From 25e76af1ed305aed0f3c3a5e3ab7fe00bb5487b1 Mon Sep 17 00:00:00 2001 From: sijun-yang Date: Mon, 17 Aug 2026 10:11:09 +0900 Subject: [PATCH 315/351] Expose symbol scopes through _symtable (#8538) Pack symbol scopes with definition flags in _symtable.symbols for Lib/symtable.py. Assisted-by: Codex:gpt-5 --- Lib/test/test_symtable.py | 6 ------ crates/vm/src/stdlib/_symtable.rs | 5 +++-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/Lib/test/test_symtable.py b/Lib/test/test_symtable.py index 8cd1da1e972..16204bc45dd 100644 --- a/Lib/test/test_symtable.py +++ b/Lib/test/test_symtable.py @@ -247,7 +247,6 @@ def test_lineno(self): self.assertEqual(self.top.get_lineno(), 0) self.assertEqual(self.spam.get_lineno(), 14) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Lists differ: [] != ['a', 'b', 'internal', 'kw', 'other_internal', 'some_var', 'var', 'x'] def test_function_info(self): func = self.spam self.assertEqual(sorted(func.get_parameters()), ["a", "b", "kw", "var"]) @@ -256,7 +255,6 @@ def test_function_info(self): self.assertEqual(sorted(func.get_globals()), ["bar", "glob", "some_assigned_global_var"]) self.assertEqual(self.internal.get_frees(), ("x",)) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_globals(self): self.assertTrue(self.spam.lookup("glob").is_global()) self.assertFalse(self.spam.lookup("glob").is_declared_global()) @@ -275,7 +273,6 @@ def test_nonlocal(self): expected = ("some_var",) self.assertEqual(self.other_internal.get_nonlocals(), expected) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_local(self): self.assertTrue(self.spam.lookup("x").is_local()) self.assertFalse(self.spam.lookup("bar").is_local()) @@ -283,7 +280,6 @@ def test_local(self): self.assertTrue(self.top.lookup("some_non_assigned_global_var").is_local()) self.assertTrue(self.top.lookup("some_assigned_global_var").is_local()) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_free(self): self.assertTrue(self.internal.lookup("x").is_free()) @@ -328,7 +324,6 @@ def test_assigned(self): self.assertTrue(self.Mine.lookup("a_method").is_assigned()) self.assertFalse(self.internal.lookup("x").is_assigned()) - @unittest.expectedFailure # TODO: RUSTPYTHON; IndexError: list index out of range def test_annotated(self): st1 = symtable.symtable('def f():\n x: int\n', 'test', 'exec') st2 = st1.get_children()[1] @@ -493,7 +488,6 @@ def test_symtable_repr(self): self.assertEqual(str(self.top), "") self.assertEqual(str(self.spam), "") - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: AssertionError: "" != "" def test_symbol_repr(self): self.assertEqual(repr(self.spam.lookup("glob")), "") diff --git a/crates/vm/src/stdlib/_symtable.rs b/crates/vm/src/stdlib/_symtable.rs index c4a4a7a2051..eb0ecaa87d7 100644 --- a/crates/vm/src/stdlib/_symtable.rs +++ b/crates/vm/src/stdlib/_symtable.rs @@ -185,8 +185,9 @@ mod _symtable { fn symbols(&self, vm: &VirtualMachine) -> PyDictRef { let dict = vm.ctx.new_dict(); for (name, symbol) in &self.symtable.symbols { - dict.set_item(name, vm.new_pyobj(symbol.flags.bits()), vm) - .unwrap(); + let packed_flags = + i32::from(symbol.flags.bits()) | (symbol.scope.as_i32() << SCOPE_OFFSET); + dict.set_item(name, vm.new_pyobj(packed_flags), vm).unwrap(); } dict } From 62d04c6d3dd74c5a10bdd141e46455b92d4c3319 Mon Sep 17 00:00:00 2001 From: Kwak Byoung Min <67214970+doma17@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:38:16 +0900 Subject: [PATCH 316/351] Add StringIO newline tracking (#8539) * Report observed StringIO newline types Constraint: Match CPython StringIO newline reporting without changing configured newline translation. Rejected: Separate newline-tracking representation | reuse the existing SeenNewline bitflags. Confidence: high Scope-risk: narrow Directive: Keep observed newline state separate from the configured newline mode. Tested: prek run --all-files; test_memoryio; cargo clippy -p rustpython-vm --lib -- -D warnings; workspace tests excluding the macOS C-API baseline SIGSEGV. Not-tested: Full macOS workspace suite is blocked by the existing rustpython-capi SIGSEGV; the Linux suite is running. Assisted-by: Codex:gpt-5.6-sol * Preserve StringIO state boundaries Reset observed newline state when StringIO is reinitialized and keep the property subject to closed-stream checks. Constraint: Match CPython StringIO reinitialization and closed-stream behavior. Rejected: Returning stale or cached newline state | violates StringIO lifecycle semantics. Confidence: high Scope-risk: narrow Directive: Keep observed newline state tied to the current open StringIO contents. Tested: cargo test --workspace --exclude rustpython_wasm --exclude rustpython-venvlauncher; cargo run --release -- extra_tests/snippets/stdlib_io_stringio.py; cargo run --release -- -m test test_memoryio; prek run --all-files; cargo clippy -p rustpython-vm --lib -- -D warnings. Assisted-by: Codex:gpt-5.6-sol --- Lib/test/test_memoryio.py | 8 -- crates/vm/src/stdlib/_io.rs | 95 ++++++++++++++++------ extra_tests/snippets/stdlib_io_stringio.py | 16 ++++ 3 files changed, 84 insertions(+), 35 deletions(-) diff --git a/Lib/test/test_memoryio.py b/Lib/test/test_memoryio.py index 7f57d20f205..7ad3aa8a527 100644 --- a/Lib/test/test_memoryio.py +++ b/Lib/test/test_memoryio.py @@ -996,10 +996,6 @@ def __str__(self): def test_flags(self): return super().test_flags() - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'StringIO' object has no attribute 'newlines'. Did you mean: 'readlines'? - def test_newlines_property(self): - return super().test_newlines_property() - class CStringIOPickleTest(PyStringIOPickleTest): UnsupportedOperation = io.UnsupportedOperation @@ -1009,9 +1005,5 @@ def __new__(cls, *args, **kwargs): def __init__(self, *args, **kwargs): pass - @unittest.expectedFailure # TODO: RUSTPYTHON; AttributeError: 'StringIO' object has no attribute 'newlines'. Did you mean: 'readlines'? - def test_newlines_property(self): - return super().test_newlines_property() - if __name__ == '__main__': unittest.main() diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index 9aad883b0d4..1b4439007b2 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -4193,6 +4193,37 @@ mod _io { } } + impl SeenNewline { + fn observe(&mut self, text: &Wtf8) { + let bytes = text.as_bytes(); + let mut matches = memchr::memchr2_iter(b'\r', b'\n', bytes); + while !self.is_all() { + let Some(i) = matches.next() else { break }; + match bytes[i] { + b'\n' => self.insert(Self::LF), + _ if bytes.get(i + 1) == Some(&b'\n') => { + matches.next(); + self.insert(Self::CRLF); + } + _ => self.insert(Self::CR), + } + } + } + + fn to_pyobject(self, vm: &VirtualMachine) -> PyObjectRef { + match self.bits() { + 1 => "\n".to_pyobject(vm), + 2 => "\r".to_pyobject(vm), + 3 => ("\r", "\n").to_pyobject(vm), + 4 => "\r\n".to_pyobject(vm), + 5 => ("\n", "\r\n").to_pyobject(vm), + 6 => ("\r", "\r\n").to_pyobject(vm), + 7 => ("\r", "\n", "\r\n").to_pyobject(vm), + _ => vm.ctx.none(), + } + } + } + impl DefaultConstructor for IncrementalNewlineDecoder {} #[derive(FromArgs)] @@ -4284,16 +4315,7 @@ mod _io { #[pygetset] fn newlines(&self, vm: &VirtualMachine) -> PyResult { let data = self.lock(vm)?; - Ok(match data.seennl.bits() { - 1 => "\n".to_pyobject(vm), - 2 => "\r".to_pyobject(vm), - 3 => ("\r", "\n").to_pyobject(vm), - 4 => "\r\n".to_pyobject(vm), - 5 => ("\n", "\r\n").to_pyobject(vm), - 6 => ("\r", "\r\n").to_pyobject(vm), - 7 => ("\r", "\n", "\r\n").to_pyobject(vm), - _ => vm.ctx.none(), - }) + Ok(data.seennl.to_pyobject(vm)) } } @@ -4340,20 +4362,7 @@ mod _io { self.seennl.insert(SeenNewline::LF); } } else if !self.translate { - let output = output.as_bytes(); - let mut matches = memchr::memchr2_iter(b'\r', b'\n', output); - while !self.seennl.is_all() { - let Some(i) = matches.next() else { break }; - match output[i] { - b'\n' => self.seennl.insert(SeenNewline::LF), - // if c isn't \n, it can only be \r - _ if output.get(i + 1) == Some(&b'\n') => { - matches.next(); - self.seennl.insert(SeenNewline::CRLF); - } - _ => self.seennl.insert(SeenNewline::CR), - } - } + self.seennl.observe(&output); } else { let bytes = output.as_bytes(); let mut matches = memchr::memchr2_iter(b'\r', b'\n', bytes); @@ -4396,6 +4405,7 @@ mod _io { _base: _TextIOBase, buffer: PyRwLock, newline: AtomicCell, + seennl: AtomicCell, closed: AtomicCell, } @@ -4416,6 +4426,7 @@ mod _io { _base: Default::default(), buffer: PyRwLock::new(BufferedIO::new(Cursor::new(Vec::new()))), newline: AtomicCell::new(Newlines::Lf), + seennl: AtomicCell::new(SeenNewline::empty()), closed: AtomicCell::new(false), }) } @@ -4434,11 +4445,16 @@ mod _io { OptionalArg::Present(None) => Newlines::Universal, OptionalArg::Present(Some(newline)) => newline, }; - let raw_bytes = object.flatten().map_or_else(Vec::new, |v| { + let object = object.flatten(); + let raw_bytes = object.as_ref().map_or_else(Vec::new, |v| { Self::translate_newlines(v.as_wtf8(), newline).into_bytes() }); *zelf.buffer.write() = BufferedIO::new(Cursor::new(raw_bytes)); zelf.newline.store(newline); + zelf.seennl.store(SeenNewline::empty()); + if let Some(object) = object { + zelf.observe_newlines(object.as_wtf8(), newline); + } Ok(()) } } @@ -4463,6 +4479,14 @@ mod _io { } } + fn observe_newlines(&self, data: &Wtf8, newline: Newlines) { + if matches!(newline, Newlines::Universal | Newlines::Passthrough) { + let mut seennl = self.seennl.load(); + seennl.observe(data); + self.seennl.store(seennl); + } + } + fn text(bytes: &[u8]) -> &Wtf8 { // SAFETY: StringIO is populated only from PyStr values, which are valid WTF-8. unsafe { Wtf8::from_bytes_unchecked(bytes) } @@ -4515,6 +4539,15 @@ mod _io { self.closed.load() } + #[pygetset] + fn newlines(&self, vm: &VirtualMachine) -> PyResult { + if self.closed.load() { + Err(io_closed_error(vm)) + } else { + Ok(self.seennl.load().to_pyobject(vm)) + } + } + #[pymethod] fn close(&self) { self.closed.store(true); @@ -4523,8 +4556,11 @@ mod _io { // write string to underlying vector #[pymethod] fn write(&self, data: PyStrRef, vm: &VirtualMachine) -> PyResult { - let bytes = Self::translate_newlines(data.as_wtf8(), self.newline.load()).into_bytes(); - self.buffer(vm)? + let newline = self.newline.load(); + let bytes = Self::translate_newlines(data.as_wtf8(), newline).into_bytes(); + let mut buffer = self.buffer(vm)?; + self.observe_newlines(data.as_wtf8(), newline); + buffer .write(&bytes) .ok_or_else(|| vm.new_type_error("Error Writing String"))?; Ok(data.char_len() as u64) @@ -4684,6 +4720,11 @@ mod _io { .map_err(|err| os_err(vm, err))?; drop(buffer); zelf.newline.store(newline); + let mut seennl = SeenNewline::empty(); + if matches!(newline, Newlines::Universal | Newlines::Passthrough) { + seennl.observe(content.as_wtf8()); + } + zelf.seennl.store(seennl); // Set __dict__ if provided if !vm.is_none(dict) { diff --git a/extra_tests/snippets/stdlib_io_stringio.py b/extra_tests/snippets/stdlib_io_stringio.py index 5419eef2bb2..0adf0edac0b 100644 --- a/extra_tests/snippets/stdlib_io_stringio.py +++ b/extra_tests/snippets/stdlib_io_stringio.py @@ -69,9 +69,25 @@ def test_05(): assert f.readline() == "" +def test_06(): + f = StringIO(newline=None) + f.write("\r") + f.__init__("x\n", newline=None) + assert f.newlines == "\n" + + f.close() + try: + f.newlines + except ValueError: + pass + else: + assert False + + if __name__ == "__main__": test_01() test_02() test_03() test_04() test_05() + test_06() From 6f4c86d429d0ed7c76bab67d6ffb49f668c7bfff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:38:51 +0900 Subject: [PATCH 317/351] build(deps): bump reviewdog/action-actionlint from 1.73.0 to 1.73.1 (#8541) Bumps [reviewdog/action-actionlint](https://github.com/reviewdog/action-actionlint) from 1.73.0 to 1.73.1. - [Release notes](https://github.com/reviewdog/action-actionlint/releases) - [Commits](https://github.com/reviewdog/action-actionlint/compare/50842263c20a7c46bd0065b9e624d3c569db061e...d63ba7532e0942965320cd8d73cbae4c7b3c5283) --- updated-dependencies: - dependency-name: reviewdog/action-actionlint dependency-version: 1.73.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d832398ebf6..5a8daae06ef 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -554,7 +554,7 @@ jobs: components: rustfmt - name: actionlint - uses: reviewdog/action-actionlint@50842263c20a7c46bd0065b9e624d3c569db061e # v1.73.0 + uses: reviewdog/action-actionlint@d63ba7532e0942965320cd8d73cbae4c7b3c5283 # v1.73.1 - name: zizmor uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 From f667060e27a9dd7de6a5f448559c215929b4db70 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:39:03 +0900 Subject: [PATCH 318/351] build(deps): bump https://github.com/astral-sh/ruff-pre-commit (#8542) Bumps [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit) from v0.16.1 to 0.16.2. - [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases) - [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.16.1...v0.16.2) --- updated-dependencies: - dependency-name: https://github.com/astral-sh/ruff-pre-commit dependency-version: 0.16.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9ffc4b8d4fd..750f649403e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: priority: 0 - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.1 + rev: v0.16.2 hooks: - id: ruff-format priority: 0 From da3f6e8c1fb5387f0beb3194a0e666b83fd9de61 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:39:14 +0900 Subject: [PATCH 319/351] build(deps): bump taiki-e/install-action from 2.85.2 to 2.85.11 (#8543) Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.85.2 to 2.85.11. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/41049aa56687c35e0afa74eed4f09cec4f9afabf...7f4eb899022d8fe70b20c4f3de697aa85c309026) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.85.11 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cron-ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cron-ci.yaml b/.github/workflows/cron-ci.yaml index b93a90e8d94..e3557e3e05f 100644 --- a/.github/workflows/cron-ci.yaml +++ b/.github/workflows/cron-ci.yaml @@ -33,7 +33,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - uses: taiki-e/install-action@41049aa56687c35e0afa74eed4f09cec4f9afabf # v2.85.2 + - uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2.85.11 with: tool: cargo-llvm-cov From 67cf60746ca9f243b8d1dd1c75b0b7d582a9e516 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:39:22 +0900 Subject: [PATCH 320/351] build(deps): bump num-integer in the num group across 1 directory (#8544) Bumps the num group with 1 update in the / directory: [num-integer](https://github.com/rust-num/num-integer). Updates `num-integer` from 0.1.46 to 0.1.47 - [Changelog](https://github.com/rust-num/num-integer/blob/main/RELEASES.md) - [Commits](https://github.com/rust-num/num-integer/compare/num-integer-0.1.46...num-integer-0.1.47) --- updated-dependencies: - dependency-name: num-integer dependency-version: 0.1.47 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: num ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a9181d7c85..fa5e7b52236 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2307,9 +2307,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] From 182175b0c94efc1fa2c189d9e7ac85f29504ac6b Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:19:08 +0900 Subject: [PATCH 321/351] memoryview: count exports, hash the exporter, and index through `__index__` (#8553) * memoryview: index through `__index__` and name the dimension A tuple key was taken apart only when every item was already an `int`, so `m[I(), 2]` with an `__index__` on `I` was reported as an invalid slice key rather than indexed. What kind of key it is now follows from the types in it, the way `is_multiindex` decides, and each item is converted where it sits; an item that answers `__index__` but raises on the way reports that rather than making the whole key invalid. That conversion runs Python, which can release the view, so the multi-dimensional read goes through `unpack_single` and its re-check rather than reading the bytes itself. `IndexError` named the index that was out of bounds where `lookup_dimension` names the dimension it was out of bounds on, counted from one, and the one-dimensional path had a message of its own. Assisted-by: Claude * memoryview: count what a view has exported, and hash the exporter The view kept no count of the buffers taken from it, so `release()` always succeeded, even while something was still reading through an export. It now answers `BufferError` the way `_memory_release` does, and `__exit__` reports the same refusal. `memory_hash` asks the exporter for its hash and throws the answer away, so a view is no more hashable than what it looks at; a read-only view over a `bytearray` hashed here where CPython refuses it. Asking runs Python, so the view counts as exported for the duration and a release attempted from inside that hash is refused rather than obeyed. Assisted-by: Claude * bytes, bytearray, memoryview: measure the hex separator as `len()` does `hex()` took the separator's length from its bytes rather than from the object, so a `bytes` subclass that answers `__len__` was measured by what it holds instead of what it says: `b'abcd'.hex(S(b'::'))` with `__len__` returning 1 was refused where `_Py_strhex_impl` accepts it, and a two-byte answer was accepted where it refuses. The check also came after the `bytes_per_sep == 0` and empty-data shortcuts, so `b'abcd'.hex('::', 0)` never reached it at all. Measuring runs Python, and the bytes to be written out must not be borrowed while it does, so the separator is now resolved from the arguments before the buffer is reached. For a memoryview that also means the view counts as exported for the duration, so a release attempted from inside `__len__` is refused. Assisted-by: Claude * array: refuse to hash an array `array.array` is mutable but inherited `object.__hash__`, so an array could be put in a set or used as a dict key and then changed underneath it. It is unhashable, as `tp_hash = PyObject_HashNotImplemented` makes it. A read-only memoryview over an array now reports the exporter as unhashable too, which is what `memory_hash` asks it for. Assisted-by: Claude * Remove expectedFailure from three memoryview tests test_hash_use_after_free, test_hex_use_after_free and test_use_released_memory pass. Assisted-by: Claude * memoryview: bound the dimensions a cast can name `cast()` accepted a shape of any length, so `mv.cast('B', (1,)*64 + (8,))` built a 65-dimensional view where `memoryview_cast_impl` refuses anything past `PyBUF_MAX_NDIM`. The limit is answered before the shape is looked at any further, as it is there. Assisted-by: Claude --- Lib/test/test_memoryview.py | 3 - crates/stdlib/src/array.rs | 2 +- crates/vm/src/builtins/bytearray.rs | 6 +- crates/vm/src/builtins/bytes.rs | 4 +- crates/vm/src/builtins/memory.rs | 142 +++++++++++------ crates/vm/src/bytes_inner.rs | 88 +++++------ crates/vm/src/protocol/buffer.rs | 6 +- extra_tests/snippets/builtin_memoryview.py | 175 +++++++++++++++++++++ 8 files changed, 328 insertions(+), 98 deletions(-) diff --git a/Lib/test/test_memoryview.py b/Lib/test/test_memoryview.py index 707540f299d..a5150d25dcd 100644 --- a/Lib/test/test_memoryview.py +++ b/Lib/test/test_memoryview.py @@ -387,7 +387,6 @@ def test_hash_writable(self): m = self._view(b) self.assertRaises(ValueError, hash, m) - @unittest.expectedFailure # TODO: RUSTPYTHON; re-entrant buffer release not detected def test_hash_use_after_free(self): # Prevent crash in memoryview(v).__hash__ with re-entrant v.__hash__. # Regression test for https://github.com/python/cpython/issues/142664. @@ -457,7 +456,6 @@ def test_issue22668(self): self.assertEqual(c.format, "H") self.assertEqual(d.format, "H") - @unittest.expectedFailure # TODO: RUSTPYTHON; re-entrant buffer release not detected def test_hex_use_after_free(self): # Prevent UAF in memoryview.hex(sep) with re-entrant sep.__len__. # Regression test for https://github.com/python/cpython/issues/143195. @@ -694,7 +692,6 @@ def test_pickle(self): with self.assertRaises(TypeError): pickle.dumps(m, proto) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: ValueError not raised def test_use_released_memory(self): # gh-92888: Previously it was possible to use a memoryview even after # backing buffer is freed in certain cases. This tests that those diff --git a/crates/stdlib/src/array.rs b/crates/stdlib/src/array.rs index b1fa925e16d..7ecd8f4fd9f 100644 --- a/crates/stdlib/src/array.rs +++ b/crates/stdlib/src/array.rs @@ -670,7 +670,7 @@ pub mod array { #[pyattr] #[pyattr(name = "ArrayType")] - #[pyclass(name = "array")] + #[pyclass(name = "array", unhashable = true)] #[derive(Debug, PyPayload)] pub struct PyArray { array: PyRwLock, diff --git a/crates/vm/src/builtins/bytearray.rs b/crates/vm/src/builtins/bytearray.rs index 93046b4932e..c8782127b3f 100644 --- a/crates/vm/src/builtins/bytearray.rs +++ b/crates/vm/src/builtins/bytearray.rs @@ -320,8 +320,10 @@ impl PyByteArray { #[pymethod] fn hex(&self, options: ByteInnerHexOptions, vm: &VirtualMachine) -> PyResult { - let ByteInnerHexOptions { sep, bytes_per_sep } = options; - self.inner().hex(sep, bytes_per_sep, vm) + // Measuring the separator runs Python, so it happens before the buffer + // is borrowed. + let (sep, bytes_per_sep) = options.resolve(vm)?; + Ok(self.inner().hex(sep, bytes_per_sep)) } #[pyclassmethod] diff --git a/crates/vm/src/builtins/bytes.rs b/crates/vm/src/builtins/bytes.rs index 48c0e431229..bfa2fd3545b 100644 --- a/crates/vm/src/builtins/bytes.rs +++ b/crates/vm/src/builtins/bytes.rs @@ -327,8 +327,8 @@ impl PyBytes { options: ByteInnerHexOptions, vm: &VirtualMachine, ) -> PyResult { - let ByteInnerHexOptions { sep, bytes_per_sep } = options; - self.inner.hex(sep, bytes_per_sep, vm) + let (sep, bytes_per_sep) = options.resolve(vm)?; + Ok(self.inner.hex(sep, bytes_per_sep)) } #[pyclassmethod] diff --git a/crates/vm/src/builtins/memory.rs b/crates/vm/src/builtins/memory.rs index a4c29fe443c..0b04de133e7 100644 --- a/crates/vm/src/builtins/memory.rs +++ b/crates/vm/src/builtins/memory.rs @@ -32,6 +32,9 @@ use crossbeam_utils::atomic::AtomicCell; use itertools::Itertools; use rustpython_common::lock::PyMutex; +/// The most dimensions a view can describe. PyBUF_MAX_NDIM +const MAX_NDIM: usize = 64; + #[derive(FromArgs)] pub struct PyMemoryViewNewArgs { object: PyObjectRef, @@ -59,9 +62,10 @@ pub struct PyMemoryView { // memoryview's options could be different from buffer's options desc: BufferDescriptor, hash: OnceCell, - // exports - // memoryview has no exports count by itself - // instead it relay on the buffer it viewing to maintain the count + /// Buffers handed out of this view that have not been given back yet. The + /// view cannot be released while any of them is outstanding, so that what + /// reads them keeps reading memory that is still there. self->exports + exports: AtomicCell, } impl Constructor for PyMemoryView { @@ -146,6 +150,7 @@ impl PyMemoryView { format_spec, desc, hash: OnceCell::new(), + exports: AtomicCell::new(0), }) } @@ -174,6 +179,7 @@ impl PyMemoryView { format_spec: self.format_spec.clone(), desc: self.desc.clone(), hash: OnceCell::new(), + exports: AtomicCell::new(0), } } @@ -189,6 +195,7 @@ impl PyMemoryView { format_spec: self.format_spec.clone(), desc: self.desc.clone(), hash: OnceCell::new(), + exports: AtomicCell::new(0), } } @@ -274,9 +281,10 @@ impl PyMemoryView { ); } let (shape, _, _) = self.desc.dim_desc[0]; + // ptr_from_index let index = i .wrapped_at(shape) - .ok_or_else(|| vm.new_index_error("index out of range"))?; + .ok_or_else(|| vm.new_index_error("index out of bounds on dimension 1"))?; self.unpack_single(self.desc.fast_position(&[index]) as usize, vm) } @@ -291,12 +299,7 @@ impl PyMemoryView { fn getitem_by_multi_idx(&self, indexes: &[isize], vm: &VirtualMachine) -> PyResult { let pos = self.pos_from_multi_index(indexes, vm)?; - let bytes = self.buffer.obj_bytes(); - format_unpack( - &self.format_spec, - &bytes[pos..pos + self.format_spec.size()], - vm, - ) + self.unpack_single(pos, vm) } fn setitem_by_idx(&self, i: isize, value: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { @@ -304,9 +307,10 @@ impl PyMemoryView { return Err(vm.new_not_implemented_error("sub-views are not implemented")); } let (shape, _, _) = self.desc.dim_desc[0]; + // ptr_from_index let index = i .wrapped_at(shape) - .ok_or_else(|| vm.new_index_error("index out of range"))?; + .ok_or_else(|| vm.new_index_error("index out of bounds on dimension 1"))?; self.pack_single(self.desc.fast_position(&[index]) as usize, value, vm) } @@ -673,13 +677,37 @@ impl PyMemoryView { Self::from_object_with_flags(&args.object, flags, vm).map(|mv| mv.into_ref(&vm.ctx)) } - #[pymethod] + #[pymethod(name = "release")] + fn py_release(&self, vm: &VirtualMachine) -> PyResult<()> { + // _memory_release: what still reads this view holds it open. + let exports = self.exports.load(); + if !self.released.load() && exports > 0 { + let plural = if exports == 1 { "" } else { "s" }; + return Err( + vm.new_buffer_error(format!("memoryview has {exports} exported buffer{plural}")) + ); + } + self.release(); + Ok(()) + } + + /// Give up the view's share without asking whether anything is reading it. + /// The teardown paths have nowhere to report a refusal. pub fn release(&self) { if self.released.compare_exchange(false, true).is_ok() { self.buffer.release(); } } + /// Count this view as exported while `f` runs, so Python reached from inside + /// it cannot release the memory being read out from under it. + fn while_exported(&self, f: impl FnOnce() -> R) -> R { + self.exports.fetch_add(1); + let result = f(); + self.exports.fetch_sub(1); + result + } + #[pygetset] fn obj(&self, vm: &VirtualMachine) -> PyResult { self.try_not_released(vm)?; @@ -785,9 +813,10 @@ impl PyMemoryView { zelf.try_not_released(vm).map(|_| zelf) } + // memory_exit #[pymethod] - fn __exit__(&self, _args: FuncArgs) { - self.release(); + fn __exit__(&self, _args: FuncArgs, vm: &VirtualMachine) -> PyResult<()> { + self.py_release(vm) } fn __getitem__(zelf: PyRef, needle: PyObjectRef, vm: &VirtualMachine) -> PyResult { @@ -888,9 +917,12 @@ impl PyMemoryView { #[pymethod] fn hex(&self, options: ByteInnerHexOptions, vm: &VirtualMachine) -> PyResult { - let ByteInnerHexOptions { sep, bytes_per_sep } = options; self.try_not_released(vm)?; - self.contiguous_or_collect(|x| bytes_to_hex(x, sep, bytes_per_sep, vm)) + // Measuring the separator runs Python, which must not release the bytes + // being written out. memoryview_hex_impl + let (sep, bytes_per_sep) = self.while_exported(|| options.resolve(vm))?; + self.try_not_released(vm)?; + Ok(self.contiguous_or_collect(|x| bytes_to_hex(x, sep, bytes_per_sep))) } #[pymethod] @@ -984,6 +1016,7 @@ impl PyMemoryView { dim_desc: vec![(self.desc.len / itemsize, itemsize as isize, 0)], }, hash: OnceCell::new(), + exports: AtomicCell::new(0), }; Ok(zelf) } @@ -1020,7 +1053,11 @@ impl PyMemoryView { }; let shape_ndim = shape.len(); - // TODO: MAX_NDIM + if shape_ndim > MAX_NDIM { + return Err(vm.new_value_error(format!( + "memoryview: number of dimensions must not exceed {MAX_NDIM}" + ))); + } if self.desc.ndim() != 1 && shape_ndim != 1 { return Err(vm.new_type_error("memoryview: cast must be 1D -> ND or ND -> 1D")); } @@ -1158,34 +1195,36 @@ enum SubscriptNeedle { // MultiSlice(Vec), } +/// memory_subscript +/// +/// Which kind of key this is follows from the types in it alone, so an item that +/// answers `__index__` but raises on the way reports that rather than making the +/// whole key invalid. is_multiindex / is_multislice impl TryFromObject for SubscriptNeedle { fn try_from_object(vm: &VirtualMachine, obj: PyObjectRef) -> PyResult { - // TODO: number protocol - if let Some(i) = obj.downcast_ref::() { - Ok(Self::Index(i.try_to_primitive(vm)?)) - } else if obj.downcastable::() { - Ok(Self::Slice(unsafe { obj.downcast_unchecked::() })) - } else if let Ok(i) = obj.try_index(vm) { - Ok(Self::Index(i.try_to_primitive(vm)?)) - } else { - if let Some(tuple) = obj.downcast_ref::() { - if tuple.iter().all(|x| x.downcastable::()) { - let v = tuple - .iter() - .map(|x| { - unsafe { x.downcast_unchecked_ref::() } - .try_to_primitive::(vm) - }) - .try_collect()?; - return Ok(Self::MultiIndex(v)); - } else if tuple.iter().all(|x| x.downcastable::()) { - return Err(vm.new_not_implemented_error( - "multi-dimensional slicing is not implemented", - )); - } + if obj.number().is_index() { + return Ok(Self::Index(obj.try_index(vm)?.try_to_primitive(vm)?)); + } + if obj.downcastable::() { + return Ok(Self::Slice(unsafe { obj.downcast_unchecked::() })); + } + if let Some(tuple) = obj.downcast_ref::() { + if tuple.iter().all(|x| x.number().is_index()) { + // ptr_from_tuple: each item is converted where it sits, and the + // conversion can run Python that releases the view. + let indices = tuple + .iter() + .map(|x| x.try_index(vm)?.try_to_primitive::(vm)) + .try_collect()?; + return Ok(Self::MultiIndex(indices)); + } + if tuple.iter().all(|x| x.downcastable::()) { + return Err( + vm.new_not_implemented_error("multi-dimensional slicing is not implemented") + ); } - Err(vm.new_type_error("memoryview: invalid slice key")) } + Err(vm.new_type_error("memoryview: invalid slice key")) } } @@ -1193,9 +1232,18 @@ static BUFFER_METHODS: BufferMethods = BufferMethods { obj_bytes: |buffer| buffer.obj_as::().buffer.obj_bytes(), obj_bytes_mut: |buffer| buffer.obj_as::().buffer.obj_bytes_mut(), // memory_releasebuf / memory_getbuf: a consumer's export of this view is a - // share of the acquisition the view is looking at. - release: |buffer| buffer.obj_as::().buffer.release_share(), - retain: |buffer| buffer.obj_as::().buffer.retain_share(), + // share of the acquisition the view is looking at, and one more reason the + // view itself cannot be released. + release: |buffer| { + let mv = buffer.obj_as::(); + mv.exports.fetch_sub(1); + mv.buffer.release_share(); + }, + retain: |buffer| { + let mv = buffer.obj_as::(); + mv.exports.fetch_add(1); + mv.buffer.retain_share(); + }, }; impl AsBuffer for PyMemoryView { @@ -1308,6 +1356,12 @@ impl Hashable for PyMemoryView { vm.new_value_error("memoryview: hashing is restricted to formats 'B', 'b' or 'c'") ); } + // A view is no more hashable than what it looks at, and asking that runs + // Python, which must not release the memory the hash is taken over. + // memory_hash + if !zelf.buffer.obj.downcastable::() { + zelf.while_exported(|| zelf.buffer.obj.hash(vm))?; + } let val = zelf.contiguous_or_collect(|bytes| vm.state.hash_secret.hash_bytes(bytes)); let _ = zelf.hash.set(val); Ok(*zelf.hash.get().unwrap()) diff --git a/crates/vm/src/bytes_inner.rs b/crates/vm/src/bytes_inner.rs index 65a9dc0a01c..e16c636a964 100644 --- a/crates/vm/src/bytes_inner.rs +++ b/crates/vm/src/bytes_inner.rs @@ -497,13 +497,8 @@ impl PyBytesInner { swapcase_ascii(self.as_bytes()) } - pub fn hex( - &self, - sep: OptionalArg>, - bytes_per_sep: OptionalArg, - vm: &VirtualMachine, - ) -> PyResult { - bytes_to_hex(self.elements.as_slice(), sep, bytes_per_sep, vm) + pub fn hex(&self, sep: Option, bytes_per_sep: OptionalArg) -> String { + bytes_to_hex(self.elements.as_slice(), sep, bytes_per_sep) } pub fn fromhex(bytes: &[u8], vm: &VirtualMachine) -> PyResult> { @@ -1211,6 +1206,42 @@ pub(crate) struct ByteInnerHexOptions { pub bytes_per_sep: OptionalArg, } +impl ByteInnerHexOptions { + /// The separator byte, and how many bytes go between two of them. + /// + /// Measuring the separator runs Python, so it happens here, before the + /// bytes to be written out are borrowed. _Py_strhex_impl + pub(crate) fn resolve(self, vm: &VirtualMachine) -> PyResult<(Option, OptionalArg)> { + let Self { sep, bytes_per_sep } = self; + let OptionalArg::Present(sep) = sep else { + return Ok((None, bytes_per_sep)); + }; + + let s_guard; + let b_guard; + let (obj, bytes) = match &sep { + Either::A(s) => { + s_guard = s.as_wtf8(); + (s.as_object(), s_guard.as_bytes()) + } + Either::B(b) => { + b_guard = b.as_bytes(); + (b.as_object(), b_guard) + } + }; + if obj.length(vm)? != 1 { + return Err(vm.new_value_error("sep must be length 1.")); + } + // An object that claims a length it does not have separates with NUL, + // which is what reading past the end of its data gives. + let sep = bytes.first().copied().unwrap_or(0); + if sep > 127 { + return Err(vm.new_value_error("sep must be ASCII.")); + } + Ok((Some(sep), bytes_per_sep)) + } +} + fn hex_impl_no_sep(bytes: &[u8]) -> String { let mut buf: Vec = vec![0; bytes.len() * 2]; hex::encode_to_slice(bytes, buf.as_mut_slice()).unwrap(); @@ -1265,44 +1296,13 @@ fn hex_impl(bytes: &[u8], sep: u8, bytes_per_sep: isize) -> String { pub(crate) fn bytes_to_hex( bytes: &[u8], - sep: OptionalArg>, + sep: Option, bytes_per_sep: OptionalArg, - vm: &VirtualMachine, -) -> PyResult { - if bytes.is_empty() { - return Ok("".to_owned()); - } - - if let OptionalArg::Present(sep) = sep { - let bytes_per_sep = bytes_per_sep.unwrap_or(1); - if bytes_per_sep == 0 { - return Ok(hex_impl_no_sep(bytes)); - } - - let s_guard; - let b_guard; - let sep = match &sep { - Either::A(s) => { - s_guard = s.as_wtf8(); - s_guard.as_bytes() - } - Either::B(bytes) => { - b_guard = bytes.as_bytes(); - b_guard - } - }; - - if sep.len() != 1 { - return Err(vm.new_value_error("sep must be length 1.")); - } - let sep = sep[0]; - if sep > 127 { - return Err(vm.new_value_error("sep must be ASCII.")); - } - - Ok(hex_impl(bytes, sep, bytes_per_sep)) - } else { - Ok(hex_impl_no_sep(bytes)) +) -> String { + let bytes_per_sep = bytes_per_sep.unwrap_or(1); + match sep { + Some(sep) if bytes_per_sep != 0 && !bytes.is_empty() => hex_impl(bytes, sep, bytes_per_sep), + _ => hex_impl_no_sep(bytes), } } diff --git a/crates/vm/src/protocol/buffer.rs b/crates/vm/src/protocol/buffer.rs index 050c568b7ac..9a184ee853f 100644 --- a/crates/vm/src/protocol/buffer.rs +++ b/crates/vm/src/protocol/buffer.rs @@ -608,13 +608,15 @@ impl BufferDescriptor { /// panic if indices.len() != ndim pub fn position(&self, indices: &[isize], vm: &VirtualMachine) -> PyResult { let mut pos = self.offset; - for (i, (shape, stride, suboffset)) in indices + for (dim, (i, (shape, stride, suboffset))) in indices .iter() .copied() .zip_eq(self.dim_desc.iter().copied()) + .enumerate() { + // The dimension is named the way a person counts it. lookup_dimension let i = i.wrapped_at(shape).ok_or_else(|| { - vm.new_index_error(format!("index out of bounds on dimension {i}")) + vm.new_index_error(format!("index out of bounds on dimension {}", dim + 1)) })?; pos += i as isize * stride + suboffset; } diff --git a/extra_tests/snippets/builtin_memoryview.py b/extra_tests/snippets/builtin_memoryview.py index 34928041cd2..979f584a2b1 100644 --- a/extra_tests/snippets/builtin_memoryview.py +++ b/extra_tests/snippets/builtin_memoryview.py @@ -675,3 +675,178 @@ def test_tobytes_order(): test_cast_to_zero_dim() test_hash_restricted_to_byte_formats() test_tobytes_order() + + +def test_index_key_goes_through_index(): + class I: + def __index__(self): + return 1 + + c = memoryview(bytes(range(24))).cast("B", shape=(4, 6)) + assert c[I(), 2] == 8 + assert c[1, I()] == 7 + assert memoryview(b"\x00\x01")[(I(),)] == 1 + + w = memoryview(bytearray(range(24))).cast("B", shape=(4, 6)) + w[I(), 2] = 99 + assert w[1, 2] == 99 + + # What the key is follows from its types, so an item that answers + # __index__ but raises reports that rather than the key being invalid. + class Boom: + def __index__(self): + raise RuntimeError("boom") + + for key in [Boom(), (Boom(), 0), (0, Boom())]: + try: + c[key] + raise AssertionError("no error") + except RuntimeError as e: + assert str(e) == "boom", e + + try: + c[object(), 0] + raise AssertionError("no error") + except TypeError as e: + assert "invalid slice key" in str(e), e + + +test_index_key_goes_through_index() + + +def test_index_error_names_the_dimension(): + c = memoryview(bytearray(range(24))).cast("B", shape=(2, 3, 4)) + for key, dimension in [((0, 3, 0), 2), ((0, 0, -5), 3), ((2, 0, 0), 1)]: + try: + c[key] + raise AssertionError("no error") + except IndexError as e: + assert str(e) == f"index out of bounds on dimension {dimension}", e + + try: + memoryview(bytearray(range(4)))[100] + raise AssertionError("no error") + except IndexError as e: + assert str(e) == "index out of bounds on dimension 1", e + + +test_index_error_names_the_dimension() + + +def test_release_refuses_while_exported(): + ba = bytearray(b"abc") + mv = memoryview(ba) + exported = mv.__buffer__(0) + for release in [lambda: mv.release(), lambda: mv.__exit__()]: + try: + release() + raise AssertionError("released while exported") + except BufferError as e: + assert str(e) == "memoryview has 1 exported buffer", e + del exported + mv.release() + mv.release() + + +test_release_refuses_while_exported() + + +def test_hash_asks_the_exporter(): + # A view is no more hashable than what it looks at. + assert hash(memoryview(b"abc")) == hash(b"abc") + try: + hash(memoryview(bytearray(b"abc")).toreadonly()) + raise AssertionError("hashed a view on an unhashable exporter") + except TypeError as e: + assert "unhashable type: 'bytearray'" in str(e), e + + # Releasing the view from inside that hash is refused rather than obeyed. + class E(bytes): + def __hash__(self): + mv.release() + return 123 + + mv = memoryview(E(b"abcd")) + try: + hash(mv) + raise AssertionError("released the view being hashed") + except BufferError as e: + assert str(e) == "memoryview has 1 exported buffer", e + + +test_hash_asks_the_exporter() + + +def test_hex_measures_the_separator(): + # The separator is measured the way len() measures it, and read where it lies. + class One(bytes): + def __len__(self): + return 1 + + class Two(bytes): + def __len__(self): + return 2 + + for target in [b"abcd", bytearray(b"abcd"), memoryview(b"abcd")]: + assert target.hex(One(b"::")) == "61:62:63:64" + assert target.hex(b":") == "61:62:63:64" + # An object claiming a length it does not have separates with NUL. + assert target.hex(One(b"")) == "61\x0062\x0063\x0064" + for bad in [Two(b":"), b"::"]: + try: + target.hex(bad) + raise AssertionError("no error") + except ValueError as e: + assert str(e) == "sep must be length 1.", e + # The separator is checked before anything is written out. + try: + target.hex(b"::", 0) + raise AssertionError("no error") + except ValueError as e: + assert str(e) == "sep must be length 1.", e + + assert b"".hex(b":") == "" + try: + b"".hex(b"::") + raise AssertionError("no error") + except ValueError as e: + assert str(e) == "sep must be length 1.", e + + # Releasing the view from inside that measurement is refused. + ba = bytearray(b"A" * 8) + mv = memoryview(ba) + + class S(bytes): + def __len__(self): + mv.release() + return 1 + + try: + mv.hex(S(b":")) + raise AssertionError("released the view being written out") + except BufferError as e: + assert str(e) == "memoryview has 1 exported buffer", e + + +test_hex_measures_the_separator() + + +def test_cast_bounds_the_dimensions(): + mv = memoryview(bytearray(range(8))) + assert mv.cast("B", (1,) * 63 + (8,)).ndim == 64 + for shape in [(1,) * 64 + (8,), (1,) * 99 + (8,), [1] * 64 + [8]]: + try: + mv.cast("B", shape) + raise AssertionError("cast past the limit") + except ValueError as e: + assert str(e) == "memoryview: number of dimensions must not exceed 64", e + + # The limit is answered before the shape is looked at any further. + try: + mv.cast("B", (2, 4)).cast("B", (1,) * 64 + (8,)) + raise AssertionError("cast past the limit") + except ValueError as e: + assert str(e) == "memoryview: number of dimensions must not exceed 64", e + + +test_cast_bounds_the_dimensions() From 189bc9714878edecf32a7c41b94b6e3c69b4923f Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:21:18 +0900 Subject: [PATCH 322/351] marshal: let runtime bags preserve raw code bytes (#8552) --- crates/compiler-core/src/marshal.rs | 31 +++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/compiler-core/src/marshal.rs b/crates/compiler-core/src/marshal.rs index 754854e7cba..d73b51f45b4 100644 --- a/crates/compiler-core/src/marshal.rs +++ b/crates/compiler-core/src/marshal.rs @@ -586,6 +586,33 @@ pub trait MarshalBag: Copy { self.make_code(code) } + /// Decode the public `co_code` byte string into the execution-oriented + /// instruction storage. + /// + /// Runtime code-object implementations may accept byte values that the + /// compiler's `Instruction` enum cannot represent, retaining those bytes + /// separately and substituting a non-executable placeholder here. The + /// default remains the strict compiler representation. + fn code_units_from_bytes(&self, code_bytes: &[u8]) -> Result { + CodeUnits::try_from(code_bytes) + } + + /// Construct a runtime code object while retaining both its exact public + /// `co_code` bytes and the exact values read from `co_consts`. + /// + /// The default ignores the redundant byte spelling because ordinary + /// compiler code is represented losslessly by `CodeUnits`. Runtime bags + /// that accepted otherwise unrepresentable opcode bytes in + /// [`MarshalBag::code_units_from_bytes`] can preserve them here. + fn make_code_with_constants_and_bytes( + &self, + code: CodeObject<::Constant>, + constants: Vec, + _code_bytes: Vec, + ) -> Result { + self.make_code_with_constants(code, constants) + } + fn make_stop_iter(&self) -> Result; fn make_list(&self, it: impl Iterator) -> Result; @@ -967,7 +994,7 @@ fn deserialize_code_value_inner( kwonlyarg_count, flags, )?; - let instructions = CodeUnits::try_from(code_bytes.as_slice())?; + let instructions = bag.code_units_from_bytes(&code_bytes)?; let locations = linetable_to_locations(&linetable, first_line_raw, instructions.len()); let constant_bag = bag.constant_bag(); let code = CodeObject { @@ -1006,7 +1033,7 @@ fn deserialize_code_value_inner( linetable, exceptiontable, }; - bag.make_code_with_constants(code, constant_values) + bag.make_code_with_constants_and_bytes(code, constant_values, code_bytes) } fn deserialize_value_typed( From bf464874b57c379d78325ca86d7ea227a913b8e6 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:22:13 +0900 Subject: [PATCH 323/351] common: distinguish printf width and precision overflow (#8549) Assisted-by: Codex:gpt-5.6-sol --- crates/common/src/cformat.rs | 60 ++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/crates/common/src/cformat.rs b/crates/common/src/cformat.rs index d71b0a09a42..db11e0a339f 100644 --- a/crates/common/src/cformat.rs +++ b/crates/common/src/cformat.rs @@ -20,7 +20,8 @@ pub enum CFormatErrorType { MissingModuloSign, UnsupportedFormatChar(CodePoint), IncompleteFormat, - IntTooBig, + WidthTooBig, + PrecisionTooBig, // Unimplemented, } @@ -45,7 +46,8 @@ impl fmt::Display for CFormatError { c.to_u32(), self.index ), - CFormatErrorType::IntTooBig => write!(f, "width/precision too big"), + CFormatErrorType::WidthTooBig => write!(f, "width too big"), + CFormatErrorType::PrecisionTooBig => write!(f, "precision too big"), _ => write!(f, "unexpected error parsing format string"), } } @@ -307,7 +309,8 @@ impl CFormatSpecKeyed { { let mapping_key = parse_spec_mapping_key(iter)?; let flags = parse_flags(iter); - let min_field_width = parse_quantity(iter)?; + let min_field_width = + parse_quantity(iter, isize::MAX as usize, CFormatErrorType::WidthTooBig)?; let precision = parse_precision(iter)?; consume_length(iter); let format_type = parse_format_type(iter)?; @@ -649,7 +652,11 @@ where }) } -fn parse_quantity(iter: &mut ParseIter) -> Result, ParsingError> +fn parse_quantity( + iter: &mut ParseIter, + max_value: usize, + too_big: CFormatErrorType, +) -> Result, ParsingError> where C: FormatChar, I: Iterator, @@ -660,20 +667,21 @@ where return Ok(Some(CFormatQuantity::FromValuesTuple)); } if let Some(i) = c.to_char_lossy().to_digit(10) { - let mut num = i as i32; + let mut num = i as usize; iter.next().unwrap(); while let Some(&(index, c)) = iter.peek() { if let Some(i) = c.to_char_lossy().to_digit(10) { num = num .checked_mul(10) - .and_then(|num| num.checked_add(i as i32)) - .ok_or((CFormatErrorType::IntTooBig, index))?; + .and_then(|num| num.checked_add(i as usize)) + .filter(|&num| num <= max_value) + .ok_or((too_big, index))?; iter.next().unwrap(); } else { break; } } - return Ok(Some(CFormatQuantity::Amount(num.unsigned_abs() as usize))); + return Ok(Some(CFormatQuantity::Amount(num))); } } Ok(None) @@ -685,7 +693,7 @@ where I: Iterator, { if iter.next_if(|(_, c)| c.eq_char('.')).is_some() { - let quantity = parse_quantity(iter)?; + let quantity = parse_quantity(iter, i32::MAX as usize, CFormatErrorType::PrecisionTooBig)?; let precision = quantity.map_or(CFormatPrecision::Dot, CFormatPrecision::Quantity); return Ok(Some(precision)); } @@ -961,6 +969,40 @@ mod tests { ); } + #[test] + fn width_and_precision_have_distinct_limits_and_errors() { + let precision = "%.2147483648f".parse::().unwrap_err(); + assert_eq!(precision.0, CFormatErrorType::PrecisionTooBig); + assert_eq!( + CFormatError { + typ: precision.0, + index: precision.1, + } + .to_string(), + "precision too big" + ); + + let oversized_width = format!("%{}f", isize::MAX as u128 + 1); + let width = oversized_width.parse::().unwrap_err(); + assert_eq!(width.0, CFormatErrorType::WidthTooBig); + assert_eq!( + CFormatError { + typ: width.0, + index: width.1, + } + .to_string(), + "width too big" + ); + + if usize::BITS > 32 { + let spec = "%2147483648f".parse::().unwrap(); + assert_eq!( + spec.min_field_width, + Some(CFormatQuantity::Amount(2_147_483_648)) + ); + } + } + #[test] fn parse_flags() { let expected = Ok(CFormatSpec { From fdd101b896d4aeaaafe5889da9cb5daa494084d1 Mon Sep 17 00:00:00 2001 From: YujinBae Date: Tue, 18 Aug 2026 22:24:02 +0900 Subject: [PATCH 324/351] Fix int unicode decimal digits (#8521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Accept Unicode decimal digits in int(), Decimal() and complex() CPython runs a string argument through _PyUnicode_TransformDecimalAndSpaceToASCII before parsing it, so decimal digits from any script are accepted: int('١٢٣') # 123 int('0x١f', 16) # 31 Decimal('١٢٣') # Decimal('123') complex('1+2j') # (1+2j) RustPython only did this for float(), which had the transform inlined. int() handed the raw UTF-8 bytes to bytes_to_int(), whose digit check is is_ascii_alphanumeric(), so every non-ASCII digit was rejected — even though float() accepted the same string. Lift the inlined transform out of float_from_string() into common::str::transform_decimal_and_space_to_ascii() and apply it to the str paths of int() and complex() too. The result is always ASCII: as in CPython, a character that is neither ASCII, whitespace nor a decimal digit becomes '?' and truncates the string, which no parser accepts at any base, leaving the caller to raise the error from the original string. Bytes-like input keeps going straight to the parser, matching CPython's split between PyLong_FromUnicodeObject and PyLong_FromString. This unmarks two expectedFailure tests: test_int.test_unicode and test_decimal.test_unicode_digits. * Share one PyStr-to-numeric-literal step across int, float and complex All three constructors need the same thing from a str argument: trim it, fold Unicode decimal digits and whitespace to ASCII, and give up on a string holding surrogates. Each expressed that last part differently — float matched PyKindStr and returned b"", complex leaned on to_str() returning None, int returned an empty Cow — so the rule lived in three places at once. Move it into protocol::numeric_literal_from_str() and have all three call it. CPython repeats this per type because its wrapper is three lines over a single PyUnicode representation; ours has to match over Ascii/Utf8/Wtf8, which is worth writing once. Only the shared step moves. int keeps its base handling, int and float keep accepting bytes-like input, complex keeps rejecting it, and each keeps raising its own error, because none of that is shared. No behavior change: the CPython differential suite is byte-identical before and after. * Drop the now-empty test_unicode_digits override in test_decimal Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 Co-authored-by: Jeong, YunWon <69878+youknowone@users.noreply.github.com> --- Lib/test/test_decimal.py | 4 --- Lib/test/test_int.py | 1 - crates/common/src/str.rs | 54 +++++++++++++++++++++++++++++++ crates/vm/src/builtins/complex.rs | 8 ++--- crates/vm/src/builtins/float.rs | 25 ++------------ crates/vm/src/builtins/int.rs | 4 +-- crates/vm/src/protocol/mod.rs | 1 + crates/vm/src/protocol/number.rs | 27 ++++++++++++++-- 8 files changed, 88 insertions(+), 36 deletions(-) diff --git a/Lib/test/test_decimal.py b/Lib/test/test_decimal.py index 47ab0a79702..c621b7ac08c 100644 --- a/Lib/test/test_decimal.py +++ b/Lib/test/test_decimal.py @@ -854,10 +854,6 @@ class CExplicitConstructionTest(ExplicitConstructionTest, unittest.TestCase): class PyExplicitConstructionTest(ExplicitConstructionTest, unittest.TestCase): decimal = P - @unittest.expectedFailure # TODO: RUSTPYTHON - def test_unicode_digits(self): - return super().test_unicode_digits() - class ImplicitConstructionTest: '''Unit tests for Implicit Construction cases of Decimal.''' diff --git a/Lib/test/test_int.py b/Lib/test/test_int.py index a18683098e1..e281763a6c9 100644 --- a/Lib/test/test_int.py +++ b/Lib/test/test_int.py @@ -247,7 +247,6 @@ def test_invalid_signs(self): with self.assertRaises(ValueError): int(' + 1 ') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_unicode(self): self.assertEqual(int("१२३४५६७८९०1234567890"), 12345678901234567890) self.assertEqual(int('١٢٣٤٥٦٧٨٩٠'), 1234567890) diff --git a/crates/common/src/str.rs b/crates/common/src/str.rs index 39ec7da1de5..c649c057de6 100644 --- a/crates/common/src/str.rs +++ b/crates/common/src/str.rs @@ -3,6 +3,7 @@ use crate::atomic::{OncePtr, PyAtomic, Radium}; use crate::format::CharLen; use crate::wtf8::{CodePoint, Wtf8, Wtf8Buf}; use crate::wtf8_index::Wtf8Index; +use alloc::borrow::Cow; use ascii::{AsciiChar, AsciiStr, AsciiString}; use core::fmt; use core::ops::{Bound, RangeBounds}; @@ -835,10 +836,63 @@ pub fn char_to_decimal(ch: char) -> Option { .map(|i| (i % 10) as u8) } +/// Replace Unicode decimal digits with their ASCII equivalents and any Unicode +/// whitespace with a plain space, so the byte-oriented numeric parsers can read +/// them. Mirrors CPython's `_PyUnicode_TransformDecimalAndSpaceToASCII`. +/// +/// The result is always ASCII. Any other non-ASCII character cannot appear in a +/// numeric literal, so it becomes a `?` and the rest of the string is dropped: +/// `?` is rejected by every parser at every base, which leaves the caller — the +/// one that knows the base and owns the original string — to raise the error. +#[must_use] +pub fn transform_decimal_and_space_to_ascii(s: &str) -> Cow<'_, str> { + if s.is_ascii() { + return Cow::Borrowed(s); + } + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if (c as u32) < 127 { + out.push(c); + } else if c.is_whitespace() { + out.push(' '); + } else if let Some(n) = char_to_decimal(c) { + out.push(char::from_digit(n.into(), 10).unwrap()); + } else { + out.push('?'); + break; + } + } + debug_assert!(out.is_ascii()); + Cow::Owned(out) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn transform_decimal_and_space() { + // ASCII input is passed through untouched, without allocating. + assert!(matches!( + transform_decimal_and_space_to_ascii("123"), + Cow::Borrowed("123") + )); + // Decimal digits from any script fold to ASCII. + assert_eq!(transform_decimal_and_space_to_ascii("١٢٣"), "123"); + assert_eq!(transform_decimal_and_space_to_ascii("12३"), "123"); + assert_eq!(transform_decimal_and_space_to_ascii("1٢3"), "123"); + // Unicode whitespace folds to a plain space. + assert_eq!(transform_decimal_and_space_to_ascii("\u{3000}٣"), " 3"); + // ASCII characters ride through untouched, whatever they are. + assert_eq!(transform_decimal_and_space_to_ascii("0x١f"), "0x1f"); + assert_eq!(transform_decimal_and_space_to_ascii("-١_٢"), "-1_2"); + // Anything else poisons the literal and truncates it, so the result stays + // ASCII and the caller's parser is guaranteed to reject it. + assert_eq!(transform_decimal_and_space_to_ascii("½가"), "?"); + assert_eq!(transform_decimal_and_space_to_ascii("١٢가٣"), "12?"); + assert_eq!(transform_decimal_and_space_to_ascii("١\u{7f}"), "1?"); + } + #[test] fn get_chars_basic() { let s = "0123456789"; diff --git a/crates/vm/src/builtins/complex.rs b/crates/vm/src/builtins/complex.rs index c54b3bc1731..7dcbedf7e17 100644 --- a/crates/vm/src/builtins/complex.rs +++ b/crates/vm/src/builtins/complex.rs @@ -220,10 +220,10 @@ impl Constructor for PyComplex { "complex() can't take second arg if first is a string", )); } - let (re, im) = s - .to_str() - .and_then(rustpython_literal::complex::parse_str) - .ok_or_else(|| vm.new_value_error("complex() arg is a malformed string"))?; + let (re, im) = rustpython_literal::complex::parse_str( + &crate::protocol::numeric_literal_from_str(s), + ) + .ok_or_else(|| vm.new_value_error("complex() arg is a malformed string"))?; return Ok(Self::from(Complex64 { re, im })); } else { return Err(vm.new_type_error(format!( diff --git a/crates/vm/src/builtins/float.rs b/crates/vm/src/builtins/float.rs index 0b739694623..6a646440ec2 100644 --- a/crates/vm/src/builtins/float.rs +++ b/crates/vm/src/builtins/float.rs @@ -210,29 +210,8 @@ impl Constructor for PyFloat { pub fn float_from_string(val: PyObjectRef, vm: &VirtualMachine) -> PyResult { let (bytearray, buffer, buffer_lock, mapped_string); let b = if let Some(s) = val.downcast_ref::() { - use crate::common::str::PyKindStr; - match s.as_str_kind() { - PyKindStr::Ascii(s) => s.trim().as_bytes(), - PyKindStr::Utf8(s) => { - mapped_string = s - .trim() - .chars() - .map(|c| { - if let Some(n) = rustpython_common::str::char_to_decimal(c) { - char::from_digit(n.into(), 10).unwrap() - } else if c.is_whitespace() { - ' ' - } else { - c - } - }) - .collect::(); - mapped_string.as_bytes() - } - // if there are surrogates, it's not gonna parse anyway, - // so we can just choose a known bad value - PyKindStr::Wtf8(_) => b"", - } + mapped_string = crate::protocol::numeric_literal_from_str(s); + mapped_string.as_bytes() } else if let Some(bytes) = val.downcast_ref::() { bytes.as_bytes() } else if let Some(buf) = val.downcast_ref::() { diff --git a/crates/vm/src/builtins/int.rs b/crates/vm/src/builtins/int.rs index 60463ed0d58..bb7b5128073 100644 --- a/crates/vm/src/builtins/int.rs +++ b/crates/vm/src/builtins/int.rs @@ -16,7 +16,7 @@ use crate::{ ArgByteOrder, ArgIntoBool, FuncArgs, OptionalArg, OptionalOption, PyArithmeticValue, PyComparisonValue, }, - protocol::{PyNumberMethods, handle_bytes_to_int_err}, + protocol::{PyNumberMethods, handle_bytes_to_int_err, numeric_literal_from_str}, types::{AsNumber, Comparable, Constructor, Hashable, PyComparisonOp, Representable}, }; use alloc::fmt; @@ -822,7 +822,7 @@ struct IntToByteArgs { fn try_int_radix(obj: &PyObject, base: u32, vm: &VirtualMachine) -> PyResult { match_class!(match obj.to_owned() { string @ PyStr => { - let s = string.as_wtf8().trim(); + let s = numeric_literal_from_str(&string); bytes_to_int(s.as_bytes(), base, vm.state.int_max_str_digits.load()) .map_err(|e| handle_bytes_to_int_err(e, obj, vm)) } diff --git a/crates/vm/src/protocol/mod.rs b/crates/vm/src/protocol/mod.rs index 4061e06458a..1d191daee31 100644 --- a/crates/vm/src/protocol/mod.rs +++ b/crates/vm/src/protocol/mod.rs @@ -16,5 +16,6 @@ pub use mapping::{PyMapping, PyMappingMethods, PyMappingSlots}; pub use number::{ PyNumber, PyNumberBinaryFunc, PyNumberBinaryOp, PyNumberMethods, PyNumberSlots, PyNumberTernaryFunc, PyNumberTernaryOp, PyNumberUnaryFunc, handle_bytes_to_int_err, + numeric_literal_from_str, }; pub use sequence::{PySequence, PySequenceMethods, PySequenceSlots}; diff --git a/crates/vm/src/protocol/number.rs b/crates/vm/src/protocol/number.rs index 301499aa115..6f566431da7 100644 --- a/crates/vm/src/protocol/number.rs +++ b/crates/vm/src/protocol/number.rs @@ -8,11 +8,34 @@ use crate::{ builtins::{ PyBaseExceptionRef, PyByteArray, PyBytes, PyComplex, PyFloat, PyInt, PyIntRef, PyStr, int, }, - common::int::{BytesToIntError, bytes_to_int}, + common::{ + int::{BytesToIntError, bytes_to_int}, + str::{PyKindStr, transform_decimal_and_space_to_ascii}, + }, function::ArgBytesLike, object::{Traverse, TraverseFn}, stdlib::_warnings, }; +use alloc::borrow::Cow; + +/// Normalize a `str` for the byte-oriented numeric parsers: Unicode decimal digits +/// and whitespace fold to their ASCII equivalents, the way CPython runs every +/// numeric constructor's string argument through +/// `_PyUnicode_TransformDecimalAndSpaceToASCII` first. +/// +/// `int`, `float` and `complex` share this step and nothing else — only `int` takes +/// a base, and only `int` and `float` accept bytes-like input, so each keeps its own +/// entry point around this one. +/// +/// A string holding surrogates can never be a valid literal, so it folds to an +/// empty — and therefore invalid — one. +pub fn numeric_literal_from_str(s: &PyStr) -> Cow<'_, str> { + match s.as_str_kind() { + PyKindStr::Ascii(s) => Cow::Borrowed(s.trim().as_str()), + PyKindStr::Utf8(s) => transform_decimal_and_space_to_ascii(s.trim()), + PyKindStr::Wtf8(_) => Cow::Borrowed(""), + } +} pub type PyNumberUnaryFunc = fn(PyNumber<'_>, &VirtualMachine) -> PyResult; pub type PyNumberBinaryFunc = fn(&PyObject, &PyObject, &VirtualMachine) -> PyResult; @@ -59,7 +82,7 @@ impl PyObject { } else if let Some(i) = self.number().int(vm).or_else(|| self.try_index_opt(vm)) { i } else if let Some(s) = self.downcast_ref::() { - try_convert(self, s.as_wtf8().trim().as_bytes(), vm) + try_convert(self, numeric_literal_from_str(s).as_bytes(), vm) } else if let Some(bytes) = self.downcast_ref::() { try_convert(self, bytes, vm) } else if let Some(bytearray) = self.downcast_ref::() { From d8bb7bbd6f4bd581f687e652205870b2309784ab Mon Sep 17 00:00:00 2001 From: Hanjeong Lee Date: Tue, 18 Aug 2026 23:16:45 +0900 Subject: [PATCH 325/351] builtins: generate accurate __text_signature__ (#8512) * Drop the $module marker from generated __text_signature__ CPython's C functions receive the module as their first argument, so PyCFunction.__self__ is the module and inspect strips the $module parameter when building a Signature. A #[pyfunction] takes no such argument, PyNativeFunction::zelf is None, and inspect has nothing to strip, so the marker surfaced as a parameter that does not exist: inspect.signature(len) (module, /, obj) # was (obj) # now All 45 builtins shared with CPython carried it. Methods are unaffected; their $self marker comes from func_sig and both branches now produce the same string. Assisted-by: Claude Code:claude-opus-5 * Mark generated __text_signature__ parameters positional-only Arguments bind through `FuncArgs::take_positional`, which pops from the positional list and never consults the keyword map, so a #[pyfunction] argument cannot be passed by name: >>> len(obj=[1, 2]) TypeError The generated signature omitted the `/` marker, so inspect reported those parameters as POSITIONAL_OR_KEYWORD, contradicting the call above. Emit the marker, except for `*args`/`**kwargs`, which cannot be followed by `/`, and for empty parameter lists. 14 of the 45 builtins shared with CPython now report an identical signature, up from 0. Assisted-by: Claude Code:claude-opus-5 * Emit no __text_signature__ when an argument has no name Arguments bound by a destructuring pattern, e.g. fn round(RoundArgs { number, ndigits }: RoundArgs, ..) have no name to report, and func_sig stringified the pattern verbatim: >>> round.__text_signature__ '($module, RoundArgs { number, ndigits })' That is not valid Python, so inspect.signature() raised "builtin has invalid signature". Return None instead, which leaves __text_signature__ unset and makes inspect raise "no signature found", the same as for a CPython builtin that has no signature. Affects round, sum, os.pathconf, binascii.b2a_base64 and binascii.b2a_uu. Their docstrings are unchanged; only the signature prefix is dropped. Assisted-by: Claude Code:claude-opus-5 * Name builtin parameters after CPython These parameters are positional-only, so their names only ever appear in __text_signature__ and cannot be used at a call site. Naming them after CPython makes the generated signatures directly comparable: bin x -> number ord string -> character divmod a, b -> x, y setattr attr -> name delattr attr -> name hasattr attr -> name isinstance typ -> class_or_tuple issubclass subclass,typ -> cls, class_or_tuple aiter iter_target -> async_iterable 23 of the 45 builtins shared with CPython now report an identical signature, up from 0 before this branch. The remainder need FromArgs to report the parameters of its own structs, which is left for a follow-up. Add extra_tests/snippets/builtin_signature.py covering the phantom module parameter, the positional-only marker, the names above, and the signature-less builtins. Assisted-by: Claude Code:claude-opus-5 * Drop expectedFailure from test_module_level_callable_noargs pydoc's summary line for time.time was "time(module)" because the generated signature carried a $module parameter that inspect could not strip. It now reads "time()", as the test expects. Assisted-by: Claude Code:claude-opus-5 * Guard the signature-less assertions to RustPython test_snippets runs every snippet under CPython as well, and CPython does have Argument Clinic signatures for round and sum, so that block only holds for RustPython. Assisted-by: Claude Code:claude-opus-5 * Update crates/derive-impl/src/util.rs * Fix ord's parameter reference after the merge The merge of main took ord's signature from this branch, which renamed the parameter to character, and its body from main, which rewrote ord to accept bytes and bytearray through a parameter named c. The body then referenced a name that no longer existed and the build failed. Assisted-by: Claude Code:claude-opus-5 --------- Co-authored-by: Jeong, YunWon <69878+youknowone@users.noreply.github.com> --- Lib/test/test_pydoc/test_pydoc.py | 1 - crates/derive-impl/src/pyclass.rs | 5 +- crates/derive-impl/src/pymodule.rs | 16 +++-- crates/derive-impl/src/util.rs | 85 +++++++++++++---------- crates/vm/src/stdlib/builtins.rs | 56 ++++++++------- extra_tests/snippets/builtin_signature.py | 66 ++++++++++++++++++ 6 files changed, 161 insertions(+), 68 deletions(-) create mode 100644 extra_tests/snippets/builtin_signature.py diff --git a/Lib/test/test_pydoc/test_pydoc.py b/Lib/test/test_pydoc/test_pydoc.py index d206e5a910d..f44c56da652 100644 --- a/Lib/test/test_pydoc/test_pydoc.py +++ b/Lib/test/test_pydoc/test_pydoc.py @@ -1582,7 +1582,6 @@ def test_module_level_callable(self): self.assertEqual(self._get_summary_line(os.stat), "stat(path, *, dir_fd=None, follow_symlinks=True)") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_module_level_callable_noargs(self): self.assertEqual(self._get_summary_line(time.time), "time()") diff --git a/crates/derive-impl/src/pyclass.rs b/crates/derive-impl/src/pyclass.rs index 94bb445fec6..dd7973d352e 100644 --- a/crates/derive-impl/src/pyclass.rs +++ b/crates/derive-impl/src/pyclass.rs @@ -1096,7 +1096,10 @@ where args.attrs.push(allow_attr); } - let doc = args.attrs.doc().map(|doc| format_doc(&sig_doc, &doc)); + let doc = args.attrs.doc().map(|doc| match &sig_doc { + Some(sig_doc) => format_doc(sig_doc, &doc), + None => doc, + }); args.context.method_items.add_item(MethodNurseryItem { py_name, cfgs: args.cfgs.to_vec(), diff --git a/crates/derive-impl/src/pymodule.rs b/crates/derive-impl/src/pymodule.rs index 32d7a0fa6bf..20c94abe94f 100644 --- a/crates/derive-impl/src/pymodule.rs +++ b/crates/derive-impl/src/pymodule.rs @@ -524,7 +524,7 @@ struct FunctionNurseryItem { py_names: Vec, cfgs: Vec, ident: Ident, - doc: String, + doc: Option, call_flags: TokenStream, } @@ -556,8 +556,10 @@ impl ToTokens for ValidatedFunctionNursery { let cfgs = &item.cfgs; let cfgs = quote!(#(#cfgs)*); let py_names = &item.py_names; - let doc = &item.doc; - let doc = quote!(Some(#doc)); + let doc = match &item.doc { + Some(doc) => quote!(Some(#doc)), + None => quote!(None), + }; let flags = &item.call_flags; inner_tokens.extend(quote![ @@ -671,10 +673,10 @@ impl ModuleItem for FunctionItem { .copied() .map(str::to_owned) }); - let doc = if let Some(doc) = doc { - format_doc(&sig_doc, &doc) - } else { - sig_doc + let doc = match (sig_doc, doc) { + (Some(sig_doc), Some(doc)) => Some(format_doc(&sig_doc, &doc)), + (Some(sig_doc), None) => Some(sig_doc), + (None, doc) => doc, }; let py_names = { diff --git a/crates/derive-impl/src/util.rs b/crates/derive-impl/src/util.rs index 1ee878c1313..a8b4b6ff49b 100644 --- a/crates/derive-impl/src/util.rs +++ b/crates/derive-impl/src/util.rs @@ -732,13 +732,20 @@ where // Best effort attempt to generate a template from which a // __text_signature__ can be created. -pub(crate) fn text_signature(sig: &Signature, name: &str) -> String { - let signature = func_sig(sig); - if signature.starts_with("$self") { +// +// Unlike CPython, a `#[pyfunction]` doesn't take the module as an argument yet, +// so there's no module to mark with `$module`. +pub(crate) fn text_signature(sig: &Signature, name: &str) -> Option { + let signature = func_sig(sig)?; + // Arguments bind through `FuncArgs::take_positional`, which never consults + // the keyword map, so they are positional-only. `*args`/`**kwargs` cannot be + // followed by `/`, and an empty parameter list has nothing to mark. + let signature = if signature.is_empty() || signature.contains('*') { format!("{name}({signature})") } else { - format!("{}({}, {})", name, "$module", signature) - } + format!("{name}({signature}, /)") + }; + Some(signature) } pub(crate) fn infer_native_call_flags(sig: &Signature, drop_first_typed: usize) -> TokenStream { @@ -812,37 +819,45 @@ pub(crate) fn infer_native_call_flags(sig: &Signature, drop_first_typed: usize) } } -fn func_sig(sig: &Signature) -> String { - sig.inputs - .iter() - .filter_map(|arg| { - let arg = match arg { - FnArg::Typed(typed) => typed, - FnArg::Receiver(_) => return Some("$self".to_owned()), - }; - let ty = arg.ty.as_ref(); - let ty = quote!(#ty).to_string(); - if ty == "FuncArgs" { - return Some("*args, **kwargs".to_owned()); - } - if ty.starts_with('&') && ty.ends_with("VirtualMachine") { - return None; - } - let ident = match arg.pat.as_ref() { - syn::Pat::Ident(p) => p.ident.to_string(), - // FIXME: other => unreachable!("function arg pattern must be ident but found `{}`", quote!(fn #ident(.. #other ..))), - other => quote!(#other).to_string(), - }; - if ident == "zelf" { - return Some("$self".to_owned()); - } - if ident == "vm" { - unreachable!("type &VirtualMachine(`{ty}`) must be filtered already"); +/// Returns None when an argument has no name to report, in which case no +/// signature can be generated for the function. +fn func_sig(sig: &Signature) -> Option { + let mut params = Vec::new(); + for arg in &sig.inputs { + let arg = match arg { + FnArg::Typed(typed) => typed, + FnArg::Receiver(_) => { + params.push("$self".to_owned()); + continue; } - Some(ident) - }) - .collect::>() - .join(", ") + }; + let ty = arg.ty.as_ref(); + let ty = quote!(#ty).to_string(); + if ty == "FuncArgs" { + params.push("*args, **kwargs".to_owned()); + continue; + } + if ty.starts_with('&') && ty.ends_with("VirtualMachine") { + continue; + } + // An argument bound by a destructuring pattern, e.g. + // `fn round(RoundArgs { number, ndigits }: RoundArgs, ..)`, has no name + // to report. Stringifying the pattern would emit Rust syntax, which + // makes inspect.signature() raise "invalid signature". + let syn::Pat::Ident(pat) = arg.pat.as_ref() else { + return None; + }; + let ident = pat.ident.to_string(); + if ident == "zelf" { + params.push("$self".to_owned()); + continue; + } + if ident == "vm" { + unreachable!("type &VirtualMachine(`{ty}`) must be filtered already"); + } + params.push(ident); + } + Some(params.join(", ")) } pub(crate) fn format_doc(sig: &str, doc: &str) -> String { diff --git a/crates/vm/src/stdlib/builtins.rs b/crates/vm/src/stdlib/builtins.rs index 19e33110a5b..34be8c6178d 100644 --- a/crates/vm/src/stdlib/builtins.rs +++ b/crates/vm/src/stdlib/builtins.rs @@ -74,8 +74,8 @@ mod builtins { } #[pyfunction] - fn bin(x: PyIntRef) -> String { - let x = x.as_bigint(); + fn bin(number: PyIntRef) -> String { + let x = number.as_bigint(); if x.is_negative() { format!("-0b{:b}", x.abs()) } else { @@ -392,11 +392,11 @@ mod builtins { } #[pyfunction] - fn delattr(obj: PyObjectRef, attr: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { - let attr = attr.try_to_ref::(vm).map_err(|_e| { + fn delattr(obj: PyObjectRef, name: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { + let attr = name.try_to_ref::(vm).map_err(|_e| { vm.new_type_error(format!( "attribute name must be string, not '{}'", - attr.class().name() + name.class().name() )) })?; obj.del_attr(attr, vm) @@ -408,8 +408,8 @@ mod builtins { } #[pyfunction] - fn divmod(a: PyObjectRef, b: PyObjectRef, vm: &VirtualMachine) -> PyResult { - vm._divmod(&a, &b) + fn divmod(x: PyObjectRef, y: PyObjectRef, vm: &VirtualMachine) -> PyResult { + vm._divmod(&x, &y) } #[derive(FromArgs)] @@ -715,11 +715,11 @@ mod builtins { } #[pyfunction] - fn hasattr(obj: PyObjectRef, attr: PyObjectRef, vm: &VirtualMachine) -> PyResult { - let attr = attr.try_to_ref::(vm).map_err(|_e| { + fn hasattr(obj: PyObjectRef, name: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let attr = name.try_to_ref::(vm).map_err(|_e| { vm.new_type_error(format!( "attribute name must be string, not '{}'", - attr.class().name() + name.class().name() )) })?; Ok(vm.get_attribute_opt(obj, attr)?.is_some()) @@ -821,13 +821,21 @@ mod builtins { } #[pyfunction] - fn isinstance(obj: PyObjectRef, typ: PyObjectRef, vm: &VirtualMachine) -> PyResult { - obj.is_instance(&typ, vm) + fn isinstance( + obj: PyObjectRef, + class_or_tuple: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { + obj.is_instance(&class_or_tuple, vm) } #[pyfunction] - fn issubclass(subclass: PyObjectRef, typ: PyObjectRef, vm: &VirtualMachine) -> PyResult { - subclass.is_subclass(&typ, vm) + fn issubclass( + cls: PyObjectRef, + class_or_tuple: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { + cls.is_subclass(&class_or_tuple, vm) } #[pyfunction] @@ -848,8 +856,8 @@ mod builtins { } #[pyfunction] - fn aiter(iter_target: PyObjectRef, vm: &VirtualMachine) -> PyResult { - iter_target.get_aiter(vm) + fn aiter(async_iterable: PyObjectRef, vm: &VirtualMachine) -> PyResult { + async_iterable.get_aiter(vm) } #[pyfunction] @@ -998,8 +1006,8 @@ mod builtins { #[pyfunction] // builtin_ord - fn ord(c: PyObjectRef, vm: &VirtualMachine) -> PyResult { - let bytes = if let Some(string) = c.downcast_ref::() { + fn ord(character: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let bytes = if let Some(string) = character.downcast_ref::() { return match string.as_wtf8().code_points().exactly_one() { Ok(character) => Ok(character.to_u32()), Err(_) => { @@ -1009,14 +1017,14 @@ mod builtins { ))) } }; - } else if let Some(bytes) = c.downcast_ref::() { + } else if let Some(bytes) = character.downcast_ref::() { bytes.as_bytes().to_vec() - } else if let Some(bytearray) = c.downcast_ref::() { + } else if let Some(bytearray) = character.downcast_ref::() { bytearray.borrow_buf().to_vec() } else { return Err(vm.new_type_error(format!( "ord() expected string of length 1, but {} found", - c.class().name() + character.class().name() ))); }; let bytes_len = bytes.len(); @@ -1149,14 +1157,14 @@ mod builtins { #[pyfunction] fn setattr( obj: PyObjectRef, - attr: PyObjectRef, + name: PyObjectRef, value: PyObjectRef, vm: &VirtualMachine, ) -> PyResult<()> { - let attr = attr.try_to_ref::(vm).map_err(|_e| { + let attr = name.try_to_ref::(vm).map_err(|_e| { vm.new_type_error(format!( "attribute name must be string, not '{}'", - attr.class().name() + name.class().name() )) })?; obj.set_attr(attr, value, vm)?; diff --git a/extra_tests/snippets/builtin_signature.py b/extra_tests/snippets/builtin_signature.py new file mode 100644 index 00000000000..320a395d882 --- /dev/null +++ b/extra_tests/snippets/builtin_signature.py @@ -0,0 +1,66 @@ +import inspect +import sys + +# __text_signature__ is generated from the Rust parameter list, so it must not +# describe parameters the function does not actually take, and must mark the +# ones it does take as positional-only. + +# No phantom `module` parameter. RustPython's #[pyfunction]s take no module +# argument, so `__self__` is None and inspect has nothing to strip. +for f in (len, abs, hash, id, repr, bin, ord, divmod, hex, oct, chr, callable): + assert "module" not in inspect.signature(f).parameters, f.__name__ + +# Plain arguments bind through take_positional(), so they are positional-only. +try: + len(obj=[1, 2]) +except TypeError: + pass +else: + raise AssertionError("len() should not accept keyword arguments") + +assert str(inspect.signature(len)) == "(obj, /)" +assert str(inspect.signature(abs)) == "(x, /)" +assert str(inspect.signature(hash)) == "(obj, /)" +assert str(inspect.signature(chr)) == "(i, /)" +assert str(inspect.signature(callable)) == "(obj, /)" + +assert ( + inspect.signature(len).parameters["obj"].kind == inspect.Parameter.POSITIONAL_ONLY +) + +# *args/**kwargs cannot be followed by `/`. The parameter names themselves still +# differ from CPython here, which is out of scope. +breakpoint_kinds = [p.kind for p in inspect.signature(breakpoint).parameters.values()] +assert breakpoint_kinds == [ + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, +], breakpoint_kinds + +# Parameter names follow CPython, so signatures are directly comparable. +assert str(inspect.signature(bin)) == "(number, /)" +assert str(inspect.signature(ord)) == "(character, /)" +assert str(inspect.signature(divmod)) == "(x, y, /)" +assert str(inspect.signature(hasattr)) == "(obj, name, /)" +assert str(inspect.signature(setattr)) == "(obj, name, value, /)" +assert str(inspect.signature(delattr)) == "(obj, name, /)" +assert str(inspect.signature(isinstance)) == "(obj, class_or_tuple, /)" +assert str(inspect.signature(issubclass)) == "(cls, class_or_tuple, /)" +assert str(inspect.signature(aiter)) == "(async_iterable, /)" + +if sys.implementation.name == "rustpython": + # Functions whose Rust arguments are destructuring patterns rather than + # plain names get no signature at all, instead of emitting text that is not + # valid Python and makes inspect.signature() raise "invalid signature". + # + # CPython does have signatures for these, hand-written via Argument Clinic. + # We cannot derive them until FromArgs reports the parameters of its own + # structs, so until then we report no signature, which is at least how + # CPython behaves for the builtins it has no signature for. + for f in (round, sum): + assert f.__text_signature__ is None, f.__name__ + try: + inspect.signature(f) + except ValueError as e: + assert "no signature found" in str(e), str(e) + else: + raise AssertionError(f"{f.__name__} should have no signature") From 419a0b228d23d9c26def43128d4a5f98d0104302 Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:30:34 -0400 Subject: [PATCH 326/351] ffi: No interior NULs (part 1) (#8245) * ffi: No interior NULs (part 1) Interior NULs is a security hazard for C-style strings. A NUL byte truncates a string which can lead the caller and callee to see two different strings. It can cause path traversal attacks where a path in Python looks complete but it is interpreted differently through FFI. RustPython needs to handle this for some of its C-API as well as raw libc or Windows calls. Both Rust's standard library as well as Rustix handle interior NULs for us with CStrings, so this mostly affects a handful of Windows functions or areas where we have raw bytes that weren't checked by CString. Finally, this PR is non-exhaustive. I will have to rely heavily on CodeRabbit to help lint it to ensure that interior NUL checks are only introduced for FFI and not outside of it. Most of RustPython seems to handle interior NULs already due to CString as well as WideCString. **Sources:** * https://owasp.org/www-community/attacks/Embedding_Null_Code * python/cpython#11656 * rename null_terminated_bytes --------- Co-authored-by: Jeong, YunWon <69878+youknowone@users.noreply.github.com> Co-authored-by: Jeong, YunWon --- crates/host_env/src/ctypes.rs | 18 +- crates/host_env/src/fileutils.rs | 29 +- crates/host_env/src/nt.rs | 402 +++++++++-------------- crates/host_env/src/overlapped.rs | 17 +- crates/host_env/src/posix_windows.rs | 2 +- crates/host_env/src/winapi.rs | 6 + crates/host_env/src/windows.rs | 62 ++-- crates/host_env/src/winreg.rs | 41 +-- crates/host_env/src/wmi.rs | 4 +- crates/stdlib/src/overlapped.rs | 11 +- crates/vm/src/exceptions.rs | 9 +- crates/vm/src/stdlib/_codecs.rs | 13 +- crates/vm/src/stdlib/_ctypes/base.rs | 9 +- crates/vm/src/stdlib/_ctypes/function.rs | 4 +- crates/vm/src/stdlib/_io.rs | 5 +- crates/vm/src/stdlib/_winapi.rs | 153 +++++---- crates/vm/src/stdlib/nt.rs | 75 ++--- crates/vm/src/stdlib/os.rs | 16 +- crates/vm/src/stdlib/winreg.rs | 113 ++++--- crates/vm/src/stdlib/winsound.rs | 41 ++- 20 files changed, 501 insertions(+), 529 deletions(-) diff --git a/crates/host_env/src/ctypes.rs b/crates/host_env/src/ctypes.rs index a038e7a6d49..9b999d7c537 100644 --- a/crates/host_env/src/ctypes.rs +++ b/crates/host_env/src/ctypes.rs @@ -33,8 +33,7 @@ use libloading::Library; use libloading::os::unix::Library as UnixLibrary; #[cfg(any(unix, windows))] use parking_lot::{Mutex, RwLock}; -use rustpython_wtf8::Wtf8; -use rustpython_wtf8::Wtf8Buf; +use rustpython_wtf8::{Wtf8, Wtf8Buf}; #[cfg(any(unix, windows))] use std::{collections::HashMap, ffi::OsStr, sync::OnceLock}; use widestring::WideCStr; @@ -503,7 +502,7 @@ pub fn encode_wtf8_to_wchar_padded(s: &Wtf8, size: usize) -> Vec { wchar_bytes } -pub fn wchar_null_terminated_bytes(s: &Wtf8) -> Vec { +pub fn clone_wchar_null_terminated(s: &Wtf8) -> Vec { if size_of::() == 2 { // We can't cast u32 to WChar because it would truncate the value on platforms where WChar // is two bytes. Wtf8::encode_wide does all of the hard work for us, so all we have to do @@ -1091,10 +1090,15 @@ pub fn utf16z_bytes(s: &Wtf8) -> Vec { .collect() } -pub fn null_terminated_bytes(bytes: &[u8]) -> Vec { - let mut buffer = bytes.to_vec(); - buffer.push(0); - buffer +/// Return a NUL terminated copy of `bytes`. +/// +/// The input may contain interior NULs. +pub fn clone_as_null_terminated(bytes: &[u8]) -> Vec { + if bytes.last() == Some(&0) { + bytes.to_vec() + } else { + bytes.iter().copied().chain(Some(0)).collect() + } } pub fn decode_type_code(type_code: &str, bytes: &[u8]) -> DecodedValue { diff --git a/crates/host_env/src/fileutils.rs b/crates/host_env/src/fileutils.rs index a8e56bb1c0b..9d7e4430681 100644 --- a/crates/host_env/src/fileutils.rs +++ b/crates/host_env/src/fileutils.rs @@ -17,10 +17,10 @@ pub fn fstat(fd: crate::crt_fd::Borrowed<'_>) -> std::io::Result { #[cfg(windows)] pub mod windows { use crate::crt_fd; - use crate::windows::ToWideString; use libc::{S_IFCHR, S_IFDIR, S_IFMT}; use std::ffi::OsStr; use std::os::windows::io::AsRawHandle; + use std::path::Path; use std::sync::OnceLock; use windows_sys::Win32::Foundation::{ ERROR_INVALID_HANDLE, ERROR_NOT_SUPPORTED, FILETIME, FreeLibrary, SetLastError, @@ -67,21 +67,15 @@ pub mod windows { impl StatStruct { // update_st_mode_from_path in cpython pub fn update_st_mode_from_path(&mut self, path: &OsStr, attr: u32) { - if attr & FILE_ATTRIBUTE_DIRECTORY == 0 { - let file_extension = path - .to_wide() - .split(|&c| c == '.' as u16) - .next_back() - .and_then(|s| String::from_utf16(s).ok()); - - if let Some(file_extension) = file_extension - && (file_extension.eq_ignore_ascii_case("exe") - || file_extension.eq_ignore_ascii_case("bat") - || file_extension.eq_ignore_ascii_case("cmd") - || file_extension.eq_ignore_ascii_case("com")) - { - self.st_mode |= 0o111; - } + if attr & FILE_ATTRIBUTE_DIRECTORY == 0 + && let Some(file_extension) = + Path::new(path).extension().and_then(|ext| ext.to_str()) + && (file_extension.eq_ignore_ascii_case("exe") + || file_extension.eq_ignore_ascii_case("bat") + || file_extension.eq_ignore_ascii_case("cmd") + || file_extension.eq_ignore_ascii_case("com")) + { + self.st_mode |= 0o111; } } } @@ -288,7 +282,7 @@ pub mod windows { // _Py_GetFileInformationByName in cpython pub fn get_file_information_by_name( - file_name: &OsStr, + file_name: &widestring::WideCStr, file_information_class: FILE_INFO_BY_NAME_CLASS, ) -> std::io::Result { static GET_FILE_INFORMATION_BY_NAME: OnceLock< @@ -329,7 +323,6 @@ pub mod windows { }) .ok_or_else(|| std::io::Error::from_raw_os_error(ERROR_NOT_SUPPORTED as _))?; - let file_name = file_name.to_wide_with_nul(); let file_info_buffer_size = core::mem::size_of::() as u32; let mut file_info_buffer = core::mem::MaybeUninit::::uninit(); unsafe { diff --git a/crates/host_env/src/nt.rs b/crates/host_env/src/nt.rs index 7e0591600b1..8a571939a16 100644 --- a/crates/host_env/src/nt.rs +++ b/crates/host_env/src/nt.rs @@ -5,7 +5,7 @@ // cspell:ignore hchmod use std::{ - ffi::{OsStr, OsString}, + ffi::OsString, io, os::windows::{ffi::OsStringExt, io::AsRawHandle}, path::Path, @@ -19,27 +19,61 @@ use crate::{ StatStruct, windows::{FILE_INFO_BY_NAME_CLASS, get_file_information_by_name, stat_basic_info_to_stat}, }, - windows::{CheckWin32Bool, CheckWin32Handle, CheckWin32Sentinel, HandleToOwned, ToWideString}, + windows::{CheckWin32Bool, CheckWin32Handle, CheckWin32Sentinel, HandleToOwned}, }; use libc::intptr_t; +use widestring::WideCString; use windows_sys::{ Win32::{ Foundation::{ - CloseHandle, ERROR_INVALID_HANDLE, GetLastError, HANDLE, INVALID_HANDLE_VALUE, MAX_PATH, + CloseHandle, ERROR_ACCESS_DENIED, ERROR_BAD_NET_NAME, ERROR_BAD_NETPATH, + ERROR_BAD_PATHNAME, ERROR_CANT_ACCESS_FILE, ERROR_DIRECTORY, ERROR_FILE_NOT_FOUND, + ERROR_FILENAME_EXCED_RANGE, ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_FUNCTION, + ERROR_INVALID_HANDLE, ERROR_INVALID_NAME, ERROR_INVALID_PARAMETER, ERROR_MORE_DATA, + ERROR_NOT_READY, ERROR_NOT_SUPPORTED, ERROR_PATH_NOT_FOUND, ERROR_SHARING_VIOLATION, + GENERIC_READ, GENERIC_WRITE, GetHandleInformation, GetLastError, HANDLE, + HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, MAX_PATH, SetHandleInformation, }, Globalization::{CP_UTF8, MultiByteToWideChar, WideCharToMultiByte}, Storage::FileSystem::{ - CreateFileW, FILE_BASIC_INFO, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, - FILE_READ_ATTRIBUTES, FILE_TYPE_UNKNOWN, FileBasicInfo, FindClose, FindFirstFileW, - GetFileAttributesW, GetFileInformationByHandleEx, GetFileType, GetFullPathNameW, - INVALID_FILE_ATTRIBUTES, OPEN_EXISTING, SetFileAttributesW, SetFileInformationByHandle, - WIN32_FIND_DATAW, + BY_HANDLE_FILE_INFORMATION, CreateFileW, CreateSymbolicLinkW, DeleteFileW, + FILE_ATTRIBUTE_TAG_INFO, FILE_BASIC_INFO, FILE_DEVICE_CD_ROM, FILE_DEVICE_DISK, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, + FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + FILE_TYPE_CHAR, FILE_TYPE_DISK, FILE_TYPE_PIPE, FILE_TYPE_UNKNOWN, + FILE_WRITE_ATTRIBUTES, FileAttributeTagInfo as FileAttributeTagInfoClass, + FileBasicInfo, FileIdInfo, FindClose, FindFirstFileW, GetDiskFreeSpaceExW, + GetDriveTypeW, GetFileAttributesExW, GetFileAttributesW, GetFileInformationByHandle, + GetFileInformationByHandleEx, GetFileType, GetFullPathNameW, GetLogicalDriveStringsW, + GetVolumePathNameW, GetVolumePathNamesForVolumeNameW, INVALID_FILE_ATTRIBUTES, + OPEN_EXISTING, RemoveDirectoryW, SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE, + SYMBOLIC_LINK_FLAG_DIRECTORY, SetFileAttributesW, SetFileInformationByHandle, + WIN32_FILE_ATTRIBUTE_DATA, WIN32_FIND_DATAW, + }, + System::{ + Console, + IO::DeviceIoControl, + Ioctl::{ + FILE_DEVICE_VIRTUAL_DISK, FSCTL_GET_REPARSE_POINT, + FSCTL_QUERY_PERSISTENT_VOLUME_STATE, + }, + SystemServices::IO_REPARSE_TAG_MOUNT_POINT, + Threading, + WindowsProgramming::{DRIVE_FIXED, GetUserNameW}, }, - System::{Console, Threading}, }, w, }; +pub use windows_sys::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_ARCHIVE, FILE_ATTRIBUTE_COMPRESSED, FILE_ATTRIBUTE_DEVICE, + FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_ENCRYPTED, FILE_ATTRIBUTE_HIDDEN, + FILE_ATTRIBUTE_INTEGRITY_STREAM, FILE_ATTRIBUTE_NO_SCRUB_DATA, FILE_ATTRIBUTE_NORMAL, + FILE_ATTRIBUTE_NOT_CONTENT_INDEXED, FILE_ATTRIBUTE_OFFLINE, FILE_ATTRIBUTE_READONLY, + FILE_ATTRIBUTE_REPARSE_POINT, FILE_ATTRIBUTE_SPARSE_FILE, FILE_ATTRIBUTE_SYSTEM, + FILE_ATTRIBUTE_TEMPORARY, FILE_ATTRIBUTE_VIRTUAL, +}; + pub type Handle = HANDLE; pub const MAX_PATH_USIZE: usize = MAX_PATH as usize; pub const ERROR_INVALID_HANDLE_I32: i32 = ERROR_INVALID_HANDLE as i32; @@ -54,15 +88,6 @@ pub const LOAD_LIBRARY_SEARCH_SYSTEM32: u32 = pub const LOAD_LIBRARY_SEARCH_USER_DIRS: u32 = windows_sys::Win32::System::LibraryLoader::LOAD_LIBRARY_SEARCH_USER_DIRS; -pub use windows_sys::Win32::Storage::FileSystem::{ - FILE_ATTRIBUTE_ARCHIVE, FILE_ATTRIBUTE_COMPRESSED, FILE_ATTRIBUTE_DEVICE, - FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_ENCRYPTED, FILE_ATTRIBUTE_HIDDEN, - FILE_ATTRIBUTE_INTEGRITY_STREAM, FILE_ATTRIBUTE_NO_SCRUB_DATA, FILE_ATTRIBUTE_NORMAL, - FILE_ATTRIBUTE_NOT_CONTENT_INDEXED, FILE_ATTRIBUTE_OFFLINE, FILE_ATTRIBUTE_READONLY, - FILE_ATTRIBUTE_REPARSE_POINT, FILE_ATTRIBUTE_SPARSE_FILE, FILE_ATTRIBUTE_SYSTEM, - FILE_ATTRIBUTE_TEMPORARY, FILE_ATTRIBUTE_VIRTUAL, -}; - #[cfg(target_env = "msvc")] unsafe extern "C" { fn _cwait(termstat: *mut i32, procHandle: intptr_t, action: i32) -> intptr_t; @@ -100,13 +125,13 @@ struct FileAttributeTagInfo { reparse_tag: u32, } -fn win32_large_integer_to_time(li: i64) -> (libc::time_t, i32) { +const fn win32_large_integer_to_time(li: i64) -> (libc::time_t, i32) { let nsec = ((li % 10_000_000) * 100) as i32; let sec = (li / 10_000_000 - crate::fileutils::windows::SECS_BETWEEN_EPOCHS) as libc::time_t; (sec, nsec) } -fn win32_filetime_to_time(ft_low: u32, ft_high: u32) -> (libc::time_t, i32) { +const fn win32_filetime_to_time(ft_low: u32, ft_high: u32) -> (libc::time_t, i32) { let ticks = ((ft_high as i64) << 32) | (ft_low as i64); let nsec = ((ticks % 10_000_000) * 100) as i32; let sec = (ticks / 10_000_000 - crate::fileutils::windows::SECS_BETWEEN_EPOCHS) as libc::time_t; @@ -114,15 +139,11 @@ fn win32_filetime_to_time(ft_low: u32, ft_high: u32) -> (libc::time_t, i32) { } fn win32_attribute_data_to_stat( - info: &windows_sys::Win32::Storage::FileSystem::BY_HANDLE_FILE_INFORMATION, + info: &BY_HANDLE_FILE_INFORMATION, reparse_tag: u32, - basic_info: Option<&windows_sys::Win32::Storage::FileSystem::FILE_BASIC_INFO>, - id_info: Option<&windows_sys::Win32::Storage::FileSystem::FILE_ID_INFO>, + basic_info: Option<&FILE_BASIC_INFO>, + id_info: Option<&FILE_ID_INFO>, ) -> StatStruct { - use windows_sys::Win32::Storage::FileSystem::{ - FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_READONLY, FILE_ATTRIBUTE_REPARSE_POINT, - }; - let mut st_mode: u16 = 0; if info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY != 0 { st_mode |= S_IFDIR_MODE | 0o111; @@ -221,37 +242,28 @@ pub enum ReadConsoleError { } pub fn access(path: &Path, mode: u8) -> bool { - let wide = path.as_os_str().to_wide_with_nul(); + let Ok(wide) = WideCString::from_os_str(path.as_os_str()) else { + return false; + }; let attr = unsafe { GetFileAttributesW(wide.as_ptr()) }; attr != INVALID_FILE_ATTRIBUTES && (mode & 2 == 0 || attr & FILE_ATTRIBUTE_READONLY == 0 - || attr & windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_DIRECTORY != 0) + || attr & FILE_ATTRIBUTE_DIRECTORY != 0) } -pub fn remove(path: &Path) -> io::Result<()> { - use windows_sys::Win32::Storage::FileSystem::{ - DeleteFileW, RemoveDirectoryW, WIN32_FIND_DATAW, - }; - use windows_sys::Win32::System::SystemServices::{ - IO_REPARSE_TAG_MOUNT_POINT, IO_REPARSE_TAG_SYMLINK, - }; - - let wide_path = path.as_os_str().to_wide_with_nul(); - let attrs = unsafe { GetFileAttributesW(wide_path.as_ptr()) }; +pub fn remove(path: &widestring::WideCStr) -> io::Result<()> { + let attrs = unsafe { GetFileAttributesW(path.as_ptr()) }; let mut is_directory = false; let mut is_link = false; if attrs != INVALID_FILE_ATTRIBUTES { - is_directory = - (attrs & windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_DIRECTORY) != 0; + is_directory = (attrs & FILE_ATTRIBUTE_DIRECTORY) != 0; - if is_directory - && (attrs & windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT) != 0 - { + if is_directory && (attrs & FILE_ATTRIBUTE_REPARSE_POINT) != 0 { let mut find_data: WIN32_FIND_DATAW = unsafe { core::mem::zeroed() }; - let handle = unsafe { FindFirstFileW(wide_path.as_ptr(), &mut find_data) }; + let handle = unsafe { FindFirstFileW(path.as_ptr(), &mut find_data) }; if handle != INVALID_HANDLE_VALUE { is_link = find_data.dwReserved0 == IO_REPARSE_TAG_SYMLINK || find_data.dwReserved0 == IO_REPARSE_TAG_MOUNT_POINT; @@ -261,9 +273,9 @@ pub fn remove(path: &Path) -> io::Result<()> { } if is_directory && is_link { - unsafe { RemoveDirectoryW(wide_path.as_ptr()) } + unsafe { RemoveDirectoryW(path.as_ptr()) } } else { - unsafe { DeleteFileW(wide_path.as_ptr()) } + unsafe { DeleteFileW(path.as_ptr()) } } .check_win32_bool() } @@ -282,12 +294,6 @@ pub fn symlink( dst_wide: &widestring::WideCStr, target_is_directory: bool, ) -> io::Result<()> { - use windows_sys::Win32::Storage::FileSystem::WIN32_FILE_ATTRIBUTE_DATA; - use windows_sys::Win32::Storage::FileSystem::{ - CreateSymbolicLinkW, FILE_ATTRIBUTE_DIRECTORY, GetFileAttributesExW, - SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE, SYMBOLIC_LINK_FLAG_DIRECTORY, - }; - static HAS_UNPRIVILEGED_FLAG: AtomicBool = AtomicBool::new(true); fn check_dir(src: &Path, dst: &Path) -> bool { @@ -327,15 +333,11 @@ pub fn symlink( let mut result = unsafe { CreateSymbolicLinkW(dst_wide.as_ptr(), src_wide.as_ptr(), flags) }; if !result && HAS_UNPRIVILEGED_FLAG.load(Ordering::Relaxed) - && unsafe { windows_sys::Win32::Foundation::GetLastError() } - == windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER + && unsafe { GetLastError() } == ERROR_INVALID_PARAMETER { let flags = flags & !SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE; result = unsafe { CreateSymbolicLinkW(dst_wide.as_ptr(), src_wide.as_ptr(), flags) }; - if result - || unsafe { windows_sys::Win32::Foundation::GetLastError() } - != windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER - { + if result || unsafe { GetLastError() } != ERROR_INVALID_PARAMETER { HAS_UNPRIVILEGED_FLAG.store(false, Ordering::Relaxed); } } @@ -383,23 +385,17 @@ pub fn fchmod(fd: i32, mode: u32, write_bit: u32) -> io::Result<()> { win32_hchmod(handle.as_raw_handle() as HANDLE, mode, write_bit) } -pub fn win32_lchmod(path: &OsStr, mode: u32, write_bit: u32) -> io::Result<()> { - let wide = path.to_wide_with_nul(); - let attr = unsafe { GetFileAttributesW(wide.as_ptr()) }.check_ne(INVALID_FILE_ATTRIBUTES)?; +pub fn win32_lchmod(path: &widestring::WideCStr, mode: u32, write_bit: u32) -> io::Result<()> { + let attr = unsafe { GetFileAttributesW(path.as_ptr()) }.check_ne(INVALID_FILE_ATTRIBUTES)?; let new_attr = if mode & write_bit != 0 { attr & !FILE_ATTRIBUTE_READONLY } else { attr | FILE_ATTRIBUTE_READONLY }; - unsafe { SetFileAttributesW(wide.as_ptr(), new_attr) }.check_win32_bool() + unsafe { SetFileAttributesW(path.as_ptr(), new_attr) }.check_win32_bool() } pub fn chmod_follow(path: &widestring::WideCStr, mode: u32, write_bit: u32) -> io::Result<()> { - use windows_sys::Win32::Storage::FileSystem::{ - FILE_FLAG_BACKUP_SEMANTICS, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, - FILE_SHARE_WRITE, FILE_WRITE_ATTRIBUTES, OPEN_EXISTING, - }; - let handle = unsafe { CreateFileW( path.as_ptr(), @@ -416,11 +412,10 @@ pub fn chmod_follow(path: &widestring::WideCStr, mode: u32, write_bit: u32) -> i win32_hchmod(handle.as_raw_handle() as HANDLE, mode, write_bit) } -pub fn find_first_file_name(path: &Path) -> io::Result { - let wide_path = path.as_os_str().to_wide_with_nul(); +pub fn find_first_file_name(path: &widestring::WideCStr) -> io::Result { let mut find_data: WIN32_FIND_DATAW = unsafe { core::mem::zeroed() }; - let handle = unsafe { FindFirstFileW(wide_path.as_ptr(), &mut find_data) }.check_valid()?; + let handle = unsafe { FindFirstFileW(path.as_ptr(), &mut find_data) }.check_valid()?; unsafe { FindClose(handle) }; let len = find_data @@ -431,14 +426,7 @@ pub fn find_first_file_name(path: &Path) -> io::Result { Ok(OsString::from_wide(&find_data.cFileName[..len])) } -pub fn path_isdevdrive(path: &Path) -> io::Result { - use windows_sys::Win32::Storage::FileSystem::{ - FILE_SHARE_READ, FILE_SHARE_WRITE, GetDriveTypeW, GetVolumePathNameW, - }; - use windows_sys::Win32::System::IO::DeviceIoControl; - use windows_sys::Win32::System::Ioctl::FSCTL_QUERY_PERSISTENT_VOLUME_STATE; - use windows_sys::Win32::System::WindowsProgramming::DRIVE_FIXED; - +pub fn path_isdevdrive(path: &widestring::WideCStr) -> io::Result { const PERSISTENT_VOLUME_STATE_DEV_VOLUME: u32 = 0x0000_2000; #[repr(C)] @@ -449,9 +437,8 @@ pub fn path_isdevdrive(path: &Path) -> io::Result { reserved: u32, } - let wide_path = path.as_os_str().to_wide_with_nul(); let mut volume = [0u16; MAX_PATH as usize]; - unsafe { GetVolumePathNameW(wide_path.as_ptr(), volume.as_mut_ptr(), volume.len() as _) } + unsafe { GetVolumePathNameW(path.as_ptr(), volume.as_mut_ptr(), volume.len() as _) } .check_win32_bool()?; if unsafe { GetDriveTypeW(volume.as_ptr()) } != DRIVE_FIXED { return Ok(false); @@ -503,38 +490,30 @@ pub fn path_isdevdrive(path: &Path) -> io::Result { Ok((volume_state.volume_flags & PERSISTENT_VOLUME_STATE_DEV_VOLUME) != 0) } -pub fn is_reparse_tag_name_surrogate(tag: u32) -> bool { +pub const fn is_reparse_tag_name_surrogate(tag: u32) -> bool { (tag & 0x20000000) != 0 } -pub fn file_info_error_is_trustworthy(error: u32) -> bool { - use windows_sys::Win32::Foundation; +pub const fn file_info_error_is_trustworthy(error: u32) -> bool { matches!( error, - Foundation::ERROR_FILE_NOT_FOUND - | Foundation::ERROR_PATH_NOT_FOUND - | Foundation::ERROR_NOT_READY - | Foundation::ERROR_BAD_NET_NAME - | Foundation::ERROR_BAD_NETPATH - | Foundation::ERROR_BAD_PATHNAME - | Foundation::ERROR_INVALID_NAME - | Foundation::ERROR_FILENAME_EXCED_RANGE + ERROR_FILE_NOT_FOUND + | ERROR_PATH_NOT_FOUND + | ERROR_NOT_READY + | ERROR_BAD_NET_NAME + | ERROR_BAD_NETPATH + | ERROR_BAD_PATHNAME + | ERROR_INVALID_NAME + | ERROR_FILENAME_EXCED_RANGE ) } -pub fn test_info( +pub const fn test_info( attributes: u32, reparse_tag: u32, disk_device: bool, tested_type: TestType, ) -> bool { - use windows_sys::Win32::Storage::FileSystem::{ - FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, - }; - use windows_sys::Win32::System::SystemServices::{ - IO_REPARSE_TAG_MOUNT_POINT, IO_REPARSE_TAG_SYMLINK, - }; - match tested_type { TestType::RegularFile => { disk_device && attributes != 0 && (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 @@ -561,10 +540,6 @@ pub fn test_info( } pub fn test_file_type_by_handle(handle: HANDLE, tested_type: TestType, disk_only: bool) -> bool { - use windows_sys::Win32::Storage::FileSystem::{ - FILE_ATTRIBUTE_TAG_INFO, FILE_TYPE_DISK, FileAttributeTagInfo as FileAttributeTagInfoClass, - }; - let disk_device = unsafe { GetFileType(handle) } == FILE_TYPE_DISK; if disk_only && !disk_device { return false; @@ -607,19 +582,11 @@ pub fn test_file_type_by_handle(handle: HANDLE, tested_type: TestType, disk_only } fn win32_xstat_attributes_from_dir( - path: &OsStr, -) -> io::Result<( - windows_sys::Win32::Storage::FileSystem::BY_HANDLE_FILE_INFORMATION, - u32, -)> { - use windows_sys::Win32::Storage::FileSystem::{ - BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_REPARSE_POINT, - }; - - let wide: Vec = path.to_wide_with_nul(); + path: &widestring::WideCStr, +) -> io::Result<(BY_HANDLE_FILE_INFORMATION, u32)> { let mut find_data: WIN32_FIND_DATAW = unsafe { core::mem::zeroed() }; - let handle = unsafe { FindFirstFileW(wide.as_ptr(), &mut find_data) }.check_valid()?; + let handle = unsafe { FindFirstFileW(path.as_ptr(), &mut find_data) }.check_valid()?; unsafe { FindClose(handle) }; let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { core::mem::zeroed() }; @@ -639,22 +606,7 @@ fn win32_xstat_attributes_from_dir( Ok((info, reparse_tag)) } -fn win32_xstat_slow_impl(path: &OsStr, traverse: bool) -> io::Result { - use windows_sys::Win32::{ - Foundation::{ - ERROR_ACCESS_DENIED, ERROR_CANT_ACCESS_FILE, ERROR_INVALID_FUNCTION, - ERROR_INVALID_PARAMETER, ERROR_NOT_SUPPORTED, ERROR_SHARING_VIOLATION, GENERIC_READ, - }, - Storage::FileSystem::{ - BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, - FILE_ATTRIBUTE_REPARSE_POINT, FILE_BASIC_INFO, FILE_ID_INFO, FILE_SHARE_READ, - FILE_SHARE_WRITE, FILE_TYPE_CHAR, FILE_TYPE_PIPE, - FileAttributeTagInfo as FileAttributeTagInfoClass, FileBasicInfo, FileIdInfo, - GetFileAttributesW, GetFileInformationByHandle, - }, - }; - - let wide: Vec = path.to_wide_with_nul(); +fn win32_xstat_slow_impl(path: &widestring::WideCStr, traverse: bool) -> io::Result { let access = FILE_READ_ATTRIBUTES; let mut flags = FILE_FLAG_BACKUP_SEMANTICS; if !traverse { @@ -663,7 +615,7 @@ fn win32_xstat_slow_impl(path: &OsStr, traverse: bool) -> io::Result let mut h_file = unsafe { CreateFileW( - wide.as_ptr(), + path.as_ptr(), access, 0, core::ptr::null(), @@ -694,7 +646,7 @@ fn win32_xstat_slow_impl(path: &OsStr, traverse: bool) -> io::Result ERROR_INVALID_PARAMETER => { h_file = unsafe { CreateFileW( - wide.as_ptr(), + path.as_ptr(), access | GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, core::ptr::null(), @@ -711,7 +663,7 @@ fn win32_xstat_slow_impl(path: &OsStr, traverse: bool) -> io::Result is_unhandled_tag = true; h_file = unsafe { CreateFileW( - wide.as_ptr(), + path.as_ptr(), access, 0, core::ptr::null(), @@ -731,14 +683,14 @@ fn win32_xstat_slow_impl(path: &OsStr, traverse: bool) -> io::Result let result = (|| -> io::Result { if h_file != INVALID_HANDLE_VALUE { let file_type = unsafe { GetFileType(h_file) }; - if file_type != windows_sys::Win32::Storage::FileSystem::FILE_TYPE_DISK { + if file_type != FILE_TYPE_DISK { if file_type == FILE_TYPE_UNKNOWN { let err = io::Error::last_os_error(); if err.raw_os_error().unwrap_or(0) != 0 { return Err(err); } } - let file_attributes = unsafe { GetFileAttributesW(wide.as_ptr()) }; + let file_attributes = unsafe { GetFileAttributesW(path.as_ptr()) }; let mut st_mode = 0; if file_attributes != INVALID_FILE_ATTRIBUTES && file_attributes & FILE_ATTRIBUTE_DIRECTORY != 0 @@ -831,12 +783,12 @@ fn win32_xstat_slow_impl(path: &OsStr, traverse: bool) -> io::Result }, if has_id_info { Some(&id_info) } else { None }, ); - result.update_st_mode_from_path(path, file_info.dwFileAttributes); + result.update_st_mode_from_path(&path.to_os_string(), file_info.dwFileAttributes); Ok(result) } else { let mut result = win32_attribute_data_to_stat(&file_info, tag_info.reparse_tag, None, None); - result.update_st_mode_from_path(path, file_info.dwFileAttributes); + result.update_st_mode_from_path(&path.to_os_string(), file_info.dwFileAttributes); Ok(result) } })(); @@ -847,9 +799,7 @@ fn win32_xstat_slow_impl(path: &OsStr, traverse: bool) -> io::Result result } -pub fn win32_xstat(path: &OsStr, traverse: bool) -> io::Result { - use windows_sys::Win32::{Foundation, Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT}; - +pub fn win32_xstat(path: &widestring::WideCStr, traverse: bool) -> io::Result { match get_file_information_by_name(path, FILE_INFO_BY_NAME_CLASS::FileStatBasicByNameInfo) { Ok(stat_info) => { if (stat_info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT == 0) @@ -857,7 +807,7 @@ pub fn win32_xstat(path: &OsStr, traverse: bool) -> io::Result { { let mut result = stat_basic_info_to_stat(&stat_info); if result.st_ino != 0 || result.st_ino_high != 0 { - result.update_st_mode_from_path(path, stat_info.FileAttributes); + result.update_st_mode_from_path(&path.to_os_string(), stat_info.FileAttributes); result.st_ctime = result.st_birthtime; result.st_ctime_nsec = result.st_birthtime_nsec; return Ok(result); @@ -868,10 +818,10 @@ pub fn win32_xstat(path: &OsStr, traverse: bool) -> io::Result { if let Some(errno) = err.raw_os_error() && matches!( errno as u32, - Foundation::ERROR_FILE_NOT_FOUND - | Foundation::ERROR_PATH_NOT_FOUND - | Foundation::ERROR_NOT_READY - | Foundation::ERROR_BAD_NET_NAME + ERROR_FILE_NOT_FOUND + | ERROR_PATH_NOT_FOUND + | ERROR_NOT_READY + | ERROR_BAD_NET_NAME ) { return Err(err); @@ -885,17 +835,12 @@ pub fn win32_xstat(path: &OsStr, traverse: bool) -> io::Result { Ok(result) } -pub fn test_file_type_by_name(path: &Path, tested_type: TestType) -> bool { - match get_file_information_by_name( - path.as_os_str(), - FILE_INFO_BY_NAME_CLASS::FileStatBasicByNameInfo, - ) { +pub fn test_file_type_by_name(path: &widestring::WideCStr, tested_type: TestType) -> bool { + match get_file_information_by_name(path, FILE_INFO_BY_NAME_CLASS::FileStatBasicByNameInfo) { Ok(info) => { let disk_device = matches!( info.DeviceType, - windows_sys::Win32::Storage::FileSystem::FILE_DEVICE_DISK - | windows_sys::Win32::System::Ioctl::FILE_DEVICE_VIRTUAL_DISK - | windows_sys::Win32::Storage::FileSystem::FILE_DEVICE_CD_ROM + FILE_DEVICE_DISK | FILE_DEVICE_VIRTUAL_DISK | FILE_DEVICE_CD_ROM ); let result = test_info( info.FileAttributes, @@ -905,9 +850,7 @@ pub fn test_file_type_by_name(path: &Path, tested_type: TestType) -> bool { ); if !result || !matches!(tested_type, TestType::RegularFile | TestType::Directory) - || (info.FileAttributes - & windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT) - == 0 + || (info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 { return result; } @@ -925,10 +868,9 @@ pub fn test_file_type_by_name(path: &Path, tested_type: TestType) -> bool { if !matches!(tested_type, TestType::RegularFile | TestType::Directory) { flags |= FILE_FLAG_OPEN_REPARSE_POINT; } - let wide_path = path.as_os_str().to_wide_with_nul(); let handle = unsafe { CreateFileW( - wide_path.as_ptr(), + path.as_ptr(), FILE_READ_ATTRIBUTES, 0, core::ptr::null(), @@ -949,7 +891,7 @@ pub fn test_file_type_by_name(path: &Path, tested_type: TestType) -> bool { | windows_sys::Win32::Foundation::ERROR_CANT_ACCESS_FILE | windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER => { let stat = win32_xstat( - path.as_os_str(), + path, matches!(tested_type, TestType::RegularFile | TestType::Directory), ); if let Ok(st) = stat { @@ -968,15 +910,10 @@ pub fn test_file_type_by_name(path: &Path, tested_type: TestType) -> bool { false } -pub fn test_file_exists_by_name(path: &Path, follow_links: bool) -> bool { - match get_file_information_by_name( - path.as_os_str(), - FILE_INFO_BY_NAME_CLASS::FileStatBasicByNameInfo, - ) { +pub fn test_file_exists_by_name(path: &widestring::WideCStr, follow_links: bool) -> bool { + match get_file_information_by_name(path, FILE_INFO_BY_NAME_CLASS::FileStatBasicByNameInfo) { Ok(info) => { - if (info.FileAttributes - & windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT) - == 0 + if (info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 || (!follow_links && is_reparse_tag_name_surrogate(info.ReparseTag)) { return true; @@ -991,14 +928,13 @@ pub fn test_file_exists_by_name(path: &Path, follow_links: bool) -> bool { } } - let wide_path = path.as_os_str().to_wide_with_nul(); let mut flags = FILE_FLAG_BACKUP_SEMANTICS; if !follow_links { flags |= FILE_FLAG_OPEN_REPARSE_POINT; } let handle = unsafe { CreateFileW( - wide_path.as_ptr(), + path.as_ptr(), FILE_READ_ATTRIBUTES, 0, core::ptr::null(), @@ -1020,7 +956,7 @@ pub fn test_file_exists_by_name(path: &Path, follow_links: bool) -> bool { } let handle = unsafe { CreateFileW( - wide_path.as_ptr(), + path.as_ptr(), FILE_READ_ATTRIBUTES, 0, core::ptr::null(), @@ -1036,11 +972,11 @@ pub fn test_file_exists_by_name(path: &Path, follow_links: bool) -> bool { } match unsafe { GetLastError() } { - windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED - | windows_sys::Win32::Foundation::ERROR_SHARING_VIOLATION - | windows_sys::Win32::Foundation::ERROR_CANT_ACCESS_FILE - | windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER => { - return win32_xstat(path.as_os_str(), follow_links).is_ok(); + ERROR_ACCESS_DENIED + | ERROR_SHARING_VIOLATION + | ERROR_CANT_ACCESS_FILE + | ERROR_INVALID_PARAMETER => { + return win32_xstat(path, follow_links).is_ok(); } _ => {} } @@ -1049,7 +985,9 @@ pub fn test_file_exists_by_name(path: &Path, follow_links: bool) -> bool { } pub fn path_exists_via_open(path: &Path, follow_links: bool) -> bool { - let wide_path = path.as_os_str().to_wide_with_nul(); + let Ok(wide_path) = WideCString::from_os_str(path.as_os_str()) else { + return false; + }; let mut flags = FILE_FLAG_BACKUP_SEMANTICS; if !follow_links { flags |= FILE_FLAG_OPEN_REPARSE_POINT; @@ -1260,21 +1198,10 @@ pub fn dup2(fd: i32, fd2: i32, inheritable: bool) -> io::Result { Ok(fd2) } -pub fn readlink(path: &Path) -> Result { - use windows_sys::Win32::Storage::FileSystem::{ - FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, - FILE_SHARE_READ, FILE_SHARE_WRITE, - }; - use windows_sys::Win32::System::IO::DeviceIoControl; - use windows_sys::Win32::System::Ioctl::FSCTL_GET_REPARSE_POINT; - use windows_sys::Win32::System::SystemServices::{ - IO_REPARSE_TAG_MOUNT_POINT, IO_REPARSE_TAG_SYMLINK, - }; - - let wide_path = path.as_os_str().to_wide_with_nul(); +pub fn readlink(path: &widestring::WideCStr) -> Result { let handle = unsafe { CreateFileW( - wide_path.as_ptr(), + path.as_ptr(), 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, core::ptr::null(), @@ -1369,13 +1296,12 @@ pub fn kill(pid: u32, sig: u32) -> io::Result<()> { } } -pub fn getfinalpathname(path: &Path) -> io::Result { +pub fn getfinalpathname(path: &widestring::WideCStr) -> io::Result { use windows_sys::Win32::Storage::FileSystem::{GetFinalPathNameByHandleW, VOLUME_NAME_DOS}; - let wide = path.as_os_str().to_wide_with_nul(); let handle = unsafe { CreateFileW( - wide.as_ptr(), + path.as_ptr(), 0, 0, core::ptr::null(), @@ -1409,12 +1335,11 @@ pub fn getfinalpathname(path: &Path) -> io::Result { result } -pub fn getfullpathname(path: &Path) -> io::Result { - let wide = path.as_os_str().to_wide_with_nul(); +pub fn getfullpathname(path: &widestring::WideCStr) -> io::Result { let mut buffer = vec![0u16; MAX_PATH as usize]; let mut ret = unsafe { windows_sys::Win32::Storage::FileSystem::GetFullPathNameW( - wide.as_ptr(), + path.as_ptr(), buffer.len() as u32, buffer.as_mut_ptr(), core::ptr::null_mut(), @@ -1425,7 +1350,7 @@ pub fn getfullpathname(path: &Path) -> io::Result { buffer.resize(ret as usize, 0); ret = unsafe { windows_sys::Win32::Storage::FileSystem::GetFullPathNameW( - wide.as_ptr(), + path.as_ptr(), buffer.len() as u32, buffer.as_mut_ptr(), core::ptr::null_mut(), @@ -1437,13 +1362,12 @@ pub fn getfullpathname(path: &Path) -> io::Result { Ok(widestring::WideCString::from_vec_truncate(buffer).to_os_string()) } -pub fn getvolumepathname(path: &Path) -> io::Result { - let wide = path.as_os_str().to_wide_with_nul(); - let buflen = core::cmp::max(wide.len(), MAX_PATH as usize); +pub fn getvolumepathname(path: &widestring::WideCStr) -> io::Result { + let buflen = core::cmp::max(path.len(), MAX_PATH as usize); let mut buffer = vec![0u16; buflen]; unsafe { windows_sys::Win32::Storage::FileSystem::GetVolumePathNameW( - wide.as_ptr(), + path.as_ptr(), buffer.as_mut_ptr(), buflen as u32, ) @@ -1452,23 +1376,21 @@ pub fn getvolumepathname(path: &Path) -> io::Result { Ok(widestring::WideCString::from_vec_truncate(buffer).to_os_string()) } -pub fn getdiskusage(path: &Path) -> io::Result<(u64, u64)> { - use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW; - - let wide = path.as_os_str().to_wide_with_nul(); +pub fn getdiskusage(path: &widestring::WideCStr) -> io::Result<(u64, u64)> { let mut free_to_me = 0u64; let mut total = 0u64; let mut free = 0u64; - let ok = unsafe { GetDiskFreeSpaceExW(wide.as_ptr(), &mut free_to_me, &mut total, &mut free) }; + let ok = unsafe { GetDiskFreeSpaceExW(path.as_ptr(), &mut free_to_me, &mut total, &mut free) }; if ok != 0 { return Ok((total, free)); } let err = io::Error::last_os_error(); - if err.raw_os_error() == Some(windows_sys::Win32::Foundation::ERROR_DIRECTORY as i32) - && let Some(parent) = path.parent() + if err.raw_os_error() == Some(ERROR_DIRECTORY as i32) + && let Some(parent) = Path::new(&path.to_os_string()).parent() { - let parent = widestring::WideCString::from_os_str(parent).unwrap(); + let parent = widestring::WideCString::from_os_str(parent) + .expect("interior NULs are impossible because parent was constructed from a WideCStr"); let ok = unsafe { GetDiskFreeSpaceExW(parent.as_ptr(), &mut free_to_me, &mut total, &mut free) }; if ok != 0 { @@ -1480,28 +1402,17 @@ pub fn getdiskusage(path: &Path) -> io::Result<(u64, u64)> { pub fn get_handle_inheritable(handle: intptr_t) -> io::Result { let mut flags = 0; - let ok = - unsafe { windows_sys::Win32::Foundation::GetHandleInformation(handle as _, &mut flags) }; + let ok = unsafe { GetHandleInformation(handle as _, &mut flags) }; if ok == 0 { Err(io::Error::last_os_error()) } else { - Ok(flags & windows_sys::Win32::Foundation::HANDLE_FLAG_INHERIT != 0) + Ok(flags & HANDLE_FLAG_INHERIT != 0) } } pub fn set_handle_inheritable(handle: intptr_t, inheritable: bool) -> io::Result<()> { - let flags = if inheritable { - windows_sys::Win32::Foundation::HANDLE_FLAG_INHERIT - } else { - 0 - }; - let ok = unsafe { - windows_sys::Win32::Foundation::SetHandleInformation( - handle as _, - windows_sys::Win32::Foundation::HANDLE_FLAG_INHERIT, - flags, - ) - }; + let flags = if inheritable { HANDLE_FLAG_INHERIT } else { 0 }; + let ok = unsafe { SetHandleInformation(handle as _, HANDLE_FLAG_INHERIT, flags) }; if ok == 0 { Err(io::Error::last_os_error()) } else { @@ -1512,9 +1423,7 @@ pub fn set_handle_inheritable(handle: intptr_t, inheritable: bool) -> io::Result pub fn getlogin() -> io::Result { let mut buffer = [0u16; 257]; let mut size = buffer.len() as u32; - let ok = unsafe { - windows_sys::Win32::System::WindowsProgramming::GetUserNameW(buffer.as_mut_ptr(), &mut size) - }; + let ok = unsafe { GetUserNameW(buffer.as_mut_ptr(), &mut size) }; if ok == 0 { return Err(io::Error::last_os_error()); } @@ -1526,19 +1435,12 @@ pub fn getlogin() -> io::Result { pub fn listdrives() -> io::Result> { let mut buffer = [0u16; 256]; - let len = unsafe { - windows_sys::Win32::Storage::FileSystem::GetLogicalDriveStringsW( - buffer.len() as u32, - buffer.as_mut_ptr(), - ) - }; + let len = unsafe { GetLogicalDriveStringsW(buffer.len() as u32, buffer.as_mut_ptr()) }; if len == 0 { return Err(io::Error::last_os_error()); } if len as usize >= buffer.len() { - return Err(io::Error::from_raw_os_error( - windows_sys::Win32::Foundation::ERROR_MORE_DATA as i32, - )); + return Err(io::Error::from_raw_os_error(ERROR_MORE_DATA as i32)); } Ok(buffer[..(len - 1) as usize] .split(|&c| c == 0) @@ -1586,15 +1488,14 @@ pub fn listvolumes() -> io::Result> { Ok(result) } -pub fn listmounts(volume: &Path) -> io::Result> { - let wide = volume.as_os_str().to_wide_with_nul(); +pub fn listmounts(volume: &widestring::WideCStr) -> io::Result> { let mut buflen: u32 = MAX_PATH + 1; let mut buffer = vec![0u16; buflen as usize]; loop { let ok = unsafe { - windows_sys::Win32::Storage::FileSystem::GetVolumePathNamesForVolumeNameW( - wide.as_ptr(), + GetVolumePathNamesForVolumeNameW( + volume.as_ptr(), buffer.as_mut_ptr(), buflen, &mut buflen, @@ -1604,7 +1505,7 @@ pub fn listmounts(volume: &Path) -> io::Result> { break; } let err = io::Error::last_os_error(); - if err.raw_os_error() == Some(windows_sys::Win32::Foundation::ERROR_MORE_DATA as i32) { + if err.raw_os_error() == Some(ERROR_MORE_DATA as i32) { buffer.resize(buflen as usize, 0); continue; } @@ -1679,6 +1580,7 @@ pub fn getppid() -> u32 { pub fn path_skip_root(path: &widestring::WideCStr) -> Option { let mut end: *const u16 = core::ptr::null(); + // SAFETY: `path` is a valid pointer to a nul terminated wide string without interior nuls. let hr = unsafe { windows_sys::Win32::UI::Shell::PathCchSkipRoot(path.as_ptr(), &mut end) }; if hr >= 0 { assert!(!end.is_null()); @@ -1697,19 +1599,17 @@ pub fn get_terminal_size_handle(h: HANDLE) -> io::Result<(usize, usize)> { let ret = unsafe { Console::GetConsoleScreenBufferInfo(h, csbi.as_mut_ptr()) }; if ret == 0 { let err = unsafe { GetLastError() }; - if err != windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED { + if err != ERROR_ACCESS_DENIED { return Err(io::Error::last_os_error()); } let conout = w!("CONOUT$"); let console_handle = unsafe { CreateFileW( conout, - windows_sys::Win32::Foundation::GENERIC_READ - | windows_sys::Win32::Foundation::GENERIC_WRITE, - windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ - | windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, core::ptr::null(), - windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING, + OPEN_EXISTING, 0, core::ptr::null_mut(), ) @@ -1964,8 +1864,7 @@ pub fn read_console_into( } let err = io::Error::last_os_error(); - if err.raw_os_error() == Some(windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER as i32) - { + if err.raw_os_error() == Some(ERROR_INSUFFICIENT_BUFFER as i32) { let needed = unsafe { WideCharToMultiByte( CP_UTF8, @@ -2119,11 +2018,6 @@ pub fn write_console_utf8(handle: HANDLE, data: &[u8], max_bytes: usize) -> io:: } pub fn open_console_path_fd(path: &widestring::WideCStr, writable: bool) -> io::Result { - use windows_sys::Win32::{ - Foundation::{GENERIC_READ, GENERIC_WRITE}, - Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE}, - }; - let access = if writable { GENERIC_WRITE } else { diff --git a/crates/host_env/src/overlapped.rs b/crates/host_env/src/overlapped.rs index be5e75f585f..2547ab9f58b 100644 --- a/crates/host_env/src/overlapped.rs +++ b/crates/host_env/src/overlapped.rs @@ -15,7 +15,9 @@ use std::{ sync::{Mutex, OnceLock}, }; -use crate::windows::{CheckWin32Bool, CheckWin32Handle}; +use crate::windows::{CheckWin32Bool, CheckWin32Handle, ToWideString}; +use rustpython_wtf8::Wtf8; +use widestring::WideCStr; use windows_sys::Win32::{ Foundation::{CloseHandle, ERROR_IO_PENDING, ERROR_MORE_DATA, ERROR_SUCCESS, HANDLE}, Networking::WinSock::{AF_INET, AF_INET6, SOCKADDR, SOCKADDR_IN, SOCKADDR_IN6}, @@ -1020,7 +1022,10 @@ pub fn bind_local(socket: isize, family: i32) -> io::Result<()> { } } -pub fn parse_address_v4_wide(host_wide: &[u16], port: u16) -> io::Result<(Vec, i32)> { +pub fn parse_address_v4_wide( + host_wide: &widestring::WideCStr, + port: u16, +) -> io::Result<(Vec, i32)> { use windows_sys::Win32::Networking::WinSock::{WSAGetLastError, WSAStringToAddressW}; let mut addr: SOCKADDR_IN = unsafe { core::mem::zeroed() }; @@ -1028,6 +1033,7 @@ pub fn parse_address_v4_wide(host_wide: &[u16], port: u16) -> io::Result<(Vec() as i32; + // SAFETY: host_wide is nul capped and doesn't have interior nuls let ret = unsafe { WSAStringToAddressW( host_wide.as_ptr(), @@ -1056,7 +1062,7 @@ pub fn parse_address_v4_wide(host_wide: &[u16], port: u16) -> io::Result<(Vec io::Result<(Vec, i32)> { - let host_wide: Vec = host.encode_utf16().chain([0]).collect(); + let host_wide = Wtf8::new(host).to_wide_cstring()?; parse_address_v4_wide(&host_wide, port) } @@ -1066,12 +1072,12 @@ pub fn parse_address_v6( flowinfo: u32, scope_id: u32, ) -> io::Result<(Vec, i32)> { - let host_wide: Vec = host.encode_utf16().chain([0]).collect(); + let host_wide = Wtf8::new(host).to_wide_cstring()?; parse_address_v6_wide(&host_wide, port, flowinfo, scope_id) } pub fn parse_address_v6_wide( - host_wide: &[u16], + host_wide: &WideCStr, port: u16, flowinfo: u32, scope_id: u32, @@ -1083,6 +1089,7 @@ pub fn parse_address_v6_wide( let mut addr_len = core::mem::size_of::() as i32; + // SAFETY: host_wide is nul capped and doesn't have interior nuls let ret = unsafe { WSAStringToAddressW( host_wide.as_ptr(), diff --git a/crates/host_env/src/posix_windows.rs b/crates/host_env/src/posix_windows.rs index e78bd8f743f..61c20e9b229 100644 --- a/crates/host_env/src/posix_windows.rs +++ b/crates/host_env/src/posix_windows.rs @@ -78,7 +78,7 @@ fn rename_impl( .into_vec_with_nul(); // SAFETY: - // * from and to are NUL terminated wide strings + // * from and to are NUL terminated wide strings without interior nuls let success = unsafe { // Rust's [`std::fs::rename`] is more complicated than CPython's. Rust attempts to use modern APIs // where available, such as `FileRenameInfoEx`, which better map to POSIX. CPython simply diff --git a/crates/host_env/src/winapi.rs b/crates/host_env/src/winapi.rs index 19e18f32d3f..31783847311 100644 --- a/crates/host_env/src/winapi.rs +++ b/crates/host_env/src/winapi.rs @@ -1140,6 +1140,12 @@ pub fn lc_map_string_ex( src: &[u16], ) -> io::Result> { let src_len = src.len() as i32; + // SAFETY: + // * locale does not have interior NULs and ends with a NUL. This is guaranteed by + // WideCStr. + // * src CAN have interior NULs and DOES NOT need to end with a NUL. However, the length must be + // passed into LCMapStringEx. If the length is NOT passed in, Windows calculates the length + // and interior NULs are not allowed. let dest_size = unsafe { windows_sys::Win32::Globalization::LCMapStringEx( locale.as_ptr(), diff --git a/crates/host_env/src/windows.rs b/crates/host_env/src/windows.rs index 635f12f3f38..54710467c33 100644 --- a/crates/host_env/src/windows.rs +++ b/crates/host_env/src/windows.rs @@ -2,8 +2,9 @@ use rustpython_wtf8::Wtf8; use std::{ ffi::{OsStr, OsString}, io, - os::windows::ffi::{OsStrExt, OsStringExt}, + os::windows::ffi::OsStringExt, }; +use widestring::WideCString; use windows_sys::{ Win32::{ Foundation::{ @@ -397,54 +398,39 @@ pub fn multi_byte_to_wide( } } +/// [`OsStr`] to [`WideCString`] for Windows FFI. +/// +/// Prefer using this trait when encoding bytes to pass to Windows. Interior NULs are memory safe +/// but possibly a security hazard for FFI. +/// +/// https://github.com/python/cpython/issues/111656 pub trait ToWideString { - fn to_wide(&self) -> Vec; - fn to_wide_with_nul(&self) -> Vec; - fn to_wide_cstring(&self) -> widestring::WideCString { - widestring::WideCString::from_vec_truncate(self.to_wide()) - } + fn to_wide_cstring(&self) -> Result; } impl ToWideString for T where T: AsRef, { - fn to_wide(&self) -> Vec { - self.as_ref().encode_wide().collect() - } - fn to_wide_with_nul(&self) -> Vec { - self.as_ref().encode_wide().chain(Some(0)).collect() - } -} - -impl ToWideString for OsStr { - fn to_wide(&self) -> Vec { - self.encode_wide().collect() - } - fn to_wide_with_nul(&self) -> Vec { - self.encode_wide().chain(Some(0)).collect() + fn to_wide_cstring(&self) -> Result { + WideCString::from_os_str(self).map_err(|_| io::Error::other("embedded null character")) } } impl ToWideString for Wtf8 { - fn to_wide(&self) -> Vec { - self.encode_wide().collect() - } - fn to_wide_with_nul(&self) -> Vec { - self.encode_wide().chain(Some(0)).collect() - } -} - -pub trait FromWideString -where - Self: Sized, -{ - fn from_wides_until_nul(wide: &[u16]) -> Self; -} + fn to_wide_cstring(&self) -> Result { + // CPython's "test_invalid_cmd" test calls Popen with "pass#\0" as a command line. + // That's technically valid since it caps the string, but CString and WideCString differ + // in how they handle it. Rust's CString rejects any NULs whereas WideCString accepts a NUL + // only if it appears at the end of a buffer. + // + // For the sake of that behavior, fail on trailing NUL. + if self.as_bytes().last().is_some_and(|&b| b == 0) { + return Err(io::Error::other("embedded null character")); + } -impl FromWideString for OsString { - fn from_wides_until_nul(wide: &[u16]) -> Self { - let len = wide.iter().take_while(|&&c| c != 0).count(); - Self::from_wide(&wide[..len]) + let mut buf = Vec::with_capacity(self.len() + 1); + buf.extend(self.encode_wide()); + WideCString::from_vec(buf).map_err(|_| io::Error::other("embedded null character")) } } diff --git a/crates/host_env/src/winreg.rs b/crates/host_env/src/winreg.rs index 5324ac258e2..5a67e4e2b89 100644 --- a/crates/host_env/src/winreg.rs +++ b/crates/host_env/src/winreg.rs @@ -14,9 +14,7 @@ extern crate alloc; use alloc::string::FromUtf16Error; -use std::ffi::OsStr; -use crate::windows::ToWideString; use windows_sys::Win32::{ Foundation, Security::SECURITY_ATTRIBUTES, @@ -347,20 +345,11 @@ pub enum QueryStringError { pub fn query_default_value( hkey: Registry::HKEY, - sub_key: Option<&OsStr>, + sub_key: Option<&widestring::WideCStr>, ) -> Result { let child_key = if let Some(sub_key) = sub_key.filter(|s| !s.is_empty()) { - let wide_sub_key = sub_key.to_wide_cstring(); let mut out_key = core::ptr::null_mut(); - let res = unsafe { - open_key_ex( - hkey, - &wide_sub_key, - 0, - Registry::KEY_QUERY_VALUE, - &mut out_key, - ) - }; + let res = unsafe { open_key_ex(hkey, sub_key, 0, Registry::KEY_QUERY_VALUE, &mut out_key) }; if res != 0 { return Err(QueryStringError::Code(res)); } @@ -415,13 +404,15 @@ pub fn query_default_value( result } -pub fn query_value_bytes(hkey: Registry::HKEY, value_name: &OsStr) -> Result<(Vec, u32), u32> { - let wide_name = value_name.to_wide_cstring(); +pub fn query_value_bytes( + hkey: Registry::HKEY, + wide_name: &widestring::WideCStr, +) -> Result<(Vec, u32), u32> { let mut buf_size: u32 = 0; let res = unsafe { query_value_ex( hkey, - Some(&wide_name), + Some(wide_name), core::ptr::null_mut(), core::ptr::null_mut(), &mut buf_size, @@ -441,7 +432,7 @@ pub fn query_value_bytes(hkey: Registry::HKEY, value_name: &OsStr) -> Result<(Ve let res = unsafe { query_value_ex( hkey, - Some(&wide_name), + Some(wide_name), &mut typ, ret_buf.as_mut_ptr(), &mut ret_size, @@ -459,14 +450,18 @@ pub fn query_value_bytes(hkey: Registry::HKEY, value_name: &OsStr) -> Result<(Ve } } -pub fn set_default_value(hkey: Registry::HKEY, sub_key: &OsStr, typ: u32, value: &OsStr) -> u32 { +pub fn set_default_value( + hkey: Registry::HKEY, + sub_key: &widestring::WideCStr, + typ: u32, + wide_value: &widestring::WideStr, +) -> u32 { let child_key = if !sub_key.is_empty() { - let wide_sub_key = sub_key.to_wide_cstring(); let mut out_key = core::ptr::null_mut(); let res = unsafe { create_key_ex( hkey, - &wide_sub_key, + sub_key, 0, core::ptr::null_mut(), 0, @@ -485,7 +480,6 @@ pub fn set_default_value(hkey: Registry::HKEY, sub_key: &OsStr, typ: u32, value: }; let target_key = child_key.unwrap_or(hkey); - let wide_value = value.to_wide_with_nul(); let res = unsafe { set_value_ex( target_key, @@ -502,8 +496,9 @@ pub fn set_default_value(hkey: Registry::HKEY, sub_key: &OsStr, typ: u32, value: res } -pub fn expand_environment_strings(input: &OsStr) -> Result { - let wide_input = input.to_wide_with_nul(); +pub fn expand_environment_strings( + wide_input: &widestring::WideCStr, +) -> Result { let required_size = unsafe { Environment::ExpandEnvironmentStringsW(wide_input.as_ptr(), core::ptr::null_mut(), 0) }; diff --git a/crates/host_env/src/wmi.rs b/crates/host_env/src/wmi.rs index 2b46eebcbe5..74620048492 100644 --- a/crates/host_env/src/wmi.rs +++ b/crates/host_env/src/wmi.rs @@ -556,8 +556,8 @@ unsafe fn query_thread_impl(param: *mut c_void) -> u32 { pub fn exec_query(query_str: &str) -> Result { let query = WideCString::from_str(query_str) - .map_err(|_| ExecQueryError::Code(ERROR_INVALID_NAME))? - .into(); + .map(WideCString::into_vec_with_nul) + .map_err(|_| ExecQueryError::Code(ERROR_INVALID_NAME))?; let mut h_thread: HANDLE = null_mut(); let mut err: u32 = 0; diff --git a/crates/stdlib/src/overlapped.rs b/crates/stdlib/src/overlapped.rs index 6cdd0014604..545db30c586 100644 --- a/crates/stdlib/src/overlapped.rs +++ b/crates/stdlib/src/overlapped.rs @@ -19,6 +19,7 @@ mod _overlapped { }; use rustpython_host_env::{ overlapped as host_overlapped, winapi as host_winapi, windows as host_windows, + windows::ToWideString, }; pub(crate) fn module_exec(vm: &VirtualMachine, module: &Py) -> PyResult<()> { @@ -210,7 +211,10 @@ mod _overlapped { // IPv4: (host, port) let host: PyStrRef = addr_obj[0].clone().try_into_value(vm)?; let port: u16 = addr_obj[1].clone().try_to_value(vm)?; - let host_wide: Vec = host.as_wtf8().encode_wide().chain([0]).collect(); + let host_wide = host + .as_wtf8() + .to_wide_cstring() + .map_err(|e| e.to_pyexception(vm))?; host_overlapped::parse_address_v4_wide(&host_wide, port) .map_err(|err| set_from_windows_err(err.raw_os_error().unwrap_or(0) as u32, vm)) } @@ -220,7 +224,10 @@ mod _overlapped { let port: u16 = addr_obj[1].clone().try_to_value(vm)?; let flowinfo: u32 = addr_obj[2].clone().try_to_value(vm)?; let scope_id: u32 = addr_obj[3].clone().try_to_value(vm)?; - let host_wide: Vec = host.as_wtf8().encode_wide().chain([0]).collect(); + let host_wide = host + .as_wtf8() + .to_wide_cstring() + .map_err(|e| e.to_pyexception(vm))?; host_overlapped::parse_address_v6_wide(&host_wide, port, flowinfo, scope_id) .map_err(|err| set_from_windows_err(err.raw_os_error().unwrap_or(0) as u32, vm)) } diff --git a/crates/vm/src/exceptions.rs b/crates/vm/src/exceptions.rs index 0a1c2cb75ee..e5f296b0292 100644 --- a/crates/vm/src/exceptions.rs +++ b/crates/vm/src/exceptions.rs @@ -1251,7 +1251,14 @@ impl ToPyException for widestring::error::ContainsNul { #[cfg(windows)] impl ToPyException for widestring::error::MissingNulTerminator { fn to_pyexception(&self, vm: &VirtualMachine) -> PyBaseExceptionRef { - vm.new_value_error(self.to_string()) + nul_char_error(vm) + } +} + +#[cfg(windows)] +impl ToPyException for widestring::error::NulError { + fn to_pyexception(&self, vm: &VirtualMachine) -> PyBaseExceptionRef { + nul_char_error(vm) } } diff --git a/crates/vm/src/stdlib/_codecs.rs b/crates/vm/src/stdlib/_codecs.rs index 8f6ea5f1900..5bceccd7c6b 100644 --- a/crates/vm/src/stdlib/_codecs.rs +++ b/crates/vm/src/stdlib/_codecs.rs @@ -381,6 +381,7 @@ mod _codecs_windows { use crate::{PyResult, VirtualMachine}; use crate::{builtins::PyStrRef, builtins::PyUtf8StrRef, function::ArgBytesLike}; use rustpython_host_env::windows as host_windows; + use std::{ffi::OsStr, os::windows::ffi::OsStrExt}; fn string_from_utf16( encoding: &str, @@ -409,8 +410,6 @@ mod _codecs_windows { #[pyfunction] fn mbcs_encode(args: MbcsEncodeArgs, vm: &VirtualMachine) -> PyResult<(Vec, usize)> { - use crate::host_env::windows::ToWideString; - let errors = args.errors.as_ref().map_or("strict", |s| s.as_str()); let s = match args.s.to_str() { Some(s) => s, @@ -426,7 +425,7 @@ mod _codecs_windows { } // Convert UTF-8 string to UTF-16 - let wide: Vec = std::ffi::OsStr::new(s).to_wide(); + let wide: Vec<_> = OsStr::new(s).encode_wide().collect(); // Get the required buffer size let (size, _) = host_windows::wide_char_to_multi_byte_len( @@ -527,8 +526,6 @@ mod _codecs_windows { #[pyfunction] fn oem_encode(args: OemEncodeArgs, vm: &VirtualMachine) -> PyResult<(Vec, usize)> { - use crate::host_env::windows::ToWideString; - let errors = args.errors.as_ref().map_or("strict", |s| s.as_str()); let s = match args.s.to_str() { Some(s) => s, @@ -544,7 +541,7 @@ mod _codecs_windows { } // Convert UTF-8 string to UTF-16 - let wide: Vec = std::ffi::OsStr::new(s).to_wide(); + let wide: Vec<_> = OsStr::new(s).encode_wide().collect(); // Get the required buffer size let (size, _) = host_windows::wide_char_to_multi_byte_len( @@ -884,8 +881,6 @@ mod _codecs_windows { args: CodePageEncodeArgs, vm: &VirtualMachine, ) -> PyResult<(Vec, usize)> { - use crate::host_env::windows::ToWideString; - if args.code_page < 0 { return Err(vm.new_value_error("invalid code page number")); } @@ -902,7 +897,7 @@ mod _codecs_windows { // Fast path: try encoding the whole string at once (only if no surrogates) if let Some(str_data) = args.s.to_str() { - let wide: Vec = std::ffi::OsStr::new(str_data).to_wide(); + let wide: Vec<_> = OsStr::new(str_data).encode_wide().collect(); if let Some(result) = try_encode_code_page_strict(code_page, &wide, vm)? { return Ok((result, char_len)); } diff --git a/crates/vm/src/stdlib/_ctypes/base.rs b/crates/vm/src/stdlib/_ctypes/base.rs index 6067fc61bf0..3d084db1255 100644 --- a/crates/vm/src/stdlib/_ctypes/base.rs +++ b/crates/vm/src/stdlib/_ctypes/base.rs @@ -18,8 +18,8 @@ use num_traits::{Signed, ToPrimitive}; use rustpython_common::lock::PyRwLock; use rustpython_common::wtf8::Wtf8; use rustpython_host_env::ctypes::{ - CTypeLayout, char_array_assignment_bytes, char_array_field_value, wchar_array_field_value, - write_cow_bytes_at_offset, + CTypeLayout, char_array_assignment_bytes, char_array_field_value, clone_wchar_null_terminated, + wchar_array_field_value, write_cow_bytes_at_offset, }; // StgInfo - Storage information for ctypes types @@ -381,12 +381,13 @@ pub(super) static CDATA_BUFFER_METHODS: BufferMethods = BufferMethods { }; /// Ensure PyBytes data is null-terminated. Returns (kept_alive_obj, pointer). +/// /// The caller must keep the returned object alive to keep the pointer valid. pub(super) fn ensure_z_null_terminated( bytes: &PyBytes, vm: &VirtualMachine, ) -> (PyObjectRef, usize) { - let buffer = rustpython_host_env::ctypes::null_terminated_bytes(bytes.as_bytes()); + let buffer = rustpython_host_env::ctypes::clone_as_null_terminated(bytes.as_bytes()); let ptr = buffer.as_ptr() as usize; let kept_alive: PyObjectRef = vm.ctx.new_bytes(buffer).into(); (kept_alive, ptr) @@ -394,7 +395,7 @@ pub(super) fn ensure_z_null_terminated( /// Convert str to null-terminated wchar_t buffer. Returns (PyBytes holder, pointer). pub(super) fn str_to_wchar_bytes(s: &Wtf8, vm: &VirtualMachine) -> (PyObjectRef, usize) { - let bytes = rustpython_host_env::ctypes::wchar_null_terminated_bytes(s); + let bytes = clone_wchar_null_terminated(s); let ptr = bytes.as_ptr() as usize; let holder: PyObjectRef = vm.ctx.new_bytes(bytes).into(); (holder, ptr) diff --git a/crates/vm/src/stdlib/_ctypes/function.rs b/crates/vm/src/stdlib/_ctypes/function.rs index afbe0ae76ea..0263c0db705 100644 --- a/crates/vm/src/stdlib/_ctypes/function.rs +++ b/crates/vm/src/stdlib/_ctypes/function.rs @@ -149,7 +149,7 @@ fn conv_param(value: &PyObject, vm: &VirtualMachine) -> PyResult { // 4. Python str -> wide string pointer (like PyUnicode_AsWideCharString) if let Some(s) = value.downcast_ref::() { - let wide_bytes = rustpython_host_env::ctypes::utf16z_bytes(s.as_wtf8()); + let wide_bytes: Vec = rustpython_host_env::ctypes::utf16z_bytes(s.as_wtf8()); let keep = vm.ctx.new_bytes(wide_bytes); let addr = keep.as_bytes().as_ptr() as usize; return Ok(Argument { @@ -161,7 +161,7 @@ fn conv_param(value: &PyObject, vm: &VirtualMachine) -> PyResult { // 9. Python bytes -> null-terminated buffer pointer // Need to ensure null termination like c_char_p if let Some(bytes) = value.downcast_ref::() { - let buffer = rustpython_host_env::ctypes::null_terminated_bytes(bytes.as_bytes()); + let buffer = rustpython_host_env::ctypes::clone_as_null_terminated(bytes.as_bytes()); let keep = vm.ctx.new_bytes(buffer); let addr = keep.as_bytes().as_ptr() as usize; return Ok(Argument { diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index 1b4439007b2..5479c47abc4 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -6274,7 +6274,10 @@ mod winconsoleio { } let name_str = nameobj.str(vm)?; - let wide = name_str.as_wtf8().to_wide_cstring(); + let wide = name_str + .as_wtf8() + .to_wide_cstring() + .map_err(|e| e.to_pyexception(vm))?; fd = host_nt::open_console_path_fd(&wide, writable) .map_err(|err| err.to_pyexception(vm))?; diff --git a/crates/vm/src/stdlib/_winapi.rs b/crates/vm/src/stdlib/_winapi.rs index 34e7b897e12..0f195b8f91b 100644 --- a/crates/vm/src/stdlib/_winapi.rs +++ b/crates/vm/src/stdlib/_winapi.rs @@ -10,6 +10,7 @@ mod _winapi { builtins::PyStrRef, common::lock::{PyMutex, PyMutexGuard}, convert::ToPyException, + exceptions::nul_char_error, function::{ArgMapping, ArgSequence, OptionalArg}, types::Constructor, windows::{WinHandle, WindowsSysResult}, @@ -92,7 +93,10 @@ mod _winapi { _template_file: PyObjectRef, // Always NULL (0) vm: &VirtualMachine, ) -> PyResult { - let file_name_wide = file_name.as_wtf8().to_wide_cstring(); + let file_name_wide = file_name + .as_wtf8() + .to_wide_cstring() + .map_err(|_| nul_char_error(vm))?; host_winapi::create_file_w( &file_name_wide, desired_access, @@ -231,25 +235,19 @@ mod _winapi { let handle_list = get_handle_list(args.startup_info.get_attr("lpAttributeList", vm)?, vm)?; // Validate no embedded null bytes in command name and command line - // before handing the strings off; to_wide_cstring truncates at NUL. - if let Some(ref name) = args.name - && name.as_bytes().contains(&0) - { - return Err(crate::exceptions::nul_char_error(vm)); - } - if let Some(ref cmd) = args.command_line - && cmd.as_bytes().contains(&0) - { - return Err(crate::exceptions::nul_char_error(vm)); - } - - let wcstring = |s: PyStrRef| s.as_wtf8().to_wide_cstring(); - let app_name = args.name.as_ref().map(|s| wcstring(s.clone())); - let current_dir = args.current_dir.as_ref().map(|s| wcstring(s.clone())); + // before handing the strings off; to_wide_cstring rejects interior NULs. + let wcstring = |s: PyStrRef| { + s.as_wtf8() + .to_wide_cstring() + .map_err(|_| nul_char_error(vm)) + }; + let app_name = args.name.map(wcstring).transpose()?; + let current_dir = args.current_dir.map(wcstring).transpose()?; let mut command_line = args .command_line - .as_ref() - .map(|s| wcstring(s.clone()).into_vec_with_nul()); + .map(|s| wcstring(s).map(widestring::WideCString::into_vec_with_nul)) + .transpose() + .map_err(|_| nul_char_error(vm))?; let procinfo = host_winapi::create_process( app_name.as_deref(), @@ -289,9 +287,12 @@ mod _winapi { } #[pyfunction] - fn NeedCurrentDirectoryForExePath(exe_name: PyStrRef) -> bool { - let exe_name = exe_name.as_wtf8().to_wide_cstring(); - host_winapi::need_current_directory_for_exe_path_w(&exe_name) + fn NeedCurrentDirectoryForExePath(exe_name: PyStrRef, vm: &VirtualMachine) -> PyResult { + exe_name + .as_wtf8() + .to_wide_cstring() + .map(|exe_name| host_winapi::need_current_directory_for_exe_path_w(&exe_name)) + .map_err(|_| nul_char_error(vm)) } #[pyfunction] @@ -404,7 +405,11 @@ mod _winapi { name: OptionalArg>, vm: &VirtualMachine, ) -> PyResult { - let name = name.flatten().map(|name| name.as_wtf8().to_wide_cstring()); + let name = name + .flatten() + .map(|name| name.as_wtf8().to_wide_cstring()) + .transpose() + .map_err(|_| nul_char_error(vm))?; host_winapi::create_job_object_w(name.as_deref()) .map(WinHandle) .map_err(|e| e.to_pyexception(vm)) @@ -450,8 +455,11 @@ mod _winapi { name: PyStrRef, vm: &VirtualMachine, ) -> PyResult { - let name_wide = name.as_wtf8().to_wide_cstring(); - host_winapi::open_mutex_w(desired_access, inherit_handle, &name_wide) + let name = name + .as_wtf8() + .to_wide_cstring() + .map_err(|_| nul_char_error(vm))?; + host_winapi::open_mutex_w(desired_access, inherit_handle, &name) .map(WinHandle) .map_err(|e| e.to_pyexception(vm)) } @@ -494,8 +502,13 @@ mod _winapi { } // Use ToWideString which properly handles WTF-8 (including surrogates) - let locale_wide = locale.as_wtf8().to_wide_cstring(); - let src_wide = src.as_wtf8().to_wide(); + let locale_wide = locale + .as_wtf8() + .to_wide_cstring() + .map_err(|_| nul_char_error(vm))?; + // SAFETY: Interior NULs and non-NUL capped strings are fine here because the API takes + // in a length. + let src_wide: Vec<_> = src.as_wtf8().encode_wide().collect(); if src_wide.len() > i32::MAX as usize { return Err(vm.new_overflow_error("input string is too long")); @@ -532,9 +545,13 @@ mod _winapi { /// CreateNamedPipe - Create a named pipe #[pyfunction] fn CreateNamedPipe(args: CreateNamedPipeArgs, vm: &VirtualMachine) -> PyResult { - let name_wide = args.name.as_wtf8().to_wide_cstring(); + let name = args + .name + .as_wtf8() + .to_wide_cstring() + .map_err(|_| nul_char_error(vm))?; host_winapi::create_named_pipe_w( - &name_wide, + &name, args.open_mode, args.pipe_mode, args.max_instances, @@ -661,26 +678,33 @@ mod _winapi { /// GetShortPathName - Return the short version of the provided path. #[pyfunction] fn GetShortPathName(path: PyStrRef, vm: &VirtualMachine) -> PyResult { - let path_wide = path.as_wtf8().to_wide_cstring(); - let wide = - host_winapi::get_short_path_name_w(&path_wide).map_err(|e| e.to_pyexception(vm))?; + let path = path + .as_wtf8() + .to_wide_cstring() + .map_err(|_| nul_char_error(vm))?; + let wide = host_winapi::get_short_path_name_w(&path).map_err(|e| e.to_pyexception(vm))?; Ok(path_name_result_to_pystr(wide, vm)) } /// GetLongPathName - Return the long version of the provided path. #[pyfunction] fn GetLongPathName(path: PyStrRef, vm: &VirtualMachine) -> PyResult { - let path_wide = path.as_wtf8().to_wide_cstring(); - let wide = - host_winapi::get_long_path_name_w(&path_wide).map_err(|e| e.to_pyexception(vm))?; + let path = path + .as_wtf8() + .to_wide_cstring() + .map_err(|_| nul_char_error(vm))?; + let wide = host_winapi::get_long_path_name_w(&path).map_err(|e| e.to_pyexception(vm))?; Ok(path_name_result_to_pystr(wide, vm)) } /// WaitNamedPipe - Wait for an instance of a named pipe to become available. #[pyfunction] fn WaitNamedPipe(name: PyStrRef, timeout: u32, vm: &VirtualMachine) -> PyResult<()> { - let name_wide = name.as_wtf8().to_wide_cstring(); - host_winapi::wait_named_pipe_w(&name_wide, timeout).map_err(|e| e.to_pyexception(vm)) + let name = name + .as_wtf8() + .to_wide_cstring() + .map_err(|_| nul_char_error(vm))?; + host_winapi::wait_named_pipe_w(&name, timeout).map_err(|e| e.to_pyexception(vm)) } /// PeekNamedPipe - Peek at data in a named pipe without removing it. @@ -733,8 +757,11 @@ mod _winapi { ) -> PyResult { let _ = security_attributes; // Ignored, always NULL - let name_wide = name.map(|n| n.as_wtf8().to_wide_cstring()); - host_winapi::create_event_w(manual_reset, initial_state, name_wide.as_deref()) + let name = name + .map(|n| n.as_wtf8().to_wide_cstring()) + .transpose() + .map_err(|_| nul_char_error(vm))?; + host_winapi::create_event_w(manual_reset, initial_state, name.as_deref()) .map(WinHandle) .map_err(|e| e.to_pyexception(vm)) } @@ -861,8 +888,11 @@ mod _winapi { vm: &VirtualMachine, ) -> PyResult { let _ = security_attributes; - let name_wide = name.map(|n| n.as_wtf8().to_wide_cstring()); - host_winapi::create_mutex_w(initial_owner, name_wide.as_deref()) + let name = name + .map(|n| n.as_wtf8().to_wide_cstring()) + .transpose() + .map_err(|_| nul_char_error(vm))?; + host_winapi::create_mutex_w(initial_owner, name.as_deref()) .map(WinHandle) .map_err(|e| e.to_pyexception(vm)) } @@ -875,8 +905,11 @@ mod _winapi { name: PyStrRef, vm: &VirtualMachine, ) -> PyResult { - let name_wide = name.as_wtf8().to_wide_cstring(); - host_winapi::open_event_w(desired_access, inherit_handle, &name_wide) + let name = name + .as_wtf8() + .to_wide_cstring() + .map_err(|_| nul_char_error(vm))?; + host_winapi::open_event_w(desired_access, inherit_handle, &name) .map(WinHandle) .map_err(|e| e.to_pyexception(vm)) } @@ -974,20 +1007,16 @@ mod _winapi { name: Option, vm: &VirtualMachine, ) -> PyResult { - if let Some(ref n) = name - && n.as_bytes().contains(&0) - { - return Err( - vm.new_value_error("CreateFileMapping: name must not contain null characters") - ); - } - let name_wide = name.as_ref().map(|n| n.as_wtf8().to_wide_cstring()); + let name = name + .map(|n| n.as_wtf8().to_wide_cstring()) + .transpose() + .map_err(|_| nul_char_error(vm))?; host_winapi::create_file_mapping_w( file_handle.0, protect, max_size_high, max_size_low, - name_wide.as_deref(), + name.as_deref(), ) .map(WinHandle) .map_err(|e| e.to_pyexception(vm)) @@ -1001,13 +1030,11 @@ mod _winapi { name: PyStrRef, vm: &VirtualMachine, ) -> PyResult { - if name.as_bytes().contains(&0) { - return Err( - vm.new_value_error("OpenFileMapping: name must not contain null characters") - ); - } - let name_wide = name.as_wtf8().to_wide_cstring(); - host_winapi::open_file_mapping_w(desired_access, inherit_handle, &name_wide) + let name = name + .as_wtf8() + .to_wide_cstring() + .map_err(|_| nul_char_error(vm))?; + host_winapi::open_file_mapping_w(desired_access, inherit_handle, &name) .map(WinHandle) .map_err(|e| e.to_pyexception(vm)) } @@ -1053,8 +1080,14 @@ mod _winapi { _progress_routine: OptionalArg, vm: &VirtualMachine, ) -> PyResult<()> { - let src_wide = existing_file_name.as_wtf8().to_wide_cstring(); - let dst_wide = new_file_name.as_wtf8().to_wide_cstring(); + let src_wide = existing_file_name + .as_wtf8() + .to_wide_cstring() + .map_err(|_| nul_char_error(vm))?; + let dst_wide = new_file_name + .as_wtf8() + .to_wide_cstring() + .map_err(|_| nul_char_error(vm))?; host_winapi::copy_file2(&src_wide, &dst_wide, flags).map_err(|e| e.to_pyexception(vm)) } diff --git a/crates/vm/src/stdlib/nt.rs b/crates/vm/src/stdlib/nt.rs index 26412e352ce..ebe539621c1 100644 --- a/crates/vm/src/stdlib/nt.rs +++ b/crates/vm/src/stdlib/nt.rs @@ -9,9 +9,9 @@ pub(crate) mod module { Py, PyResult, TryFromObject, VirtualMachine, builtins::{PyBytes, PyDictRef, PyListRef, PyStr, PyStrRef, PyTupleRef}, convert::ToPyException, - exceptions::{self, OSErrorBuilder}, + exceptions::{self, OSErrorBuilder, ToOSErrorBuilder}, function::{ArgMapping, Either, OptionalArg}, - host_env::{crt_fd, windows::ToWideString}, + host_env::crt_fd, ospath::{OsPath, OsPathOrFd}, stdlib::os::{_os, DirFd, SupportFunc, TargetIsDirectory}, }; @@ -92,8 +92,8 @@ pub(crate) mod module { vm: &VirtualMachine, ) -> PyResult<()> { let [] = dir_fd.0; - let _ = path.to_wide_cstring(vm)?; - host_nt::remove(path.as_ref()).map_err(|err| OSErrorBuilder::with_filename(&err, path, vm)) + let wide = path.to_wide_cstring(vm)?; + host_nt::remove(&wide).map_err(|err| OSErrorBuilder::with_filename(&err, path, vm)) } #[pyfunction] @@ -113,7 +113,6 @@ pub(crate) mod module { #[pyfunction] pub(super) fn symlink(args: SymlinkArgs<'_>, vm: &VirtualMachine) -> PyResult<()> { - use crate::exceptions::ToOSErrorBuilder; let src = args.src.to_wide_cstring(vm)?; let dst = args.dst.to_wide_cstring(vm)?; if let Err(err) = host_nt::symlink( @@ -184,7 +183,8 @@ pub(crate) mod module { } fn win32_lchmod(path: &OsPath, mode: u32, vm: &VirtualMachine) -> PyResult<()> { - host_nt::win32_lchmod(path.path.as_os_str(), mode, S_IWRITE) + let wide = path.to_wide_cstring(vm)?; + host_nt::win32_lchmod(&wide, mode, S_IWRITE) .map_err(|err| OSErrorBuilder::with_filename(&err, path.clone(), vm)) } @@ -232,7 +232,8 @@ pub(crate) mod module { /// Uses FindFirstFileW to get the name as stored on the filesystem. #[pyfunction] fn _findfirstfile(path: OsPath, vm: &VirtualMachine) -> PyResult { - let filename = host_nt::find_first_file_name(path.as_ref()) + let wide = path.to_wide_cstring(vm)?; + let filename = host_nt::find_first_file_name(&wide) .map_err(|err| OSErrorBuilder::with_filename(&err, path.clone(), vm))?; let filename_wide: Vec<_> = filename.encode_wide().collect(); Ok(vm.ctx.new_str(Wtf8Buf::from_wide(&filename_wide))) @@ -291,7 +292,7 @@ pub(crate) mod module { } /// _testFileTypeByName - test file type by path name - fn _test_file_type_by_name(path: &std::path::Path, tested_type: u32) -> bool { + fn _test_file_type_by_name(path: &widestring::WideCStr, tested_type: u32) -> bool { let tested_type = match tested_type { PY_IFREG => host_nt::TestType::RegularFile, PY_IFDIR => host_nt::TestType::Directory, @@ -304,11 +305,6 @@ pub(crate) mod module { host_nt::test_file_type_by_name(path, tested_type) } - /// _testFileExistsByName - test if path exists - fn _test_file_exists_by_name(path: &std::path::Path, follow_links: bool) -> bool { - host_nt::test_file_exists_by_name(path, follow_links) - } - /// _testFileType wrapper - handles both fd and path fn _test_file_type(path_or_fd: &OsPathOrFd<'_>, tested_type: u32) -> bool { match path_or_fd { @@ -320,7 +316,8 @@ pub(crate) mod module { false } } - OsPathOrFd::Path(path) => _test_file_type_by_name(path.as_ref(), tested_type), + OsPathOrFd::Path(path) => widestring::WideCString::from_os_str(&path.path) + .is_ok_and(|path| _test_file_type_by_name(&path, tested_type)), } } @@ -328,7 +325,8 @@ pub(crate) mod module { fn _test_file_exists(path_or_fd: &OsPathOrFd<'_>, follow_links: bool) -> bool { match path_or_fd { OsPathOrFd::Fd(fd) => host_nt::fd_exists(*fd), - OsPathOrFd::Path(path) => _test_file_exists_by_name(path.as_ref(), follow_links), + OsPathOrFd::Path(path) => widestring::WideCString::from_os_str(&path.path) + .is_ok_and(|path| host_nt::test_file_exists_by_name(&path, follow_links)), } } @@ -383,8 +381,8 @@ pub(crate) mod module { /// Check if a path is on a Windows Dev Drive. #[pyfunction] fn _path_isdevdrive(path: OsPath, vm: &VirtualMachine) -> PyResult { - let _ = path.to_wide_cstring(vm)?; - host_nt::path_isdevdrive(path.as_ref()).map_err(|err| err.to_pyexception(vm)) + let path = path.to_wide_cstring(vm)?; + host_nt::path_isdevdrive(&path).map_err(|err| err.to_pyexception(vm)) } #[cfg(target_env = "msvc")] @@ -592,16 +590,16 @@ pub(crate) mod module { #[pyfunction] fn _getfinalpathname(path: OsPath, vm: &VirtualMachine) -> PyResult { - let _ = path.to_wide_cstring(vm)?; - let final_path = host_nt::getfinalpathname(path.as_ref()) + let wide = path.to_wide_cstring(vm)?; + let final_path = host_nt::getfinalpathname(&wide) .map_err(|err| OSErrorBuilder::with_filename(&err, path.clone(), vm))?; Ok(path.mode().process_path(final_path, vm)) } #[pyfunction] fn _getfullpathname(path: OsPath, vm: &VirtualMachine) -> PyResult { - let _ = path.to_wide_cstring(vm)?; - let buffer = host_nt::getfullpathname(path.as_ref()) + let wide = path.to_wide_cstring(vm)?; + let buffer = host_nt::getfullpathname(&wide) .map_err(|err| OSErrorBuilder::with_filename(&err, path.clone(), vm))?; Ok(path.mode().process_path(buffer, vm)) } @@ -613,7 +611,7 @@ pub(crate) mod module { if buflen > u32::MAX as usize { return Err(vm.new_overflow_error("path too long")); } - let buffer = host_nt::getvolumepathname(path.as_ref()) + let buffer = host_nt::getvolumepathname(&wide) .map_err(|err| OSErrorBuilder::with_filename(&err, path.clone(), vm))?; Ok(path.mode().process_path(buffer, vm)) } @@ -749,10 +747,12 @@ pub(crate) mod module { } #[pyfunction] - fn _path_splitroot(path: OsPath, _vm: &VirtualMachine) -> (Wtf8Buf, Wtf8Buf) { - let orig: Vec<_> = path.path.to_wide(); + fn _path_splitroot(path: OsPath, vm: &VirtualMachine) -> PyResult<(Wtf8Buf, Wtf8Buf)> { + let orig: Vec<_> = widestring::WideCString::from_os_str(path.path) + .map_err(|e| e.to_pyexception(vm))? + .into_vec(); if orig.is_empty() { - return (Wtf8Buf::new(), Wtf8Buf::new()); + return Ok((Wtf8Buf::new(), Wtf8Buf::new())); } let backslashed: Vec<_> = orig .iter() @@ -761,8 +761,8 @@ pub(crate) mod module { .chain(core::iter::once(0)) // null-terminated .collect(); - let backslashed_wide = widestring::WideCStr::from_slice_truncate(&backslashed) - .expect("backslashed is null-terminated"); + let backslashed_wide = widestring::WideCStr::from_slice(&backslashed) + .expect("backslashed is null-terminated and does not contain interior nulls"); if let Some(len) = host_nt::path_skip_root(backslashed_wide) { assert!( len < backslashed.len(), // backslashed is null-terminated @@ -772,15 +772,15 @@ pub(crate) mod module { backslashed.len() ); if len != 0 { - ( + Ok(( Wtf8Buf::from_wide(&orig[..len]), Wtf8Buf::from_wide(&orig[len..]), - ) + )) } else { - (Wtf8Buf::from_wide(&orig), Wtf8Buf::new()) + Ok((Wtf8Buf::from_wide(&orig), Wtf8Buf::new())) } } else { - (Wtf8Buf::new(), Wtf8Buf::from_wide(&orig)) + Ok((Wtf8Buf::new(), Wtf8Buf::from_wide(&orig))) } } @@ -940,8 +940,8 @@ pub(crate) mod module { #[pyfunction] fn _getdiskusage(path: OsPath, vm: &VirtualMachine) -> PyResult<(u64, u64)> { - let _ = path.to_wide_cstring(vm)?; - host_nt::getdiskusage(path.as_ref()).map_err(|err| err.to_pyexception(vm)) + let path = path.to_wide_cstring(vm)?; + host_nt::getdiskusage(&path).map_err(|err| err.to_pyexception(vm)) } #[pyfunction] @@ -987,8 +987,8 @@ pub(crate) mod module { #[pyfunction] fn listmounts(volume: OsPath, vm: &VirtualMachine) -> PyResult { - let _ = volume.to_wide_cstring(vm)?; - let result = host_nt::listmounts(volume.as_ref()) + let volume = volume.to_wide_cstring(vm)?; + let result = host_nt::listmounts(&volume) .map_err(|err| err.to_pyexception(vm))? .into_iter() .map(|mount| vm.new_pyobj(mount.to_string_lossy().into_owned())) @@ -1062,10 +1062,11 @@ pub(crate) mod module { #[pyfunction] fn readlink(path: OsPath, vm: &VirtualMachine) -> PyResult { let mode = path.mode(); - match host_nt::readlink(path.as_ref()) { + let wide = path.to_wide_cstring(vm)?; + match host_nt::readlink(&wide) { Ok(result_path) => Ok(mode.process_path(std::path::PathBuf::from(result_path), vm)), Err(host_nt::ReadlinkError::Io(err)) => { - Err(OSErrorBuilder::with_filename(&err, path.clone(), vm)) + Err(OSErrorBuilder::with_filename(&err, path, vm)) } Err(err) => Err(err.to_pyexception(vm)), } diff --git a/crates/vm/src/stdlib/os.rs b/crates/vm/src/stdlib/os.rs index dccc0ae7e47..668545a3cec 100644 --- a/crates/vm/src/stdlib/os.rs +++ b/crates/vm/src/stdlib/os.rs @@ -218,7 +218,8 @@ pub(super) mod _os { use crossbeam_utils::atomic::AtomicCell; use rustpython_common::wtf8::Wtf8Buf; #[cfg(windows)] - use rustpython_host_env::nt as host_nt; + use rustpython_host_env::{nt as host_nt, windows::ToWideString}; + #[cfg(all(any(unix, target_os = "wasi"), not(target_os = "redox")))] use rustpython_host_env::posix as host_posix; use std::{fs, io, path::PathBuf, time::SystemTime}; @@ -873,7 +874,9 @@ pub(super) mod _os { #[cfg(windows)] #[pymethod] fn is_junction(&self, _vm: &VirtualMachine) -> bool { - host_nt::test_file_type_by_name(&self.pathval, host_nt::TestType::Junction) + self.pathval.to_wide_cstring().is_ok_and(|path| { + host_nt::test_file_type_by_name(&path, host_nt::TestType::Junction) + }) } #[pymethod] @@ -1007,8 +1010,8 @@ pub(super) mod _os { #[cfg(windows)] let lstat = { let cell = OnceCell::new(); - if let Ok(stat_struct) = - host_nt::win32_xstat(pathval.as_os_str(), false) + if let Ok(wide) = pathval.as_os_str().to_wide_cstring() + && let Ok(stat_struct) = host_nt::win32_xstat(&wide, false) { let stat_obj = StatResultData::from_stat(&stat_struct, vm).to_pyobject(vm); @@ -1350,7 +1353,10 @@ pub(super) mod _os { ) -> io::Result> { let [] = dir_fd.0; match file { - OsPathOrFd::Path(path) => host_nt::win32_xstat(&path.path, follow_symlinks.0), + OsPathOrFd::Path(path) => { + let path = path.path.to_wide_cstring()?; + host_nt::win32_xstat(&path, follow_symlinks.0) + } OsPathOrFd::Fd(fd) => crate::host_env::fileutils::fstat(fd), } .map(Some) diff --git a/crates/vm/src/stdlib/winreg.rs b/crates/vm/src/stdlib/winreg.rs index 468767e9d38..df3959acdab 100644 --- a/crates/vm/src/stdlib/winreg.rs +++ b/crates/vm/src/stdlib/winreg.rs @@ -8,8 +8,8 @@ mod winreg { use crate::builtins::{PyInt, PyStr, PyTuple, PyTypeRef}; use crate::common::hash::PyHash; use crate::convert::{ToPyException, TryFromObject}; + use crate::exceptions::nul_char_error; use crate::function::FuncArgs; - use crate::host_env::windows::ToWideString; use crate::object::AsObject; use crate::protocol::PyNumberMethods; use crate::types::{AsNumber, Hashable}; @@ -18,7 +18,9 @@ mod winreg { use crossbeam_utils::atomic::AtomicCell; use malachite_bigint::Sign; use num_traits::ToPrimitive; + use rustpython_host_env::windows::ToWideString; use rustpython_host_env::winreg as host_winreg; + use widestring::{WideCString, WideString}; /// Atomic HKEY handle type for lock-free thread-safe access type AtomicHKEY = AtomicCell; @@ -255,14 +257,13 @@ mod winreg { key: PyRef, vm: &VirtualMachine, ) -> PyResult { - let wide_computer_name = computer_name.map(|n| n.to_wide_cstring()); + let computer_name = computer_name + .map(WideCString::from_str) + .transpose() + .map_err(|e| e.to_pyexception(vm))?; let mut ret_key = core::ptr::null_mut(); let res = unsafe { - host_winreg::connect_registry( - wide_computer_name.as_deref(), - key.hkey.load(), - &mut ret_key, - ) + host_winreg::connect_registry(computer_name.as_deref(), key.hkey.load(), &mut ret_key) }; if res == 0 { Ok(PyHkey::new(ret_key)) @@ -273,9 +274,9 @@ mod winreg { #[pyfunction] fn CreateKey(key: PyRef, sub_key: String, vm: &VirtualMachine) -> PyResult { - let wide_sub_key = sub_key.to_wide_cstring(); + let sub_key = WideCString::from_str(sub_key).map_err(|e| e.to_pyexception(vm))?; let mut out_key = core::ptr::null_mut(); - let res = unsafe { host_winreg::create_key(key.hkey.load(), &wide_sub_key, &mut out_key) }; + let res = unsafe { host_winreg::create_key(key.hkey.load(), &sub_key, &mut out_key) }; if res == 0 { Ok(PyHkey::new(out_key)) } else { @@ -297,7 +298,7 @@ mod winreg { #[pyfunction] fn CreateKeyEx(args: CreateKeyExArgs, vm: &VirtualMachine) -> PyResult { - let wide_sub_key = args.sub_key.to_wide_cstring(); + let wide_sub_key = WideCString::from_str(args.sub_key).map_err(|e| e.to_pyexception(vm))?; let mut res: host_winreg::HKEY = core::ptr::null_mut(); let err = unsafe { let key = args.key.hkey.load(); @@ -330,8 +331,8 @@ mod winreg { #[pyfunction] fn DeleteKey(key: PyRef, sub_key: String, vm: &VirtualMachine) -> PyResult<()> { - let wide_sub_key = sub_key.to_wide_cstring(); - let res = unsafe { host_winreg::delete_key(key.hkey.load(), &wide_sub_key) }; + let sub_key = WideCString::from_str(sub_key).map_err(|e| e.to_pyexception(vm))?; + let res = unsafe { host_winreg::delete_key(key.hkey.load(), &sub_key) }; if res == 0 { Ok(()) } else { @@ -341,7 +342,10 @@ mod winreg { #[pyfunction] fn DeleteValue(key: PyRef, value: Option, vm: &VirtualMachine) -> PyResult<()> { - let wide_value = value.map(|v| v.to_wide_cstring()); + let wide_value = value + .map(WideCString::from_str) + .transpose() + .map_err(|e| e.to_pyexception(vm))?; let res = unsafe { host_winreg::delete_value(key.hkey.load(), wide_value.as_deref()) }; if res == 0 { Ok(()) @@ -364,7 +368,7 @@ mod winreg { #[pyfunction] fn DeleteKeyEx(args: DeleteKeyExArgs, vm: &VirtualMachine) -> PyResult<()> { - let wide_sub_key = args.sub_key.to_wide_cstring(); + let wide_sub_key = WideCString::from_str(args.sub_key).map_err(|e| e.to_pyexception(vm))?; let res = unsafe { host_winreg::delete_key_ex( args.key.hkey.load(), @@ -501,8 +505,12 @@ mod winreg { file_name: String, vm: &VirtualMachine, ) -> PyResult<()> { - let sub_key = sub_key.to_wide_cstring(); - let file_name = file_name.to_wide_cstring(); + let (Ok(sub_key), Ok(file_name)) = ( + WideCString::from_str(sub_key), + WideCString::from_str(file_name), + ) else { + return Err(nul_char_error(vm)); + }; let res = unsafe { host_winreg::load_key(key.hkey.load(), &sub_key, &file_name) }; if res == 0 { Ok(()) @@ -526,11 +534,11 @@ mod winreg { #[pyfunction] #[pyfunction(name = "OpenKeyEx")] fn OpenKey(args: OpenKeyArgs, vm: &VirtualMachine) -> PyResult { - let wide_sub_key = args.sub_key.to_wide_cstring(); + let sub_key = WideCString::from_str(args.sub_key).map_err(|e| e.to_pyexception(vm))?; let mut res: host_winreg::HKEY = core::ptr::null_mut(); let err = unsafe { let key = args.key.hkey.load(); - host_winreg::open_key_ex(key, &wide_sub_key, args.reserved, args.access, &mut res) + host_winreg::open_key_ex(key, &sub_key, args.reserved, args.access, &mut res) }; if err == 0 { Ok(PyHkey { @@ -566,14 +574,19 @@ mod winreg { )); } - host_winreg::query_default_value(hkey, sub_key.as_deref().map(std::ffi::OsStr::new)) + let sub_key = sub_key + .map(WideCString::from_str) + .transpose() + .map_err(|e| e.to_pyexception(vm))?; + host_winreg::query_default_value(hkey, sub_key.as_deref()) .map_err(|err| err.to_pyexception(vm)) } #[pyfunction] fn QueryValueEx(key: HKEYArg, name: String, vm: &VirtualMachine) -> PyResult> { let hkey = key.0; - let (ret_buf, typ) = host_winreg::query_value_bytes(hkey, std::ffi::OsStr::new(&name)) + let wide_name = WideCString::from_str(name).map_err(|e| e.to_pyexception(vm))?; + let (ret_buf, typ) = host_winreg::query_value_bytes(hkey, &wide_name) .map_err(|err| os_error_from_windows_code(vm, err as i32))?; let obj = reg_to_py(vm, &ret_buf, typ)?; // Return tuple (value, type) @@ -582,7 +595,7 @@ mod winreg { #[pyfunction] fn SaveKey(key: PyRef, file_name: String, vm: &VirtualMachine) -> PyResult<()> { - let file_name = file_name.to_wide_cstring(); + let file_name = WideCString::from_str(file_name).map_err(|e| e.to_pyexception(vm))?; let res = unsafe { host_winreg::save_key(key.hkey.load(), &file_name) }; if res == 0 { Ok(()) @@ -611,12 +624,12 @@ mod winreg { )); } - let res = host_winreg::set_default_value( - hkey, - std::ffi::OsStr::new(&sub_key), - typ, - std::ffi::OsStr::new(&value), - ); + let sub_key = WideCString::from_str(sub_key).map_err(|e| e.to_pyexception(vm))?; + // Value can contain interior NULs. + let mut wide_value = WideString::with_capacity(value.len() + 1); + wide_value.push_str(&value); + wide_value.push_str("\0"); + let res = host_winreg::set_default_value(hkey, &sub_key, typ, &wide_value); if res == 0 { Ok(()) @@ -738,12 +751,15 @@ mod winreg { // Return empty string as UTF-16 null terminator return Ok(Some(vec![0u8, 0u8])); } - let s = value + // Registry values are allowed to contain interior NULs. + let bytes: Vec = value .downcast::() - .map_err(|_| vm.new_type_error("value must be a string"))?; - let wide = s.as_wtf8().to_wide_with_nul(); - // Convert Vec to Vec - let bytes: Vec = wide.iter().flat_map(|&c| c.to_le_bytes()).collect(); + .map_err(|_| vm.new_type_error("value must be a string"))? + .as_wtf8() + .encode_wide() + .chain([0u16]) + .flat_map(u16::to_le_bytes) + .collect(); Ok(Some(bytes)) } REG_MULTI_SZ => { @@ -755,16 +771,26 @@ mod winreg { .downcast::() .map_err(|_| vm.new_type_error("value must be a list of strings"))?; - let mut bytes: Vec = Vec::new(); + let mut encoded = Vec::new(); for item in list.borrow_vec().iter() { + // The final vector is a list of NUL terminated strings. The list itself is + // NUL terminated as well. Unlike REG_SZ, interior NULs are forbidden because + // it would truncate the list. + // https://learn.microsoft.com/en-us/windows/win32/sysinfo/registry-value-types let s = item .downcast_ref::() - .ok_or_else(|| vm.new_type_error("list items must be strings"))?; - let wide = s.as_wtf8().to_wide_with_nul(); - bytes.extend(wide.iter().flat_map(|&c| c.to_le_bytes())); + .ok_or_else(|| vm.new_type_error("list items must be strings"))? + .as_wtf8() + .to_wide_cstring() + .map(WideCString::into_vec_with_nul) + .map_err(|e| e.to_pyexception(vm))?; + encoded.extend(s); } - // Add final null terminator (double null at end) - bytes.extend([0u8, 0u8]); + let bytes = encoded + .into_iter() + .flat_map(u16::to_le_bytes) + .chain(0u16.to_le_bytes()) + .collect(); Ok(Some(bytes)) } // REG_BINARY and other types @@ -793,14 +819,17 @@ mod winreg { value: PyObjectRef, vm: &VirtualMachine, ) -> PyResult<()> { - let wide_value_name = value_name.as_deref().map(|s| s.to_wide_cstring()); + let value_name = value_name + .map(WideCString::from_str) + .transpose() + .map_err(|e| e.to_pyexception(vm))?; let reg_value = py2reg(value, typ, vm)?; let (ptr, len) = match ®_value { Some(v) => (v.as_ptr(), v.len() as u32), None => (core::ptr::null(), 0), }; let res = unsafe { - host_winreg::set_value_ex(key.hkey.load(), wide_value_name.as_deref(), typ, ptr, len) + host_winreg::set_value_ex(key.hkey.load(), value_name.as_deref(), typ, ptr, len) }; if res != 0 { return Err(os_error_from_windows_code(vm, res as i32)); @@ -841,7 +870,7 @@ mod winreg { #[pyfunction] fn ExpandEnvironmentStrings(i: String, vm: &VirtualMachine) -> PyResult { - host_winreg::expand_environment_strings(std::ffi::OsStr::new(&i)) - .map_err(|err| err.to_pyexception(vm)) + let i = WideCString::from_str(i).map_err(|err| err.to_pyexception(vm))?; + host_winreg::expand_environment_strings(&i).map_err(|err| err.to_pyexception(vm)) } } diff --git a/crates/vm/src/stdlib/winsound.rs b/crates/vm/src/stdlib/winsound.rs index 091a3f801aa..75f576adf81 100644 --- a/crates/vm/src/stdlib/winsound.rs +++ b/crates/vm/src/stdlib/winsound.rs @@ -6,7 +6,7 @@ pub(crate) use winsound::module_def; #[pymodule] mod winsound { use crate::builtins::{PyBaseExceptionRef, PyBytes, PyStr}; - use crate::convert::{IntoPyException, ToPyException}; + use crate::convert::{IntoPyException, ToPyException, TryFromBorrowedObject}; use crate::exceptions; use crate::host_env::windows::ToWideString; use crate::protocol::{BufferFlags, PyBuffer}; @@ -106,7 +106,12 @@ mod winsound { // os.fspath(sound) let path = match sound.downcast_ref::() { - Some(s) => s.as_wtf8().to_owned(), + Some(s) => { + let s = s.as_wtf8(); + let mut buf = Vec::with_capacity(s.len() + 1); + buf.extend(s.encode_wide()); + buf + } None => { let fspath = vm.get_method_or_type_error( sound.clone(), @@ -129,27 +134,27 @@ mod winsound { return Err(vm.new_type_error("'sound' must resolve to str, not bytes")); } - let s: &PyStr = result.downcast_ref().ok_or_else(|| { - vm.new_type_error(format!( - "expected {}.__fspath__() to return str or bytes, not {}", - sound.class().name(), - result.class().name() - )) - })?; - - s.as_wtf8().to_owned() + let s = result + .downcast_ref::() + .ok_or_else(|| { + vm.new_type_error(format!( + "expected {}.__fspath__() to return str or bytes, not {}", + sound.class().name(), + result.class().name() + )) + })? + .as_wtf8(); + + let mut buf = Vec::with_capacity(s.len() + 1); + buf.extend(s.encode_wide()); + buf } }; // Check for embedded null characters - if path.as_bytes().contains(&0) { - return Err(exceptions::nul_char_error(vm)); - } - - let wide = path.to_wide_with_nul(); let wide_cstr = - widestring::WideCStr::from_slice_truncate(&wide).map_err(|e| e.to_pyexception(vm))?; - play_sound(PlaySoundSource::Name(wide_cstr), flags).map_err(map_play_err(vm)) + widestring::WideCString::from_vec(path).map_err(|e| e.to_pyexception(vm))?; + play_sound(PlaySoundSource::Name(&wide_cstr), flags).map_err(map_play_err(vm)) } #[derive(FromArgs)] From ee0bb649bcade0cffef9ccc7c6ed5e75c7ff8cb7 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:12:35 +0900 Subject: [PATCH 327/351] winsound: drop the imports the NUL-safe PlaySound left behind (#8554) WideCString::from_vec rejects interior NULs itself, so the explicit check through exceptions::nul_char_error and the to_wide_with_nul call it fed both went away; TryFromBorrowedObject arrived unused. Windows clippy rejects all three, which is every Windows clippy run since. Assisted-by: Claude --- crates/vm/src/stdlib/winsound.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/vm/src/stdlib/winsound.rs b/crates/vm/src/stdlib/winsound.rs index 75f576adf81..fbefa236c6a 100644 --- a/crates/vm/src/stdlib/winsound.rs +++ b/crates/vm/src/stdlib/winsound.rs @@ -6,9 +6,7 @@ pub(crate) use winsound::module_def; #[pymodule] mod winsound { use crate::builtins::{PyBaseExceptionRef, PyBytes, PyStr}; - use crate::convert::{IntoPyException, ToPyException, TryFromBorrowedObject}; - use crate::exceptions; - use crate::host_env::windows::ToWideString; + use crate::convert::{IntoPyException, ToPyException}; use crate::protocol::{BufferFlags, PyBuffer}; use crate::{AsObject, PyObjectRef, PyResult, VirtualMachine}; use rustpython_host_env::winsound::{PlaySoundError, PlaySoundSource, play_sound}; From ebc0459801f1d5bc342253047df8f00ec2e1ad7d Mon Sep 17 00:00:00 2001 From: Joshua Megnauth <48846352+joshuamegnauth54@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:13:03 -0400 Subject: [PATCH 328/351] host_env: Reject interior NULs for CreateProcessW (#8555) --- crates/host_env/src/winapi.rs | 60 ++++++++++++++--------------------- 1 file changed, 23 insertions(+), 37 deletions(-) diff --git a/crates/host_env/src/winapi.rs b/crates/host_env/src/winapi.rs index 31783847311..16698098f0c 100644 --- a/crates/host_env/src/winapi.rs +++ b/crates/host_env/src/winapi.rs @@ -188,7 +188,7 @@ pub fn create_file_w( /// `startup_info` must point to a valid `STARTUPINFOW` (or extended). unsafe fn create_process_w_raw( app_name: Option<&widestring::WideCStr>, - command_line: Option<&mut [u16]>, + command_line: Option<&mut widestring::WideCStr>, inherit_handles: i32, creation_flags: u32, env: Option<&[u16]>, @@ -214,37 +214,6 @@ unsafe fn create_process_w_raw( Ok(unsafe { procinfo.assume_init() }) } -/// Win32 `CreateProcessW` requires `lpCommandLine` to be NUL-terminated. -/// The buffer is passed `&mut [u16]` because `CreateProcessW` may modify it -/// in place. -#[inline] -fn validate_command_line_terminated(buf: &[u16]) -> io::Result<()> { - if buf.last() == Some(&0) { - Ok(()) - } else { - Err(io::Error::new( - io::ErrorKind::InvalidInput, - "command_line buffer passed to create_process must be NUL-terminated", - )) - } -} - -/// Win32 `CreateProcessW` with `CREATE_UNICODE_ENVIRONMENT` requires -/// `lpEnvironment` to be a sequence of `KEY=value\0` strings followed by a -/// final terminating `\0` — i.e. the block ends with two consecutive zero -/// `u16`s. -#[inline] -fn validate_environment_block_terminated(buf: &[u16]) -> io::Result<()> { - if buf.len() >= 2 && buf[buf.len() - 2..] == [0, 0] { - Ok(()) - } else { - Err(io::Error::new( - io::ErrorKind::InvalidInput, - "env block passed to create_process must end with a double NUL terminator", - )) - } -} - #[allow( clippy::too_many_arguments, reason = "This is the semantic host wrapper for Win32 CreateProcess parameters." @@ -259,11 +228,28 @@ pub fn create_process( startup_info: StartupInfoData, handle_list: Option>, ) -> io::Result { - if let Some(cmd) = command_line.as_deref() { - validate_command_line_terminated(cmd)?; - } - if let Some(env_block) = env { - validate_environment_block_terminated(env_block)?; + // Win32 `CreateProcessW` requires `lpCommandLine` to be NUL-terminated. + // The buffer is passed `&mut [u16]` because `CreateProcessW` may modify it in place. + let command_line = command_line + .map(widestring::WideCStr::from_slice_mut) + .transpose() + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "command_line buffer passed to create_process must be NUL-terminated", + ) + })?; + // Win32 `CreateProcessW` with `CREATE_UNICODE_ENVIRONMENT` requires + // `lpEnvironment` to be a sequence of `KEY=value\0` strings followed by a + // final terminating `\0` — i.e. the block ends with two consecutive zero + // `u16`s. + if let Some(env_block) = env + && !env_block.ends_with(&[0, 0]) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "env block passed to create_process must end with a double NUL terminator", + )); } let mut si: windows_sys::Win32::System::Threading::STARTUPINFOEXW = From ca907d1e626134f5ddd9fd40ae70128410b40fd2 Mon Sep 17 00:00:00 2001 From: mumallaeng Date: Wed, 19 Aug 2026 20:07:39 +0900 Subject: [PATCH 329/351] Collapse bare ExpectedExpression to 'invalid syntax'; fix '<>' diagnostic offset (#8540) * Collapse bare ExpectedExpression to 'invalid syntax'; fix '<>' diagnostic offset `ParseErrorType::ExpectedExpression` currently surfaces as the raw ruff parser message (e.g. "Expected an expression") to callers that only depend on `rustpython-compiler` (no `rustpython-vm`). `rustpython-vm`'s `vm_new.rs` already collapses this to CPython's generic "invalid syntax" for its own callers; mirror that same collapse inside `cpython_parse_diagnostic_override` so non-vm consumers get the same CPython-compatible message. A bare `<>` outside Barry-as-BDFL mode (`2 <> 3`) lexes as `Less` then an unexpected `Greater`, so the resulting `ExpectedExpression` location points at the `>` -- one character past where CPython's tokenizer (which treats `<>` as a single obsolete token) reports the error. Detect the `<` immediately preceding the location and shift the reported range back over it. Assisted-by: Claude Code:claude-sonnet-5 * Require an adjacent '>' before treating '<' as the obsolete <> operator CodeRabbit review on #8540: the previous check only looked at the byte before the ExpectedExpression location for '<', without confirming an adjacent '>' really follows it. For inputs like a trailing '<' at EOF this could misclassify an unrelated ExpectedExpression as the bare '<>' case and report a bogus range. Require both bytes are present before constructing the diagnostic. Also remove the now-stale `@unittest.expectedFailure # TODO: RUSTPYTHON` markers on test_guido_as_bdfl and test_barry_as_bdfl_relative_import, which pass with this fix (test_barry_as_bdfl and test_barry_as_bdfl_look_ma_with_no_compiler_flags still need real Barry-as-BDFL tokenizer support and stay marked). Assisted-by: Claude Code:claude-sonnet-5 --- Lib/test/test_flufl.py | 2 -- crates/compiler/src/lib.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_flufl.py b/Lib/test/test_flufl.py index bd6267d45ae..62360d9f9e4 100644 --- a/Lib/test/test_flufl.py +++ b/Lib/test/test_flufl.py @@ -22,7 +22,6 @@ def test_barry_as_bdfl(self): # parser reports the start of the token self.assertEqual(cm.exception.offset, 3) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_guido_as_bdfl(self): code = '2 {0} 3' compile(code.format('!='), '', 'exec') @@ -50,7 +49,6 @@ def test_barry_as_bdfl_look_ma_with_no_compiler_flags(self): self.assertEqual(cm.exception.lineno, 1) self.assertEqual(cm.exception.offset, len(code) - 4) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_barry_as_bdfl_relative_import(self): code = "from .__future__ import barry_as_FLUFL;2 {0} 3" compile(code.format('!='), '', 'exec') diff --git a/crates/compiler/src/lib.rs b/crates/compiler/src/lib.rs index 7562e8939b9..fc9b67614b5 100644 --- a/crates/compiler/src/lib.rs +++ b/crates/compiler/src/lib.rs @@ -362,9 +362,45 @@ fn cpython_parse_diagnostic_override( )); } + // `2 <> 3` outside Barry mode: ruff lexes `<` then an unexpected `>` and + // reports `ExpectedExpression` starting at the `>`. CPython's tokenizer + // treats `<>` as a single obsolete token and points at its start (the + // `<`) instead, so shift the reported location back over it. + source_error!(barry_flufl_obsolete_operator_error(error, source_text)); + + // CPython's PEG parser collapses a bare "expected an expression" failure + // into the generic "invalid syntax" message. rustpython-vm's `vm_new.rs` + // does this same collapse for its own callers; rustpython-compiler has no + // vm dependency, so mirror it here. + if matches!(&error.error, parser::ParseErrorType::ExpectedExpression) { + let (loc, end_loc) = adjusted_error_locations(source_file, error.location); + return Some(NormalizedParseDiagnostic::new( + parser::ParseErrorType::OtherError("invalid syntax".into()), + loc, + end_loc, + )); + } + None } +fn barry_flufl_obsolete_operator_error( + error: &parser::ParseError, + source: &str, +) -> Option<(String, usize, usize)> { + if !matches!(&error.error, parser::ParseErrorType::ExpectedExpression) { + return None; + } + let start = error.location.start().to_usize(); + if start == 0 || source.as_bytes().get(start - 1) != Some(&b'<') { + return None; + } + if source.as_bytes().get(start) != Some(&b'>') { + return None; + } + Some(("invalid syntax".to_string(), start - 1, start + 1)) +} + fn eof_parse_diagnostic( error: &parser::ParseError, source_file: &SourceFile, From 86d9407b3d6a3f97e42dd11e9dfa5aaf78ec7f65 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:08:03 +0900 Subject: [PATCH 330/351] Raise where the fuzzer found aborts and stack overflows, and collect with the count in the object header (#8551) * Correct what the non-unix frame stack is for The comment said non-unix threading builds have no stop-the-world. CollectStopTheWorld is gated on `feature = "threading"` alone, and sys._current_frames stops the world on both paths; the field is the fallback a reader uses when there is no `top_iframe` to materialize from. Assisted-by: Claude * Seek by the whole file position on Windows `SetFilePointer` answers with the low half of the new position and signals failure with INVALID_SET_FILE_POINTER, which is also that half of a position four gigabytes in; telling them apart takes the error code, which this did not read, so such a seek was reported as an error. Deciding seekability from it also called such a file unseekable. `SetFilePointerEx` returns the whole position and a success flag of its own, which also removes the transmute of the position into halves. Assisted-by: Claude * memoryview: tell a value of the wrong kind from one that does not fit Assigning an item reported every packing failure as a TypeError, so m[0] = 300 on a 'B' view said the value was the wrong type rather than out of range. Packing now says which of the two it was, and whether the value's own code raised, in which case that error is the answer as it is: m[0] = 300 ValueError: invalid value for format 'B' m[0] = "x" TypeError: invalid type for format 'B' m[0] = <__index__ that raises> the raised error `struct` reports both as `struct.error` and is unchanged; the kind travels beside the exception for the caller that tells them apart. Also: None was read as a deletion, so m[0] = None answered "cannot delete memory" instead of packing it; and deleting through the mapping protocol never reached the read-only check, which comes first. Assisted-by: Claude * Raise MemoryError for allocations sized by Python input os.read, _RawIOBase.read, int.to_bytes, struct.pack, ctypes array creation and the _ssl RAND functions sized a Vec from a Python-supplied length with vec![], which calls handle_alloc_error and, under panic = "abort", ends the process. They now allocate through vm.new_zeroed_bytes and raise MemoryError. itertools.product built its pools without checking that len(iterables) * repeat is representable; it now raises OverflowError "repeat argument too large" and reserves the pool and index vectors fallibly. repeat is read as isize, so a negative one raises ValueError "repeat argument cannot be negative" instead of the conversion's message. _ssl.RAND_bytes and RAND_pseudo_bytes read n as i32, matching the int they are declared with. Assisted-by: Claude * Collect with the count in the object header A collection kept the count it was working with in a table keyed by the object's address, and the objects it had proved reachable in a second one. Between them they were hashed once per candidate and twice per edge in the heap, which is where most of a collection over a live heap went. The count now lives in `PyInner::gc_refs`, with `GcBits::COLLECTING` saying it is meaningful, and reachability is `gc_refs == GC_REACHABLE` rather than membership in a set. Step 5 splits the candidates and clears the bit in one pass. gcbench, five interleaved pairs, median: a live heap of 423k objects goes from 0.101s to 0.055s and a dead one from 0.402s to 0.383s. The bits, generation, owner and count take eight bytes between them. A 64-bit header had those eight as the padding its alignment forces, so it is unchanged at 48 bytes; a 32-bit header grows from 24 to 28. Assisted-by: Claude * Take an explicit thread stack size as a floor in debug builds A debug build already started Python threads on 8 MiB rather than Rust's 2 MiB default, but an explicit threading.stack_size(N) went through verbatim. test_threading asks for 256 KiB, and starting a thread on that walked off the end of the stack: the guard page fault landed in the prologue of ExecutingFrame::run. Unoptimized, that prologue reserves 80,848 bytes where the optimized one reserves 656 -- execute_instruction is #[inline(always)] and LLVM only colors stack slots from opt-level 1, so the frame is the sum of all 200 instruction arms' temporaries rather than the largest. A Python call costs 88,672 bytes of native stack there, and threading's bootstrap is six frames deep, so 256 KiB holds less than half of what starting a thread takes. The floor reaches thread::Builder only; threading.stack_size() still answers with what was asked for, and release builds are unchanged. Assisted-by: Claude * Check the native stack on every frame entry The C-stack guard ran on one frame entry in eight. That asks the margin to cover eight frames rather than one, and it does not: an unoptimized frame entered through native code takes 88,672 bytes against a debug margin of 262,144. A recursion whose steps re-enter that way -- `__add__` calling itself, a sort key that sorts -- ran off the end of the stack instead of raising RecursionError. On a debug build `class Add: __add__ = lambda s, o: s + o; Add() + 1` segfaulted on the main thread; it now raises, as it does under CPython and in release builds. enter_iframe checked and then called enter_iframe_unchecked, which checked again; it now leaves the check to the one call. Measured on a call-dominated benchmark, five interleaved pairs: instructions retired go up 0.17%, about four per call, which is the stack pointer read and the compare. Assisted-by: Claude * Take an iterable's length hint when a list is filled from it `map_py_iter` read `__length_hint__` only to pass it to `PyIterIter`, and returned an empty vector when the hint was `isize::MAX` or more. Collecting through `PyResult` dropped the iterator's lower bound, so nothing reserved the room the hint asked for. `list()`, `list.extend()` and `list.__iadd__()` now reserve it and report a hint they cannot honour as `MemoryError`; a hint that leaves no room for the elements the list already holds is passed over, as `list_extend()` does. `tuple()` and the other callers keep filling up without reserving. `length_hint_opt` errors other than the `TypeError` it already turns into `None` now reach the caller instead of being dropped. `__iadd__` and `inplace_concat` went through `extract_cloned`, which reads `__len__` and not `__length_hint__`; both call `PyList::extend` now, the way `list_inplace_concat()` calls `list_extend()`. The tuple, list and dict fast paths of `extract_elements_inner` reserve their known length, which the `collect()` they used dropped. Assisted-by: Claude * Drop map.__length_hint__ `map_methods` has no `__length_hint__`, so `operator.length_hint()` on a map answers 0, not the length of what it draws from. The method walked into the length hint of every iterator it holds, and a chain of maps 10000 long overflowed the native stack answering for the outermost one. It also took the longest of its iterators, where a map stops at the shortest. Assisted-by: Claude * Ask for a length hint where each caller asks for it `map_py_iter` asked the iterable it was handed, for every caller, and reported what asking raised. Only some callers ask it: `list_extend()` and `_PyBytes_FromIterator()` ask the iterable, `PySequence_Tuple()` asks the iterator, and the bytearray constructor asks nothing. `tuple()`, `min()`, `max()`, `collections.deque()` and `f(*x)` raised for an iterable whose `__len__` or `__length_hint__` does, where they answer. Which object is asked is now the caller's to say. `sorted()` asks the iterable, being `PySequence_List()`. `bytes_from_object()` stood in for `PyBytes_FromObject()`, for `bytearray_extend()` and for the bytearray constructor, which do not agree on this: the first two ask, the last does not. It is split, and assigning to a bytearray slice takes the constructor's side with `PyByteArray_FromObject()`. `list.extend()` counted what it held before the iterable had been asked, where `list_extend()` reads `Py_SIZE(self)` after. A `__length_hint__` that adds to the list made the overflow guard read a count too small and raise `MemoryError` where nothing is wrong; one that empties it made the guard skip a reservation that cannot be served. Assisted-by: Claude * Settle product's pool count before it reads its arguments `product_new()` checks `repeat` and works out `npools` before it calls `PySequence_Tuple()` on any argument, and fills the pools `npools` times. The pools were filled by repeating the arguments `repeat` times instead, which walks that many steps even with no arguments to repeat: `product(repeat=2**62)` counted up to it rather than answering `[()]`. The count was also worked out after the arguments had been read, so a repeat too large to serve ran their code first. Assisted-by: Claude * Let the bool format answer with the error its value raised `pack_single()` leaves `'?'` to `PyObject_IsTrue()` and returns what that raised. Packing classified the error instead, so a `ValueError` from a `__bool__` came back as "memoryview: invalid value for format '?'". Assisted-by: Claude * Release a cell's old value after the lock `PyCell::set` dropped what it replaced while still holding the mutex guarding the cell contents. A `__del__` running from that drop and reading the same cell waited on a lock its own caller held, so `del it` on a closure variable whose value has such a `__del__` deadlocked. The replaced value is now released once the guard is gone, as `Py_XSETREF` stores before it decrefs. Assisted-by: Claude * Have a set iterator hold the set it iterates The iterator kept only a reference to the inner hash table, so in `it = iter(A(*args))` the `A()` temporary was the last owner and died as the call returned, before `it` was bound. A `__del__` reading `it` there saw an unbound name and its error was printed and ignored. The iterator now holds the set object, as `si_set` does, and releases it once exhausted. That release happens after the lock is dropped, where `setiter_iternext()` puts its `Py_DECREF(so)` past `Py_END_CRITICAL_SECTION()`, so a `__del__` that iterates again does not wait on a lock the call holds. Assisted-by: Claude * Release an exhausted iterator's container after the lock `PositionIterInternal::_next` overwrote its `IterStatus::Active` while the caller still held the mutex around it. Dropping the container there ran any `__del__` under that lock, and a `__del__` that iterated the same object again blocked on it. `exhaust()` now hands the container back instead of dropping it, and `locked_step()` releases it after the guard. list, list_reverseiterator, tuple, str, dict and its views and reverse views, bytes, bytearray, memoryview, array, deque, and the enumerate and sequence iterators all go through it. Assisted-by: Claude * Unskip test_free_after_iterating Assisted-by: Claude * Stop listing test_set as an environment polluter `check_free_after_iterating` no longer leaves an ignored exception behind, and the job that reruns the listed tests ten times fails once one of them stops polluting. Assisted-by: Claude * Drop winsound imports left unused `TryFromBorrowedObject`, `exceptions`, and `ToWideString` have no reference in the module, which fails the Windows clippy line under `-Dwarnings`. Assisted-by: Claude * Ask nothing where the caller takes no room `map_py_iter` asked the iterator for a length hint on behalf of every caller that does not reserve, and reported what asking raised. Those callers ask nothing at all: `tuple()`, `f(*x)`, `bytearray(x)`, `min()`, `max()` and `collections.deque()` answer for an iterator whose `__length_hint__` raises, where they had been raising it. The answer was also never spent. It reached `PyIterIter` for a `size_hint()` the push loop does not read, so the lookup and any call it made were work thrown away: `tuple()` over a generator drops 22% of its instructions, and 17% over an iterator with a `__length_hint__` written in Python. Assisted-by: Claude * Stop asking an iterator how long it is to walk it `PyIter::iter` and `PyIter::into_iter` asked for a length hint and reported what asking raised. Nothing spent the answer: it reached `PyIterIter` for a `size_hint()` that every caller either loops past or drops, since collecting into a `Result` reports no lower bound. 23 operations answered for an iterator whose `__length_hint__` raises, where they had been raising it: `set`, `frozenset` and the nine `set` methods that take an iterable, `dict.fromkeys` and the dict view operators, `array` and `array.extend`, `all`, `any`, `sum`, `io.writelines`, `math.fsum`, `math.prod`, and `csv.writerow` and `writerows`. Over a generator, `set()` drops 16% of its instructions and `all()` 23%. `str.join` and `bytes.join` do ask, reaching their elements through `PySequence_Fast()`, which fills a list from the iterator. They take `iter_sized()`, which is now the only way to ask. `iter_without_hint` is gone, its callers being what `iter` already does. Assisted-by: Claude * Name the matrix jobs rather than let the matrix name them A generated job name lists every value in the matrix entry, so `cargo check` carried the booleans its dependencies and `skip_ssl` keys expand to, and the snippets job carried its test arguments and timeout. Adding or removing a key renames the check, which drops it from the required list until that list is edited to match. Emptying `env_polluting_tests` renamed three checks this way. `Run rust tests` and `clippy` keep the names they had. `cargo check` drops the booleans from six of its nine, and the snippets job drops its arguments and timeout from all three. Assisted-by: Claude * Keep a sequence iterator active when an element raises `PositionIterInternal::_next` exhausted the iterator for any non-`Return` result, so an error from `__getitem__` ended the walk. `iter_iternext()` lets go of its sequence for `IndexError` and `StopIteration` alone, and `PyIterReturn::from_getitem_result` has already turned the first of those into the second, so only `StopIteration` exhausts now. Both deque iterators keep exhausting on their own mutation guard, which `deque_iternext()` does by zeroing the counter before it raises; they share one step function for it, and the message it raises is lowercased to match the three other sites in the module. Assisted-by: Claude * Name the caller in the bytes conversion TypeError An object with no iteration protocol reached `PyObject_GetIter`'s "not iterable" message, and `bytearray.extend()` reported bytes. Each entry point now checks for the protocol first and names itself: bytes(object()) cannot convert 'object' object to bytes bytearray(object()) cannot convert 'object' object to bytearray bytearray().extend(object()) can't extend bytearray with object Assisted-by: Claude * Take a strong count too large to hold as reachable `start_gc_refs` clipped a count at `GC_REACHABLE - 1`, a number the per-reference subtraction could still walk down to zero and collect a live object. It now stores `GC_REACHABLE`, and `subtract_gc_ref` leaves that value alone. Assisted-by: Claude * Raise AssertionError where a snippet asserts False `assert False` is removed under `-O`, which the snippet suite may run. Assisted-by: Claude * Keep raising for a collection that moved under its iterator A deque, set or dict iterator raised once and then read as spent. The guard is sticky: `deque_iternext()` looks at the deque's state before the count it keeps, and `dictiter_iternextkey()` and `setiter_iternext()` write a size no collection can have, so every later call finds the same thing and raises again. `dequereviter_next()` is the one exception, looking at its count first, so it runs out after the first raise. Both deque iterators now carry `dequeiterobject.counter` rather than reading a length back from the deque, and the dict and set iterators compare the size they captured against the collection's own every time they are asked how much is left, which is what makes that answer nothing from the moment the collection changes rather than only once the iterator has raised. The set's message is capitalized to match `setiter_iternext()`. Measured against 3.14.6, for each of deque, reversed deque, set, dict and a reversed dict view: the hint after the change, the error, the hint after the error, and what a later call answers. Assisted-by: Claude * Give apt-get update a deadline to fail on The step waits on `apt-get update`, which has no deadline of its own, so a source that takes the connection and then stops answering holds the job until the workflow's own timeout. The retry that disables the Microsoft and azure-cli sources runs only when the update exits non-zero, which a held connection never does; three jobs on this branch sat on this step for 20 minutes to five and a half hours. Each attempt is now bounded, and the transports are given a timeout, so a source that stops answering reaches the retry. Assisted-by: Claude --- .cspell.dict/cpython.txt | 2 + .github/actions/install-linux-deps/action.yml | 18 +- .github/workflows/ci.yaml | 29 ++- Lib/test/seq_tests.py | 1 - Lib/test/test_array.py | 1 - Lib/test/test_bytes.py | 1 - Lib/test/test_dict.py | 1 - Lib/test/test_iter.py | 1 - Lib/test/test_set.py | 1 - Lib/test/test_str.py | 1 - crates/host_env/src/os.rs | 28 +-- crates/stdlib/src/array.rs | 4 +- crates/stdlib/src/openssl.rs | 4 +- crates/stdlib/src/ssl.rs | 9 +- crates/vm/src/buffer.rs | 174 ++++++++++++---- crates/vm/src/builtins/bytearray.rs | 12 +- crates/vm/src/builtins/bytes.rs | 8 +- crates/vm/src/builtins/dict.rs | 133 ++++++++----- crates/vm/src/builtins/enumerate.rs | 8 +- crates/vm/src/builtins/function.rs | 6 +- crates/vm/src/builtins/int.rs | 10 +- crates/vm/src/builtins/iter.rs | 101 ++++++++-- crates/vm/src/builtins/list.rs | 39 ++-- crates/vm/src/builtins/map.rs | 9 - crates/vm/src/builtins/memory.rs | 33 ++-- crates/vm/src/builtins/range.rs | 2 +- crates/vm/src/builtins/set.rs | 94 ++++++--- crates/vm/src/builtins/str.rs | 15 +- crates/vm/src/builtins/tuple.rs | 25 ++- crates/vm/src/byte.rs | 53 ++++- crates/vm/src/bytes_inner.rs | 34 +++- crates/vm/src/function/protocol.rs | 24 ++- crates/vm/src/gc_state.rs | 79 ++++---- crates/vm/src/object/core.rs | 86 +++++++- crates/vm/src/object/mod.rs | 2 +- crates/vm/src/protocol/iter.rs | 26 ++- crates/vm/src/stdlib/_collections.rs | 100 ++++++++-- crates/vm/src/stdlib/_ctypes/array.rs | 2 +- crates/vm/src/stdlib/_functools.rs | 2 +- crates/vm/src/stdlib/_io.rs | 3 +- crates/vm/src/stdlib/_operator.rs | 4 +- crates/vm/src/stdlib/_thread.rs | 50 ++++- crates/vm/src/stdlib/builtins.rs | 4 +- crates/vm/src/stdlib/itertools.rs | 42 +++- crates/vm/src/stdlib/os.rs | 4 +- crates/vm/src/vm/mod.rs | 186 +++++++++++++----- crates/vm/src/vm/thread.rs | 3 +- extra_tests/snippets/builtin_bytes.py | 47 +++++ extra_tests/snippets/builtin_int.py | 6 + extra_tests/snippets/builtin_iter.py | 144 ++++++++++++++ extra_tests/snippets/builtin_list.py | 121 ++++++++++++ extra_tests/snippets/builtin_map.py | 13 ++ extra_tests/snippets/builtin_memoryview.py | 111 +++++++++++ extra_tests/snippets/stdlib_ctypes.py | 12 +- extra_tests/snippets/stdlib_itertools.py | 29 +++ extra_tests/snippets/stdlib_struct.py | 6 + 56 files changed, 1548 insertions(+), 415 deletions(-) diff --git a/.cspell.dict/cpython.txt b/.cspell.dict/cpython.txt index da85c312898..f9aa440edee 100644 --- a/.cspell.dict/cpython.txt +++ b/.cspell.dict/cpython.txt @@ -165,6 +165,7 @@ Nondescriptor noninteger nops noraise +npools nseen NSIGNALS numer @@ -276,5 +277,6 @@ winconsoleio withitem withs worklist +XSETREF xstat XXPRIME diff --git a/.github/actions/install-linux-deps/action.yml b/.github/actions/install-linux-deps/action.yml index c2f1b20f2d9..6ce8393ce41 100644 --- a/.github/actions/install-linux-deps/action.yml +++ b/.github/actions/install-linux-deps/action.yml @@ -50,14 +50,26 @@ runs: GCC_AARCH64_LINUX_GNU: ${{ inputs.gcc-aarch64-linux-gnu }} GCC_MINGW_W64_X86_64: ${{ inputs.gcc-mingw-w64-x86-64 }} run: | - if ! sudo apt-get update; then - echo "::warning::apt-get update failed; disabling nonessential Microsoft apt sources and retrying" + # `apt-get update` has no deadline of its own, so a source that takes + # the connection and then stops answering holds the job rather than + # failing it, and the retry below never runs. Bound each attempt and + # give the transports a timeout to fail on. + apt_update() { + sudo timeout 300 apt-get \ + -o Acquire::Retries=3 \ + -o Acquire::http::Timeout=20 \ + -o Acquire::https::Timeout=20 \ + update + } + + if ! apt_update; then + echo "::warning::apt-get update did not finish; disabling nonessential Microsoft apt sources and retrying" for source in /etc/apt/sources.list.d/*microsoft* /etc/apt/sources.list.d/*azure-cli*; do if [ -e "$source" ]; then sudo mv "$source" "$source.disabled" fi done - sudo apt-get update + apt_update fi packages=() diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5a8daae06ef..354902f9571 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -80,7 +80,10 @@ jobs: if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip:ci') }} env: RUST_BACKTRACE: full - name: Run rust tests + # Named after the matrix entry rather than left to be named for it: a + # generated name lists every value in the entry, so adding or removing one + # renames the check and drops it from the required list. + name: Run rust tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} timeout-minutes: 45 strategy: @@ -165,7 +168,10 @@ jobs: if: runner.os == 'Linux' cargo_check: - name: cargo check + # Named after the matrix entry rather than left to be named for it: a + # generated name lists every value in the entry, so adding or removing one + # renames the check and drops it from the required list. + name: cargo check (${{ matrix.os }}, ${{ matrix.target }}) runs-on: ${{ matrix.os }} needs: - determine_changes @@ -292,7 +298,10 @@ jobs: test_multiprocessing_fork test_multiprocessing_forkserver test_multiprocessing_spawn - name: Run snippets and cpython tests + # Named after the matrix entry rather than left to be named for it: a + # generated name lists every value in the entry, so adding or removing one + # renames the check and drops it from the required list. + name: Run snippets and cpython tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: matrix: @@ -302,8 +311,7 @@ jobs: - '-u all' - '--timeout 600' - '--dont-add-python-opts' - env_polluting_tests: - - test_set + env_polluting_tests: [] skips: [] timeout: 50 - os: ubuntu-latest @@ -311,8 +319,7 @@ jobs: - '-u all' - '--timeout 600' - '--dont-add-python-opts' - env_polluting_tests: - - test_set + env_polluting_tests: [] skips: [] timeout: 60 - os: windows-2025 @@ -320,8 +327,7 @@ jobs: - '-u all' - '--timeout 600' - '--dont-add-python-opts' - env_polluting_tests: - - test_set + env_polluting_tests: [] skips: [] timeout: 50 fail-fast: false @@ -465,7 +471,10 @@ jobs: run: python -I scripts/whats_left.py ${{ env.CARGO_ARGS }} --features jit clippy: - name: clippy + # Named after the matrix entry rather than left to be named for it: a + # generated name lists every value in the entry, so adding or removing one + # renames the check and drops it from the required list. + name: clippy (${{ matrix.os }}) runs-on: ${{ matrix.os }} needs: - determine_changes diff --git a/Lib/test/seq_tests.py b/Lib/test/seq_tests.py index e8834c2bafc..b7875fe8f2f 100644 --- a/Lib/test/seq_tests.py +++ b/Lib/test/seq_tests.py @@ -439,7 +439,6 @@ def test_pickle(self): self.assertEqual(lst2, lst) self.assertNotEqual(id(lst2), id(lst)) - @unittest.skip("TODO: RUSTPYTHON; hangs") def test_free_after_iterating(self): support.check_free_after_iterating(self, iter, self.type2test) support.check_free_after_iterating(self, reversed, self.type2test) diff --git a/Lib/test/test_array.py b/Lib/test/test_array.py index 13df6134882..ae6fec1210e 100644 --- a/Lib/test/test_array.py +++ b/Lib/test/test_array.py @@ -1198,7 +1198,6 @@ def test_obsolete_write_lock(self): a = array.array('B', b"") self.assertRaises(BufferError, _testcapi.getbuffer_with_null_view, a) - @unittest.skip("TODO: RUSTPYTHON; hangs") def test_free_after_iterating(self): support.check_free_after_iterating(self, iter, array.array, (self.typecode,)) diff --git a/Lib/test/test_bytes.py b/Lib/test/test_bytes.py index 32a9ca7df87..16099ceb665 100644 --- a/Lib/test/test_bytes.py +++ b/Lib/test/test_bytes.py @@ -1041,7 +1041,6 @@ def test_find_etc_raise_correct_error_messages(self): self.assertRaisesRegex(TypeError, r'\bendswith\b', b.endswith, x, None, None, None) - @unittest.skip("TODO: RUSTPYTHON; hangs") def test_free_after_iterating(self): test.support.check_free_after_iterating(self, iter, self.type2test) test.support.check_free_after_iterating(self, reversed, self.type2test) diff --git a/Lib/test/test_dict.py b/Lib/test/test_dict.py index e2a73773cc2..046146dbfa6 100644 --- a/Lib/test/test_dict.py +++ b/Lib/test/test_dict.py @@ -1258,7 +1258,6 @@ def __eq__(self, o): d = {X(): 0, 1: 1} self.assertRaises(RuntimeError, d.update, other) - @unittest.skip("TODO: RUSTPYTHON; hangs") def test_free_after_iterating(self): support.check_free_after_iterating(self, iter, dict) support.check_free_after_iterating(self, lambda d: iter(d.keys()), dict) diff --git a/Lib/test/test_iter.py b/Lib/test/test_iter.py index 7ac48a50233..18e4b676c53 100644 --- a/Lib/test/test_iter.py +++ b/Lib/test/test_iter.py @@ -1137,7 +1137,6 @@ def test_iter_neg_setstate(self): self.assertEqual(next(it), 0) self.assertEqual(next(it), 1) - @unittest.skip("TODO: RUSTPYTHON; hangs") def test_free_after_iterating(self): check_free_after_iterating(self, iter, SequenceClass, (0,)) diff --git a/Lib/test/test_set.py b/Lib/test/test_set.py index 42f11c9eb28..40997a34e15 100644 --- a/Lib/test/test_set.py +++ b/Lib/test/test_set.py @@ -362,7 +362,6 @@ class C(object): gc.collect() self.assertTrue(ref() is None, "Cycle was not collected") - @unittest.skipIf("RUSTPYTHON_SKIP_ENV_POLLUTERS" in __import__("os").environ, "TODO: RUSTPYTHON") def test_free_after_iterating(self): support.check_free_after_iterating(self, iter, self.thetype) diff --git a/Lib/test/test_str.py b/Lib/test/test_str.py index 2a3c36f2e57..4869c5ca9b0 100644 --- a/Lib/test/test_str.py +++ b/Lib/test/test_str.py @@ -2606,7 +2606,6 @@ def test_compare(self): self.assertTrue(astral >= bmp2) self.assertFalse(astral >= astral2) - @unittest.skip("TODO: RUSTPYTHON; hangs") def test_free_after_iterating(self): support.check_free_after_iterating(self, iter, str) if not support.Py_GIL_DISABLED: diff --git a/crates/host_env/src/os.rs b/crates/host_env/src/os.rs index 7af8f586110..6c2521ca12a 100644 --- a/crates/host_env/src/os.rs +++ b/crates/host_env/src/os.rs @@ -25,9 +25,7 @@ use { std::{os::windows::io::AsRawHandle, path::Path}, windows_sys::Win32::{ Foundation::FILETIME, - Storage::FileSystem::{ - FILE_FLAG_BACKUP_SEMANTICS, INVALID_SET_FILE_POINTER, SetFilePointer, SetFileTime, - }, + Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, SetFilePointerEx, SetFileTime}, System::SystemInformation::{GetSystemInfo, SYSTEM_INFO}, }, }; @@ -292,22 +290,24 @@ pub fn seek_fd( position: crt_fd::Offset, how: i32, ) -> io::Result { + use crate::windows::CheckWin32Bool; + let handle = crt_fd::as_handle(fd)?; - let mut distance_to_move: [i32; 2] = unsafe { core::mem::transmute(position) }; - let ret = unsafe { - SetFilePointer( + // `SetFilePointer` returns the low half of the new position and reports + // failure with the value a position four gigabytes in also has, so the two + // are only told apart through the error code. The `Ex` form answers with + // the whole position and a success flag of its own. + let mut new_position = 0; + unsafe { + SetFilePointerEx( handle.as_raw_handle(), - distance_to_move[0], - &mut distance_to_move[1], + position, + &mut new_position, how as _, ) - }; - if ret == INVALID_SET_FILE_POINTER { - Err(io::Error::last_os_error()) - } else { - distance_to_move[0] = ret as _; - Ok(unsafe { core::mem::transmute::<[i32; 2], i64>(distance_to_move) }) } + .check_win32_bool()?; + Ok(new_position) } #[cfg(any(unix, target_os = "wasi"))] diff --git a/crates/stdlib/src/array.rs b/crates/stdlib/src/array.rs index 7ecd8f4fd9f..22eb4837d48 100644 --- a/crates/stdlib/src/array.rs +++ b/crates/stdlib/src/array.rs @@ -19,7 +19,7 @@ pub mod array { builtins::{ PositionIterInternal, PyByteArray, PyBytes, PyBytesRef, PyDictRef, PyFloat, PyGenericAlias, PyInt, PyList, PyListRef, PyStr, PyStrRef, PyTupleRef, PyType, - PyTypeRef, PyUtf8StrRef, builtins_iter, + PyTypeRef, PyUtf8StrRef, builtins_iter, locked_next, }, class_or_notimplemented, convert::{ToPyObject, ToPyResult, TryFromBorrowedObject, TryFromObject}, @@ -1517,7 +1517,7 @@ pub mod array { impl IterNext for PyArrayIter { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { - zelf.internal.lock().next(|array, pos| { + locked_next(&zelf.internal, |array, pos| { let value = array.read().get(pos, vm); Ok(if let Some(item) = value { PyIterReturn::Return(item?) diff --git a/crates/stdlib/src/openssl.rs b/crates/stdlib/src/openssl.rs index fe4a5298d12..ee9d9ae84e0 100644 --- a/crates/stdlib/src/openssl.rs +++ b/crates/stdlib/src/openssl.rs @@ -525,7 +525,7 @@ mod _ssl { if n < 0 { return Err(vm.new_value_error("num must be positive")); } - let mut buf = vec![0; n as usize]; + let mut buf = vm.new_zeroed_bytes(n as usize)?; openssl::rand::rand_bytes(&mut buf).map_err(|e| convert_openssl_error(vm, e))?; Ok(buf) } @@ -872,7 +872,7 @@ mod _ssl { if n < 0 { return Err(vm.new_value_error("num must be positive")); } - let mut buf = vec![0; n as usize]; + let mut buf = vm.new_zeroed_bytes(n as usize)?; let ret = unsafe { sys::RAND_bytes(buf.as_mut_ptr(), n) }; match ret { 0 | 1 => Ok((buf, ret == 1)), diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index b942e27fc69..262c3936e0a 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -3771,7 +3771,7 @@ mod _ssl { // Use compat layer for unified read logic with proper EOF handling // This matches SSL_read_ex() approach - let mut buf = vec![0u8; len]; + let mut buf = vm.new_zeroed_bytes(len)?; let read_result = { let mut conn_guard = self.connection.lock(); let conn = conn_guard @@ -5030,14 +5030,13 @@ mod _ssl { } #[pyfunction] - fn RAND_bytes(n: i64, vm: &VirtualMachine) -> PyResult { + fn RAND_bytes(n: i32, vm: &VirtualMachine) -> PyResult { // Validate n is not negative if n < 0 { return Err(vm.new_value_error("num must be positive")); } - let n_usize = n as usize; - let mut buf = vec![0u8; n_usize]; + let mut buf = vm.new_zeroed_bytes(n as usize)?; CryptoExt::get_provider() .secure_random .fill(&mut buf) @@ -5046,7 +5045,7 @@ mod _ssl { } #[pyfunction] - fn RAND_pseudo_bytes(n: i64, vm: &VirtualMachine) -> PyResult<(PyBytesRef, bool)> { + fn RAND_pseudo_bytes(n: i32, vm: &VirtualMachine) -> PyResult<(PyBytesRef, bool)> { // Rustls providers expose cryptographically strong random bytes. let bytes = RAND_bytes(n, vm)?; Ok((bytes, true)) diff --git a/crates/vm/src/buffer.rs b/crates/vm/src/buffer.rs index 038e7cae9f3..b1de6eafbe2 100644 --- a/crates/vm/src/buffer.rs +++ b/crates/vm/src/buffer.rs @@ -1,5 +1,5 @@ use crate::{ - PyObjectRef, PyResult, TryFromObject, VirtualMachine, + AsObject, PyObjectRef, PyResult, TryFromObject, VirtualMachine, builtins::{PyBaseExceptionRef, PyBytesRef, PyTuple, PyTupleRef, PyTypeRef}, common::{static_cell, str::wchar_t}, convert::ToPyObject, @@ -16,9 +16,62 @@ use malachite_bigint::BigInt; use num_traits::{PrimInt, ToPrimitive}; use std::os::raw; -type PackFunc = fn(&VirtualMachine, FormatType, PyObjectRef, &mut [u8]) -> PyResult<()>; +type PackFunc = fn(&VirtualMachine, FormatType, PyObjectRef, &mut [u8]) -> Result<(), PackError>; type UnpackFunc = fn(&VirtualMachine, &[u8]) -> PyObjectRef; +/// Why a value could not be packed. +/// +/// `struct` reports both as `struct.error`, so the kind travels beside the +/// exception rather than in it; `memoryview`, which reports them as TypeError +/// and ValueError, is what needs to tell them apart. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PackErrorKind { + /// The value was not the kind of thing the format takes. + Type, + /// The value was the right kind, and the format has no room for it. + Value, + /// The value's own code raised, and that error is the answer as it is. + Raised, +} + +pub struct PackError { + pub kind: PackErrorKind, + pub exception: PyBaseExceptionRef, +} + +impl PackError { + fn new>(kind: PackErrorKind, vm: &VirtualMachine, msg: T) -> Self { + Self { + kind, + exception: new_struct_error(vm, msg), + } + } + + /// An error raised by something other than the packing itself, such as a + /// conversion running the value's own code. + fn from_exception(exception: PyBaseExceptionRef, vm: &VirtualMachine) -> Self { + let kind = if exception.fast_isinstance(vm.ctx.exceptions.type_error) { + PackErrorKind::Type + } else if exception.fast_isinstance(vm.ctx.exceptions.overflow_error) + || exception.fast_isinstance(vm.ctx.exceptions.value_error) + { + PackErrorKind::Value + } else { + PackErrorKind::Raised + }; + Self { kind, exception } + } + + /// An error that is the answer exactly as it was raised. `pack_single()` + /// leaves `'?'` to `PyObject_IsTrue()` this way, with no message of its own. + fn raised(exception: PyBaseExceptionRef) -> Self { + Self { + kind: PackErrorKind::Raised, + exception, + } + } +} + static OVERFLOW_MSG: &str = "total struct size too long"; // not a const to reduce code size #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -438,22 +491,45 @@ impl FormatSpec { } pub fn pack(&self, args: Vec, vm: &VirtualMachine) -> PyResult> { + self.try_pack(args, vm).map_err(|e| e.exception) + } + + /// [`Self::pack`], keeping why a value could not be packed. + pub fn try_pack( + &self, + args: Vec, + vm: &VirtualMachine, + ) -> Result, PackError> { // Create data vector: - let mut data = vec![0; self.size]; + let mut data = vm + .new_zeroed_bytes(self.size) + .map_err(|e| PackError::from_exception(e, vm))?; - self.pack_into(&mut data, args, vm)?; + self.try_pack_into(&mut data, args, vm)?; Ok(data) } pub fn pack_into( &self, - mut buffer: &mut [u8], + buffer: &mut [u8], args: Vec, vm: &VirtualMachine, ) -> PyResult<()> { + self.try_pack_into(buffer, args, vm) + .map_err(|e| e.exception) + } + + /// [`Self::pack_into`], keeping why a value could not be packed. + pub fn try_pack_into( + &self, + mut buffer: &mut [u8], + args: Vec, + vm: &VirtualMachine, + ) -> Result<(), PackError> { if self.arg_count != args.len() { - return Err(new_struct_error( + return Err(PackError::new( + PackErrorKind::Type, vm, format!( "pack expected {} items for packing (got {})", @@ -471,12 +547,14 @@ impl FormatSpec { match code.code { FormatType::Str => { let (buf, rest) = buffer.split_at_mut(code.repeat); - pack_string(vm, args.next().unwrap(), buf)?; + pack_string(vm, args.next().unwrap(), buf) + .map_err(|e| PackError::from_exception(e, vm))?; buffer = rest; } FormatType::Pascal => { let (buf, rest) = buffer.split_at_mut(code.repeat); - pack_pascal(vm, args.next().unwrap(), buf)?; + pack_pascal(vm, args.next().unwrap(), buf) + .map_err(|e| PackError::from_exception(e, vm))?; buffer = rest; } FormatType::Pad => { @@ -554,7 +632,7 @@ trait Packable { code: FormatType, arg: PyObjectRef, data: &mut [u8], - ) -> PyResult<()>; + ) -> Result<(), PackError>; fn unpack(vm: &VirtualMachine, data: &[u8]) -> PyObjectRef; } @@ -584,7 +662,7 @@ macro_rules! make_pack_prim_int { code: FormatType, arg: PyObjectRef, data: &mut [u8], - ) -> PyResult<()> { + ) -> Result<(), PackError> { let i: $T = get_int_or_index(vm, code, arg)?; i.pack_int::(data); Ok(()) @@ -598,13 +676,25 @@ macro_rules! make_pack_prim_int { }; } -fn get_int_or_index(vm: &VirtualMachine, code: FormatType, arg: PyObjectRef) -> PyResult +fn get_int_or_index( + vm: &VirtualMachine, + code: FormatType, + arg: PyObjectRef, +) -> Result where T: PrimInt + fmt::Display + for<'a> TryFrom<&'a BigInt>, { - let index = arg - .try_index_opt(vm) - .unwrap_or_else(|| Err(new_struct_error(vm, "required argument is not an integer")))?; + let index = match arg.try_index_opt(vm) { + None => { + return Err(PackError::new( + PackErrorKind::Type, + vm, + "required argument is not an integer", + )); + } + Some(Err(e)) => return Err(PackError::from_exception(e, vm)), + Some(Ok(index)) => index, + }; index.try_to_primitive(vm).map_err(|_| { // A pointer is converted rather than checked against the range of a // named format, so what it reports is the conversion failing. @@ -618,7 +708,7 @@ where T::max_value() ) }; - new_struct_error(vm, msg) + PackError::new(PackErrorKind::Value, vm, msg) }) } @@ -641,15 +731,20 @@ macro_rules! make_pack_float { _code: FormatType, arg: PyObjectRef, data: &mut [u8], - ) -> PyResult<()> { - let f_64 = ArgIntoFloat::try_from_object(vm, arg)?.into_float(); + ) -> Result<(), PackError> { + let f_64 = ArgIntoFloat::try_from_object(vm, arg) + .map_err(|e| PackError::from_exception(e, vm))? + .into_float(); let f = f_64 as $T; if f.is_infinite() != f_64.is_infinite() { - return Err(vm.new_overflow_error(concat!( - "float too large to pack with ", - $fmt, - " format" - ))); + return Err(PackError { + kind: PackErrorKind::Value, + exception: vm.new_overflow_error(concat!( + "float too large to pack with ", + $fmt, + " format" + )), + }); } f.to_bits().pack_int::(data); Ok(()) @@ -672,12 +767,17 @@ impl Packable for f16 { _code: FormatType, arg: PyObjectRef, data: &mut [u8], - ) -> PyResult<()> { - let f_64 = ArgIntoFloat::try_from_object(vm, arg)?.into_float(); + ) -> Result<(), PackError> { + let f_64 = ArgIntoFloat::try_from_object(vm, arg) + .map_err(|e| PackError::from_exception(e, vm))? + .into_float(); // "from_f64 should be preferred in any non-`const` context" except it gives the wrong result :/ let f_16 = Self::from_f64_const(f_64); if f_16.is_infinite() != f_64.is_infinite() { - return Err(vm.new_overflow_error("float too large to pack with e format")); + return Err(PackError { + kind: PackErrorKind::Value, + exception: vm.new_overflow_error("float too large to pack with e format"), + }); } f_16.to_bits().pack_int::(data); Ok(()) @@ -695,7 +795,7 @@ impl Packable for *mut raw::c_void { code: FormatType, arg: PyObjectRef, data: &mut [u8], - ) -> PyResult<()> { + ) -> Result<(), PackError> { usize::pack::(vm, code, arg, data) } @@ -710,8 +810,10 @@ impl Packable for bool { _code: FormatType, arg: PyObjectRef, data: &mut [u8], - ) -> PyResult<()> { - let v = ArgIntoBool::try_from_object(vm, arg)?.into_bool() as u8; + ) -> Result<(), PackError> { + let v = ArgIntoBool::try_from_object(vm, arg) + .map_err(PackError::raised)? + .into_bool() as u8; v.pack_int::(data); Ok(()) } @@ -727,13 +829,15 @@ fn pack_char( _code: FormatType, arg: PyObjectRef, data: &mut [u8], -) -> PyResult<()> { - let v = PyBytesRef::try_from_object(vm, arg)?; - let ch = *v - .as_bytes() - .iter() - .exactly_one() - .map_err(|_| new_struct_error(vm, "char format requires a bytes object of length 1"))?; +) -> Result<(), PackError> { + let v = PyBytesRef::try_from_object(vm, arg).map_err(|e| PackError::from_exception(e, vm))?; + let ch = *v.as_bytes().iter().exactly_one().map_err(|_| { + PackError::new( + PackErrorKind::Value, + vm, + "char format requires a bytes object of length 1", + ) + })?; data[0] = ch; Ok(()) } diff --git a/crates/vm/src/builtins/bytearray.rs b/crates/vm/src/builtins/bytearray.rs index c8782127b3f..594ecc569d8 100644 --- a/crates/vm/src/builtins/bytearray.rs +++ b/crates/vm/src/builtins/bytearray.rs @@ -1,14 +1,14 @@ //! Implementation of the python bytearray object. use super::{ PositionIterInternal, PyBytes, PyDictRef, PyGenericAlias, PyStrRef, PyTuple, PyTupleRef, - PyType, PyTypeRef, iter::builtins_iter, + PyType, PyTypeRef, iter::builtins_iter, locked_next, }; use crate::{ AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, VirtualMachine, anystr::{self, AnyStr}, atomic_func, - byte::{bytes_from_object, value_from_object}, + byte::{bytearray_extend_from_object, bytearray_from_object, value_from_object}, bytes_inner::{ ByteInnerFindOptions, ByteInnerHexOptions, ByteInnerNewOptions, ByteInnerPaddingOptions, ByteInnerSplitOptions, ByteInnerSub, ByteInnerTranslateOptions, DecodeArgs, PyBytesInner, @@ -115,7 +115,7 @@ impl PyByteArray { let items = if zelf.is(&value) { zelf.borrow_buf().to_vec() } else { - bytes_from_object(vm, &value)? + bytearray_from_object(vm, &value)? }; if let Some(mut w) = zelf.try_resizable_opt() { w.elements.setitem_by_slice(vm, slice, &items) @@ -643,7 +643,7 @@ impl Py { vm.new_buffer_error("non-contiguous buffer is not a bytes-like object") })? .to_vec(), - None => bytes_from_object(vm, &object)?, + None => bytearray_extend_from_object(vm, &object)?, }; self.try_resizable(vm)?.elements.extend(items); Ok(()) @@ -716,7 +716,7 @@ impl Initializer for PyByteArray { fn init(zelf: PyRef, options: Self::Args, vm: &VirtualMachine) -> PyResult<()> { // First unpack bytearray and *then* get a lock to set it. - let mut inner = options.get_bytearray_inner(vm)?; + let mut inner = options.get_inner(bytearray_from_object, vm)?; core::mem::swap(&mut *zelf.inner_mut(), &mut inner); Ok(()) } @@ -936,7 +936,7 @@ impl PyByteArrayIterator { impl SelfIter for PyByteArrayIterator {} impl IterNext for PyByteArrayIterator { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { - zelf.internal.lock().next(|bytearray, pos| { + locked_next(&zelf.internal, |bytearray, pos| { let buf = bytearray.borrow_buf(); Ok(PyIterReturn::from_result( buf.get(pos).map(|&x| vm.new_pyobj(x)).ok_or(None), diff --git a/crates/vm/src/builtins/bytes.rs b/crates/vm/src/builtins/bytes.rs index bfa2fd3545b..e611e1929f0 100644 --- a/crates/vm/src/builtins/bytes.rs +++ b/crates/vm/src/builtins/bytes.rs @@ -1,6 +1,6 @@ use super::{ PositionIterInternal, PyDictRef, PyGenericAlias, PyStrRef, PyTuple, PyTupleRef, PyType, - PyTypeRef, iter::builtins_iter, + PyTypeRef, iter::builtins_iter, locked_next, }; use crate::common::lock::LazyLock; use crate::{ @@ -8,6 +8,7 @@ use crate::{ TryFromBorrowedObject, VirtualMachine, anystr::{self, AnyStr}, atomic_func, + byte::bytes_from_object, bytes_inner::{ ByteInnerFindOptions, ByteInnerHexOptions, ByteInnerNewOptions, ByteInnerPaddingOptions, ByteInnerSplitOptions, ByteInnerSub, ByteInnerTranslateOptions, DecodeArgs, PyBytesInner, @@ -138,8 +139,7 @@ impl Constructor for PyBytes { return payload.into_ref_with_type(vm, cls).map(Into::into); } - // Fallback to get_bytearray_inner - let elements = options.get_bytearray_inner(vm)?.elements; + let elements = options.get_inner(bytes_from_object, vm)?.elements; // Return empty bytes singleton for exact bytes types if elements.is_empty() && cls.is(vm.ctx.types.bytes_type) { @@ -797,7 +797,7 @@ impl PyBytesIterator { impl SelfIter for PyBytesIterator {} impl IterNext for PyBytesIterator { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { - zelf.internal.lock().next(|bytes, pos| { + locked_next(&zelf.internal, |bytes, pos| { Ok(PyIterReturn::from_result( bytes .as_bytes() diff --git a/crates/vm/src/builtins/dict.rs b/crates/vm/src/builtins/dict.rs index d2b9dea31fa..9c09833d321 100644 --- a/crates/vm/src/builtins/dict.rs +++ b/crates/vm/src/builtins/dict.rs @@ -1,6 +1,6 @@ use super::{ IterStatus, PositionIterInternal, PyBaseExceptionRef, PyGenericAlias, PyMappingProxy, PySet, - PyStr, PyStrRef, PyTupleRef, PyType, PyTypeRef, set, set::PySetInner, + PyStr, PyStrRef, PyTupleRef, PyType, PyTypeRef, locked_step, set, set::PySetInner, }; use crate::common::lock::LazyLock; use crate::object::{Traverse, TraverseFn}; @@ -24,6 +24,7 @@ use crate::{ use alloc::fmt; use core::cell::Cell; use core::ptr::NonNull; +use rustpython_common::atomic::{Ordering, PyAtomic, Radium}; use rustpython_common::lock::PyMutex; use rustpython_common::wtf8::Wtf8Buf; @@ -240,7 +241,7 @@ impl PyDict { } })?; elem_iter - .into_iter::(vm)? + .into_iter::(vm) .collect::>>() })() .map_err(|exc| Self::add_update_sequence_note(exc, index, vm))?; @@ -257,7 +258,7 @@ impl PyDict { let iter = seq2.get_iter(vm)?; let dict = &self.entries; - for (index, element) in iter.iter_without_hint::(vm)?.enumerate() { + for (index, element) in iter.iter::(vm)?.enumerate() { let (key, value) = Self::update_sequence_pair(element?, index, vm)?; if !override_existing && dict.contains(vm, &*key)? { @@ -1164,6 +1165,11 @@ macro_rules! dict_view { #[derive(Debug)] pub(crate) struct $iter_name { pub(crate) size: dict_inner::DictSize, + /// Whether the dict was found to have changed, which + /// `dictiter_iternextkey()` records by writing a size no dict can + /// have. Sticky: what it makes the iterator answer, it answers + /// from then on. + changed: PyAtomic, pub(crate) internal: PyMutex>, } @@ -1179,13 +1185,26 @@ macro_rules! dict_view { fn new(dict: PyDictRef) -> Self { $iter_name { size: dict.size(), + changed: Radium::new(false), internal: PyMutex::new(PositionIterInternal::new(dict, 0)), } } #[pymethod] fn __length_hint__(&self) -> usize { - self.internal.lock().length_hint(|_| self.size.entries_size) + // `dictiter_len()` answers for a dict it can no longer walk + // with nothing, comparing the size it captured against the + // dict's own every time it is asked. + if self.changed.load(Ordering::Relaxed) { + return 0; + } + self.internal.lock().length_hint(|dict| { + if dict.size() == self.size { + self.size.entries_size + } else { + 0 + } + }) } #[pymethod] @@ -1214,32 +1233,33 @@ macro_rules! dict_view { impl IterNext for $iter_name { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { - let mut internal = zelf.internal.lock(); - let next = if let IterStatus::Active(dict) = &internal.status { - match dict.entries.next_entry_checked( - internal.position, - &zelf.size, - $project_fn, - ) { + locked_step(&zelf.internal, |internal| { + let IterStatus::Active(dict) = &internal.status else { + return (Ok(PyIterReturn::StopIteration(None)), None); + }; + let mutated = + || vm.new_runtime_error("dictionary changed size during iteration"); + if zelf.changed.load(Ordering::Relaxed) { + // The dict is not looked at again once it has been + // found to change: an iterator that has raised keeps + // raising. + return (Err(mutated()), None); + } + let entry = + dict.entries + .next_entry_checked(internal.position, &zelf.size, $project_fn); + match entry { Err(dict_inner::DictChanged) => { - internal.status = IterStatus::Exhausted; - return Err( - vm.new_runtime_error("dictionary changed size during iteration") - ); + zelf.changed.store(true, Ordering::Relaxed); + (Err(mutated()), None) } Ok(Some((position, item))) => { internal.position = position; - PyIterReturn::Return(($result_fn)(vm, item)) - } - Ok(None) => { - internal.status = IterStatus::Exhausted; - PyIterReturn::StopIteration(None) + (Ok(PyIterReturn::Return(($result_fn)(vm, item))), None) } + Ok(None) => (Ok(PyIterReturn::StopIteration(None)), internal.exhaust()), } - } else { - PyIterReturn::StopIteration(None) - }; - Ok(next) + }) } } @@ -1247,6 +1267,8 @@ macro_rules! dict_view { #[derive(Debug)] pub(crate) struct $reverse_iter_name { pub(crate) size: dict_inner::DictSize, + /// As in `$iter_name`. + changed: PyAtomic, internal: PyMutex>, } @@ -1264,6 +1286,7 @@ macro_rules! dict_view { let position = size.entries_size.saturating_sub(1); $reverse_iter_name { size, + changed: Radium::new(false), internal: PyMutex::new(PositionIterInternal::new(dict, position)), } } @@ -1294,9 +1317,17 @@ macro_rules! dict_view { #[pymethod] fn __length_hint__(&self) -> usize { - self.internal - .lock() - .rev_length_hint(|_| self.size.entries_size) + // As in `$iter_name`. + if self.changed.load(Ordering::Relaxed) { + return 0; + } + let internal = self.internal.lock(); + match &internal.status { + IterStatus::Active(dict) if dict.size() == self.size => { + internal.rev_length_hint(|_| self.size.entries_size) + } + _ => 0, + } } } @@ -1304,36 +1335,38 @@ macro_rules! dict_view { impl IterNext for $reverse_iter_name { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { - let mut internal = zelf.internal.lock(); - let next = if let IterStatus::Active(dict) = &internal.status { - match dict.entries.prev_entry_checked( - internal.position, - &zelf.size, - $project_fn, - ) { + locked_step(&zelf.internal, |internal| { + let IterStatus::Active(dict) = &internal.status else { + return (Ok(PyIterReturn::StopIteration(None)), None); + }; + let mutated = + || vm.new_runtime_error("dictionary changed size during iteration"); + if zelf.changed.load(Ordering::Relaxed) { + // The dict is not looked at again once it has been + // found to change: an iterator that has raised keeps + // raising. + return (Err(mutated()), None); + } + let entry = + dict.entries + .prev_entry_checked(internal.position, &zelf.size, $project_fn); + match entry { Err(dict_inner::DictChanged) => { - internal.status = IterStatus::Exhausted; - return Err( - vm.new_runtime_error("dictionary changed size during iteration") - ); + zelf.changed.store(true, Ordering::Relaxed); + (Err(mutated()), None) } Ok(Some((found_index, item))) => { - if found_index == 0 { - internal.status = IterStatus::Exhausted; + let released = if found_index == 0 { + internal.exhaust() } else { internal.position = found_index - 1; - } - PyIterReturn::Return(($result_fn)(vm, item)) - } - Ok(None) => { - internal.status = IterStatus::Exhausted; - PyIterReturn::StopIteration(None) + None + }; + (Ok(PyIterReturn::Return(($result_fn)(vm, item))), released) } + Ok(None) => (Ok(PyIterReturn::StopIteration(None)), internal.exhaust()), } - } else { - PyIterReturn::StopIteration(None) - }; - Ok(next) + }) } } }; diff --git a/crates/vm/src/builtins/enumerate.rs b/crates/vm/src/builtins/enumerate.rs index 95e144dad21..dac19dd89cc 100644 --- a/crates/vm/src/builtins/enumerate.rs +++ b/crates/vm/src/builtins/enumerate.rs @@ -1,6 +1,6 @@ use super::{ IterStatus, PositionIterInternal, PyGenericAlias, PyIntRef, PyTupleRef, PyType, PyTypeRef, - iter::builtins_reversed, + iter::builtins_reversed, locked_rev_next, }; use crate::common::lock::{PyMutex, PyRwLock}; use crate::{ @@ -142,9 +142,9 @@ impl PyReverseSequenceIterator { impl SelfIter for PyReverseSequenceIterator {} impl IterNext for PyReverseSequenceIterator { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { - zelf.internal - .lock() - .rev_next(|obj, pos| PyIterReturn::from_getitem_result(obj.get_item(&pos, vm), vm)) + locked_rev_next(&zelf.internal, |obj, pos| { + PyIterReturn::from_getitem_result(obj.get_item(&pos, vm), vm) + }) } } diff --git a/crates/vm/src/builtins/function.rs b/crates/vm/src/builtins/function.rs index a5342d1df3a..94c231b0b83 100644 --- a/crates/vm/src/builtins/function.rs +++ b/crates/vm/src/builtins/function.rs @@ -1578,7 +1578,11 @@ impl PyCell { } pub(crate) fn set(&self, x: Option) { - *self.contents.lock() = x; + // What was here is released after the lock, the way `Py_XSETREF` stores + // before it decrefs. Releasing it under the lock would let a `__del__` + // that reads this cell wait on a lock this call still holds. + let replaced = core::mem::replace(&mut *self.contents.lock(), x); + drop(replaced); } #[pygetset] diff --git a/crates/vm/src/builtins/int.rs b/crates/vm/src/builtins/int.rs index bb7b5128073..3a4c18a3cdd 100644 --- a/crates/vm/src/builtins/int.rs +++ b/crates/vm/src/builtins/int.rs @@ -593,7 +593,7 @@ impl PyInt { Sign::Minus if !signed => { return Err(vm.new_overflow_error("can't convert negative int to unsigned")); } - Sign::NoSign => return Ok(vec![0u8; byte_len].into()), + Sign::NoSign => return Ok(vm.new_zeroed_bytes(byte_len)?.into()), _ => {} } @@ -609,10 +609,10 @@ impl PyInt { return Err(vm.new_overflow_error("int too big to convert")); } - let mut append_bytes = match value.sign() { - Sign::Minus => vec![255u8; byte_len - origin_len], - _ => vec![0u8; byte_len - origin_len], - }; + let mut append_bytes = vm.new_zeroed_bytes(byte_len - origin_len)?; + if value.sign() == Sign::Minus { + append_bytes.fill(255); + } let bytes = match args.byteorder { ArgByteOrder::Big => { diff --git a/crates/vm/src/builtins/iter.rs b/crates/vm/src/builtins/iter.rs index 4e29df583a8..2d231e9e6a8 100644 --- a/crates/vm/src/builtins/iter.rs +++ b/crates/vm/src/builtins/iter.rs @@ -91,41 +91,67 @@ impl PositionIterInternal { } } - fn _next(&mut self, f: F, op: OP) -> PyResult + /// `op` answers whether the step it took left this exhausted. + fn _next(&mut self, f: F, op: OP) -> (PyResult, Option) where F: FnOnce(&T, usize) -> PyResult, - OP: FnOnce(&mut Self), + OP: FnOnce(&mut Self) -> bool, { - if let IterStatus::Active(obj) = &self.status { - let ret = f(obj, self.position); - if let Ok(PyIterReturn::Return(_)) = ret { - op(self); - } else { - self.status = IterStatus::Exhausted; - } - ret - } else { - Ok(PyIterReturn::StopIteration(None)) + let IterStatus::Active(obj) = &self.status else { + return (Ok(PyIterReturn::StopIteration(None)), None); + }; + let ret = f(obj, self.position); + let done = match &ret { + Ok(PyIterReturn::Return(_)) => op(self), + Ok(PyIterReturn::StopIteration(_)) => true, + // An error belongs to the element, not to the walk, so the next + // call reaches for the same one again. `iter_iternext()` lets go of + // its sequence for `IndexError` and `StopIteration` alone, and + // `PyIterReturn::from_getitem_result` has already turned the first + // of those into the second. + Err(_) => false, + }; + let released = if done { self.exhaust() } else { None }; + (ret, released) + } + + /// Mark this exhausted and hand back what it was holding, for the caller to + /// release once it has dropped the lock guarding this. Releasing it under + /// that lock would let a `__del__` that iterates again deadlock. + #[must_use] + pub fn exhaust(&mut self) -> Option { + match core::mem::replace(&mut self.status, IterStatus::Exhausted) { + IterStatus::Active(obj) => Some(obj), + IterStatus::Exhausted => None, } } - pub fn next(&mut self, f: F) -> PyResult + /// Advance, along with what this was holding if the step exhausted it. See + /// [`Self::exhaust`] for why the caller is handed it rather than the drop + /// happening here; [`locked_next`] does the release for the common case. + #[must_use = "what this hands back is released after the lock, not here"] + pub fn next(&mut self, f: F) -> (PyResult, Option) where F: FnOnce(&T, usize) -> PyResult, { - self._next(f, |zelf| zelf.position += 1) + self._next(f, |zelf| { + zelf.position += 1; + false + }) } - pub fn rev_next(&mut self, f: F) -> PyResult + /// [`Self::next`] walking backwards, exhausted once it steps off the front. + #[must_use = "what this hands back is released after the lock, not here"] + pub fn rev_next(&mut self, f: F) -> (PyResult, Option) where F: FnOnce(&T, usize) -> PyResult, { self._next(f, |zelf| { if zelf.position == 0 { - zelf.status = IterStatus::Exhausted; - } else { - zelf.position -= 1; + return true; } + zelf.position -= 1; + false }) } @@ -153,6 +179,43 @@ impl PositionIterInternal { } } +/// Take `step` under the lock `internal` holds, releasing whatever the step +/// hands back only after that lock is gone. `setiter_iternext()` puts its +/// `Py_DECREF(so)` past `Py_END_CRITICAL_SECTION()` for the same reason: a +/// `__del__` that iterates again would otherwise wait on a lock still held here. +pub(crate) fn locked_step( + internal: &PyMutex>, + step: impl FnOnce(&mut PositionIterInternal) -> (PyResult, Option), +) -> PyResult { + let mut guard = internal.lock(); + let (ret, released) = step(&mut guard); + drop(guard); + drop(released); + ret +} + +/// [`PositionIterInternal::next`] with the release [`locked_step`] describes. +pub fn locked_next( + internal: &PyMutex>, + f: F, +) -> PyResult +where + F: FnOnce(&T, usize) -> PyResult, +{ + locked_step(internal, |internal| internal.next(f)) +} + +/// [`locked_next`] walking backwards. +pub fn locked_rev_next( + internal: &PyMutex>, + f: F, +) -> PyResult +where + F: FnOnce(&T, usize) -> PyResult, +{ + locked_step(internal, |internal| internal.rev_next(f)) +} + pub fn builtins_iter(vm: &VirtualMachine) -> PyObjectRef { vm.builtins.get_attr("iter", vm).unwrap() } @@ -227,7 +290,7 @@ impl PySequenceIterator { impl SelfIter for PySequenceIterator {} impl IterNext for PySequenceIterator { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { - zelf.internal.lock().next(|obj, pos| { + locked_next(&zelf.internal, |obj, pos| { let seq = obj.sequence_unchecked(); PyIterReturn::from_getitem_result(seq.get_item(pos as isize, vm), vm) }) diff --git a/crates/vm/src/builtins/list.rs b/crates/vm/src/builtins/list.rs index fe674a45821..3ba537a81e0 100644 --- a/crates/vm/src/builtins/list.rs +++ b/crates/vm/src/builtins/list.rs @@ -1,6 +1,7 @@ use super::{ PositionIterInternal, PyGenericAlias, PyTupleRef, PyType, PyTypeRef, iter::{builtins_iter, builtins_reversed}, + locked_next, locked_rev_next, }; use crate::atomic_func; use crate::common::lock::{ @@ -187,7 +188,11 @@ impl PyList { #[pymethod] pub(crate) fn extend(&self, x: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { - let mut new_elements = x.try_to_value(vm)?; + // What is already here decides whether the iterable's length hint is + // believable, so it goes along with the request for the elements. It is + // counted where `list_extend()` reads `Py_SIZE(self)`, after the + // iterable has answered, because answering runs code that can change it. + let mut new_elements = vm.extract_elements_sized(&x, &|| self.borrow_vec().len(), Ok)?; self.borrow_vec_mut().append(&mut new_elements); Ok(()) } @@ -221,8 +226,7 @@ impl PyList { other: &PyObject, vm: &VirtualMachine, ) -> PyResult { - let mut seq = extract_cloned(other, Ok, vm)?; - zelf.borrow_vec_mut().append(&mut seq); + zelf.extend(other.to_owned(), vm)?; Ok(zelf.to_owned().into()) } @@ -231,8 +235,7 @@ impl PyList { other: PyObjectRef, vm: &VirtualMachine, ) -> PyResult> { - let mut seq = extract_cloned(&other, Ok, vm)?; - zelf.borrow_vec_mut().append(&mut seq); + zelf.extend(other, vm)?; Ok(zelf) } @@ -481,7 +484,7 @@ impl Initializer for PyList { fn init(zelf: PyRef, iterable: Self::Args, vm: &VirtualMachine) -> PyResult<()> { let mut elements = if let OptionalArg::Present(iterable) = iterable { - iterable.try_to_value(vm)? + vm.extract_elements_sized(&iterable, &|| 0, Ok)? } else { vec![] }; @@ -909,24 +912,22 @@ impl PyListIterator { impl PyListIterator { /// Fast path for FOR_ITER specialization. pub(crate) fn fast_next(&self) -> Option { - self.internal - .lock() - .next(|list, pos| { - let vec = list.borrow_vec(); - Ok(PyIterReturn::from_result(vec.get(pos).cloned().ok_or(None))) - }) - .ok() - .and_then(|r| match r { - PyIterReturn::Return(v) => Some(v), - PyIterReturn::StopIteration(_) => None, - }) + locked_next(&self.internal, |list, pos| { + let vec = list.borrow_vec(); + Ok(PyIterReturn::from_result(vec.get(pos).cloned().ok_or(None))) + }) + .ok() + .and_then(|r| match r { + PyIterReturn::Return(v) => Some(v), + PyIterReturn::StopIteration(_) => None, + }) } } impl SelfIter for PyListIterator {} impl IterNext for PyListIterator { fn next(zelf: &Py, _vm: &VirtualMachine) -> PyResult { - zelf.internal.lock().next(|list, pos| { + locked_next(&zelf.internal, |list, pos| { let vec = list.borrow_vec(); Ok(PyIterReturn::from_result(vec.get(pos).cloned().ok_or(None))) }) @@ -975,7 +976,7 @@ impl PyListReverseIterator { impl SelfIter for PyListReverseIterator {} impl IterNext for PyListReverseIterator { fn next(zelf: &Py, _vm: &VirtualMachine) -> PyResult { - zelf.internal.lock().rev_next(|list, pos| { + locked_rev_next(&zelf.internal, |list, pos| { let vec = list.borrow_vec(); Ok(PyIterReturn::from_result(vec.get(pos).cloned().ok_or(None))) }) diff --git a/crates/vm/src/builtins/map.rs b/crates/vm/src/builtins/map.rs index cb8db23e640..2606b61933a 100644 --- a/crates/vm/src/builtins/map.rs +++ b/crates/vm/src/builtins/map.rs @@ -54,15 +54,6 @@ impl Constructor for PyMap { #[pyclass(with(IterNext, Iterable, Constructor), flags(BASETYPE))] impl PyMap { - #[pymethod] - fn __length_hint__(&self, vm: &VirtualMachine) -> PyResult { - self.iterators.iter().try_fold(0, |prev, cur| { - let cur = cur.as_ref().to_owned().length_hint(0, vm)?; - let max = core::cmp::max(prev, cur); - Ok(max) - }) - } - #[pymethod] fn __reduce__(zelf: PyRef, vm: &VirtualMachine) -> PyTupleRef { let cls = zelf.class().to_owned(); diff --git a/crates/vm/src/builtins/memory.rs b/crates/vm/src/builtins/memory.rs index 0b04de133e7..86396825b0c 100644 --- a/crates/vm/src/builtins/memory.rs +++ b/crates/vm/src/builtins/memory.rs @@ -1,12 +1,13 @@ use super::{ PositionIterInternal, PyBytes, PyBytesRef, PyGenericAlias, PyInt, PyListRef, PySlice, PyStr, PyStrRef, PyTuple, PyTupleRef, PyType, PyTypeRef, PyUtf8StrRef, iter::builtins_iter, + locked_next, }; use crate::common::lock::LazyLock; use crate::{ AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, TryFromBorrowedObject, TryFromObject, VirtualMachine, atomic_func, - buffer::FormatSpec, + buffer::{FormatSpec, PackErrorKind}, bytes_inner::{ByteInnerHexOptions, bytes_to_hex}, class::{PyClassImpl, StaticType}, common::{ @@ -329,11 +330,22 @@ impl PyMemoryView { // conversion runs `__index__` or `__float__`, which can read or write the // same buffer. // TODO: Optimize - let data = self.format_spec.pack(vec![value], vm).map_err(|_| { - vm.new_type_error(format!( - "memoryview: invalid type for format '{}'", + // A value of the wrong kind and a value the format has no room for are + // different errors here, though packing reports both the same way. + let data = self.format_spec.try_pack(vec![value], vm).map_err(|err| { + let what = match err.kind { + PackErrorKind::Type => "type", + PackErrorKind::Value => "value", + PackErrorKind::Raised => return err.exception, + }; + let msg = format!( + "memoryview: invalid {what} for format '{}'", self.desc.format - )) + ); + match err.kind { + PackErrorKind::Type => vm.new_type_error(msg), + _ => vm.new_value_error(msg), + } })?; // The conversion, and the index that produced `pos`, could have released // the view; `pos` addresses a buffer that is no longer there. @@ -842,6 +854,9 @@ impl PyMemoryView { } fn __delitem__(&self, _needle: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { + self.try_not_released(vm)?; + // What cannot be written cannot be deleted from either, and that is + // the first thing answered. if self.desc.readonly { return Err(vm.new_type_error("cannot modify read-only memory")); } @@ -1133,10 +1148,6 @@ impl Py { if self.desc.readonly { return Err(vm.new_type_error("cannot modify read-only memory")); } - if value.is(&vm.ctx.none) { - return Err(vm.new_type_error("cannot delete memory")); - } - if self.desc.ndim() == 0 { // TODO: merge branches when we got conditional if let if needle.is(&vm.ctx.ellipsis) { @@ -1291,7 +1302,7 @@ impl AsMapping for PyMemoryView { if let Some(value) = value { zelf.__setitem__(needle.to_owned(), value, vm) } else { - Err(vm.new_type_error("cannot delete memory".to_owned())) + zelf.__delitem__(needle.to_owned(), vm) } }), }; @@ -1681,7 +1692,7 @@ impl PyMemoryViewIterator { impl SelfIter for PyMemoryViewIterator {} impl IterNext for PyMemoryViewIterator { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { - zelf.internal.lock().next(|mv, pos| { + locked_next(&zelf.internal, |mv, pos| { let len = mv.__len__(vm)?; Ok(if pos >= len { PyIterReturn::StopIteration(None) diff --git a/crates/vm/src/builtins/range.rs b/crates/vm/src/builtins/range.rs index 5962f90e521..928c67884a4 100644 --- a/crates/vm/src/builtins/range.rs +++ b/crates/vm/src/builtins/range.rs @@ -39,7 +39,7 @@ fn iter_search( ) -> PyResult { let mut count = 0; let iter = obj.get_iter(vm)?; - for element in iter.iter_without_hint::(vm)? { + for element in iter.iter::(vm)? { if vm.bool_eq(item, &*element?)? { match flag { SearchType::Index => return Ok(count), diff --git a/crates/vm/src/builtins/set.rs b/crates/vm/src/builtins/set.rs index d737612b158..7e6f43bbb4c 100644 --- a/crates/vm/src/builtins/set.rs +++ b/crates/vm/src/builtins/set.rs @@ -3,7 +3,7 @@ */ use super::{ IterStatus, PositionIterInternal, PyDict, PyDictRef, PyGenericAlias, PyTupleRef, PyType, - PyTypeRef, builtins_iter, + PyTypeRef, builtins_iter, locked_step, }; use crate::{ AsObject, Context, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult, TryFromObject, @@ -378,13 +378,6 @@ impl PySetInner { Ok(true) } - fn iter(&self) -> PySetIterator { - PySetIterator { - size: self.content.size(), - internal: PyMutex::new(PositionIterInternal::new(self.content.clone(), 0)), - } - } - fn repr(&self, class_name: Option<&str>, vm: &VirtualMachine) -> PyResult { let empty = format!("{}()", class_name.unwrap_or("set")); collection_repr(class_name, "{", "}", &empty, self.elements().iter(), vm) @@ -933,7 +926,10 @@ impl Comparable for PySet { impl Iterable for PySet { fn iter(zelf: PyRef, vm: &VirtualMachine) -> PyResult { - Ok(zelf.inner.iter().into_pyobject(vm)) + Ok(PySetIterator::new(AnySet { + object: zelf.into(), + }) + .into_pyobject(vm)) } } @@ -1351,7 +1347,10 @@ impl Comparable for PyFrozenSet { impl Iterable for PyFrozenSet { fn iter(zelf: PyRef, vm: &VirtualMachine) -> PyResult { - Ok(zelf.inner.iter().into_pyobject(vm)) + Ok(PySetIterator::new(AnySet { + object: zelf.into(), + }) + .into_pyobject(vm)) } } @@ -1487,7 +1486,11 @@ impl TryFromObject for AnySet { #[pyclass(module = false, name = "set_iterator")] pub(crate) struct PySetIterator { size: DictSize, - internal: PyMutex>>, + /// Whether the set was found to have changed, which `setiter_iternext()` + /// records by writing a size no set can have. Sticky: what it makes the + /// iterator answer, it answers from then on. + changed: PyAtomic, + internal: PyMutex>, } impl fmt::Debug for PySetIterator { @@ -1504,11 +1507,33 @@ impl PyPayload for PySetIterator { } } +impl PySetIterator { + fn new(set: AnySet) -> Self { + Self { + size: set.as_inner().content.size(), + changed: Radium::new(false), + internal: PyMutex::new(PositionIterInternal::new(set, 0)), + } + } +} + #[pyclass(flags(DISALLOW_INSTANTIATION), with(IterNext, Iterable))] impl PySetIterator { #[pymethod] fn __length_hint__(&self) -> usize { - self.internal.lock().length_hint(|_| self.size.entries_size) + // `setiter_len()` answers for a set it can no longer walk with nothing, + // comparing the size it captured against the set's own every time it is + // asked. + if self.changed.load(Ordering::Relaxed) { + return 0; + } + self.internal.lock().length_hint(|set| { + if set.as_inner().content.size() == self.size { + self.size.entries_size + } else { + 0 + } + }) } #[pymethod] @@ -1519,9 +1544,13 @@ impl PySetIterator { (vm.ctx .new_list(match &internal.status { IterStatus::Exhausted => vec![], - IterStatus::Active(dict) => { - dict.keys().into_iter().skip(internal.position).collect() - } + IterStatus::Active(set) => set + .as_inner() + .content + .keys() + .into_iter() + .skip(internal.position) + .collect(), }) .into(),), ) @@ -1531,26 +1560,33 @@ impl PySetIterator { impl SelfIter for PySetIterator {} impl IterNext for PySetIterator { fn next(zelf: &crate::Py, vm: &VirtualMachine) -> PyResult { - let mut internal = zelf.internal.lock(); - let next = if let IterStatus::Active(dict) = &internal.status { - match dict.next_entry_checked(internal.position, &zelf.size, |key, ()| key.clone()) { + locked_step(&zelf.internal, |internal| { + let IterStatus::Active(set) = &internal.status else { + return (Ok(PyIterReturn::StopIteration(None)), None); + }; + let mutated = || vm.new_runtime_error("Set changed size during iteration"); + if zelf.changed.load(Ordering::Relaxed) { + // The set is not looked at again once it has been found to + // change: an iterator that has raised keeps raising. + return (Err(mutated()), None); + } + let entry = set.as_inner().content.next_entry_checked( + internal.position, + &zelf.size, + |key, ()| key.clone(), + ); + match entry { Err(crate::dict_inner::DictChanged) => { - internal.status = IterStatus::Exhausted; - return Err(vm.new_runtime_error("set changed size during iteration")); + zelf.changed.store(true, Ordering::Relaxed); + (Err(mutated()), None) } Ok(Some((position, key))) => { internal.position = position; - PyIterReturn::Return(key) - } - Ok(None) => { - internal.status = IterStatus::Exhausted; - PyIterReturn::StopIteration(None) + (Ok(PyIterReturn::Return(key)), None) } + Ok(None) => (Ok(PyIterReturn::StopIteration(None)), internal.exhaust()), } - } else { - PyIterReturn::StopIteration(None) - }; - Ok(next) + }) } } diff --git a/crates/vm/src/builtins/str.rs b/crates/vm/src/builtins/str.rs index 6e774f7e652..dc5a1daf4b9 100644 --- a/crates/vm/src/builtins/str.rs +++ b/crates/vm/src/builtins/str.rs @@ -1,10 +1,7 @@ use super::{ PositionIterInternal, PyBytesRef, PyDict, PyTupleRef, PyType, PyTypeRef, int::{PyInt, PyIntRef}, - iter::{ - IterStatus::{self, Exhausted}, - builtins_iter, - }, + iter::{IterStatus, builtins_iter}, }; use crate::{ AsObject, Context, Py, PyExact, PyObject, PyObjectRef, PyPayload, PyRef, PyRefExact, PyResult, @@ -379,7 +376,11 @@ impl IterNext for PyStrIterator { internal.1 += ch.len_wtf8(); return Ok(PyIterReturn::Return(ch.to_pyobject(vm))); } - internal.0.status = Exhausted; + let released = internal.0.exhaust(); + // The string is released after the lock. A `__del__` that iterates + // again would otherwise reach for a lock this call still holds. + drop(internal); + drop(released); } Ok(PyIterReturn::StopIteration(None)) } @@ -1173,7 +1174,9 @@ impl PyStr { iterable: ArgIterable, vm: &VirtualMachine, ) -> PyResult { - let iter = iterable.iter(vm)?; + // `PyUnicode_Join()` reaches its elements through `PySequence_Fast()`, + // which fills a list from the iterator and so asks it how long it is. + let iter = iterable.iter_sized(vm)?; let joined = match iter.exactly_one() { Ok(first) => { let first = first?; diff --git a/crates/vm/src/builtins/tuple.rs b/crates/vm/src/builtins/tuple.rs index d510e35326f..3bd53094e8c 100644 --- a/crates/vm/src/builtins/tuple.rs +++ b/crates/vm/src/builtins/tuple.rs @@ -1,5 +1,6 @@ use super::{ PositionIterInternal, PyGenericAlias, PyStrRef, PyType, PyTypeRef, iter::builtins_iter, + locked_next, }; use crate::common::lock::LazyLock; use crate::common::{hash, hash::PyHash, lock::PyMutex, wtf8::wtf8_concat}; @@ -701,25 +702,23 @@ impl PyTupleIterator { impl PyTupleIterator { /// Fast path for FOR_ITER specialization. pub(crate) fn fast_next(&self) -> Option { - self.internal - .lock() - .next(|tuple, pos| { - Ok(PyIterReturn::from_result( - tuple.get(pos).cloned().ok_or(None), - )) - }) - .ok() - .and_then(|r| match r { - PyIterReturn::Return(v) => Some(v), - PyIterReturn::StopIteration(_) => None, - }) + locked_next(&self.internal, |tuple, pos| { + Ok(PyIterReturn::from_result( + tuple.get(pos).cloned().ok_or(None), + )) + }) + .ok() + .and_then(|r| match r { + PyIterReturn::Return(v) => Some(v), + PyIterReturn::StopIteration(_) => None, + }) } } impl SelfIter for PyTupleIterator {} impl IterNext for PyTupleIterator { fn next(zelf: &Py, _vm: &VirtualMachine) -> PyResult { - zelf.internal.lock().next(|tuple, pos| { + locked_next(&zelf.internal, |tuple, pos| { Ok(PyIterReturn::from_result( tuple.get(pos).cloned().ok_or(None), )) diff --git a/crates/vm/src/byte.rs b/crates/vm/src/byte.rs index 0e90f296ac9..b22fb54fa08 100644 --- a/crates/vm/src/byte.rs +++ b/crates/vm/src/byte.rs @@ -3,21 +3,64 @@ use num_traits::ToPrimitive; use crate::{ - AsObject, PyObject, PyResult, VirtualMachine, + AsObject, PyObject, PyObjectRef, PyResult, VirtualMachine, protocol::{BufferFlags, PyBuffer}, }; // PyBytes_FromObject pub fn bytes_from_object(vm: &VirtualMachine, obj: &PyObject) -> PyResult> { + collect_bytes(vm, obj, true, |name| { + format!("cannot convert '{name}' object to bytes") + }) +} + +/// [`bytes_from_object`] for the bytearray constructor and for assigning to a +/// slice of one, which run the iterator without asking the object they were +/// handed how long it is. +pub fn bytearray_from_object(vm: &VirtualMachine, obj: &PyObject) -> PyResult> { + collect_bytes(vm, obj, false, |name| { + format!("cannot convert '{name}' object to bytearray") + }) +} + +/// [`bytes_from_object`] for `bytearray_extend()`, which names what it was +/// doing rather than what it was converting to. +pub fn bytearray_extend_from_object(vm: &VirtualMachine, obj: &PyObject) -> PyResult> { + collect_bytes(vm, obj, true, |name| { + format!("can't extend bytearray with {name}") + }) +} + +/// `measured` is whether the object is asked how long it is; `unusable` names, +/// from the class name, what could not be done with one that is not iterable. +fn collect_bytes( + vm: &VirtualMachine, + obj: &PyObject, + measured: bool, + unusable: impl FnOnce(&str) -> String, +) -> PyResult> { if obj.check_buffer() { let buffer = PyBuffer::from_object(vm, obj, BufferFlags::FULL_RO)?; return Ok(buffer.contiguous_or_collect(|bytes| bytes.to_vec())); } - if !obj.fast_isinstance(vm.ctx.types.str_type) - && let Ok(elements) = vm.map_iterable_object(obj, |x| value_from_object(vm, &x)) - { - return elements; + if !obj.fast_isinstance(vm.ctx.types.str_type) { + // What `PyObject_GetIter()` cannot take is answered for by the caller, + // which knows what it was being asked to do, rather than by the + // iteration protocol saying the object is not iterable. + let cls = obj.class(); + if cls.slots.iter.load().is_none() && !cls.has_attr(identifier!(vm, __getitem__)) { + return Err(vm.new_type_error(unusable(&cls.name()))); + } + let value = |x: PyObjectRef| value_from_object(vm, &x); + let elements = if measured { + vm.map_iterable_object_sized(obj, value) + } else { + vm.map_iterable_object(obj, value) + }; + if let Ok(elements) = elements { + return elements; + } } Err(vm.new_type_error("can assign only bytes, buffers, or iterables of ints in range(0, 256)")) diff --git a/crates/vm/src/bytes_inner.rs b/crates/vm/src/bytes_inner.rs index e16c636a964..d87fee4c2a2 100644 --- a/crates/vm/src/bytes_inner.rs +++ b/crates/vm/src/bytes_inner.rs @@ -7,7 +7,6 @@ use crate::{ PyBaseExceptionRef, PyByteArray, PyBytes, PyBytesRef, PyInt, PyIntRef, PyStr, PyStrRef, pystr, pystr::PyUtf8StrRef, }, - byte::bytes_from_object, cformat::cformat_bytes, common::hash, common::wtf8::is_py_ascii_whitespace, @@ -17,6 +16,11 @@ use crate::{ sequence::{SequenceExt, SequenceMutExt}, types::PyComparisonOp, }; +/// How a source object that is neither a size nor a string is turned into +/// bytes: [`crate::byte::bytes_from_object`] or +/// [`crate::byte::bytearray_from_object`]. +pub(crate) type FromObject = fn(&VirtualMachine, &PyObject) -> PyResult>; + use bstr::ByteSlice; use itertools::Itertools; use malachite_bigint::BigInt; @@ -64,8 +68,12 @@ impl ByteInnerNewOptions { Ok(bytes.as_bytes().to_vec().into()) } - fn get_value_from_source(source: PyObjectRef, vm: &VirtualMachine) -> PyResult { - bytes_from_object(vm, &source).map(|x| x.into()) + fn get_value_from_source( + source: PyObjectRef, + from_object: FromObject, + vm: &VirtualMachine, + ) -> PyResult { + from_object(vm, &source).map(|x| x.into()) } fn get_value_from_size(size: PyIntRef, vm: &VirtualMachine) -> PyResult { @@ -81,19 +89,26 @@ impl ByteInnerNewOptions { Ok(vm.new_zeroed_bytes(size)?.into()) } - fn handle_object_fallback(obj: PyObjectRef, vm: &VirtualMachine) -> PyResult { + fn handle_object_fallback( + obj: PyObjectRef, + from_object: FromObject, + vm: &VirtualMachine, + ) -> PyResult { match_class!(match obj { i @ PyInt => { Self::get_value_from_size(i, vm) } _s @ PyStr => Err(vm.new_type_error(STRING_WITHOUT_ENCODING.to_owned())), obj => { - Self::get_value_from_source(obj, vm) + Self::get_value_from_source(obj, from_object, vm) } }) } - pub fn get_bytearray_inner(self, vm: &VirtualMachine) -> PyResult { + /// `from_object` is how a source that is neither a size nor a string is + /// read: `bytes()` and `bytearray()` differ in whether they ask it how long + /// it is. + pub fn get_inner(self, from_object: FromObject, vm: &VirtualMachine) -> PyResult { match (self.source, self.encoding, self.errors) { (OptionalArg::Present(obj), OptionalArg::Missing, OptionalArg::Missing) => { // Try __index__ first to handle int-like objects that might raise custom exceptions @@ -105,7 +120,7 @@ impl ByteInnerNewOptions { // TypeError means the object doesn't support __index__, so fall back if e.fast_isinstance(vm.ctx.exceptions.type_error) { // Fall back to treating as buffer-like object - Self::handle_object_fallback(obj, vm) + Self::handle_object_fallback(obj, from_object, vm) } else { // Propagate other exceptions (e.g., ZeroDivisionError) Err(e) @@ -113,7 +128,7 @@ impl ByteInnerNewOptions { } } } else { - Self::handle_object_fallback(obj, vm) + Self::handle_object_fallback(obj, from_object, vm) } } (OptionalArg::Present(obj), OptionalArg::Present(encoding), errors) => { @@ -614,7 +629,8 @@ impl PyBytesInner { } pub fn join(&self, iterable: ArgIterable, vm: &VirtualMachine) -> PyResult> { - let iter = iterable.iter(vm)?; + // `PySequence_Fast()`, as in `PyUnicode_Join()`. + let iter = iterable.iter_sized(vm)?; self.elements.py_join(iter) } diff --git a/crates/vm/src/function/protocol.rs b/crates/vm/src/function/protocol.rs index d503fabaca8..5d5e4527f57 100644 --- a/crates/vm/src/function/protocol.rs +++ b/crates/vm/src/function/protocol.rs @@ -91,16 +91,30 @@ impl ArgIterable { &self.iterable } - /// Returns an iterator over this sequence of objects. + /// This object's iterator. /// /// This operation may fail if an exception is raised while invoking the /// `__iter__` method of the iterable object. - pub fn iter<'a>(&self, vm: &'a VirtualMachine) -> PyResult> { - let iter = PyIter::new(match self.iter_fn { + fn get_iter(&self, vm: &VirtualMachine) -> PyResult { + Ok(PyIter::new(match self.iter_fn { Some(f) => f(self.iterable.clone(), vm)?, None => PySequenceIterator::new(self.iterable.clone(), vm)?.into_pyobject(vm), - }); - iter.into_iter(vm) + })) + } + + /// Returns an iterator over this sequence of objects. See [`PyIter::iter`] + /// for why it does not ask how long the iterator is. + /// + /// This operation may fail if an exception is raised while invoking the + /// `__iter__` method of the iterable object. + pub fn iter<'a>(&self, vm: &'a VirtualMachine) -> PyResult> { + Ok(self.get_iter(vm)?.into_iter(vm)) + } + + /// [`Self::iter`] for a caller that fills a sized container from the + /// iterator, the way `PySequence_Fast()` does. + pub fn iter_sized<'a>(&self, vm: &'a VirtualMachine) -> PyResult> { + self.get_iter(vm)?.into_iter_sized(vm) } } diff --git a/crates/vm/src/gc_state.rs b/crates/vm/src/gc_state.rs index e5eb3758950..6cfeebe97df 100644 --- a/crates/vm/src/gc_state.rs +++ b/crates/vm/src/gc_state.rs @@ -4,7 +4,7 @@ use crate::common::linked_list::LinkedList; use crate::common::lock::{PyMutex, PyRwLock}; -use crate::object::{GC_NO_OWNER, GC_PERMANENT, GC_UNTRACKED, GcLink, GcOwner}; +use crate::object::{GC_NO_OWNER, GC_PERMANENT, GC_REACHABLE, GC_UNTRACKED, GcLink, GcOwner}; use crate::{AsObject, PyObject, PyObjectRef}; use core::ptr::NonNull; use core::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, AtomicUsize, Ordering}; @@ -160,11 +160,11 @@ struct GcPtr(NonNull); /// choosing keys that collide. Nothing chooses these keys: they are addresses /// this process handed out, and the tables live and die inside one collection. /// What a collection needs from them is speed -- it hashes every tracked -/// object and every edge between them -- so this runs the address through a -/// handful of multiplies and shifts instead. The shifts are what earns the -/// speed: a table picks its bucket from the low bits, and an address arrives -/// with its low bits zeroed by alignment, so entropy has to be carried -/// downward or every object lands in the same few buckets. +/// object -- so this runs the address through a handful of multiplies and +/// shifts instead. The shifts are what earns the speed: a table picks its +/// bucket from the low bits, and an address arrives with its low bits zeroed +/// by alignment, so entropy has to be carried downward or every object lands +/// in the same few buckets. #[derive(Default)] struct GcPtrHasher(u64); @@ -594,12 +594,12 @@ impl GcState { retired.sort_unstable(); retired }; - // The candidates and their reference counts go in one table, not a set - // beside a map: every edge in the heap is looked up here, and the two - // held the same keys, so a second table only bought a second hash of - // the same address. `candidate_ptrs` keeps them in a walkable order, - // since the counts are written while the candidates are read. - let mut gc_refs: GcMap = GcMap::default(); + // Each candidate carries its own count, with `GcBits::COLLECTING` + // saying the count is there. Every edge in the heap is answered from + // that bit and that field; a table keyed by address turned each of + // those answers into a hash of the address instead. `candidate_ptrs` + // keeps the candidates in a walkable order, and the bit is what keeps + // an object that appears in two generation lists out of it twice. let mut candidate_ptrs: Vec = Vec::new(); for gen_list in &gen_locks { for obj in gen_list.iter() { @@ -607,12 +607,9 @@ impl GcState { obj.set_gc_owner(GC_NO_OWNER); } let strong_count = obj.strong_count(); - let ptr = GcPtr(NonNull::from(obj)); - if strong_count > 0 - && is_owned_by(obj, owner) - && gc_refs.insert(ptr, strong_count).is_none() - { - candidate_ptrs.push(ptr); + if strong_count > 0 && is_owned_by(obj, owner) && !obj.is_gc_collecting() { + obj.start_gc_refs(strong_count); + candidate_ptrs.push(GcPtr(NonNull::from(obj))); } } } @@ -679,24 +676,23 @@ impl GcState { unsafe { obj.gc_extend_referent_ptrs(&mut referent_ptrs) }; let end = referent_ptrs.len(); for &child_ptr in &referent_ptrs[start..end] { - if let Some(refs) = gc_refs.get_mut(&GcPtr(child_ptr)) { - *refs = refs.saturating_sub(1); + // SAFETY: the referents came from `traverse`, which handed out + // live references to them, and the world is stopped. + let child = unsafe { child_ptr.as_ref() }; + if child.is_gc_collecting() { + child.subtract_gc_ref(); } } referent_ranges.insert(ptr, (start, end)); } // Step 4: Find reachable objects (gc_refs > 0) and traverse from them - let mut reachable: GcSet = GcSet::default(); let mut worklist: Vec = Vec::new(); - #[expect( - clippy::iter_over_hash_type, - reason = "Iteration order doesn't matter here" - )] - for (&ptr, &refs) in &gc_refs { - if refs > 0 { - reachable.insert(ptr); + for &ptr in &candidate_ptrs { + let obj = unsafe { ptr.0.as_ref() }; + if obj.gc_refs() > 0 { + obj.mark_gc_reachable(); worklist.push(ptr); } } @@ -717,20 +713,29 @@ impl GcState { } }; for &child_ptr in children { - let gc_ptr = GcPtr(child_ptr); - if gc_refs.contains_key(&gc_ptr) && reachable.insert(gc_ptr) { - worklist.push(gc_ptr); + // SAFETY: as in step 3, the referents are live. + let child = unsafe { child_ptr.as_ref() }; + if child.is_gc_collecting() && child.mark_gc_reachable() { + worklist.push(GcPtr(child_ptr)); } } } } - // Step 5: Find unreachable objects - let unreachable: Vec = candidate_ptrs - .iter() - .filter(|ptr| !reachable.contains(ptr)) - .copied() - .collect(); + // Step 5: Split the candidates on what step 4 concluded, and hand the + // headers back: nothing past here reads `gc_refs`, and a candidate that + // kept the bit would be passed over by every later collection. + let mut reachable: Vec = Vec::new(); + let mut unreachable: Vec = Vec::new(); + for &ptr in &candidate_ptrs { + let obj = unsafe { ptr.0.as_ref() }; + if obj.gc_refs() == GC_REACHABLE { + reachable.push(ptr); + } else { + unreachable.push(ptr); + } + obj.end_gc_refs(); + } // With the world stopped, every frame on any thread's call stack is a // live root that is externally referenced and must have been diff --git a/crates/vm/src/object/core.rs b/crates/vm/src/object/core.rs index bdacb7c5b83..5534666da5f 100644 --- a/crates/vm/src/object/core.rs +++ b/crates/vm/src/object/core.rs @@ -297,6 +297,9 @@ bitflags::bitflags! { const SHARED_INLINE = 1 << 5; /// Use deferred reference counting const DEFERRED = 1 << 6; + /// In the candidate set of the collection that is running, so its + /// `gc_refs` is meaningful. `_PyGC_PREV_MASK_COLLECTING`. + const COLLECTING = 1 << 7; } } @@ -316,6 +319,11 @@ pub(crate) type GcOwner = u16; /// current. Every interpreter collects these. pub(crate) const GC_NO_OWNER: GcOwner = 0; +/// `gc_refs` of an object a running collection has proved reachable. One past +/// the largest count [`PyObject::start_gc_refs`] stores, so no real count can +/// be taken for it. +pub(crate) const GC_REACHABLE: u32 = u32::MAX; + /// Link implementation for GC intrusive linked list tracking pub(crate) struct GcLink; @@ -405,6 +413,11 @@ pub(super) struct PyInner { /// `track_object`; read to scope a collection to one interpreter. /// Sits in what would otherwise be padding, so it costs no space. pub(super) gc_owner: PyAtomic, + /// The count a running collection is working with: the strong count with + /// the references held from inside the candidate set taken off, or + /// [`GC_REACHABLE`] once the object has been proved reachable. Only + /// meaningful while `gc_bits` has [`GcBits::COLLECTING`]. + pub(super) gc_refs: PyAtomic, /// Intrusive linked list pointers for GC generational tracking pub(super) gc_pointers: Pointers, @@ -415,9 +428,11 @@ pub(super) struct PyInner { pub(crate) const SIZEOF_PYOBJECT_HEAD: usize = core::mem::size_of::>(); // ref_count, vtable, gc_pointers (two) and typ are one word each; the gc bits, -// generation and owner share the word of padding their alignment forces. Adding -// to that group is free only while this holds. -const _: () = assert!(SIZEOF_PYOBJECT_HEAD == 6 * core::mem::size_of::()); +// generation, owner and refs take eight bytes between them. A 64-bit header had +// those eight as the padding its alignment forces, so they cost it nothing; a +// 32-bit header spends a word on them. Adding to that group is free only while +// this holds. +const _: () = assert!(SIZEOF_PYOBJECT_HEAD == 5 * core::mem::size_of::() + 8); impl PyInner { /// Read type flags and member_count via raw pointers to avoid Stacked Borrows @@ -1248,6 +1263,7 @@ impl PyInner { gc_bits: Radium::new(0), gc_generation: Radium::new(GC_UNTRACKED), gc_owner: Radium::new(GC_NO_OWNER), + gc_refs: Radium::new(0), gc_pointers: Pointers::new(), typ: PyAtomicRef::from(typ), payload, @@ -1261,6 +1277,7 @@ impl PyInner { gc_bits: Radium::new(0), gc_generation: Radium::new(GC_UNTRACKED), gc_owner: Radium::new(GC_NO_OWNER), + gc_refs: Radium::new(0), gc_pointers: Pointers::new(), typ: PyAtomicRef::from(typ), payload, @@ -1803,6 +1820,67 @@ impl PyObject { self.0.gc_owner.store(owner, Ordering::Relaxed); } + /// Enter the running collection's candidate set, with `strong_count` as the + /// count to subtract internal references from. A count too large to hold is + /// taken as reachable outright, rather than clipped to a number the + /// subtraction could still walk down to zero. + #[inline] + pub(crate) fn start_gc_refs(&self, strong_count: usize) { + let refs = if strong_count >= GC_REACHABLE as usize { + GC_REACHABLE + } else { + strong_count as u32 + }; + self.0.gc_refs.store(refs, Ordering::Relaxed); + self.set_gc_bit(GcBits::COLLECTING); + } + + /// The count the running collection is working with. + #[inline] + pub(crate) fn gc_refs(&self) -> u32 { + self.0.gc_refs.load(Ordering::Relaxed) + } + + /// Whether this object is in the running collection's candidate set. + #[inline] + pub(crate) fn is_gc_collecting(&self) -> bool { + GcBits::from_bits_retain(self.0.gc_bits.load(Ordering::Relaxed)) + .contains(GcBits::COLLECTING) + } + + /// Take off one reference held from inside the candidate set. A count that + /// did not fit stands for more references than every subtraction together + /// could take off, so it stays where [`Self::start_gc_refs`] put it. + #[inline] + pub(crate) fn subtract_gc_ref(&self) { + let refs = self.0.gc_refs.load(Ordering::Relaxed); + if refs == GC_REACHABLE { + return; + } + self.0 + .gc_refs + .store(refs.saturating_sub(1), Ordering::Relaxed); + } + + /// Mark the object reachable, answering whether this call was the one that + /// did it. + #[inline] + pub(crate) fn mark_gc_reachable(&self) -> bool { + if self.0.gc_refs.load(Ordering::Relaxed) == GC_REACHABLE { + return false; + } + self.0.gc_refs.store(GC_REACHABLE, Ordering::Relaxed); + true + } + + /// Leave the candidate set, whatever the collection concluded. + #[inline] + pub(crate) fn end_gc_refs(&self) { + self.0 + .gc_bits + .fetch_and(!GcBits::COLLECTING.bits(), Ordering::Relaxed); + } + /// _PyObject_GC_TRACK #[inline] pub(crate) fn set_gc_tracked(&self) { @@ -2723,6 +2801,7 @@ pub(crate) fn init_type_hierarchy() -> (PyTypeRef, PyTypeRef, PyTypeRef) { gc_bits: Radium::new(0), gc_generation: Radium::new(GC_UNTRACKED), gc_owner: Radium::new(GC_NO_OWNER), + gc_refs: Radium::new(0), gc_pointers: Pointers::new(), payload: type_payload, }, @@ -2739,6 +2818,7 @@ pub(crate) fn init_type_hierarchy() -> (PyTypeRef, PyTypeRef, PyTypeRef) { gc_bits: Radium::new(0), gc_generation: Radium::new(GC_UNTRACKED), gc_owner: Radium::new(GC_NO_OWNER), + gc_refs: Radium::new(0), gc_pointers: Pointers::new(), payload: object_payload, }, diff --git a/crates/vm/src/object/mod.rs b/crates/vm/src/object/mod.rs index becfcabb1d4..b6c7590a86d 100644 --- a/crates/vm/src/object/mod.rs +++ b/crates/vm/src/object/mod.rs @@ -9,5 +9,5 @@ pub use self::core::*; pub use self::ext::*; pub use self::payload::*; pub(crate) use core::SIZEOF_PYOBJECT_HEAD; -pub(crate) use core::{GC_NO_OWNER, GC_PERMANENT, GC_UNTRACKED, GcLink, GcOwner}; +pub(crate) use core::{GC_NO_OWNER, GC_PERMANENT, GC_REACHABLE, GC_UNTRACKED, GcLink, GcOwner}; pub use traverse::{MaybeTraverse, Traverse, TraverseFn}; diff --git a/crates/vm/src/protocol/iter.rs b/crates/vm/src/protocol/iter.rs index 1aa0bcd5b13..3df72fd80eb 100644 --- a/crates/vm/src/protocol/iter.rs +++ b/crates/vm/src/protocol/iter.rs @@ -56,25 +56,31 @@ where iternext(self.0.borrow(), vm) } + /// Walks the iterator without asking it how long it is. Almost nothing + /// asks: a loop over an iterator takes no room up front, so what the + /// object would have answered -- slowly, or by raising -- never runs. pub fn iter<'a, 'b, U>( &'b self, vm: &'a VirtualMachine, - ) -> PyResult> { - let length_hint = vm.length_hint_opt(self.as_ref().to_owned())?; - Ok(PyIterIter::new(vm, self.0.borrow(), length_hint)) - } - - pub fn iter_without_hint<'a, 'b, U>( - &'b self, - vm: &'a VirtualMachine, ) -> PyResult> { Ok(PyIterIter::new(vm, self.0.borrow(), None)) } } impl PyIter { - /// Returns an iterator over this sequence of objects. - pub fn into_iter(self, vm: &VirtualMachine) -> PyResult> { + /// Returns an iterator over this sequence of objects. See [`Self::iter`] + /// for why it does not ask how long the iterator is. + pub fn into_iter(self, vm: &VirtualMachine) -> PyIterIter<'_, U, PyObjectRef> { + PyIterIter::new(vm, self.0, None) + } + + /// [`Self::into_iter`] for a caller that fills a sized container from the + /// iterator, the way `PySequence_Fast()` does. It asks how much room that + /// takes and answers with whatever asking raised. + pub fn into_iter_sized( + self, + vm: &VirtualMachine, + ) -> PyResult> { let length_hint = vm.length_hint_opt(self.as_object().to_owned())?; Ok(PyIterIter::new(vm, self.0, length_hint)) } diff --git a/crates/vm/src/stdlib/_collections.rs b/crates/vm/src/stdlib/_collections.rs index b48c0e670ac..4bd4aee25b3 100644 --- a/crates/vm/src/stdlib/_collections.rs +++ b/crates/vm/src/stdlib/_collections.rs @@ -8,6 +8,7 @@ mod _collections { builtins::{ IterStatus::{Active, Exhausted}, PositionIterInternal, PyDict, PyGenericAlias, PyInt, PyStr, PyType, PyTypeRef, + locked_step, }, common::lock::{PyMutex, PyRwLock, PyRwLockReadGuard, PyRwLockWriteGuard}, convert::ToPyObject, @@ -277,6 +278,7 @@ mod _collections { fn __reversed__(zelf: PyRef) -> PyReverseDequeIterator { PyReverseDequeIterator { state: zelf.state.load(), + counter: AtomicCell::new(zelf.__len__()), internal: PyMutex::new(PositionIterInternal::new(zelf, 0)), } } @@ -632,6 +634,11 @@ mod _collections { #[derive(Debug, PyPayload)] struct PyDequeIterator { state: usize, + /// How many elements are left to walk, `dequeiterobject.counter`. Kept + /// beside the deque rather than read back from it, because a mutated + /// deque is walked no further and what is left of it then reads as + /// nothing. + counter: AtomicCell, internal: PyMutex>, } @@ -656,6 +663,8 @@ mod _collections { if let OptionalArg::Present(index) = index { let index = max(index, 0) as usize; iter.internal.lock().position = index; + iter.counter + .store(iter.counter.load().saturating_sub(index)); } Ok(iter) } @@ -666,13 +675,14 @@ mod _collections { pub(crate) fn new(deque: PyDequeRef) -> Self { Self { state: deque.state.load(), + counter: AtomicCell::new(deque.__len__()), internal: PyMutex::new(PositionIterInternal::new(deque, 0)), } } #[pymethod] fn __length_hint__(&self) -> usize { - self.internal.lock().length_hint(|obj| obj.__len__()) + self.counter.load() } #[pymethod] @@ -693,16 +703,62 @@ mod _collections { } impl SelfIter for PyDequeIterator {} + + /// Whether the deque moved under an iterator that captured `state`. What is + /// left to walk is emptied before the error goes out, the way + /// `deque_iternext()` zeroes its counter before it raises. + fn deque_moved( + internal: &PositionIterInternal, + state: usize, + counter: &AtomicCell, + ) -> bool { + let Active(deque) = &internal.status else { + return false; + }; + if state == deque.state.load() { + return false; + } + counter.store(0); + true + } + + /// Hand back the element at the position the iterator keeps, `at` reaching + /// for it. Both deque iterators end here; they differ in whether they look + /// at the deque or at the count first. + fn deque_take( + internal: &mut PositionIterInternal, + counter: &AtomicCell, + at: impl FnOnce(&VecDeque, usize) -> Option, + ) -> (PyResult, Option) { + let item = match &internal.status { + Active(deque) if counter.load() != 0 => at(&deque.borrow_deque(), internal.position), + _ => None, + }; + let Some(item) = item else { + counter.store(0); + return (Ok(PyIterReturn::StopIteration(None)), internal.exhaust()); + }; + internal.position += 1; + counter.store(counter.load() - 1); + (Ok(PyIterReturn::Return(item)), None) + } + + fn deque_mutated(vm: &VirtualMachine) -> PyResult { + Err(vm.new_runtime_error("deque mutated during iteration")) + } + impl IterNext for PyDequeIterator { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { - zelf.internal.lock().next(|deque, pos| { - if zelf.state != deque.state.load() { - return Err(vm.new_runtime_error("Deque mutated during iteration")); + locked_step(&zelf.internal, |internal| { + // The deque before the count, as in `deque_iternext()`, so an + // iterator still holding a deque that moved raises again on + // every call rather than running out after the first. + if deque_moved(internal, zelf.state, &zelf.counter) { + return (deque_mutated(vm), None); } - let deque = deque.borrow_deque(); - Ok(PyIterReturn::from_result( - deque.get(pos).cloned().ok_or(None), - )) + deque_take(internal, &zelf.counter, |deque, pos| { + deque.get(pos).cloned() + }) }) } } @@ -712,6 +768,8 @@ mod _collections { #[derive(Debug, PyPayload)] struct PyReverseDequeIterator { state: usize, + /// As in [`PyDequeIterator`]. + counter: AtomicCell, // position is counting from the tail internal: PyMutex>, } @@ -728,6 +786,8 @@ mod _collections { if let OptionalArg::Present(index) = index { let index = max(index, 0) as usize; iter.internal.lock().position = index; + iter.counter + .store(iter.counter.load().saturating_sub(index)); } Ok(iter) } @@ -737,7 +797,7 @@ mod _collections { impl PyReverseDequeIterator { #[pymethod] fn __length_hint__(&self) -> usize { - self.internal.lock().length_hint(|obj| obj.__len__()) + self.counter.load() } #[pymethod] @@ -761,17 +821,19 @@ mod _collections { impl IterNext for PyReverseDequeIterator { fn next(zelf: &Py, vm: &VirtualMachine) -> PyResult { - zelf.internal.lock().next(|deque, pos| { - if deque.state.load() != zelf.state { - return Err(vm.new_runtime_error("Deque mutated during iteration")); + locked_step(&zelf.internal, |internal| { + // The count before the deque, as in `dequereviter_next()`, so + // an iterator that has raised once runs out instead. + if zelf.counter.load() != 0 && deque_moved(internal, zelf.state, &zelf.counter) { + return (deque_mutated(vm), None); } - let deque = deque.borrow_deque(); - let r = deque - .len() - .checked_sub(pos + 1) - .and_then(|pos| deque.get(pos)) - .cloned(); - Ok(PyIterReturn::from_result(r.ok_or(None))) + deque_take(internal, &zelf.counter, |deque, pos| { + deque + .len() + .checked_sub(pos + 1) + .and_then(|pos| deque.get(pos)) + .cloned() + }) }) } } diff --git a/crates/vm/src/stdlib/_ctypes/array.rs b/crates/vm/src/stdlib/_ctypes/array.rs index d4674f33b07..965f7257e60 100644 --- a/crates/vm/src/stdlib/_ctypes/array.rs +++ b/crates/vm/src/stdlib/_ctypes/array.rs @@ -429,7 +429,7 @@ impl Constructor for PyCArray { } // Create array with zero-initialized buffer - let buffer = vec![0u8; total_size]; + let buffer = vm.new_zeroed_bytes(total_size)?; let instance = Self(PyCData::from_bytes_with_length(buffer, None, length)) .into_ref_with_type(vm, cls)?; diff --git a/crates/vm/src/stdlib/_functools.rs b/crates/vm/src/stdlib/_functools.rs index 944a2e8abdb..2c16632dc95 100644 --- a/crates/vm/src/stdlib/_functools.rs +++ b/crates/vm/src/stdlib/_functools.rs @@ -32,7 +32,7 @@ mod _functools { iterator, initial, } = args; - let mut iter = iterator.iter_without_hint(vm)?; + let mut iter = iterator.iter(vm)?; // OptionalOption distinguishes between: // - Missing: no argument provided → use first element from iterator // - Present(None): explicitly passed None → use None as initial value diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index 5479c47abc4..4db5fb760c1 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -647,8 +647,7 @@ mod _io { #[pymethod] fn read(instance: PyObjectRef, size: OptionalSize, vm: &VirtualMachine) -> PyResult { if let Some(size) = size.to_usize() { - // FIXME: unnecessary zero-init - let b = PyByteArray::from(vec![0; size]).into_ref(&vm.ctx); + let b = PyByteArray::from(vm.new_zeroed_bytes(size)?).into_ref(&vm.ctx); let n = >::try_from_object( vm, vm.call_method(&instance, "readinto", (b.clone(),))?, diff --git a/crates/vm/src/stdlib/_operator.rs b/crates/vm/src/stdlib/_operator.rs index 5e72ef03eb4..aac528c52aa 100644 --- a/crates/vm/src/stdlib/_operator.rs +++ b/crates/vm/src/stdlib/_operator.rs @@ -178,7 +178,7 @@ mod _operator { #[pyfunction(name = "countOf")] fn count_of(a: PyIter, b: PyObjectRef, vm: &VirtualMachine) -> PyResult { let mut count: usize = 0; - for element in a.iter_without_hint::(vm)? { + for element in a.iter::(vm)? { let element = element?; if element.is(&b) || vm.bool_eq(&b, &element)? { count += 1; @@ -199,7 +199,7 @@ mod _operator { #[pyfunction(name = "indexOf")] fn index_of(a: PyIter, b: PyObjectRef, vm: &VirtualMachine) -> PyResult { - for (index, element) in a.iter_without_hint::(vm)?.enumerate() { + for (index, element) in a.iter::(vm)?.enumerate() { let element = element?; if element.is(&b) || vm.bool_eq(&b, &element)? { return Ok(index); diff --git a/crates/vm/src/stdlib/_thread.rs b/crates/vm/src/stdlib/_thread.rs index 79dce3d21ce..a01711e1a36 100644 --- a/crates/vm/src/stdlib/_thread.rs +++ b/crates/vm/src/stdlib/_thread.rs @@ -616,25 +616,32 @@ pub(crate) mod _thread { const DEFAULT_THREAD_STACK_SIZE: usize = 8 * 1024 * 1024; /// Configure a `thread::Builder` with the stack size to use for a new - /// Python thread. Uses the value set via `threading.stack_size(N)` when - /// the user has provided one (non-zero). Otherwise, debug builds fall - /// back to [`DEFAULT_THREAD_STACK_SIZE`] and release builds leave the - /// builder unmodified (Rust's std default applies). + /// Python thread. Release builds use the value set via + /// `threading.stack_size(N)` when the user has provided one (non-zero) and + /// otherwise leave the builder unmodified (Rust's std default applies). + /// + /// Debug builds take [`DEFAULT_THREAD_STACK_SIZE`] as a floor rather than + /// only as a default: an unoptimized `ExecutingFrame::run` reserves around + /// eighty kilobytes of stack where an optimized one reserves under a + /// thousand, so a size that holds a Python call chain in release holds + /// three of its frames here — starting a thread at all needs six. The + /// value `threading.stack_size()` reports is untouched. fn apply_thread_stack_size( thread_builder: thread::Builder, vm: &VirtualMachine, ) -> thread::Builder { let configured = vm.state.stacksize.load(); - if configured != 0 { - return thread_builder.stack_size(configured); - } #[cfg(debug_assertions)] { - thread_builder.stack_size(DEFAULT_THREAD_STACK_SIZE) + thread_builder.stack_size(configured.max(DEFAULT_THREAD_STACK_SIZE)) } #[cfg(not(debug_assertions))] { - thread_builder + if configured == 0 { + thread_builder + } else { + thread_builder.stack_size(configured) + } } } @@ -2029,6 +2036,31 @@ pub(crate) mod _thread { }); } + /// A size small enough for CPython's frames is not small enough for an + /// unoptimized build's: `test_threading` asks for 256 KiB, which holds + /// three of them where starting a thread needs six. The size the + /// request set is still what `threading.stack_size()` answers with. + #[test] + #[cfg(all(debug_assertions, any(target_os = "linux", target_os = "macos")))] + fn explicit_python_thread_stack_size_is_a_floor_debug() { + const REQUESTED: usize = 256 * 1024; + + Interpreter::without_stdlib(Default::default()).enter(|vm| { + vm.state.stacksize.store(REQUESTED); + let builder = apply_thread_stack_size(thread::Builder::new(), vm); + let stack_size = builder + .spawn(current_thread_stack_size) + .expect("failed to spawn thread") + .join() + .expect("thread panicked"); + assert!( + stack_size >= DEFAULT_THREAD_STACK_SIZE, + "Python thread stack size is {stack_size} bytes, expected at least {DEFAULT_THREAD_STACK_SIZE}" + ); + assert_eq!(vm.state.stacksize.load(), REQUESTED); + }); + } + #[cfg(all(debug_assertions, target_os = "linux"))] fn current_thread_stack_size() -> usize { use libc::{ diff --git a/crates/vm/src/stdlib/builtins.rs b/crates/vm/src/stdlib/builtins.rs index 34be8c6178d..c3eb200af6d 100644 --- a/crates/vm/src/stdlib/builtins.rs +++ b/crates/vm/src/stdlib/builtins.rs @@ -1175,7 +1175,9 @@ mod builtins { #[pyfunction] fn sorted(iterable: PyObjectRef, opts: SortOptions, vm: &VirtualMachine) -> PyResult { - let items: Vec<_> = iterable.try_to_value(vm)?; + // `PySequence_List()`, so the room comes from what the iterable reports + // rather than from its iterator. + let items = vm.extract_elements_sized(&iterable, &|| 0, Ok)?; let lst = PyList::from(items); lst.sort(opts, vm)?; Ok(lst) diff --git a/crates/vm/src/stdlib/itertools.rs b/crates/vm/src/stdlib/itertools.rs index 6eb268d94c1..5f85f1b7238 100644 --- a/crates/vm/src/stdlib/itertools.rs +++ b/crates/vm/src/stdlib/itertools.rs @@ -1123,7 +1123,7 @@ mod decl { #[derive(FromArgs)] struct ProductArgs { #[pyarg(named, optional)] - repeat: OptionalArg, + repeat: OptionalArg, } impl Constructor for PyItertoolsProduct { @@ -1135,19 +1135,47 @@ mod decl { vm: &VirtualMachine, ) -> PyResult { let repeat = args.repeat.unwrap_or(1); - let mut pools = Vec::new(); + if repeat < 0 { + return Err(vm.new_value_error("repeat argument cannot be negative")); + } + let repeat = repeat as usize; + + // The count is settled before the arguments are read, the way + // `product_new()` settles it before it calls `PySequence_Tuple()` + // on any of them, so a repeat too large to serve does not run their + // code first. + let npools = iterables + .iter() + .len() + .checked_mul(repeat) + .filter(|n| *n <= isize::MAX as usize / size_of::()) + .ok_or_else(|| vm.new_overflow_error("repeat argument too large"))?; + + let mut single: Vec> = Vec::new(); for arg in iterables.iter() { - pools.push(arg.try_to_value(vm)?); + single.push(arg.try_to_value(vm)?); } - let pools = core::iter::repeat_n(pools, repeat) - .flatten() - .collect::>>(); + + let mut pools: Vec> = Vec::new(); + pools + .try_reserve_exact(npools) + .map_err(|_| vm.new_memory_error(""))?; + // Filled by index, the way `product_new()` fills a tuple of + // `npools`. Repeating the arguments `repeat` times instead walks + // that many steps even when there are no arguments to repeat, so + // `product(repeat=2**62)` would spin rather than answer `[()]`. + pools.extend((0..npools).map(|i| single[i % single.len()].clone())); + + let mut idxs = Vec::new(); + idxs.try_reserve_exact(npools) + .map_err(|_| vm.new_memory_error(""))?; + idxs.resize(npools, 0); let l = pools.len(); Ok(Self { pools, - idxs: PyRwLock::new(vec![0; l]), + idxs: PyRwLock::new(idxs), cur: AtomicCell::new(l.wrapping_sub(1)), stop: AtomicCell::new(false), }) diff --git a/crates/vm/src/stdlib/os.rs b/crates/vm/src/stdlib/os.rs index 668545a3cec..3e5e2393ee3 100644 --- a/crates/vm/src/stdlib/os.rs +++ b/crates/vm/src/stdlib/os.rs @@ -328,7 +328,7 @@ pub(super) mod _os { #[pyfunction] fn read(fd: crt_fd::Borrowed<'_>, n: usize, vm: &VirtualMachine) -> PyResult { - let mut buffer = vec![0u8; n]; + let mut buffer = vm.new_zeroed_bytes(n)?; loop { match vm.allow_threads(|| crt_fd::read(fd, &mut buffer)) { Ok(n) => { @@ -2135,7 +2135,7 @@ pub(crate) fn envobj_to_dict( } let keys = vm.call_method(obj, "keys", ())?; let dict = vm.ctx.new_dict(); - for key in keys.get_iter(vm)?.into_iter::(vm)? { + for key in keys.get_iter(vm)?.into_iter::(vm) { let key = key?; let val = obj.get_item(&*key, vm)?; dict.set_item(&*key, val, vm)?; diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 54d3e813eec..a26ab3761ce 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -879,6 +879,19 @@ struct SuspendedFrame { is_entry: bool, } +/// Whether a sequence being built asks the iterable it was handed how much room +/// to take. `list_extend()` asks and reserves; `PySequence_Tuple()` and the +/// rest ask nothing at all. +#[derive(Clone, Copy)] +enum LengthHint<'a> { + /// Grows as the loop goes, the way `tuple()`, `set()`, `min()` and + /// `deque()` do, so an object slow to answer is never asked. + Unasked, + /// Reserves what the iterable answers, unless it leaves no room for the + /// count this returns. + Iterable(&'a dyn Fn() -> usize), +} + impl VirtualMachine { fn init_callable_cache(&mut self) -> PyResult<()> { self.callable_cache.len = Some(self.builtins.get_attr("len", self)?); @@ -2161,13 +2174,12 @@ impl VirtualMachine { ) -> PyResult { self.check_recursive_call("")?; - // Check the native C stack periodically. The sampling interval - // (every 8th call) balances overhead against the risk of missing - // an overflow between checks, especially when light and heavy - // frames alternate (each recursion step uses different native - // stack amounts). - let depth = self.recursion_depth.get(); - if depth & 7 == 0 && self.check_c_stack_overflow() { + // Every entry, not every eighth. The margin only has to cover what a + // single frame takes if the check runs each time; sampling asks it to + // cover eight, and a recursion whose steps re-enter through native + // code -- an `__add__` chain, a sort key that sorts -- takes more than + // the margin in that many. + if self.check_c_stack_overflow() { return Err(self.new_recursion_error(String::new())); } @@ -2261,11 +2273,7 @@ impl VirtualMachine { ) -> PyResult { self.check_recursive_call("")?; - let depth = self.recursion_depth.get(); - if depth & 7 == 0 && self.check_c_stack_overflow() { - return Err(self.new_recursion_error(String::new())); - } - + // The C stack is checked by `enter_iframe_unchecked` below. self.enter_iframe_unchecked(iframe) } @@ -2278,8 +2286,7 @@ impl VirtualMachine { &self, iframe: &mut crate::frame::InterpreterFrame, ) -> PyResult { - let depth = self.recursion_depth.get(); - if depth & 7 == 0 && self.check_c_stack_overflow() { + if self.check_c_stack_overflow() { return Err(self.new_recursion_error(String::new())); } @@ -2635,6 +2642,49 @@ impl VirtualMachine { where F: Fn(PyObjectRef) -> PyResult, { + self.extract_elements_inner(value, LengthHint::Unasked, func) + } + + /// [`Self::extract_elements_with`] for a caller that asks the iterable + /// itself how much room to take, the way `list_extend()` does. `held` + /// answers how many elements the caller already has, and is read after the + /// iterable has been asked, since asking runs its code. + pub fn extract_elements_sized( + &self, + value: &PyObject, + held: &dyn Fn() -> usize, + func: F, + ) -> PyResult> + where + F: Fn(PyObjectRef) -> PyResult, + { + self.extract_elements_inner(value, LengthHint::Iterable(held), func) + } + + fn extract_elements_inner( + &self, + value: &PyObject, + hint: LengthHint<'_>, + func: F, + ) -> PyResult> + where + F: Fn(PyObjectRef) -> PyResult, + { + // A count known up front is taken in one go. Collecting into a + // `Result` instead would drop it: the adapter that carries the error + // may stop early, so it reports no lower bound and the vector grows a + // step at a time. + fn map_known_len( + items: impl ExactSizeIterator, + func: impl Fn(T) -> PyResult, + ) -> PyResult> { + let mut results = Vec::with_capacity(items.len()); + for item in items { + results.push(func(item)?); + } + Ok(results) + } + // Type-specific fast paths corresponding to _list_extend() in CPython // Objects/listobject.c. Each branch takes an atomic snapshot to avoid // race conditions from concurrent mutation (no GIL). @@ -2644,9 +2694,11 @@ impl VirtualMachine { } else if cls.is(self.ctx.types.list_type) { // The list is re-read on every step, the way map_iterable_object() // does it: func() runs Python, which can mutate or even clear the - // same list, and a borrow held across that call deadlocks it. + // same list, and a borrow held across that call deadlocks it. Its + // length at the start is only how much room to take, not how far + // the loop runs. let list = value.downcast_ref::().unwrap(); - let mut results = Vec::new(); + let mut results = Vec::with_capacity(list.borrow_vec().len()); let mut i = 0; loop { let elem = { @@ -2663,34 +2715,58 @@ impl VirtualMachine { return Ok(results); } else if cls.is(self.ctx.types.dict_type) { let keys = value.downcast_ref::().unwrap().keys_vec(); - return keys.into_iter().map(func).collect(); + return map_known_len(keys.into_iter(), func); } else if cls.is(self.ctx.types.dict_keys_type) { let keys = value.downcast_ref::().unwrap().dict.keys_vec(); - return keys.into_iter().map(func).collect(); + return map_known_len(keys.into_iter(), func); } else if cls.is(self.ctx.types.dict_values_type) { let values = value .downcast_ref::() .unwrap() .dict .values_vec(); - return values.into_iter().map(func).collect(); + return map_known_len(values.into_iter(), func); } else if cls.is(self.ctx.types.dict_items_type) { let items = value .downcast_ref::() .unwrap() .dict .items_vec(); - return items - .into_iter() - .map(|(k, v)| func(self.ctx.new_tuple(vec![k, v]).into())) - .collect(); + return map_known_len(items.into_iter(), |(k, v)| { + func(self.ctx.new_tuple(vec![k, v]).into()) + }); } else { - return self.map_py_iter(value, func); + return self.map_py_iter(value, hint, func); }; - slice.iter().map(|obj| func(obj.clone())).collect() + map_known_len(slice.iter(), |obj| func(obj.clone())) + } + + /// [`Self::map_iterable_object`] for a caller that asks the object it was + /// handed how long it is. + pub fn map_iterable_object_sized( + &self, + obj: &PyObject, + f: F, + ) -> PyResult>> + where + F: FnMut(PyObjectRef) -> PyResult, + { + self.map_iterable_object_inner(obj, LengthHint::Iterable(&|| 0), f) + } + + pub fn map_iterable_object(&self, obj: &PyObject, f: F) -> PyResult>> + where + F: FnMut(PyObjectRef) -> PyResult, + { + self.map_iterable_object_inner(obj, LengthHint::Unasked, f) } - pub fn map_iterable_object(&self, obj: &PyObject, mut f: F) -> PyResult>> + fn map_iterable_object_inner( + &self, + obj: &PyObject, + hint: LengthHint<'_>, + mut f: F, + ) -> PyResult>> where F: FnMut(PyObjectRef) -> PyResult, { @@ -2719,33 +2795,55 @@ impl VirtualMachine { ref t @ PyTuple => Ok(t.iter().cloned().map(f).collect()), // TODO: put internal iterable type obj => { - Ok(self.map_py_iter(obj, f)) + Ok(self.map_py_iter(obj, hint, f)) } }) } - fn map_py_iter(&self, value: &PyObject, mut f: F) -> PyResult> + fn map_py_iter( + &self, + value: &PyObject, + hint: LengthHint<'_>, + mut f: F, + ) -> PyResult> where F: FnMut(PyObjectRef) -> PyResult, { let iter = value.to_owned().get_iter(self)?; - let cap = match self.length_hint_opt(value.to_owned()) { - Err(e) if e.class().is(self.ctx.exceptions.runtime_error) => return Err(e), - Ok(Some(value)) => Some(value), - // Use a power of 2 as a default capacity. - _ => None, - }; - // TODO: fix extend to do this check (?), see test_extend in Lib/test/list_tests.py, - // https://github.com/python/cpython/blob/v3.9.0/Objects/listobject.c#L922-L928 - if let Some(cap) = cap - && cap >= isize::MAX as usize - { - return Ok(Vec::new()); - } - let mut results = PyIterIter::new(self, iter.as_ref(), cap) - .map(|element| f(element?)) - .collect::>>()?; + // Take the room the iterable asks for up front, for the callers that + // do. Collecting into a `Result` drops the iterator's lower bound -- + // the adapter may stop early -- so without this the vector grows a step + // at a time and an iterable claiming more elements than can be held is + // found out by running out of memory rather than by saying so. An error + // the ask answers with is the iterable's own and belongs to the caller + // that made it; `length_hint_opt` already answers `None` for the + // iterable that declines to guess. + // + // Nobody else asks, so what an object would have answered -- slowly, or + // by raising -- costs the rest nothing. + // + // A hint that does not leave room for what is already held is one the + // iterable cannot be telling the truth about, so it is passed over + // rather than refused: if it was honest the loop runs out of memory on + // its own, and if it lied there was nothing wrong to report. What is + // held is counted now rather than before, since asking for the hint + // runs code that can add to it or take from it. + let mut results: Vec = Vec::new(); + let mut cap = None; + if let LengthHint::Iterable(held) = hint { + cap = self.length_hint_opt(value.to_owned())?; + if let Some(cap) = cap + && held() <= (isize::MAX as usize) - cap + { + results + .try_reserve_exact(cap) + .map_err(|_| self.new_memory_error(""))?; + } + } + for element in PyIterIter::new(self, iter.as_ref(), cap) { + results.push(f(element?)?); + } results.shrink_to_fit(); Ok(results) } diff --git a/crates/vm/src/vm/thread.rs b/crates/vm/src/vm/thread.rs index 3f83d88fe70..4ba0d7ffada 100644 --- a/crates/vm/src/vm/thread.rs +++ b/crates/vm/src/vm/thread.rs @@ -53,7 +53,8 @@ pub struct ThreadSlot { pub top_iframe: AtomicUsize, /// Raw frame pointers, valid while the owning thread's call stack is active. /// Readers must hold the Mutex and convert to FrameObjectRef inside the lock. - /// Used on non-unix threading builds, which have no stop-the-world. + /// Stands in for `top_frame` where that field is not built, so a reader + /// that finds no `top_iframe` still has the frames to answer from. #[cfg(not(unix))] pub frames: parking_lot::Mutex>, pub exception: crate::PyAtomicRef>, diff --git a/extra_tests/snippets/builtin_bytes.py b/extra_tests/snippets/builtin_bytes.py index 3cbed79c069..0c45fc3571e 100644 --- a/extra_tests/snippets/builtin_bytes.py +++ b/extra_tests/snippets/builtin_bytes.py @@ -766,3 +766,50 @@ def test_huge_size(): test_huge_size() + + +# bytes() asks the object it was handed how long it is, so what answering +# raises is the answer; the bytearray constructor asks nothing. +class BadLen: + def __iter__(self): + return iter([1, 2, 3]) + + def __len__(self): + raise RuntimeError("hello") + + +with assert_raises(RuntimeError): + bytes(BadLen()) +with assert_raises(RuntimeError): + int.from_bytes(BadLen(), "big") +assert bytearray(BadLen()) == bytearray(b"\x01\x02\x03") +with assert_raises(RuntimeError): + bytearray(b"ab").extend(BadLen()) +holder = bytearray(b"xyz") +holder[:] = BadLen() +assert holder == bytearray(b"\x01\x02\x03") + + +# What could not be turned into bytes is answered for by whatever was asked, +# rather than by the iteration protocol. +def cannot(fn, message): + try: + fn() + except TypeError as e: + assert str(e) == message, e + else: + raise AssertionError(f"expected TypeError: {message}") + + +cannot(lambda: bytes(object()), "cannot convert 'object' object to bytes") +cannot(lambda: bytes(1.5), "cannot convert 'float' object to bytes") +cannot(lambda: bytearray(object()), "cannot convert 'object' object to bytearray") +cannot( + lambda: bytearray(b"ab").__setitem__(slice(0, 2), object()), + "cannot convert 'object' object to bytearray", +) +cannot(lambda: bytearray().extend(object()), "can't extend bytearray with object") +cannot( + lambda: bytearray(b"ab").__setitem__(slice(0, 2), "ab"), + "can assign only bytes, buffers, or iterables of ints in range(0, 256)", +) diff --git a/extra_tests/snippets/builtin_int.py b/extra_tests/snippets/builtin_int.py index 2828b5ad26d..c111d253254 100644 --- a/extra_tests/snippets/builtin_int.py +++ b/extra_tests/snippets/builtin_int.py @@ -401,3 +401,9 @@ class SubInt(int): assert str(huge) finally: sys.set_int_max_str_digits(_orig_limit) + +# to_bytes is handed the length to allocate, so one that cannot be satisfied +# must raise. Zero and non-zero take different paths to the same buffer. +for value in (0, 1, -1): + with assert_raises(MemoryError): + value.to_bytes(2**60, "big", signed=True) diff --git a/extra_tests/snippets/builtin_iter.py b/extra_tests/snippets/builtin_iter.py index 02d469a47ee..09fb94eeea2 100644 --- a/extra_tests/snippets/builtin_iter.py +++ b/extra_tests/snippets/builtin_iter.py @@ -69,3 +69,147 @@ def __len__(self): assert seq_it.__length_hint__() == 3 next(seq_it) assert seq_it.__length_hint__() == 2 + + +# Walking an iterator takes no room up front, so nothing on the way asks it how +# long it is. Only join does, reaching its elements through PySequence_Fast(), +# which fills a list from the iterator. +import array +import collections +import io +import math + + +class LoudIterator: + def __init__(self, seq): + self.i = iter(seq) + + def __iter__(self): + return self + + def __next__(self): + return next(self.i) + + def __length_hint__(self): + raise NotImplementedError("iterator hint") + + +def handing(seq=(1, 2, 3)): + class Handing: + def __iter__(self): + return LoudIterator(seq) + + return Handing() + + +assert set(handing()) == {1, 2, 3} +assert frozenset(handing()) == frozenset({1, 2, 3}) +assert {1}.difference(handing()) == set() +assert {1}.intersection(handing()) == {1} +assert {1}.symmetric_difference(handing()) == {2, 3} +assert {1}.issubset(handing()) +assert not {9}.issuperset(handing()) +assert dict.fromkeys(handing()) == {1: None, 2: None, 3: None} +assert array.array("b", handing()) == array.array("b", [1, 2, 3]) +assert all(handing()) and any(handing()) +assert sum(handing()) == 6 +assert math.fsum(handing()) == 6.0 +assert math.prod(handing()) == 6 +assert collections.deque(handing()) == collections.deque([1, 2, 3]) +assert tuple(handing()) == (1, 2, 3) +assert list(handing()) == [1, 2, 3] +assert min(handing()) == 1 +assert bytes(handing()) == b"\x01\x02\x03" +assert bytearray(handing()) == bytearray(b"\x01\x02\x03") +io.StringIO().writelines(handing(("a", "b"))) + +# join asks, and answers with what asking raised. +for empty in ("", b""): + try: + empty.join(handing((empty.__class__(),))) + except NotImplementedError: + pass + else: + raise AssertionError(f"{empty.__class__.__name__}.join did not ask") + + +# An error from an element is the element's, not the end of the walk, so the +# next step reaches for the same one again. +class Balky: + def __getitem__(self, i): + if i == 1: + raise ValueError("boom") + if i > 2: + raise IndexError + return i + + +it = iter(Balky()) +assert next(it) == 0 +for _ in range(2): + try: + next(it) + except ValueError as e: + assert str(e) == "boom", e + else: + raise AssertionError("the element's error did not reach the caller") + + +# A collection that moved under its iterator raises every time it is asked +# again, rather than reading as spent after the first. What is left to walk +# reads as nothing from the moment the collection no longer matches. +from collections import deque +from operator import length_hint + + +def moved(make, mutate, restore, moved_hint, again): + it = make() + next(it) + assert length_hint(it) == 9, length_hint(it) + mutate() + assert length_hint(it) == moved_hint, length_hint(it) + try: + next(it) + except RuntimeError: + pass + else: + raise AssertionError("a collection that moved was walked further") + assert length_hint(it) == 0, length_hint(it) + restore() + try: + next(it) + except RuntimeError: + got = RuntimeError + except StopIteration: + got = StopIteration + else: + raise AssertionError("a collection that moved was walked further") + assert got is again, got + assert length_hint(it) == 0, length_hint(it) + + +# A deque iterator carries its own count, so what the deque does to its own +# length before the iterator is asked again is not what the count answers. +d = deque(range(10)) +moved(lambda: iter(d), d.pop, lambda: d.append(99), 9, RuntimeError) +d2 = deque(range(10)) +# `dequereviter_next()` looks at the count before the deque, so once the count +# is spent the deque is never looked at again. +moved(lambda: reversed(d2), d2.pop, lambda: d2.append(99), 9, StopIteration) + +# A dict or set iterator answers from the size it captured, which the +# collection stops matching the moment it changes. +s = set(range(10)) +moved(lambda: iter(s), lambda: s.add(99), lambda: s.discard(99), 0, RuntimeError) +dd = {i: i for i in range(10)} +moved( + lambda: iter(dd), lambda: dd.update({99: 99}), lambda: dd.pop(99), 0, RuntimeError +) +dv = {i: i for i in range(10)} +moved( + lambda: reversed(dv.items()), + lambda: dv.update({99: 99}), + lambda: dv.pop(99), + 0, + RuntimeError, +) diff --git a/extra_tests/snippets/builtin_list.py b/extra_tests/snippets/builtin_list.py index 44492092bad..d2fb75b3576 100644 --- a/extra_tests/snippets/builtin_list.py +++ b/extra_tests/snippets/builtin_list.py @@ -930,3 +930,124 @@ def __eq__(self, other): # that product must raise instead of wrapping into a short allocation. with assert_raises(MemoryError): [1] * sys.maxsize + + +# A list takes the length an iterable reports before reading it, so one that +# reports more than can be held says so instead of filling memory. +class Reports: + def __init__(self, hint): + self.hint = hint + + def __iter__(self): + return iter([1, 2, 3]) + + def __length_hint__(self): + return self.hint + + +with assert_raises(MemoryError): + list(Reports(sys.maxsize)) +with assert_raises(MemoryError): + [].extend(Reports(sys.maxsize)) +with assert_raises(MemoryError): + empty = [] + empty += Reports(sys.maxsize) + +# A report that leaves no room for what the list already holds cannot be true, +# so it is passed over rather than refused. +held = [1, 2, 3, 4] +held.extend(Reports(sys.maxsize)) +assert held == [1, 2, 3, 4, 1, 2, 3] + + +# Reporting runs the iterable's own code, so what the list holds is counted +# after the report rather than before it. +grew = [] + + +class Grows: + def __iter__(self): + return iter([7]) + + def __length_hint__(self): + grew.extend([0] * 100) + return sys.maxsize - 50 + + +grew.extend(Grows()) +assert len(grew) == 101 and grew[-1] == 7, grew[-3:] + +shrunk = [1] * 100 + + +class Shrinks: + def __iter__(self): + return iter([]) + + def __length_hint__(self): + shrunk.clear() + return sys.maxsize + + +with assert_raises(MemoryError): + shrunk.extend(Shrinks()) + + +# Only the callers that take the room ask at all, which is why an iterable whose +# __len__ raises reaches tuple() but not list(). +class Lazy: + def __len__(self): + raise NotImplementedError + + def __iter__(self): + return iter([1, 2, 3]) + + +assert tuple(Lazy()) == (1, 2, 3) +assert (lambda *a: a)(*Lazy()) == (1, 2, 3) +assert min(Lazy()) == 1 +with assert_raises(NotImplementedError): + list(Lazy()) +with assert_raises(NotImplementedError): + sorted(Lazy()) +with assert_raises(NotImplementedError): + [].extend(Lazy()) +with assert_raises(NotImplementedError): + [*Lazy()] + + +# Nothing asks the iterator, so what it would have answered never runs. +class LoudIterator: + def __init__(self): + self.i = iter([1, 2, 3]) + + def __iter__(self): + return self + + def __next__(self): + return next(self.i) + + def __length_hint__(self): + raise NotImplementedError + + +class HandsOutLoud: + def __iter__(self): + return LoudIterator() + + +assert tuple(HandsOutLoud()) == (1, 2, 3) +assert (lambda *a: a)(*HandsOutLoud()) == (1, 2, 3) +assert min(HandsOutLoud()) == 1 +assert max(HandsOutLoud()) == 3 +assert list(HandsOutLoud()) == [1, 2, 3] +assert sorted(HandsOutLoud()) == [1, 2, 3] +assert bytearray(HandsOutLoud()) == bytearray(b"\x01\x02\x03") +held = [0] +held.extend(HandsOutLoud()) +assert held == [0, 1, 2, 3] + + +# A report the list can act on is acted on. +assert list(Reports(3)) == [1, 2, 3] +assert list(Reports(0)) == [1, 2, 3] diff --git a/extra_tests/snippets/builtin_map.py b/extra_tests/snippets/builtin_map.py index 559d108e38b..042a4947ff3 100644 --- a/extra_tests/snippets/builtin_map.py +++ b/extra_tests/snippets/builtin_map.py @@ -32,3 +32,16 @@ def mapping(x): assert list(map(mapping, [1, 2, 0, 4, 5])) == [1, 2] + + +# map does not report a length hint, so a chain of them is not walked to +# answer for one. +import operator + +assert not hasattr(map(lambda x: x, [1, 2, 3]), "__length_hint__") +assert operator.length_hint(map(lambda x: x, [1, 2, 3])) == 0 + +it = iter([1, 2, 3]) +for _ in range(10000): + it = map(lambda x: x, it) +assert operator.length_hint(it) == 0 diff --git a/extra_tests/snippets/builtin_memoryview.py b/extra_tests/snippets/builtin_memoryview.py index 979f584a2b1..ef28ffc3d79 100644 --- a/extra_tests/snippets/builtin_memoryview.py +++ b/extra_tests/snippets/builtin_memoryview.py @@ -850,3 +850,114 @@ def test_cast_bounds_the_dimensions(): test_cast_bounds_the_dimensions() + + +def test_setitem_error_kinds(): + import array + + # A value the format has no room for and a value of the wrong kind are + # different errors, the way they are for any other conversion. + for fmt, over, under in ( + ("B", 300, -1), + ("b", 128, -129), + ("i", 2**31, -(2**31) - 1), + ): + view = memoryview(array.array(fmt, [0, 0])) + for value in (over, under): + try: + view[0] = value + except ValueError as e: + assert str(e) == f"memoryview: invalid value for format '{fmt}'", e + else: + raise AssertionError(f"expected ValueError for {fmt!r} {value}") + for value in ("x", 1.5, None, [1]): + try: + view[0] = value + except TypeError as e: + assert str(e) == f"memoryview: invalid type for format '{fmt}'", e + else: + raise AssertionError(f"expected TypeError for {fmt!r} {value!r}") + + # A bytes item is a value error when it is the wrong length. + chars = memoryview(bytearray(b"ab")).cast("c") + for value in (b"", b"xy"): + try: + chars[0] = value + except ValueError as e: + assert str(e) == "memoryview: invalid value for format 'c'", e + else: + raise AssertionError(f"expected ValueError for {value!r}") + + +def test_setitem_propagates_index_errors(): + # An error raised by the value's own code is the answer, not a report that + # the value was the wrong kind. + class Boom: + def __index__(self): + raise ZeroDivisionError("boom") + + class NotAnInt: + def __index__(self): + return "not an int" + + view = memoryview(bytearray(b"ab")) + try: + view[0] = Boom() + except ZeroDivisionError as e: + assert str(e) == "boom", e + else: + raise AssertionError("expected ZeroDivisionError") + + try: + view[0] = NotAnInt() + except TypeError as e: + assert str(e) == "memoryview: invalid type for format 'B'", e + else: + raise AssertionError("expected TypeError") + + +def test_delete_answers_readonly_first(): + # Nothing can be deleted from a memoryview, but what cannot be written + # says so first. + try: + del memoryview(b"ab")[0] + except TypeError as e: + assert str(e) == "cannot modify read-only memory", e + else: + raise AssertionError("expected TypeError") + + view = memoryview(bytearray(b"abcd")) + for needle in (0, slice(0, 2)): + try: + del view[needle] + except TypeError as e: + assert str(e) == "cannot delete memory", e + else: + raise AssertionError(f"expected TypeError for {needle!r}") + + +test_setitem_error_kinds() +test_setitem_propagates_index_errors() +test_delete_answers_readonly_first() + + +def test_bool_format_keeps_its_own_error(): + # Deciding truth is the value's own code, and what it raises is the answer. + class Raises: + def __init__(self, exc): + self.exc = exc + + def __bool__(self): + raise self.exc + + view = memoryview(bytearray(b"\x00")).cast("?") + for exc in (ZeroDivisionError("boom"), ValueError("nope"), TypeError("nah")): + try: + view[0] = Raises(exc) + except type(exc) as e: + assert str(e) == str(exc), e + else: + raise AssertionError(f"expected {type(exc).__name__}") + + +test_bool_format_keeps_its_own_error() diff --git a/extra_tests/snippets/stdlib_ctypes.py b/extra_tests/snippets/stdlib_ctypes.py index 109665b6a03..827fc598826 100644 --- a/extra_tests/snippets/stdlib_ctypes.py +++ b/extra_tests/snippets/stdlib_ctypes.py @@ -444,8 +444,18 @@ def get_win_folder_via_ctypes(csidl_name: str) -> str: except ValueError: pass else: - assert False, "slice assignment accepted an unbounded iterable" + raise AssertionError("slice assignment accepted an unbounded iterable") array3[0:3] = [7, 8, 9] assert list(array3) == [7, 8, 9] + +# An array type carries the size of its buffer, so one too large to allocate +# must raise instead of aborting. +try: + (ctypes.c_char * (2**60))() +except MemoryError: + pass +else: + raise AssertionError("an unallocatable array was created") + print("done") diff --git a/extra_tests/snippets/stdlib_itertools.py b/extra_tests/snippets/stdlib_itertools.py index 029d0d4229a..ef06ce52985 100644 --- a/extra_tests/snippets/stdlib_itertools.py +++ b/extra_tests/snippets/stdlib_itertools.py @@ -540,3 +540,32 @@ def __iter__(self): itertools.combinations(range(5), 2**44) with assert_raises(MemoryError): itertools.combinations_with_replacement(range(5), 2**44) + +# repeat is an arbitrary Python int: a negative one is refused, and one whose +# pool cannot be allocated must raise rather than take the process down. +with assert_raises(ValueError): + itertools.product([1], repeat=-1) +with assert_raises(OverflowError): + itertools.product([1, 2], repeat=2**60) + + +# The pools are filled by their own count, so a repeat with nothing to repeat +# answers at once instead of counting up to it. +assert list(itertools.product(repeat=2**62)) == [()] +assert list(itertools.product(repeat=0)) == [()] + + +# The count is settled before the arguments are read, so a repeat too large to +# serve does not run their code first. +ran = [] + + +class Watched: + def __iter__(self): + ran.append(True) + return iter([1]) + + +with assert_raises(OverflowError): + itertools.product(Watched(), repeat=2**62) +assert ran == [] diff --git a/extra_tests/snippets/stdlib_struct.py b/extra_tests/snippets/stdlib_struct.py index b95b6560d68..21305948269 100644 --- a/extra_tests/snippets/stdlib_struct.py +++ b/extra_tests/snippets/stdlib_struct.py @@ -156,3 +156,9 @@ def __init__(self): ): with assert_raises(RuntimeError): call() + + +# The buffer a format asks for is sized by the format: one too large to +# allocate must raise instead of aborting. +with assert_raises(MemoryError): + struct.pack("%dx" % (2**60)) From 91a725fe833dbc06912a2619e0c39fffa0c61232 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:42:23 +0900 Subject: [PATCH 331/351] compiler-core: implement DerefMut and IndexMut for Constants (#8557) `Constants` exposed `Deref` and `Index` but neither mutable counterpart, so an owner of a `CodeObject` could read a constant but not replace one in place and had to rebuild the whole boxed slice. `[T]` already carries both halves of the `VarNum` pair in this file. Assisted-by: Claude --- crates/compiler-core/src/bytecode.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/compiler-core/src/bytecode.rs b/crates/compiler-core/src/bytecode.rs index ba1639170a7..0272270526b 100644 --- a/crates/compiler-core/src/bytecode.rs +++ b/crates/compiler-core/src/bytecode.rs @@ -11,7 +11,7 @@ use bitflags::bitflags; use core::{ cell::UnsafeCell, hash, mem, - ops::{Deref, Index, IndexMut}, + ops::{Deref, DerefMut, Index, IndexMut}, sync::atomic::{AtomicU8, AtomicU16, AtomicUsize, Ordering}, }; use itertools::Itertools; @@ -383,6 +383,12 @@ impl Deref for Constants { } } +impl DerefMut for Constants { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + impl Index for Constants { type Output = C; @@ -391,6 +397,12 @@ impl Index for Constants { } } +impl IndexMut for Constants { + fn index_mut(&mut self, consti: oparg::ConstIdx) -> &mut Self::Output { + &mut self.0[consti.as_usize()] + } +} + impl FromIterator for Constants { fn from_iter>(iter: T) -> Self { Self(iter.into_iter().collect()) From dd2cc4d77a625661e5ca189f1cf9be199ecaa434 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:47:36 +0900 Subject: [PATCH 332/351] Align compiler futures, annotations, and symtable with CPython (#8550) * Align compiler futures and annotations with CPython Handle Barry parsing consistently across AST, type-comment, REPL, and WASM paths. Preserve deferred annotation source and scope metadata, and make _symtable conversion cached and linear-time. Assisted-by: Codex:gpt-5.4 * Fix f-string debug ranges and update Barry/annotate test expectations Compute the debug f-string source range from the raw leading and trailing text instead of the comment-stripped text, so a comment inside a multi-line replacement field no longer shifts the emitted LOAD_CONST location. Restrict the obsolete-operator location shift to `<>` by also requiring a `>` at the reported offset; without it any `ExpectedExpression` that followed a `<` (`2 <;`) was reported one column to the left. Add `CodeFlags::FUTURE_MASK` and use it in builtins, the shell, and the WASM VM instead of repeating the eight-flag union, and collapse the duplicated Barry diagnostic sequences into `BarrySource::diagnostic`. Update the `__annotate__` disassembly snapshot to LOAD_FAST_BORROW and replace the RustPython-only `barry_as_FLUFL` co_flags assertion in extra_tests with the CPython-matching one. Assisted-by: Claude Code:claude-opus-5 * Restore the annotated function in __annotate__ qualnames Signature annotation scopes are compiled in the scope enclosing the annotated function, so the function name has to be folded back into the qualname: `f.__annotate__`, `C.m.__annotate__`, `outer..inner.__annotate__`. test_type_annotations test_annotate_qualname covers this. test_pyrepl test_future_barry_as_flufl now passes, so drop its expectedFailure marker. Assisted-by: Claude Code:claude-opus-5 * Pass safepoints in the attached-thread interpreter tests `main_and_subinterpreter_run_sections_overlap` parked its workers on a condvar while attached, and `busy_main_interpreter_does_not_block_subinterpreter` looped over protocol calls that never reach a safepoint. An attached thread that never reaches a safepoint cannot be suspended, so a concurrent process-wide stop-the-world (from the gc test running in parallel) never completes and the already-stopped sibling interpreter stays stopped; the second worker then waits in `wait_while_suspended` until the 30s deadline. Both loops now call `vm.check_signals()` each iteration, and the condvar wait uses `wait_timeout` so the lock is released between safepoints. Reproduced on Linux (4 CPUs): the two tests with `--test-threads=2` failed 2/40 before and 0/40 after; the full `rustpython-vm` lib binary failed 1/30 before and 0/30 after. Assisted-by: Claude Code:claude-opus-5 * Report the obsolete `<>` operator from one place `<>` was located twice: `BarrySource` recorded the token ranges it rewrote to `!=`, and `cpython_parse_diagnostic_override` separately peeked at the bytes around an `ExpectedExpression` location. Both produced "invalid syntax" spanning the whole operator. `prepare_barry_as_flufl_source` now records the operator ranges in the non-Barry case too -- by plain text search, since nothing is rewritten there -- so `BarrySource::invalid_legacy_operator` covers both, and the byte-peeking helper is gone. It also defers to an unclosed bracket earlier in the source, which the diagnostic chain it used to sit in did for it. A `<>` that starts a statement now spans both characters: the parser stops at the `<` rather than one character in, which the byte peek did not handle. Assisted-by: Claude Code:claude-opus-5 * Drop a py object's payload before its class `PyInner` declares `typ` before `payload`, so `drop_in_place` dropped the class first and `PyAtomicRef::drop` left it null. A `PyWeak` payload is still linked into the weakref list of the object it points at until its own `Drop` unlinks it under the stripe lock, which happens after that. `WeakRefList::add` walks the same list looking for a proxy to reuse and reads `node.class()` off every node it passes, so it could dereference the null class of a node on its way out. `PyInner::dealloc` now runs the two destructors in the other order at all three of its exit paths, with a compile-time check that no other field has one. Reproduced by the new test in about 0.1s, as the panic CI hit in the c-api `weakrefobject::tests::new_proxy` run: panicked at crates/vm/src/object/ext.rs:312: unsafe precondition(s) violated: hint::unreachable_unchecked must never be reached Assisted-by: Claude Code:claude-opus-5 --- Lib/test/test_flufl.py | 2 - Lib/test/test_future_stmt/test_future.py | 2 - Lib/test/test_pyrepl/test_interact.py | 1 - Lib/test/test_super.py | 1 - Lib/test/test_symtable.py | 5 - crates/codegen/src/compile.rs | 358 ++++++++++++----- crates/codegen/src/lib.rs | 63 +++ crates/codegen/src/preprocess.rs | 2 +- crates/codegen/src/symboltable.rs | 132 +++++-- crates/codegen/src/unparse.rs | 106 ++++- crates/compiler-core/src/bytecode.rs | 13 + crates/compiler/src/lib.rs | 362 ++++++++++++++++-- ...k_attribute_and_subscript_expressions.snap | 2 +- crates/vm/src/object/core.rs | 84 +++- crates/vm/src/stdlib/_ast.rs | 18 +- crates/vm/src/stdlib/_symtable.rs | 137 +++++-- crates/vm/src/stdlib/builtins.rs | 10 +- crates/vm/src/vm/compile.rs | 58 ++- crates/vm/src/vm/compile_mode.rs | 13 +- crates/vm/src/vm/interpreter.rs | 26 +- crates/wasm/src/vm_class.rs | 40 +- extra_tests/snippets/builtin_compile.py | 4 +- src/shell.rs | 11 +- 23 files changed, 1201 insertions(+), 249 deletions(-) diff --git a/Lib/test/test_flufl.py b/Lib/test/test_flufl.py index 62360d9f9e4..d77e481c81d 100644 --- a/Lib/test/test_flufl.py +++ b/Lib/test/test_flufl.py @@ -4,7 +4,6 @@ class FLUFLTests(unittest.TestCase): - @unittest.expectedFailure # TODO: RUSTPYTHON def test_barry_as_bdfl(self): code = "from __future__ import barry_as_FLUFL\n2 {0} 3" compile(code.format('<>'), '', 'exec', @@ -35,7 +34,6 @@ def test_guido_as_bdfl(self): # parser reports the start of the token self.assertEqual(cm.exception.offset, 3) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_barry_as_bdfl_look_ma_with_no_compiler_flags(self): # Check that the future import is handled by the parser # even if the compiler flags are not passed. diff --git a/Lib/test/test_future_stmt/test_future.py b/Lib/test/test_future_stmt/test_future.py index 02690919cf3..71f1e616116 100644 --- a/Lib/test/test_future_stmt/test_future.py +++ b/Lib/test/test_future_stmt/test_future.py @@ -111,7 +111,6 @@ def test_future_import_not_on_top(self): """ self.assertSyntaxError(code, lineno=3) - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: SyntaxError not raised def test_future_import_with_extra_string(self): code = """ '''Docstring''' @@ -260,7 +259,6 @@ def _exec_future(self, code): ) return scope - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: "t'{a + b}'" != "t'{a + b}'" def test_annotations(self): eq = self.assertAnnotationEqual eq('...') diff --git a/Lib/test/test_pyrepl/test_interact.py b/Lib/test/test_pyrepl/test_interact.py index 65b1eed5bdd..7cbe523a92f 100644 --- a/Lib/test/test_pyrepl/test_interact.py +++ b/Lib/test/test_pyrepl/test_interact.py @@ -166,7 +166,6 @@ def g(x: int): ... self.assertFalse(result) self.assertEqual(f.getvalue(), "{'x': 'int'}\n") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_future_barry_as_flufl(self): console = InteractiveColoredConsole() f = io.StringIO() diff --git a/Lib/test/test_super.py b/Lib/test/test_super.py index 4d338bbbc5a..cde2352e6e1 100644 --- a/Lib/test/test_super.py +++ b/Lib/test/test_super.py @@ -90,7 +90,6 @@ def nested(): self.assertEqual(E().f(), 'AE') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_various___class___pathologies(self): # See issue #12370 class X(A): diff --git a/Lib/test/test_symtable.py b/Lib/test/test_symtable.py index 16204bc45dd..9537de3756f 100644 --- a/Lib/test/test_symtable.py +++ b/Lib/test/test_symtable.py @@ -299,7 +299,6 @@ def test_symbol_lookup(self): self.assertRaises(KeyError, self.top.lookup, "not_here") - @unittest.expectedFailure # TODO: RUSTPYTHON def test_namespaces(self): self.assertTrue(self.top.lookup("Mine").is_namespace()) self.assertTrue(self.Mine.lookup("a_method").is_namespace()) @@ -360,7 +359,6 @@ def test_name(self): self.assertEqual(self.spam.lookup("x").get_name(), "x") self.assertEqual(self.Mine.get_name(), "Mine") - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: Tuples differ: () != ('a_method',) def test_class_get_methods(self): deprecation_mess = ( re.escape('symtable.Class.get_methods() is deprecated ' @@ -442,7 +440,6 @@ def check_body(body, expected_methods): check_body('\n'.join((gen, func)), ('genexpr',)) check_body('\n'.join((func, gen)), ('genexpr',)) - @unittest.expectedFailure # TODO: RUSTPYTHON; SyntaxError: name 'x' is parameter and global def test_filename_correct(self): ### Bug tickler: SyntaxError file name correct whether error raised ### while parsing or building symbol table. @@ -474,7 +471,6 @@ def test_single(self): def test_exec(self): symbols = symtable.symtable("def f(x): return x", "?", "exec") - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: Expected type 'str' but 'bytes' found. def test_bytes(self): top = symtable.symtable(TEST_CODE.encode('utf8'), "?", "exec") self.assertIsNotNone(find_block(top, "Mine")) @@ -563,7 +559,6 @@ def test_loopvar_in_only_one_scope(self): class CommandLineTest(unittest.TestCase): maxDiff = None - @unittest.expectedFailure # TODO: RUSTPYTHON; TypeError: Expected type 'str' but 'bytes' found. def test_file(self): filename = os_helper.TESTFN self.addCleanup(os_helper.unlink, filename) diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index fe0a983187a..bccb600f698 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -13,7 +13,7 @@ use crate::{ IndexMap, IndexSet, ToPythonName, ast_constant_value_to_constant_data, error::{CodegenError, CodegenErrorType, InternalError}, ir::{self, Block, BlockIdx, Blocks}, - preprocess, + preprocess, strip_python_comments, symboltable::{self, CompilerScope, Symbol, SymbolFlags, SymbolScope, SymbolTable}, unparse::UnparseExpr, }; @@ -2129,6 +2129,7 @@ impl<'warnings> Compiler<'warnings> { | bytecode::CodeFlags::FUTURE_WITH_STATEMENT | bytecode::CodeFlags::FUTURE_PRINT_FUNCTION | bytecode::CodeFlags::FUTURE_UNICODE_LITERALS + | bytecode::CodeFlags::FUTURE_BARRY_AS_BDFL | bytecode::CodeFlags::FUTURE_GENERATOR_STOP | bytecode::CodeFlags::FUTURE_ANNOTATIONS)); info.metadata.argcount = arg_count; @@ -2159,6 +2160,12 @@ impl<'warnings> Compiler<'warnings> { } } + fn configure_annotation_format_parameter(&mut self) { + let info = self.current_code_info(); + info.metadata.varnames.insert(".format".to_owned()); + info.nparams = 1; + } + /// Exit a function signature annotation scope. fn exit_annotation_scope(&mut self, saved_ctx: CompileContext) -> CodeObject { self.pop_annotation_symbol_table(); @@ -2208,10 +2215,7 @@ impl<'warnings> Compiler<'warnings> { // Keep the internal ".format" name; exit_annotation_scope() // renames it to "format" on the final code object. - self.current_code_info() - .metadata - .varnames - .insert(".format".to_owned()); + self.configure_annotation_format_parameter(); // Emit format validation: if format > VALUE_WITH_FAKE_GLOBALS: raise NotImplementedError // VALUE_WITH_FAKE_GLOBALS = 2 (from annotationlib.Format) @@ -2621,9 +2625,9 @@ impl<'warnings> Compiler<'warnings> { } /// Set the qualname of an annotation scope, qualified by the function whose - /// signature it annotates. CPython records that name on the annotation - /// block's symbol table entry (`ste_function_name`) and folds it into the - /// qualname, so `f`'s annotation scope is named `f.__annotate__`. + /// signature it annotates. The annotation block's symbol table entry records + /// that name (`ste_function_name`) and folds it into the qualname, so `f`'s + /// annotation scope is named `f.__annotate__`. fn set_annotation_qualname(&mut self, function_name: &str) { self.set_qualname_for_function(Some(function_name)); } @@ -2801,6 +2805,9 @@ impl<'warnings> Compiler<'warnings> { emit!(self, PseudoInstruction::AnnotationsPlaceholder); let (doc, statements) = split_doc_with_range(&body.body, &self.opts); + if doc.is_some() { + self.done_with_future_stmts = DoneWithFuture::DoneWithDoc; + } let module_start_loc = self.module_start_location(&body.body); let annotations_used = self.current_symbol_table().annotations_used; // Handle annotation bookkeeping before the docstring assignment, as @@ -3173,9 +3180,8 @@ impl<'warnings> Compiler<'warnings> { ) }; - // Special handling for class scope implicit cell variables - // These are treated as Cell even if not explicitly marked in symbol table - // __class__ and __classdict__: only LOAD uses Cell (stores go to class namespace) + // Special handling for class scope implicit cell variables. + // __classdict__: only LOAD uses Cell (stores go to class namespace) // __conditional_annotations__: both LOAD and STORE use Cell (it's a mutable set // that the annotation scope accesses through the closure) let symbol_scope = { @@ -3183,9 +3189,7 @@ impl<'warnings> Compiler<'warnings> { if current_table.typ == CompilerScope::Class && !self.current_code_info().in_inlined_comp && ((usage == NameUsage::Load - && (name == "__class__" - || name == "__classdict__" - || name == "__conditional_annotations__")) + && (name == "__classdict__" || name == "__conditional_annotations__")) || (name == "__conditional_annotations__" && usage == NameUsage::Store)) { Some(SymbolScope::Cell) @@ -3217,6 +3221,8 @@ impl<'warnings> Compiler<'warnings> { | "__firstlineno__" | "__doc__" | "__static_attributes__" + | "__annotate__" + | "__annotate_func__" | "__classdictcell__" | "__classcell__" ) { @@ -3988,10 +3994,7 @@ impl<'warnings> Compiler<'warnings> { // Enter scope with the type parameter name self.enter_scope(name, CompilerScope::TypeVariable, key, lineno)?; - self.current_code_info() - .metadata - .varnames - .insert(".format".to_owned()); + self.configure_annotation_format_parameter(); self.emit_format_validation(); @@ -4047,10 +4050,7 @@ impl<'warnings> Compiler<'warnings> { let key = self.symbol_table_stack.len() - 1; let lineno = self.get_source_line_number().get().to_u32(); self.enter_scope(alias_name, CompilerScope::TypeAlias, key, lineno)?; - self.current_code_info() - .metadata - .varnames - .insert(".format".to_owned()); + self.configure_annotation_format_parameter(); self.emit_format_validation(); let prev_ctx = self.ctx; @@ -5299,10 +5299,7 @@ impl<'warnings> Compiler<'warnings> { // Keep the internal ".format" name; the final code object // exposes this parameter as "format". - self.current_code_info() - .metadata - .varnames - .insert(".format".to_owned()); + self.configure_annotation_format_parameter(); // Emit format validation: if format > VALUE_WITH_FAKE_GLOBALS: raise NotImplementedError self.emit_format_validation(); @@ -10542,6 +10539,7 @@ impl<'warnings> Compiler<'warnings> { | bytecode::CodeFlags::FUTURE_WITH_STATEMENT | bytecode::CodeFlags::FUTURE_PRINT_FUNCTION | bytecode::CodeFlags::FUTURE_UNICODE_LITERALS + | bytecode::CodeFlags::FUTURE_BARRY_AS_BDFL | bytecode::CodeFlags::FUTURE_GENERATOR_STOP | bytecode::CodeFlags::FUTURE_ANNOTATIONS)); info.metadata.argcount = arg_count; @@ -11277,7 +11275,11 @@ impl<'warnings> Compiler<'warnings> { .insert(bytecode::CodeFlags::FUTURE_ANNOTATIONS); } FutureFeature::BarryAsFLUFL => { - // We do not support Barry-as-BDFL parser mode yet. This is a nop for now. + self.future_features + .insert(bytecode::CodeFlags::FUTURE_BARRY_AS_BDFL); + self.current_code_info() + .flags + .insert(bytecode::CodeFlags::FUTURE_BARRY_AS_BDFL); } FutureFeature::AbsoluteImport | FutureFeature::Division @@ -13001,20 +13003,14 @@ impl<'warnings> Compiler<'warnings> { if let Some(debug_text) = &fstring_expr.debug_text { let leading = debug_text.leading.as_str(); let trailing = debug_text.trailing.as_str(); - self.emit_pending_fstring_literal( - pending_literal, - pending_literal_range, - pending_literal_no_location, - element_count, - false, - join_append_range, - ); - let range = fstring_expr.expression.range(); - let leading = strip_fstring_debug_comments(leading); - let trailing = strip_fstring_debug_comments(trailing); let source = self.source_file.slice(range); - let text = [leading.as_str(), source, trailing.as_str()].concat(); + let text = [ + strip_python_comments(leading).as_str(), + source, + strip_python_comments(trailing).as_str(), + ] + .concat(); let debug_text_range = TextRange::new( range.start() - TextSize::new( @@ -13029,10 +13025,11 @@ impl<'warnings> Compiler<'warnings> { ); let text: Wtf8Buf = text.into(); - *pending_literal_range = Some(debug_text_range); + Self::extend_pending_literal_range(pending_literal_range, debug_text_range); *pending_literal_no_location = false; - *pending_literal = Some(Wtf8Buf::new()); - pending_literal.as_mut().unwrap().push_wtf8(text.as_ref()); + pending_literal + .get_or_insert_with(Wtf8Buf::new) + .push_wtf8(text.as_ref()); // If debug text is present, apply repr conversion when no `format_spec` specified. // See action_helpers.c: fstring_find_expr_replacement @@ -13145,20 +13142,19 @@ impl<'warnings> Compiler<'warnings> { if let Some(debug_text) = &fstring_expr.debug_text { let leading = debug_text.leading.as_str(); let trailing = debug_text.trailing.as_str(); - Self::count_pending_fstring_literal(pending_literal, element_count, false); let range = fstring_expr.expression.range(); let source = self.source_file.slice(range); let text = [ - strip_fstring_debug_comments(leading).as_str(), + strip_python_comments(leading).as_str(), source, - strip_fstring_debug_comments(trailing).as_str(), + strip_python_comments(trailing).as_str(), ] .concat(); let text: Wtf8Buf = text.into(); - let mut debug_text = Wtf8Buf::new(); - debug_text.push_wtf8(text.as_ref()); - *pending_literal = Some(debug_text); + pending_literal + .get_or_insert_with(Wtf8Buf::new) + .push_wtf8(text.as_ref()); } Self::count_pending_fstring_literal(pending_literal, element_count, false); @@ -13380,9 +13376,9 @@ impl<'warnings> Compiler<'warnings> { let range = interp.expression.range(); let source = self.source_file.slice(range); let text = [ - strip_fstring_debug_comments(leading).as_str(), + strip_python_comments(leading).as_str(), source, - strip_fstring_debug_comments(trailing).as_str(), + strip_python_comments(trailing).as_str(), ] .concat(); let debug_text_range = TextRange::new( @@ -13673,27 +13669,6 @@ impl ToU32 for usize { } } -/// Strip Python comments from f-string debug text (leading/trailing around `=`). -/// A comment starts with `#` and extends to the end of the line. -/// The newline character itself is preserved. -fn strip_fstring_debug_comments(text: &str) -> String { - let mut result = String::with_capacity(text.len()); - let mut in_comment = false; - for ch in text.chars() { - if in_comment { - if ch == '\n' { - in_comment = false; - result.push(ch); - } - } else if ch == '#' { - in_comment = true; - } else { - result.push(ch); - } - } - result -} - #[cfg(test)] mod ruff_tests { use super::*; @@ -17463,6 +17438,53 @@ class C: ); } + #[test] + #[expect( + clippy::literal_string_with_formatting_args, + reason = "the literal is the expected t-string annotation" + )] + fn future_tstring_annotation_preserves_interpolation_source_like_cpython() { + let code = compile_exec( + "from __future__ import annotations\nx: t'{a + b}'\ny: t'{ a + b }'\nz: f'{a + b =}'\nu: t'{a + b =}'\nv: t'{a + b =:>10}'\np: t'{(a)}'\nq: t'{((a))!r}'\nr: t'{ ((a)) = !r:>10}'\ns: t'{(a)=}'\nt: t'{a == b = }'\na1: t'''{a= # x=y\n}'''\na2: t'''{a # x=y\n}'''\na3: t'''{(a # x=y\n)}'''\na4: t'''{a # x=y\n!r}'''\na5: t'''{a # x=y\n:>10}'''\na6: t'''{'#'}'''\na7: t'''{('#', a) # c=d\n}'''\n", + ); + let annotation_strings = code + .constants + .iter() + .filter_map(|constant| match constant { + ConstantData::Str { value } + if value.starts_with("t'") + || value.starts_with("t\"") + || value.starts_with("f'") => + { + Some(value.to_string()) + } + _ => None, + }) + .collect::>(); + assert_eq!( + annotation_strings, + [ + "t'{a + b}'", + "t'{ a + b}'", + "f'a + b ={a + b!r}'", + "t'a + b ={a + b!r}'", + "t'a + b ={a + b:>10}'", + "t'{(a)}'", + "t'{((a))!r}'", + "t' ((a)) = { ((a))!r:>10}'", + "t'(a)={(a)!r}'", + "t'a == b = {a == b!r}'", + "t'a= \\n{a!r}'", + "t'{a}'", + "t'{(a \\n)}'", + "t'{a!r}'", + "t'{a:>10}'", + "t\"{'#'}\"", + "t\"{('#', a)}\"", + ] + ); + } + #[test] fn lambda_dict_literal_ops_use_dict_location_like_cpython() { let code = compile_exec( @@ -19518,7 +19540,7 @@ def spec(x): } #[test] - fn debug_fstring_literal_location_like_cpython() { + fn debug_fstring_literal_merging_and_location_like_cpython() { fn string_load_position(code: &CodeObject, expected: &str) -> (usize, usize, usize, usize) { code.instructions .iter() @@ -19541,16 +19563,20 @@ def spec(x): } let code = compile_exec( - "\ -def simple(x): + r#"def simple(x): return f'{x=}' def prefixed(x): return f'a {x=} b' -", + +def commented(x): + return f"""{ # comment +x=}""" +"#, ); let simple = find_code(&code, "simple").expect("missing simple code"); let prefixed = find_code(&code, "prefixed").expect("missing prefixed code"); + let commented = find_code(&code, "commented").expect("missing commented code"); assert_eq!( string_load_position(simple, "x="), @@ -19558,9 +19584,14 @@ def prefixed(x): "CPython represents f'{{x=}}' debug text as a literal at the expression/debug-text location" ); assert_eq!( - string_load_position(prefixed, "x="), - (5, 17, 5, 19), - "CPython keeps debug text as a separate JoinedStr Constant instead of merging it with the preceding literal" + string_load_position(prefixed, "a x="), + (5, 14, 5, 19), + "CPython merges debug text with the preceding JoinedStr literal" + ); + assert_eq!( + string_load_position(commented, " \nx="), + (8, 17, 9, 3), + "a stripped comment shortens the debug text but not the source range it spans" ); } @@ -26980,6 +27011,43 @@ class C: ); } + #[test] + fn explicit_class_dunder_class_store_uses_namespace_like_cpython() { + let code = compile_exec( + "\ +class C: + def method(self): + return __class__ + __class__ = 413 +", + ); + let class_code = find_code(&code, "C").expect("missing class code"); + let class_name_index = class_code + .names + .iter() + .position(|name| name.as_str() == "__class__") + .expect("missing __class__ name"); + + assert!(class_code.instructions.iter().any(|unit| { + matches!( + unit.op, + Instruction::StoreName { namei } + if namei.get(OpArg::new(u32::from(u8::from(unit.arg)))) as usize + == class_name_index + ) + })); + assert!(!class_code.instructions.iter().any(|unit| { + matches!( + unit.op, + Instruction::StoreDeref { i } + if class_code.cellvars + [usize::from(i.get(OpArg::new(u32::from(u8::from(unit.arg)))))] + .as_str() + == "__class__" + ) + })); + } + #[test] fn conditional_class_body_duplicates_no_location_exit_tail() { let code = compile_exec( @@ -27528,7 +27596,7 @@ def f(): } #[test] - fn future_barry_as_flufl_is_accepted_but_ignored() { + fn future_barry_as_flufl_sets_module_and_nested_code_flags() { let code = compile_exec( "\ from __future__ import barry_as_FLUFL @@ -27537,16 +27605,48 @@ def f(): pass ", ); - let future_flags = bytecode::CodeFlags::FUTURE_DIVISION - | bytecode::CodeFlags::FUTURE_ABSOLUTE_IMPORT - | bytecode::CodeFlags::FUTURE_WITH_STATEMENT - | bytecode::CodeFlags::FUTURE_PRINT_FUNCTION - | bytecode::CodeFlags::FUTURE_UNICODE_LITERALS - | bytecode::CodeFlags::FUTURE_GENERATOR_STOP - | bytecode::CodeFlags::FUTURE_ANNOTATIONS; - assert!((code.flags & future_flags).is_empty()); + assert!( + code.flags + .contains(bytecode::CodeFlags::FUTURE_BARRY_AS_BDFL) + ); let f = find_code(&code, "f").expect("missing f code"); - assert!((f.flags & future_flags).is_empty()); + assert!(f.flags.contains(bytecode::CodeFlags::FUTURE_BARRY_AS_BDFL)); + } + + #[test] + fn function_annotation_qualnames_include_the_annotated_function() { + let code = compile_exec( + "\ +def f(x: int): + pass +class C: + def m(self, x: int): + pass +def outer(): + def inner(x: int): + pass +", + ); + let mut qualnames = Vec::new(); + fn collect(code: &CodeObject, qualnames: &mut Vec) { + for constant in code.constants.iter() { + if let ConstantData::Code { code } = constant { + if code.obj_name == "__annotate__" { + qualnames.push(code.qualname.clone()); + } + collect(code.as_ref(), qualnames); + } + } + } + collect(&code, &mut qualnames); + assert_eq!( + qualnames, + [ + "f.__annotate__", + "C.m.__annotate__", + "outer..inner.__annotate__" + ] + ); } #[test] @@ -27560,6 +27660,20 @@ x: int assert!(!code.flags.contains(bytecode::CodeFlags::FUTURE_ANNOTATIONS)); } + #[test] + fn future_import_after_extra_string_is_rejected_like_cpython() { + assert_eq!( + compile_exec_error_message( + "\ +\"\"\"Docstring\"\"\" +\"this is not a docstring\" +from __future__ import nested_scopes +", + ), + "from __future__ imports must occur at the beginning of the file" + ); + } + #[test] fn future_braces_uses_cpython_special_error() { assert_eq!( @@ -27729,6 +27843,31 @@ class C: ); } + #[test] + fn nested_class_body_loads_outer_dunder_class_while_methods_use_own_cell() { + let code = compile_exec( + "\ +class Outer: + def method(self): + class Inner: + value = __class__ + def nested(): + return __class__ +", + ); + let inner = find_code(&code, "Inner").expect("missing nested class code"); + + assert!(inner.cellvars.iter().any(|name| name == "__class__")); + assert!(inner.freevars.iter().any(|name| name == "__class__")); + assert!( + inner + .instructions + .iter() + .any(|unit| matches!(unit.op, Instruction::LoadFromDictOrDeref { .. })), + "the class body must resolve __class__ from the enclosing method while the nested method closes over the new class cell" + ); + } + #[test] fn nested_closure_parameter_class_does_not_create_outer_class_closure() { let code = compile_exec( @@ -31236,6 +31375,16 @@ class C: .map(|name| name.as_str()) .collect::>(); assert_eq!(varnames, vec!["format"]); + assert!(annotate.instructions.iter().any(|unit| matches!( + unit.op, + Instruction::LoadFastBorrow { var_num } + if usize::from(var_num.get(OpArg::new(u32::from(u8::from(unit.arg))))) == 0 + ))); + assert!(!annotate.instructions.iter().any(|unit| matches!( + unit.op, + Instruction::LoadFastCheck { var_num } + if usize::from(var_num.get(OpArg::new(u32::from(u8::from(unit.arg))))) == 0 + ))); } #[test] @@ -31259,6 +31408,39 @@ def f(x: T): pass ); } + #[test] + fn future_generic_class_annotations_do_not_capture_type_params_like_cpython() { + let code = compile_exec( + "\ +from __future__ import annotations +class A[T, *Ts, **P]: + x: T + y: tuple[*Ts] + z: Callable[P, str] +", + ); + let type_params = + find_code(&code, "").expect("missing type parameter scope"); + let class = find_direct_child_code(type_params, "A").expect("missing class body"); + + assert_eq!( + type_params + .cellvars + .iter() + .map(|name| name.as_str()) + .collect::>(), + [".type_params"] + ); + assert_eq!( + class + .freevars + .iter() + .map(|name| name.as_str()) + .collect::>(), + [".type_params"] + ); + } + #[test] fn future_unannotated_function_does_not_hide_next_annotation_block() { let code = compile_exec( diff --git a/crates/codegen/src/lib.rs b/crates/codegen/src/lib.rs index a7349a5762f..f765eb74aba 100644 --- a/crates/codegen/src/lib.rs +++ b/crates/codegen/src/lib.rs @@ -8,6 +8,7 @@ extern crate log; extern crate alloc; +use alloc::{string::String, vec::Vec}; use rustpython_compiler_core::bytecode::ConstantData; type IndexMap = indexmap::IndexMap; @@ -93,6 +94,68 @@ pub(crate) fn ast_constant_value_to_constant_data(value: ast::ConstantValue) -> } } +fn strip_python_comments(text: &str) -> String { + let chars = text.chars().collect::>(); + let mut result = String::with_capacity(text.len()); + let mut quote = None; + let mut triple_quoted = false; + let mut escaped = false; + let mut in_comment = false; + let mut index = 0; + + while index < chars.len() { + let ch = chars[index]; + if in_comment { + if matches!(ch, '\n' | '\r') { + in_comment = false; + result.push(ch); + } + index += 1; + continue; + } + + if let Some(delimiter) = quote { + result.push(ch); + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if triple_quoted + && ch == delimiter + && chars.get(index + 1) == Some(&delimiter) + && chars.get(index + 2) == Some(&delimiter) + { + result.push(delimiter); + result.push(delimiter); + quote = None; + index += 2; + } else if !triple_quoted && ch == delimiter { + quote = None; + } + index += 1; + continue; + } + + match ch { + '#' => in_comment = true, + '\'' | '"' => { + quote = Some(ch); + triple_quoted = + chars.get(index + 1) == Some(&ch) && chars.get(index + 2) == Some(&ch); + result.push(ch); + if triple_quoted { + result.push(ch); + result.push(ch); + index += 2; + } + } + _ => result.push(ch), + } + index += 1; + } + result +} + pub trait ToPythonName { /// Returns a short name for the node suitable for use in error messages. fn python_name(&self) -> &'static str; diff --git a/crates/codegen/src/preprocess.rs b/crates/codegen/src/preprocess.rs index 084b72c87f7..1bb65a76e62 100644 --- a/crates/codegen/src/preprocess.rs +++ b/crates/codegen/src/preprocess.rs @@ -257,7 +257,7 @@ pub fn checked_future_features_in_body( future_features.insert(bytecode::CodeFlags::FUTURE_ANNOTATIONS) } FutureFeature::BarryAsFLUFL => { - // We do not support Barry-as-BDFL parser mode yet. This is a nop for now. + future_features.insert(bytecode::CodeFlags::FUTURE_BARRY_AS_BDFL) } FutureFeature::AbsoluteImport | FutureFeature::Division diff --git a/crates/codegen/src/symboltable.rs b/crates/codegen/src/symboltable.rs index a771e19d36f..a09deb0bb3a 100644 --- a/crates/codegen/src/symboltable.rs +++ b/crates/codegen/src/symboltable.rs @@ -32,6 +32,9 @@ pub struct SymbolTable { /// The line number in the source code where this symboltable begins. pub line_number: u32, + /// Monotonic creation order used by public symtable children. + pub block_index: usize, + // Return True if the block is a nested class or function pub is_nested: bool, @@ -120,11 +123,18 @@ pub struct SymbolTable { } impl SymbolTable { - fn new(name: String, typ: CompilerScope, line_number: u32, is_nested: bool) -> Self { + fn new( + name: String, + typ: CompilerScope, + line_number: u32, + is_nested: bool, + block_index: usize, + ) -> Self { Self { name, typ, line_number, + block_index, is_nested, is_method: false, symbols: IndexMap::default(), @@ -993,7 +1003,7 @@ impl SymbolTableAnalyzer { || (sym.flags.contains(SymbolFlags::DEF_FREE_CLASS) && !matches!(st_typ, CompilerScope::Module)) { - if st_typ == CompilerScope::Class && name != "__class__" { + if st_typ == CompilerScope::Class { None } else { Some(SymbolScope::Cell) @@ -1044,6 +1054,7 @@ struct SymbolTableBuilder { // Mirrors symtable ENTER_RECURSIVE guards during compilation. recursion_depth: usize, recursion_limit: usize, + next_block_index: usize, } /// Enum to indicate in what mode an expression @@ -1074,6 +1085,7 @@ impl SymbolTableBuilder { in_conditional_block: false, recursion_depth: 0, recursion_limit: DEFAULT_RECURSION_LIMIT, + next_block_index: 0, }; this.enter_scope("top", CompilerScope::Module, 0); this @@ -1154,7 +1166,9 @@ impl SymbolTableBuilder { .last() .and_then(|t| t.mangled_names.clone()) .filter(|_| typ != CompilerScope::Class); - let mut table = SymbolTable::new(name.to_owned(), typ, line_number, is_nested); + let block_index = self.next_block_index; + self.next_block_index += 1; + let mut table = SymbolTable::new(name.to_owned(), typ, line_number, is_nested, block_index); table.is_method = is_method; table.future_annotations = self.future_annotations; table.mangled_names = inherited_mangled_names; @@ -1235,6 +1249,17 @@ impl SymbolTableBuilder { table } + fn resolve_future_annotation_names_as_globals(table: &mut SymbolTable) { + for symbol in table.symbols.values_mut() { + if symbol.scope == SymbolScope::Unknown + && symbol.flags.contains(SymbolFlags::USE) + && !symbol.is_bound() + { + symbol.scope = SymbolScope::GlobalImplicit; + } + } + } + /// Enter annotation scope (PEP 649) /// Creates or reuses the annotation block for the current scope fn enter_annotation_scope( @@ -1243,29 +1268,42 @@ impl SymbolTableBuilder { include_classdict_with_future: bool, include_conditional_annotations: bool, ) { - let current = self.tables.last_mut().unwrap(); - let can_see_class_scope = - current.typ == CompilerScope::Class || current.can_see_class_scope; - let has_conditional = current.has_conditional_annotations; - let is_nested = current.is_nested || Self::is_function_like_scope(current.typ); + let (can_see_class_scope, has_conditional, is_nested, needs_annotation_block) = { + let current = self.tables.last().unwrap(); + ( + current.typ == CompilerScope::Class || current.can_see_class_scope, + current.has_conditional_annotations, + current.is_nested || Self::is_function_like_scope(current.typ), + current.annotation_block.is_none(), + ) + }; // Create annotation block if not exists - if current.annotation_block.is_none() { + if needs_annotation_block { + let block_index = self.next_block_index; + self.next_block_index += 1; let mut annotation_table = SymbolTable::new( "__annotate__".to_owned(), CompilerScope::Annotation, line_number, is_nested, + block_index, ); // Annotation scope in class can see class scope annotation_table.can_see_class_scope = can_see_class_scope; annotation_table.skip_enclosing_function_scope = true; annotation_table.add_format_parameter(); - current.annotation_block = Some(Box::new(annotation_table)); + self.tables.last_mut().unwrap().annotation_block = Some(Box::new(annotation_table)); } // Take the annotation block and push to stack for processing - let annotation_table = current.annotation_block.take().unwrap(); + let annotation_table = self + .tables + .last_mut() + .unwrap() + .annotation_block + .take() + .unwrap(); self.tables.push(*annotation_table); // Save parent's varnames and seed with existing annotation varnames (e.g., "format") self.varnames_stack @@ -1287,6 +1325,9 @@ impl SymbolTableBuilder { let mut table = self.tables.pop().unwrap(); // Save the collected varnames to the symbol table table.varnames = core::mem::take(&mut self.current_varnames); + if self.future_annotations { + Self::resolve_future_annotation_names_as_globals(&mut table); + } // Store back to parent's annotation_block (not sub_tables) let parent = self.tables.last_mut().unwrap(); parent.annotation_block = Some(Box::new(table)); @@ -1320,6 +1361,13 @@ impl SymbolTableBuilder { .insert(SymbolFlags::USE | SymbolFlags::DEF_FREE_CLASS); } + fn add_format_parameter(&mut self) { + self.tables.last_mut().unwrap().add_format_parameter(); + if !self.current_varnames.iter().any(|name| name == ".format") { + self.current_varnames.push(".format".to_owned()); + } + } + /// Walk up the scope chain to determine if we're inside an async function. /// Annotation and TypeParams scopes act as async barriers (always non-async). /// Comprehension scopes are transparent (inherit parent's async context). @@ -1413,7 +1461,7 @@ impl SymbolTableBuilder { current.typ == CompilerScope::Class || current.can_see_class_scope; self.enter_scope("__annotate__", CompilerScope::Annotation, line_number); self.tables.last_mut().unwrap().can_see_class_scope = can_see_class_scope; - self.tables.last_mut().unwrap().add_format_parameter(); + self.add_format_parameter(); if can_see_class_scope { self.register_name("__classdict__", SymbolUsage::Used, TextRange::default())?; } @@ -1464,7 +1512,8 @@ impl SymbolTableBuilder { self.tables.last_mut().unwrap().in_unevaluated_annotation = was_in_unevaluated_annotation; if self.future_annotations { - let annotation_block = self.discard_scope(); + let mut annotation_block = self.discard_scope(); + Self::resolve_future_annotation_names_as_globals(&mut annotation_block); self.tables .last_mut() .unwrap() @@ -1657,8 +1706,8 @@ impl SymbolTableBuilder { self.in_conditional_block = false; self.class_name = Some(name.to_string()); if type_params.is_some() { - self.register_name(".type_params", SymbolUsage::Used, *range)?; self.register_name("__type_params__", SymbolUsage::Assigned, *range)?; + self.register_name(".type_params", SymbolUsage::Used, *range)?; } self.scan_statements(body)?; self.leave_scope(); @@ -1856,25 +1905,6 @@ impl SymbolTableBuilder { SymbolUsage::AnnotationAssigned, *target_range, )?; - // PEP 649: Register annotate function in module/class scope - let current_scope = self.tables.last().map(|t| t.typ); - match current_scope { - Some(CompilerScope::Module) => { - self.register_name( - "__annotate__", - SymbolUsage::Assigned, - *range, - )?; - } - Some(CompilerScope::Class) => { - self.register_name( - "__annotate_func__", - SymbolUsage::Assigned, - *range, - )?; - } - _ => {} - } } else if value.is_some() { self.register_name(id_str, SymbolUsage::Assigned, *target_range)?; } @@ -3522,6 +3552,7 @@ mod tests { let format = annotation_block .lookup(".format") .expect("missing annotation .format parameter"); + assert_eq!(annotation_block.varnames, [".format"]); assert!( format .flags @@ -3567,6 +3598,41 @@ mod tests { ); } + #[test] + fn deferred_annotation_store_names_are_not_public_symbols() { + let module = scan_source("x: int\n"); + assert!(module.lookup("__annotate__").is_none()); + assert!(module.annotation_block.is_some()); + + let module = scan_source("class C:\n y: str\n"); + let class = module + .sub_tables + .iter() + .find(|table| table.typ == CompilerScope::Class) + .expect("missing class scope"); + assert!(class.lookup("__annotate_func__").is_none()); + assert!(class.annotation_block.is_some()); + } + + #[test] + fn generic_class_symbols_follow_cpython_insertion_order() { + let module = scan_source("class C[T]:\n q = [lambda: i for i in range(2)]\n"); + let type_params = module + .sub_tables + .iter() + .find(|table| table.typ == CompilerScope::TypeParams) + .expect("missing type parameter scope"); + let class = type_params + .sub_tables + .iter() + .find(|table| table.typ == CompilerScope::Class) + .expect("missing generic class scope"); + assert_eq!( + class.symbols.keys().map(String::as_str).collect::>(), + ["__type_params__", ".type_params", "q", "range", "i"] + ); + } + #[test] fn function_signature_annotation_block_is_sibling_like_cpython() { let table = scan_source("def f(x: T): pass\n"); diff --git a/crates/codegen/src/unparse.rs b/crates/codegen/src/unparse.rs index 679560642e5..f890edf37a0 100644 --- a/crates/codegen/src/unparse.rs +++ b/crates/codegen/src/unparse.rs @@ -1,7 +1,8 @@ +use crate::strip_python_comments; use alloc::fmt; use core::fmt::Display as _; use ruff_python_ast as ast; -use ruff_text_size::Ranged; +use ruff_text_size::{Ranged, TextSize}; use rustpython_compiler_core::SourceFile; use rustpython_literal::escape::{AsciiEscape, UnicodeEscape}; @@ -610,7 +611,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { &mut self, val: &ast::Expr, debug_text: Option<&ast::DebugText>, - conversion: ast::ConversionFlag, + mut conversion: ast::ConversionFlag, spec: Option<&ast::InterpolatedStringFormatSpec>, ) -> fmt::Result { let buffered = @@ -622,6 +623,9 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { self.p(leading)?; self.p(self.source.slice(val.range()))?; self.p(trailing)?; + if conversion == ast::ConversionFlag::None && spec.is_none() { + conversion = ast::ConversionFlag::Repr; + } } let brace = if buffered.starts_with('{') { // put a space to avoid escaping the bracket @@ -709,7 +713,7 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { self.p("t")?; let body = fmt::from_fn(|f| { value.iter().try_for_each(|tstring| { - Unparser::new(f, self.source).unparse_fstring_body(&tstring.elements) + Unparser::new(f, self.source).unparse_tstring_body(&tstring.elements) }) }) .to_string(); @@ -717,6 +721,102 @@ impl<'a, 'b, 'c> Unparser<'a, 'b, 'c> { .str_repr() .write(self.f) } + + fn unparse_tstring_body(&mut self, elements: &[ast::InterpolatedStringElement]) -> fmt::Result { + for element in elements { + match element { + ast::InterpolatedStringElement::Literal(literal) => { + self.unparse_fstring_str(literal)?; + } + ast::InterpolatedStringElement::Interpolation(interpolation) => { + self.unparse_tstring_interpolation(interpolation)?; + } + } + } + Ok(()) + } + + fn unparse_tstring_interpolation( + &mut self, + interpolation: &ast::InterpolatedElement, + ) -> fmt::Result { + let source_conversion = interpolation.conversion; + let mut conversion = source_conversion; + let debug_parts = interpolation.debug_text.as_ref().map(|debug_text| { + ( + strip_python_comments(debug_text.leading.as_str()), + strip_python_comments(self.source.slice(interpolation.expression.range())), + strip_python_comments(debug_text.trailing.as_str()), + ) + }); + if let Some((leading, source, trailing)) = &debug_parts { + self.p(leading)?; + self.p(source)?; + self.p(trailing)?; + if conversion == ast::ConversionFlag::None && interpolation.format_spec.is_none() { + conversion = ast::ConversionFlag::Repr; + } + } + + let expression = if let Some(ast::ConstantValue::Str(value)) = &interpolation.runtime_str { + value.to_string() + } else if let Some((leading, source, trailing)) = &debug_parts { + let mut expression = leading.clone(); + expression.push_str(source); + let equal = trailing + .rfind('=') + .expect("debug interpolation must contain '='"); + expression.push_str(&trailing[..equal]); + expression.trim_end().to_owned() + } else { + let expression_range = interpolation.expression.range(); + let after_brace = interpolation.range.start() + TextSize::new(1); + let mut expression_end = interpolation.format_spec.as_ref().map_or_else( + || interpolation.range.end() - TextSize::new(1), + |format_spec| format_spec.range.start() - TextSize::new(1), + ); + if source_conversion != ast::ConversionFlag::None { + expression_end -= TextSize::new(2); + } + if interpolation.range.start() < expression_range.start() + && interpolation.range.end() >= expression_range.end() + && after_brace <= expression_end + { + strip_python_comments( + self.source + .slice(ruff_text_size::TextRange::new(after_brace, expression_end)), + ) + .trim_end() + .to_owned() + } else { + fmt::from_fn(|f| { + Unparser::new(f, self.source) + .unparse_expr(&interpolation.expression, precedence::TEST + 1) + }) + .to_string() + } + }; + + self.p(if expression.starts_with('{') { + "{ " + } else { + "{" + })?; + self.p(&expression)?; + + if conversion != ast::ConversionFlag::None { + self.p("!")?; + let conversion_byte = [conversion as u8]; + self.p(core::str::from_utf8(&conversion_byte).unwrap())?; + } + + if let Some(format_spec) = &interpolation.format_spec { + self.p(":")?; + self.unparse_tstring_body(&format_spec.elements)?; + } + + self.p("}") + } } pub(crate) struct UnparseExpr<'a> { diff --git a/crates/compiler-core/src/bytecode.rs b/crates/compiler-core/src/bytecode.rs index 0272270526b..f06587349b5 100644 --- a/crates/compiler-core/src/bytecode.rs +++ b/crates/compiler-core/src/bytecode.rs @@ -495,6 +495,19 @@ bitflags! { } } +impl CodeFlags { + /// The `__future__` flags that `compile()` accepts and that a compiled code + /// object inherits from its caller. Mirrors `PyCF_MASK`. + pub const FUTURE_MASK: Self = Self::FUTURE_DIVISION + .union(Self::FUTURE_ABSOLUTE_IMPORT) + .union(Self::FUTURE_WITH_STATEMENT) + .union(Self::FUTURE_PRINT_FUNCTION) + .union(Self::FUTURE_UNICODE_LITERALS) + .union(Self::FUTURE_BARRY_AS_BDFL) + .union(Self::FUTURE_GENERATOR_STOP) + .union(Self::FUTURE_ANNOTATIONS); +} + #[repr(C)] #[derive(Copy, Clone, Debug)] pub struct CodeUnit { diff --git a/crates/compiler/src/lib.rs b/crates/compiler/src/lib.rs index fc9b67614b5..f262cc2270c 100644 --- a/crates/compiler/src/lib.rs +++ b/crates/compiler/src/lib.rs @@ -1,3 +1,6 @@ +extern crate alloc; + +use alloc::borrow::Cow; pub use ruff_python_ast::token::{TokenKind, Tokens}; use ruff_python_parser::ParseErrorType; use ruff_source_file::{PositionEncoding, SourceFile, SourceFileBuilder, SourceLocation}; @@ -362,12 +365,6 @@ fn cpython_parse_diagnostic_override( )); } - // `2 <> 3` outside Barry mode: ruff lexes `<` then an unexpected `>` and - // reports `ExpectedExpression` starting at the `>`. CPython's tokenizer - // treats `<>` as a single obsolete token and points at its start (the - // `<`) instead, so shift the reported location back over it. - source_error!(barry_flufl_obsolete_operator_error(error, source_text)); - // CPython's PEG parser collapses a bare "expected an expression" failure // into the generic "invalid syntax" message. rustpython-vm's `vm_new.rs` // does this same collapse for its own callers; rustpython-compiler has no @@ -384,23 +381,6 @@ fn cpython_parse_diagnostic_override( None } -fn barry_flufl_obsolete_operator_error( - error: &parser::ParseError, - source: &str, -) -> Option<(String, usize, usize)> { - if !matches!(&error.error, parser::ParseErrorType::ExpectedExpression) { - return None; - } - let start = error.location.start().to_usize(); - if start == 0 || source.as_bytes().get(start - 1) != Some(&b'<') { - return None; - } - if source.as_bytes().get(start) != Some(&b'>') { - return None; - } - Some(("invalid syntax".to_string(), start - 1, start + 1)) -} - fn eof_parse_diagnostic( error: &parser::ParseError, source_file: &SourceFile, @@ -5258,8 +5238,18 @@ fn _compile_with_syntax_warning_handler<'a>( Mode::Single | Mode::BlockExpr => parser::Mode::Module, }; let parser_options = parser::ParseOptions::from(parser_mode); - let parsed = parser::parse(source_file.source_text(), parser_options) - .map_err(|err| CompileError::from_ruff_parse_error(err, &source_file, mode))?; + let barry_source = prepare_barry_as_flufl_source( + source_file.source_text(), + parser_options.clone(), + opts.future_features + .contains(core::bytecode::CodeFlags::FUTURE_BARRY_AS_BDFL), + ); + let parsed = parser::parse(barry_source.source(), parser_options); + if let Some(error) = barry_source.diagnostic(parsed.as_ref().err(), &source_file) { + return Err(error); + } + let parsed = + parsed.map_err(|err| CompileError::from_ruff_parse_error(err, &source_file, mode))?; if opts.dont_imply_dedent && matches!(mode, Mode::Single) && let Some(error) = dont_imply_dedent_source_error(&source_file) @@ -5287,6 +5277,186 @@ fn _compile_with_syntax_warning_handler<'a>( Ok(code) } +#[doc(hidden)] +pub struct BarrySource<'a> { + source: Cow<'a, str>, + not_equal: Option, + legacy_not_equal: Vec, +} + +impl BarrySource<'_> { + #[must_use] + pub fn source(&self) -> &str { + &self.source + } + + #[must_use] + pub fn not_equal_before( + &self, + parse_error: Option<&parser::ParseError>, + ) -> Option { + self.not_equal.filter(|range| { + parse_error.is_none_or(|error| { + let diagnostic_start = if matches!( + &error.error, + parser::ParseErrorType::Lexical(parser::LexicalErrorType::Eof) + ) { + find_unclosed_bracket(&self.source).map_or_else( + || error.location.start(), + |(_, offset)| TextSize::new(offset as u32), + ) + } else { + error.location.start() + }; + range.start() <= diagnostic_start + }) + }) + } + + /// The obsolete `<>` operator the parse error points at, if any. In Barry + /// mode the operator was rewritten to `!=`, so the error lands on its + /// start; outside Barry mode ruff lexes `<` and then an unexpected `>`, so + /// the error lands one character in. Either way the whole operator is one + /// token to the tokenizer, so report it as one. + #[must_use] + pub fn invalid_legacy_operator( + &self, + parse_error: &parser::ParseError, + ) -> Option { + let location = parse_error.location.start(); + self.legacy_not_equal + .iter() + .copied() + .find(|range| range.contains(location) || range.start() == location) + .filter(|range| self.outranks_unclosed_bracket(*range)) + } + + /// Whether `range` outranks an unclosed bracket. The bracket is reported + /// at itself, so it wins over anything that starts after it. + fn outranks_unclosed_bracket(&self, range: ruff_text_size::TextRange) -> bool { + find_unclosed_bracket(&self.source) + .is_none_or(|(_, offset)| range.start() <= TextSize::new(offset as u32)) + } + + /// The diagnostic for this source, if any: an obsolete `<>` the parse + /// error points at, or -- in Barry mode only -- the first `!=`. A `<>` + /// takes precedence over a `!=` reported later in the source. + #[must_use] + pub fn diagnostic( + &self, + parse_error: Option<&parser::ParseError>, + source_file: &SourceFile, + ) -> Option { + if let Some(range) = parse_error.and_then(|error| self.invalid_legacy_operator(error)) { + return Some(barry_as_flufl_invalid_legacy_operator_error( + source_file, + range, + )); + } + self.not_equal_before(parse_error) + .map(|range| barry_as_flufl_not_equal_error(source_file, range)) + } +} + +#[doc(hidden)] +#[must_use] +pub fn barry_as_flufl_not_equal_error( + source_file: &SourceFile, + range: ruff_text_size::TextRange, +) -> CompileError { + CompileError::from_source_error( + source_file, + "with Barry as BDFL, use '<>' instead of '!='".to_owned(), + range.start().to_usize(), + range.end().to_usize(), + ) +} + +#[doc(hidden)] +#[must_use] +pub fn barry_as_flufl_invalid_legacy_operator_error( + source_file: &SourceFile, + range: ruff_text_size::TextRange, +) -> CompileError { + CompileError::from_source_error( + source_file, + "invalid syntax".to_owned(), + range.start().to_usize(), + range.end().to_usize(), + ) +} + +/// Every `<>` in `source`, located by plain text search. Only used where the +/// operator is not rewritten, so an occurrence inside a string or a comment +/// costs nothing: it can never coincide with the location of a parse error. +fn textual_legacy_not_equal(source: &str) -> Vec { + source + .match_indices("<>") + .map(|(offset, matched)| { + ruff_text_size::TextRange::at( + TextSize::new(offset as u32), + TextSize::new(matched.len() as u32), + ) + }) + .collect() +} + +#[doc(hidden)] +pub fn prepare_barry_as_flufl_source( + source: &str, + parser_options: parser::ParseOptions, + inherited: bool, +) -> BarrySource<'_> { + let scanned = (inherited || source.contains("barry_as_FLUFL")) + .then(|| parser::parse_unchecked(source, parser_options)); + let enabled = scanned.as_ref().is_some_and(|scanned| { + inherited + || codegen::preprocess::future_features(scanned.syntax()) + .contains(core::bytecode::CodeFlags::FUTURE_BARRY_AS_BDFL) + }); + let Some(scanned) = scanned.filter(|_| enabled) else { + return BarrySource { + source: Cow::Borrowed(source), + not_equal: None, + legacy_not_equal: textual_legacy_not_equal(source), + }; + }; + + let not_equal = scanned + .tokens() + .iter() + .find(|token| token.kind() == TokenKind::NotEqual) + .map(Ranged::range); + let replacements = scanned + .tokens() + .windows(2) + .filter_map(|tokens| { + let [less, greater] = tokens else { + return None; + }; + (less.kind() == TokenKind::Less + && greater.kind() == TokenKind::Greater + && less.end() == greater.start()) + .then(|| ruff_text_size::TextRange::new(less.start(), greater.end())) + }) + .collect::>(); + + let source = if replacements.is_empty() { + Cow::Borrowed(source) + } else { + let mut rewritten = source.to_owned(); + for range in replacements.iter().rev() { + rewritten.replace_range(range.start().to_usize()..range.end().to_usize(), "!="); + } + Cow::Owned(rewritten) + }; + BarrySource { + source, + not_equal, + legacy_not_equal: replacements, + } +} + pub fn compile_with_syntax_warning_handler<'a>( source: &str, mode: Mode, @@ -5315,16 +5485,27 @@ pub fn _compile_symtable( source_file: SourceFile, mode: Mode, ) -> Result { + let parser_mode = match mode { + Mode::Exec | Mode::Single | Mode::BlockExpr => parser::Mode::Module, + Mode::Eval => parser::Mode::Expression, + }; + let parser_options = parser::ParseOptions::from(parser_mode); + let barry_source = + prepare_barry_as_flufl_source(source_file.source_text(), parser_options.clone(), false); let res = match mode { Mode::Exec | Mode::Single | Mode::BlockExpr => { - let ast = ruff_python_parser::parse_module(source_file.source_text()) - .map_err(|e| CompileError::from_ruff_parse_error(e, &source_file, mode))?; + let parsed = ruff_python_parser::parse(barry_source.source(), parser_options); + if let Some(error) = barry_source.diagnostic(parsed.as_ref().err(), &source_file) { + return Err(error); + } + let ast = + parsed.map_err(|e| CompileError::from_ruff_parse_error(e, &source_file, mode))?; if let Some(error) = post_parse_source_error(&source_file, ast.tokens(), &CompileOpts::default()) { return Err(error); } - let ast = ast.into_syntax(); + let ast = ast.into_syntax().expect_module(); if matches!(mode, Mode::Single) && let Some(error) = single_mode_body_error(&ast.body, &source_file) { @@ -5333,11 +5514,12 @@ pub fn _compile_symtable( symboltable::SymbolTable::scan_program(&ast, source_file.clone()) } Mode::Eval => { - let ast = ruff_python_parser::parse( - source_file.source_text(), - parser::Mode::Expression.into(), - ) - .map_err(|e| CompileError::from_ruff_parse_error(e, &source_file, mode))?; + let parsed = ruff_python_parser::parse(barry_source.source(), parser_options); + if let Some(error) = barry_source.diagnostic(parsed.as_ref().err(), &source_file) { + return Err(error); + } + let ast = + parsed.map_err(|e| CompileError::from_ruff_parse_error(e, &source_file, mode))?; if let Some(error) = post_parse_source_error(&source_file, ast.tokens(), &CompileOpts::default()) { @@ -5378,6 +5560,120 @@ mod tests { compile(code, Mode::Single, "<>", CompileOpts::default()).expect("compile error"); } + #[test] + fn barry_as_flufl_rewrites_legacy_not_equal_after_future_import() { + let code = compile( + "from __future__ import barry_as_FLUFL\nresult = 2 <> 3\n", + Mode::Exec, + "", + CompileOpts::default(), + ) + .expect("Barry comparison should compile"); + assert!( + code.flags + .contains(core::bytecode::CodeFlags::FUTURE_BARRY_AS_BDFL) + ); + } + + #[test] + fn inherited_barry_as_flufl_rewrites_legacy_not_equal() { + let opts = CompileOpts { + future_features: core::bytecode::CodeFlags::FUTURE_BARRY_AS_BDFL, + ..CompileOpts::default() + }; + compile("2 <> 3", Mode::Single, "", opts) + .expect("inherited Barry comparison should compile"); + } + + #[test] + fn barry_as_flufl_rejects_modern_not_equal() { + let err = compile( + "from __future__ import barry_as_FLUFL\n2 != 3\n", + Mode::Exec, + "", + CompileOpts::default(), + ) + .expect_err("Barry mode should reject !="); + assert_eq!( + err.to_string(), + "with Barry as BDFL, use '<>' instead of '!='" + ); + assert_eq!(err.python_location(), (2, 3)); + } + + #[test] + fn obsolete_not_equal_diagnostic_spans_the_whole_operator() { + let err = compile("2 <> 3\n", Mode::Exec, "", CompileOpts::default()) + .expect_err("'<>' outside Barry mode is a syntax error"); + assert_eq!(err.to_string(), "invalid syntax"); + assert_eq!(err.python_location(), (1, 3)); + assert_eq!(err.python_end_location(), Some((1, 5))); + + // Only `<>` spans two characters; any other token that cannot start an + // expression keeps its own location. + let err = compile("2 <;\n", Mode::Exec, "", CompileOpts::default()) + .expect_err("'<;' is a syntax error"); + assert_eq!(err.to_string(), "invalid syntax"); + assert_eq!(err.python_location(), (1, 4)); + assert_eq!(err.python_end_location(), Some((1, 5))); + + // A `<>` that starts a statement is reported at the `<` too, where the + // parser stops instead of one character in. + let err = compile("<>\n", Mode::Exec, "", CompileOpts::default()) + .expect_err("a bare '<>' is a syntax error"); + assert_eq!(err.to_string(), "invalid syntax"); + assert_eq!(err.python_location(), (1, 1)); + assert_eq!(err.python_end_location(), Some((1, 3))); + + // A bracket left open earlier in the source outranks the operator. + let err = compile( + "(\n2 <> 3", + Mode::Exec, + "", + CompileOpts::default(), + ) + .expect_err("the bracket is never closed"); + assert_eq!(err.to_string(), "'(' was never closed"); + assert_eq!(err.python_location(), (1, 1)); + } + + #[test] + fn barry_as_flufl_does_not_rewrite_strings_or_comments() { + compile( + "from __future__ import barry_as_FLUFL\nx = '<>'\n# <>\n", + Mode::Exec, + "", + CompileOpts::default(), + ) + .expect("Barry markers in strings and comments should stay untouched"); + } + + #[test] + fn syntax_error_before_barry_not_equal_takes_precedence() { + let err = compile( + "from __future__ import barry_as_FLUFL\n<>\n2 != 3\n", + Mode::Exec, + "", + CompileOpts::default(), + ) + .expect_err("the earlier invalid comparison should fail"); + assert_eq!(err.to_string(), "invalid syntax"); + assert_eq!(err.python_location(), (2, 1)); + } + + #[test] + fn unclosed_bracket_before_barry_not_equal_takes_precedence() { + let err = compile( + "from __future__ import barry_as_FLUFL\n(\n2 != 3", + Mode::Exec, + "", + CompileOpts::default(), + ) + .expect_err("the earlier unclosed bracket should fail"); + assert_eq!(err.to_string(), "'(' was never closed"); + assert_eq!(err.python_location(), (2, 1)); + } + #[test] fn compile_phello() { let code = r#" diff --git a/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__bare_function_annotations_check_attribute_and_subscript_expressions.snap b/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__bare_function_annotations_check_attribute_and_subscript_expressions.snap index 4d78128b5e6..d7ca680d9c1 100644 --- a/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__bare_function_annotations_check_attribute_and_subscript_expressions.snap +++ b/crates/stdlib/src/snapshots/rustpython_stdlib___opcode__tests__bare_function_annotations_check_attribute_and_subscript_expressions.snap @@ -16,7 +16,7 @@ expression: "dis(r#\"\ndef f(one: int):\n int.new_attr: int\n [list][0].ne Disassembly of ", line 1>: 1 RESUME 0 - LOAD_FAST_CHECK 0 (format) + LOAD_FAST_BORROW 0 (format) LOAD_SMALL_INT 2 COMPARE_OP 132 (>) POP_JUMP_IF_FALSE 3 (to L1) diff --git a/crates/vm/src/object/core.rs b/crates/vm/src/object/core.rs index 5534666da5f..02ffe2c7e5e 100644 --- a/crates/vm/src/object/core.rs +++ b/crates/vm/src/object/core.rs @@ -434,6 +434,17 @@ pub(crate) const SIZEOF_PYOBJECT_HEAD: usize = core::mem::size_of::> // this holds. const _: () = assert!(SIZEOF_PYOBJECT_HEAD == 5 * core::mem::size_of::() + 8); +// `PyInner::drop_fields` names `payload` and `typ`; it is only complete while +// every other field stays trivially destructible. +const _: () = assert!( + !core::mem::needs_drop::() + && !core::mem::needs_drop::<&'static PyObjVTable>() + && !core::mem::needs_drop::>() + && !core::mem::needs_drop::>() + && !core::mem::needs_drop::>() + && !core::mem::needs_drop::>() +); + impl PyInner { /// Read type flags and member_count via raw pointers to avoid Stacked Borrows /// violations during bootstrap, where type objects have self-referential typ pointers. @@ -1124,6 +1135,20 @@ impl InstanceDict { } impl PyInner { + /// Run the destructors of the fields that have one, payload first. + /// + /// Declaration order would drop `typ` first, and `PyAtomicRef::drop` + /// leaves it null. A weakref payload is still linked into the list of the + /// object it points at until its own `Drop` unlinks it, and a thread + /// walking that list reads the class off every node it passes, so the + /// class has to outlive the payload. + unsafe fn drop_fields(ptr: *mut Self) { + unsafe { + core::ptr::drop_in_place(&raw mut (*ptr).payload); + core::ptr::drop_in_place(&raw mut (*ptr).typ); + } + } + /// Deallocate a PyInner, handling optional prefix(es). /// Layout: [ObjExt?][WeakRefList?][PyInner] /// @@ -1161,8 +1186,7 @@ impl PyInner { let alloc_ptr = (ptr as *mut u8).sub(inner_offset); - // Drop PyInner (payload, typ, etc.) - core::ptr::drop_in_place(ptr); + Self::drop_fields(ptr); // Drop ObjExt if present (dict, slots) if has_ext { @@ -1177,10 +1201,13 @@ impl PyInner { } } else if published { let layout = core::alloc::Layout::new::(); - core::ptr::drop_in_place(ptr); + Self::drop_fields(ptr); crate::object::qsbr::free_delayed(ptr as *mut u8, layout); } else { - drop(Box::from_raw(ptr)); + Self::drop_fields(ptr); + // The fields are gone; the box is only here to free the memory + // the matching `Box::new` in `new` allocated. + drop(Box::from_raw(ptr.cast::>())); } } } @@ -2907,4 +2934,53 @@ mod tests { let obj = ctx.new_bytes(b"dfghjkl".to_vec()); drop(obj); } + + /// A weakref node stays linked into its target's list until its own + /// `Drop` unlinks it, and `WeakRefList::add` reads the class off every + /// node it walks looking for a proxy to reuse. A node that lost its class + /// while still linked made that walk dereference a null type pointer. + #[cfg(feature = "threading")] + #[test] + fn weakref_proxies_keep_their_class_while_linked() { + const THREADS: usize = 8; + const ROUNDS: usize = 20_000; + + crate::Interpreter::without_stdlib(Default::default()).enter(|vm| { + let target: PyObjectRef = vm + .ctx + .new_class( + None, + "WeakrefTarget", + vm.ctx.types.object_type.to_owned(), + Default::default(), + ) + .into(); + let workers = (0..THREADS) + .map(|_| { + let thread_vm = vm.new_thread(); + let target = target.clone(); + std::thread::spawn(move || { + thread_vm.run(|vm| { + let proxy_type = vm.ctx.types.weakproxy_type.to_owned(); + for _ in 0..ROUNDS { + let proxy = target + .downgrade_with_typ(None, proxy_type.clone(), vm) + .expect("a type object takes weakrefs"); + drop(proxy); + vm.check_signals().unwrap(); + } + }) + }) + }) + .collect::>(); + // Detach while joining: a thread that blocks attached never + // reaches a safepoint, so a collection started by a worker could + // not finish. + vm.allow_threads(|| { + for worker in workers { + worker.join().unwrap(); + } + }); + }); + } } diff --git a/crates/vm/src/stdlib/_ast.rs b/crates/vm/src/stdlib/_ast.rs index b36277f5456..5cac1576676 100644 --- a/crates/vm/src/stdlib/_ast.rs +++ b/crates/vm/src/stdlib/_ast.rs @@ -1803,7 +1803,7 @@ pub(crate) fn parse( type_comments: bool, optimized_ast: bool, interactive: bool, - explicit_future_annotations: bool, + explicit_future_features: crate::bytecode::CodeFlags, dont_imply_dedent: bool, ) -> Result { let source_file = SourceFileBuilder::new("".to_owned(), source.to_owned()).finish(); @@ -1813,7 +1813,12 @@ pub(crate) fn parse( return Err(error); } options = options.with_target_version(target_version); - let parsed = parser::parse_unchecked(source, options); + let barry_source = rustpython_compiler::prepare_barry_as_flufl_source( + source, + options.clone(), + explicit_future_features.contains(crate::bytecode::CodeFlags::FUTURE_BARRY_AS_BDFL), + ); + let parsed = parser::parse_unchecked(barry_source.source(), options); let type_comment_source = type_comments.then(|| TypeCommentSource::new(source, parsed.tokens())); if let Some(lines) = &type_comment_source @@ -1823,6 +1828,9 @@ pub(crate) fn parse( } if let Err(errors) = parsed.as_result() { let parse_error = errors[0].clone(); + if let Some(error) = barry_source.diagnostic(Some(&parse_error), &source_file) { + return Err(error); + } let range = text_range_to_source_range(&source_file, parse_error.location); return Err(ParseError { error: parse_error.error, @@ -1834,6 +1842,9 @@ pub(crate) fn parse( } .into()); } + if let Some(error) = barry_source.diagnostic(None, &source_file) { + return Err(error); + } if dont_imply_dedent && interactive && let Some(error) = rustpython_compiler::dont_imply_dedent_source_error(&source_file) @@ -1880,7 +1891,8 @@ pub(crate) fn parse( { let future_features = codegen::preprocess::checked_future_features(&top) .map_err(|err| future_feature_compile_error(&source_file, err))?; - let future_annotations = explicit_future_annotations + let future_annotations = explicit_future_features + .contains(crate::bytecode::CodeFlags::FUTURE_ANNOTATIONS) || future_features.contains(crate::bytecode::CodeFlags::FUTURE_ANNOTATIONS); if interactive && let ast::Mod::Module(module) = &mut top { codegen::preprocess::preprocess_statements( diff --git a/crates/vm/src/stdlib/_symtable.rs b/crates/vm/src/stdlib/_symtable.rs index eb0ecaa87d7..cf3fa8bafc8 100644 --- a/crates/vm/src/stdlib/_symtable.rs +++ b/crates/vm/src/stdlib/_symtable.rs @@ -3,9 +3,10 @@ pub(crate) use _symtable::module_def; #[pymodule] mod _symtable { use crate::{ - Py, PyObjectRef, PyPayload, PyRef, PyResult, VirtualMachine, - builtins::{PyDictRef, PyUtf8StrRef}, + AsObject, Py, PyPayload, PyRef, PyResult, VirtualMachine, + builtins::{PyBaseExceptionRef, PyDictRef, PyListRef, PyStrRef, PyUtf8StrRef}, compiler, + function::{ArgStrOrBytesLike, FsPath}, types::Representable, }; use alloc::fmt; @@ -97,8 +98,8 @@ mod _symtable { #[pyfunction] fn symtable( - source: PyUtf8StrRef, - filename: PyUtf8StrRef, + source: ArgStrOrBytesLike, + filename: FsPath, mode: PyUtf8StrRef, vm: &VirtualMachine, ) -> PyResult> { @@ -107,15 +108,97 @@ mod _symtable { .parse::() .map_err(|err| vm.new_value_error(err.to_string()))?; - let symtable = compiler::compile_symtable(source.as_str(), mode, filename.as_str()) - .map_err(|err| vm.new_syntax_error(&err, Some(source.as_str())))?; + let filename_obj = match &filename { + FsPath::Str(filename) => filename.clone(), + FsPath::Bytes(filename) => { + let filename = FsPath::bytes_as_os_str(filename.as_bytes(), vm)?.to_owned(); + vm.fsdecode(filename) + } + }; + let filename = filename_obj.to_string_lossy(); + let source = match &source { + ArgStrOrBytesLike::Str(source) => source.try_as_utf8(vm)?.as_str().to_owned(), + ArgStrOrBytesLike::Buf(source) => vm + .decode_source_bytes(&source.borrow_buf(), &filename, false) + .map_err(|err| set_syntax_error_filename(err, &filename_obj, vm))?, + }; + if source.as_bytes().contains(&0) { + return Err(vm.new_exception_msg( + vm.ctx.exceptions.syntax_error.to_owned(), + "source code string cannot contain null bytes".into(), + )); + } + let symtable = compiler::compile_symtable(&source, mode, &filename).map_err(|err| { + let err = vm.new_syntax_error(&err, Some(&source)); + set_syntax_error_filename(err, &filename_obj, vm) + })?; + + Ok(to_py_symbol_table(symtable, vm)) + } + + fn set_syntax_error_filename( + err: PyBaseExceptionRef, + filename: &PyStrRef, + vm: &VirtualMachine, + ) -> PyBaseExceptionRef { + if err.fast_isinstance(vm.ctx.exceptions.syntax_error) { + err.as_object() + .set_attr("filename", filename.clone(), vm) + .unwrap(); + } + err + } - let py_symbol_table = to_py_symbol_table(symtable); - Ok(py_symbol_table.into_ref(&vm.ctx)) + fn append_visible_child(table: SymbolTable, children: &mut Vec) { + if table.comp_inlined { + for child in table.sub_tables { + append_visible_child(child, children); + } + } else { + children.push(table); + } } - const fn to_py_symbol_table(symtable: SymbolTable) -> PySymbolTable { - PySymbolTable { symtable } + fn to_py_symbol_table(mut symtable: SymbolTable, vm: &VirtualMachine) -> PyRef { + let mut child_tables = Vec::new(); + for table in core::mem::take(&mut symtable.sub_tables) { + append_visible_child(table, &mut child_tables); + } + if !symtable.future_annotations + && let Some(annotation_block) = symtable.annotation_block.take() + { + child_tables.push(*annotation_block); + } + child_tables.sort_by_key(|table| table.block_index); + + let children = vm.ctx.new_list( + child_tables + .into_iter() + .map(|table| to_py_symbol_table(table, vm).into()) + .collect(), + ); + let symbols = vm.ctx.new_dict(); + for (name, symbol) in &symtable.symbols { + let packed_flags = + i32::from(symbol.flags.bits()) | (symbol.scope.as_i32() << SCOPE_OFFSET); + symbols + .set_item(name, vm.new_pyobj(packed_flags), vm) + .unwrap(); + } + let varnames = vm.ctx.new_list( + symtable + .varnames + .iter() + .map(|name| vm.ctx.new_str(name.as_str()).into()) + .collect(), + ); + PySymbolTable { + symtable, + children, + symbols, + varnames, + } + .into_ref(&vm.ctx) } #[pyattr] @@ -123,6 +206,9 @@ mod _symtable { #[derive(PyPayload)] struct PySymbolTable { symtable: SymbolTable, + children: PyListRef, + symbols: PyDictRef, + varnames: PyListRef, } impl fmt::Debug for PySymbolTable { @@ -160,20 +246,8 @@ mod _symtable { } #[pygetset] - fn children(&self, vm: &VirtualMachine) -> Vec { - self.symtable - .sub_tables - .iter() - .flat_map(|t| { - if t.comp_inlined { - // Flatten: replace inlined comprehension tables with their children - t.sub_tables.iter().collect::>() - } else { - vec![t] - } - }) - .map(|t| to_py_symbol_table(t.clone()).into_pyobject(vm)) - .collect() + fn children(&self) -> PyListRef { + self.children.clone() } #[pygetset] @@ -182,14 +256,13 @@ mod _symtable { } #[pygetset] - fn symbols(&self, vm: &VirtualMachine) -> PyDictRef { - let dict = vm.ctx.new_dict(); - for (name, symbol) in &self.symtable.symbols { - let packed_flags = - i32::from(symbol.flags.bits()) | (symbol.scope.as_i32() << SCOPE_OFFSET); - dict.set_item(name, vm.new_pyobj(packed_flags), vm).unwrap(); - } - dict + fn symbols(&self) -> PyDictRef { + self.symbols.clone() + } + + #[pygetset] + fn varnames(&self) -> PyListRef { + self.varnames.clone() } #[pygetset] diff --git a/crates/vm/src/stdlib/builtins.rs b/crates/vm/src/stdlib/builtins.rs index c3eb200af6d..75d84abfb70 100644 --- a/crates/vm/src/stdlib/builtins.rs +++ b/crates/vm/src/stdlib/builtins.rs @@ -32,7 +32,7 @@ mod builtins { types::PyComparisonOp, vm::compile_mode::{ CompilerFlags, PY_EVAL_INPUT, PY_FILE_INPUT, PY_FUNC_TYPE_INPUT, PY_SINGLE_INPUT, - compile_future_feature_mask, compile_future_features_from_flags, + compile_future_features_from_flags, }, }; use itertools::Itertools; @@ -130,9 +130,7 @@ mod builtins { ) -> bytecode::CodeFlags { let mut future_features = compile_future_features_from_flags(flags); if !dont_inherit && let Some(code) = crate::frame::current_code() { - future_features |= bytecode::CodeFlags::from_bits_truncate( - code.flags.bits() & compile_future_feature_mask().bits(), - ); + future_features |= code.flags & bytecode::CodeFlags::FUTURE_MASK; } future_features } @@ -653,9 +651,7 @@ mod builtins { let source = string.as_str(); let mut opts = vm.compile_opts(); if let Some(code) = crate::frame::current_code() { - opts.future_features = bytecode::CodeFlags::from_bits_truncate( - code.flags.bits() & compile_future_feature_mask().bits(), - ); + opts.future_features = code.flags & bytecode::CodeFlags::FUTURE_MASK; } vm.compile_with_opts(source, mode, "", opts) .map_err(|err| err.into_pyexception(vm, Some(source)))? diff --git a/crates/vm/src/vm/compile.rs b/crates/vm/src/vm/compile.rs index 2beaf24f07c..9b6c727f0ef 100644 --- a/crates/vm/src/vm/compile.rs +++ b/crates/vm/src/vm/compile.rs @@ -278,8 +278,6 @@ impl VirtualMachine { let is_ast_only = cf.contains(CompilerFlags::ONLY_AST); let optimized_ast = cf.contains(CompilerFlags::OPTIMIZED_AST); let future_features = compile_future_features_from_flags(flags); - let explicit_future_annotations = - future_features.contains(crate::bytecode::CodeFlags::FUTURE_ANNOTATIONS); let target_version = if is_ast_only { Some(ruff_python_ast::PythonVersion { major: 3, @@ -313,7 +311,7 @@ impl VirtualMachine { type_comments, optimized_ast, interactive, - explicit_future_annotations, + future_features, dont_imply_dedent, ) .map_err(|e| (e, Some(source), allow_incomplete).to_pyexception(self))?; @@ -342,7 +340,7 @@ impl VirtualMachine { type_comments, false, start == PY_SINGLE_INPUT, - explicit_future_annotations, + future_features, dont_imply_dedent, ) .map_err(|e| (e, Some(source), allow_incomplete).to_pyexception(self))?; @@ -1116,6 +1114,58 @@ mod escape_warnings { }) } + #[test] + fn ast_only_compile_honors_barry_as_flufl() { + Interpreter::without_stdlib(Default::default()).enter(|vm| { + let flags = CompilerFlags::ONLY_AST.bits(); + vm.compile_string_object_with_flags( + b"from __future__ import barry_as_FLUFL\n2 <> 3\n", + "", + PY_FILE_INPUT, + flags, + -1, + -1, + ) + .expect("PyCF_ONLY_AST should accept <> in Barry mode"); + + let err = vm + .compile_string_object_with_flags( + b"from __future__ import barry_as_FLUFL\n2 != 3\n", + "", + PY_FILE_INPUT, + flags, + -1, + -1, + ) + .expect_err("PyCF_ONLY_AST should reject != in Barry mode"); + assert!( + err.as_object() + .str(vm) + .unwrap() + .as_wtf8() + .to_string() + .contains("with Barry as BDFL") + ); + }); + } + + #[test] + fn type_comment_preparse_honors_inherited_barry_as_flufl() { + Interpreter::without_stdlib(Default::default()).enter(|vm| { + let flags = CompilerFlags::TYPE_COMMENTS.bits() + | crate::bytecode::CodeFlags::FUTURE_BARRY_AS_BDFL.bits() as i32; + vm.compile_string_object_with_flags( + b"2 <> 3\n", + "", + PY_FILE_INPUT, + flags, + -1, + -1, + ) + .expect("type-comment preparse should accept <> in inherited Barry mode"); + }); + } + #[test] fn codegen_caller_warning_precedes_later_return_error() { let message = compile_error_message("(1)()\nreturn\n"); diff --git a/crates/vm/src/vm/compile_mode.rs b/crates/vm/src/vm/compile_mode.rs index 9885ba2e1f7..6da531bbc1f 100644 --- a/crates/vm/src/vm/compile_mode.rs +++ b/crates/vm/src/vm/compile_mode.rs @@ -67,17 +67,6 @@ pub(crate) const PY_CF_ALLOW_TOP_LEVEL_AWAIT: i32 = CompilerFlags::ALLOW_TOP_LEV pub(crate) const PY_CF_ALLOW_INCOMPLETE_INPUT: i32 = CompilerFlags::ALLOW_INCOMPLETE_INPUT.bits(); pub(crate) const PY_CF_OPTIMIZED_AST: i32 = CompilerFlags::OPTIMIZED_AST.bits(); -pub(crate) fn compile_future_feature_mask() -> bytecode::CodeFlags { - // RustPython accepts barry_as_FLUFL but leaves its parser mode disabled. - bytecode::CodeFlags::FUTURE_DIVISION - | bytecode::CodeFlags::FUTURE_ABSOLUTE_IMPORT - | bytecode::CodeFlags::FUTURE_WITH_STATEMENT - | bytecode::CodeFlags::FUTURE_PRINT_FUNCTION - | bytecode::CodeFlags::FUTURE_UNICODE_LITERALS - | bytecode::CodeFlags::FUTURE_GENERATOR_STOP - | bytecode::CodeFlags::FUTURE_ANNOTATIONS -} - pub(crate) fn compile_future_features_from_flags(flags: i32) -> bytecode::CodeFlags { - bytecode::CodeFlags::from_bits_truncate(flags as u32 & compile_future_feature_mask().bits()) + bytecode::CodeFlags::from_bits_truncate(flags as u32 & bytecode::CodeFlags::FUTURE_MASK.bits()) } diff --git a/crates/vm/src/vm/interpreter.rs b/crates/vm/src/vm/interpreter.rs index c538eb32ec8..6d4e1f75a22 100644 --- a/crates/vm/src/vm/interpreter.rs +++ b/crates/vm/src/vm/interpreter.rs @@ -1095,11 +1095,22 @@ mod tests { ); let (lock, ready) = &*state; - let mut state = lock.lock().unwrap(); - state.entered += 1; - ready.notify_all(); - while !state.release { - state = ready.wait(state).unwrap(); + { + let mut state = lock.lock().unwrap(); + state.entered += 1; + ready.notify_all(); + } + // Wait attached, but keep passing safepoints: a thread + // that blocks outright while attached never suspends, + // so a concurrent stop-the-world could not finish and + // the other worker could never attach. + loop { + vm.check_signals().unwrap(); + let state = lock.lock().unwrap(); + if state.release { + break; + } + let _ = ready.wait_timeout(state, Duration::from_millis(1)).unwrap(); } }); }) @@ -1165,6 +1176,11 @@ mod tests { let result = vm._add(&a, &b).unwrap(); assert_eq!(*int::get_value(&result), 42_i32.to_bigint().unwrap()); operations += 1; + // The protocol calls above never reach a safepoint on + // their own; a bytecode loop would. Without this, a + // concurrent stop-the-world could not finish while this + // thread stays attached. + vm.check_signals().unwrap(); std::thread::yield_now(); } (sub_finished_worker.load(Ordering::Acquire), operations) diff --git a/crates/wasm/src/vm_class.rs b/crates/wasm/src/vm_class.rs index 80f3ece1358..6780d4d1bc6 100644 --- a/crates/wasm/src/vm_class.rs +++ b/crates/wasm/src/vm_class.rs @@ -10,6 +10,7 @@ use ruff_text_size::Ranged; use rustpython_vm::{ Interpreter, PyObjectRef, PyRef, PyResult, Settings, VirtualMachine, builtins::PyWeak, + bytecode::CodeFlags, compiler::{self, Mode}, function::ArgMapping, scope::Scope, @@ -24,6 +25,7 @@ pub(crate) struct StoredVirtualMachine { /// you can put a Rc in here, keep it as a Weak, and it'll be held only for /// as long as the StoredVM is alive held_objects: RefCell>, + future_features: RefCell, } fn compile_err_to_js(vm: &VirtualMachine, err: VmCompileError) -> JsValue { @@ -33,8 +35,17 @@ fn compile_err_to_js(vm: &VirtualMachine, err: VmCompileError) -> JsValue { } } -fn statement_chunks(source: &str) -> Option> { - let module = compiler::parser::parse_module(source).ok()?.into_syntax(); +fn statement_chunks(source: &str, future_features: CodeFlags) -> Option> { + let parser_options = compiler::parser::ParseOptions::from(compiler::parser::Mode::Module); + let prepared = compiler::prepare_barry_as_flufl_source( + source, + parser_options.clone(), + future_features.contains(CodeFlags::FUTURE_BARRY_AS_BDFL), + ); + let module = compiler::parser::parse(prepared.source(), parser_options) + .ok()? + .into_syntax() + .expect_module(); module .body .iter() @@ -99,6 +110,7 @@ impl StoredVirtualMachine { interp, scope, held_objects: RefCell::new(Vec::new()), + future_features: RefCell::new(CodeFlags::empty()), } } } @@ -394,11 +406,24 @@ impl WASMVirtualMachine { source: &str, source_path: Option, ) -> Result { - self.with_vm(|vm, StoredVirtualMachine { scope, .. }| { + self.with_vm(|vm, stored| { + let scope = &stored.scope; let source_path = source_path.unwrap_or_else(|| "".to_owned()); - let Some(chunks) = statement_chunks(source) else { - let code = vm.compile(source, Mode::Single, source_path.as_str()); - let code = code.map_err(|err| compile_err_to_js(vm, err))?; + let compile = |source: &str, mode: Mode| -> Result<_, JsValue> { + let future_features = *stored.future_features.borrow(); + let opts = compiler::CompileOpts { + future_features, + ..vm.compile_opts() + }; + let code = vm + .compile_with_opts(source, mode, source_path.as_str(), opts) + .map_err(|err| compile_err_to_js(vm, err))?; + *stored.future_features.borrow_mut() |= code.code.flags & CodeFlags::FUTURE_MASK; + Ok(code) + }; + let future_features = *stored.future_features.borrow(); + let Some(chunks) = statement_chunks(source, future_features) else { + let code = compile(source, Mode::Single)?; let result = vm.run_code_obj(code, scope.clone()); return convert::pyresult_to_js_result(vm, result); }; @@ -413,8 +438,7 @@ impl WASMVirtualMachine { .map_err(|_| TypeError::new("lost sys.displayhook"))?; let mut result = vm.ctx.none(); for chunk in chunks { - let code = vm.compile(chunk, Mode::BlockExpr, source_path.as_str()); - let code = code.map_err(|err| compile_err_to_js(vm, err))?; + let code = compile(chunk, Mode::BlockExpr)?; result = vm.run_code_obj(code, scope.clone()).into_js(vm)?; displayhook.call((result.clone(),), vm).into_js(vm)?; } diff --git a/extra_tests/snippets/builtin_compile.py b/extra_tests/snippets/builtin_compile.py index 73247e50df1..ff11bbda2d8 100644 --- a/extra_tests/snippets/builtin_compile.py +++ b/extra_tests/snippets/builtin_compile.py @@ -1,7 +1,6 @@ import __future__ import ast -import sys from testutils import assert_raises @@ -66,8 +65,7 @@ def _check_flags_error(flags): barry_flag = __future__.barry_as_FLUFL.compiler_flag barry_code = compile("x = 1", "", "exec", flags=barry_flag) compile("from __future__ import barry_as_FLUFL\nx = 1\n", "", "exec") -if sys.implementation.name == "rustpython": - assert not (barry_code.co_flags & barry_flag) +assert barry_code.co_flags & barry_flag n = ast.parse('x = "# type: int"\n', type_comments=True) assert n.body[0].type_comment is None diff --git a/src/shell.rs b/src/shell.rs index 7fb9336af4b..d99d38765e0 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -7,6 +7,7 @@ use rustpython_compiler::{ use rustpython_vm::{ AsObject, PyResult, VirtualMachine, builtins::PyBaseExceptionRef, + bytecode::CodeFlags, compiler::{self}, readline::{Readline, ReadlineResult}, scope::Scope, @@ -26,6 +27,7 @@ fn shell_exec( scope: Scope, empty_line_given: bool, continuing_block: bool, + future_features: &mut CodeFlags, ) -> ShellExecResult { // compiling expects only UNIX style line endings, and will replace windows line endings // internally. Since we might need to analyze the source to determine if an error could be @@ -33,8 +35,13 @@ fn shell_exec( // was actually compiled. #[cfg(windows)] let source = &source.replace("\r\n", "\n"); - match vm.compile(source, compiler::Mode::Single, "") { + let opts = compiler::CompileOpts { + future_features: *future_features, + ..vm.compile_opts() + }; + match vm.compile_with_opts(source, compiler::Mode::Single, "", opts) { Ok(code) => { + *future_features |= code.code.flags & CodeFlags::FUTURE_MASK; if empty_line_given || !continuing_block { // We want to execute the full code match vm.run_code_obj(code, scope) { @@ -131,6 +138,7 @@ pub fn run_shell(vm: &VirtualMachine, scope: Scope) -> PyResult<()> { // valid. let mut continuing_block = false; let mut continuing_line = false; + let mut future_features = CodeFlags::empty(); loop { let prompt_name = if continuing_block || continuing_line { @@ -170,6 +178,7 @@ pub fn run_shell(vm: &VirtualMachine, scope: Scope) -> PyResult<()> { scope.clone(), empty_line_given, continuing_block, + &mut future_features, ) { ShellExecResult::Ok => { if continuing_block { From 95dbc128fb03c24bbe799958da371570e6cd1ab7 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:34:56 +0900 Subject: [PATCH 333/351] clarify AI policy checkbox Updated the pull request template to clarify issue closing and AI usage. --- .github/PULL_REQUEST_TEMPLATE.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 18ba1b6951f..51c6dd747a8 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,7 +2,10 @@ Thanks for your contribution! --> -- [ ] Closes #xxxx +- [ ] Closes #xxxx + +One of checkbox below must be checked. +- [ ] I did not use AI to write the code of this patch. - [ ] This PR follows our [AI policy](https://github.com/RustPython/.github/blob/main/AI_POLICY.md) ## Summary From 02a80ea3adb1548e083c25803ac00c6a99748a0b Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 21 Aug 2026 11:36:32 +0500 Subject: [PATCH 334/351] `str.format`: fix not raising for unknown conversions (#8560) * `str.format`: fix not raising for unknown conversions * Move test to Python, test error msg too --- crates/vm/src/format.rs | 21 ++++++++++++++------- extra_tests/snippets/builtin_format.py | 10 ++++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/crates/vm/src/format.rs b/crates/vm/src/format.rs index 657601e1470..4099cbd9b0f 100644 --- a/crates/vm/src/format.rs +++ b/crates/vm/src/format.rs @@ -143,14 +143,21 @@ fn format_internal( FormatString::from_str(format_spec).map_err(|e| e.to_pyexception(vm))?; let format_spec = format_internal(vm, &nested_format, field_func)?; - let argument = match conversion_spec.and_then(FormatConversion::from_char) { - Some(FormatConversion::Str) => argument.str(vm)?.into(), - Some(FormatConversion::Repr) => argument.repr(vm)?.into(), - Some(FormatConversion::Ascii) => builtins::ascii(argument, vm)?.into(), - Some(FormatConversion::Bytes) => { - vm.call_method(&argument, identifier!(vm, decode).as_str(), ())? - } + let argument = match conversion_spec { None => argument, + Some(c) => match FormatConversion::from_char(*c) { + Some(FormatConversion::Str) => argument.str(vm)?.into(), + Some(FormatConversion::Repr) => argument.repr(vm)?.into(), + Some(FormatConversion::Ascii) => builtins::ascii(argument, vm)?.into(), + Some(FormatConversion::Bytes) => { + vm.call_method(&argument, identifier!(vm, decode).as_str(), ())? + } + None => { + return Err( + vm.new_value_error(format!("Unknown conversion specifier {c}")) + ); + } + }, }; // FIXME: compiler can intern specs using parser tree. Then this call can be interned_str diff --git a/extra_tests/snippets/builtin_format.py b/extra_tests/snippets/builtin_format.py index c2e2a897470..8ec8f3d5c2c 100644 --- a/extra_tests/snippets/builtin_format.py +++ b/extra_tests/snippets/builtin_format.py @@ -32,6 +32,16 @@ def test_zero_padding(): else: raise AssertionError("expected ValueError for '=8s' string format specifier") +# regression: unknown conversion specifiers used to be silently ignored instead of raising. +# The ValueError case itself is covered by test_str, but here we're testing the error message. +try: + "{0!x}".format(3) +except ValueError as error: + if str(error) != "Unknown conversion specifier x": + raise AssertionError(f"unexpected error message: {error}") from error +else: + raise AssertionError("expected ValueError for unknown conversion specifier '!x'") + assert "{:,}".format(100) == "100" assert "{:,}".format(1024) == "1,024" assert "{:_}".format(65536) == "65_536" From dbf6e6d04861adaaaaa2f5614651c328c4fbd847 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:37:08 -0300 Subject: [PATCH 335/351] Fix str.replace with an empty pattern splitting characters (#8561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wtf8::replace looks for the pattern with a byte search. An empty pattern matches at every byte, so the replacement was inserted between the bytes of a multi-byte character and the result was no longer WTF-8: >>> "á".replace("", "-") '-ím' # CPython: '-á-' from_bytes_unchecked then took that as valid without looking, so every later read of the string returned something else. An empty pattern now walks code points instead. A pattern that is not empty stays on the byte search, which is safe because a WTF-8 sequence never starts inside another one. Assisted-by: Claude Code:claude-opus-5 --- crates/wtf8/src/lib.rs | 89 +++++++++++++++++++++++++++++ extra_tests/snippets/builtin_str.py | 36 ++++++++++++ 2 files changed, 125 insertions(+) diff --git a/crates/wtf8/src/lib.rs b/crates/wtf8/src/lib.rs index 2167b6b04c6..7625e6f189a 100644 --- a/crates/wtf8/src/lib.rs +++ b/crates/wtf8/src/lib.rs @@ -1175,14 +1175,47 @@ impl Wtf8 { } pub fn replace(&self, from: &Wtf8, to: &Wtf8) -> Wtf8Buf { + if from.is_empty() { + return self.insert_at_boundaries(to, usize::MAX); + } let w = self.bytes.replace(from, to); unsafe { Wtf8Buf::from_bytes_unchecked(w) } } pub fn replacen(&self, from: &Wtf8, to: &Wtf8, n: usize) -> Wtf8Buf { + if from.is_empty() { + return self.insert_at_boundaries(to, n); + } let w = self.bytes.replacen(from, to, n); unsafe { Wtf8Buf::from_bytes_unchecked(w) } } + + /// Inserts `to` before every code point and once after the last one, + /// stopping after `limit` insertions. + /// + /// This is what an empty needle asks for. It cannot go through the byte + /// search the other cases use: that one matches an empty needle at every + /// *byte*, so it splits a multi-byte code point down the middle and leaves + /// bytes that are no longer WTF-8 at all. A needle that is not empty is + /// safe there, because a WTF-8 sequence never starts inside another one. + fn insert_at_boundaries(&self, to: &Wtf8, limit: usize) -> Wtf8Buf { + let mut result = Wtf8Buf::with_capacity(self.len()); + let mut inserted = 0; + + for code_point in self.code_points() { + if inserted < limit { + result.push_wtf8(to); + inserted += 1; + } + result.push(code_point); + } + + if inserted < limit { + result.push_wtf8(to); + } + + result + } } impl AsRef for str { @@ -1619,3 +1652,59 @@ impl From for Box { mod concat; pub use concat::Wtf8Concat; + +#[cfg(test)] +mod tests { + use super::{String, Wtf8, Wtf8Buf}; + + fn w(s: &str) -> &Wtf8 { + Wtf8::new(s) + } + + fn buf(s: &str) -> Wtf8Buf { + Wtf8Buf::from_string(String::from(s)) + } + + #[test] + fn replace_empty_needle_splits_on_code_points() { + assert_eq!(w("abc").replace(w(""), w("-")), buf("-a-b-c-")); + assert_eq!(w("ábç").replace(w(""), w("#")), buf("#á#b#ç#")); + assert_eq!(w("😀").replace(w(""), w("-")), buf("-😀-")); + assert_eq!(w("").replace(w(""), w("-")), buf("-")); + assert_eq!(w("abc").replace(w(""), w("")), buf("abc")); + } + + #[test] + fn replacen_empty_needle_counts_insertions() { + assert_eq!(w("abc").replacen(w(""), w("-"), 0), buf("abc")); + assert_eq!(w("abc").replacen(w(""), w("-"), 1), buf("-abc")); + assert_eq!(w("abc").replacen(w(""), w("-"), 3), buf("-a-b-c")); + assert_eq!(w("abc").replacen(w(""), w("-"), 4), buf("-a-b-c-")); + assert_eq!(w("abc").replacen(w(""), w("-"), 99), buf("-a-b-c-")); + assert_eq!(w("ábç").replacen(w(""), w("#"), 2), buf("#á#bç")); + } + + #[test] + fn replace_result_stays_valid_wtf8() { + // The byte search behind the non-empty cases matches an empty needle at + // every byte, which used to leave a replacement inside a multi-byte code + // point. What came out was not WTF-8, and every later read of it was + // reading something else. + for subject in ["abc", "á", "ábç", "😀", "a😀b", ""] { + for insert in ["-", "", "ç", "😀"] { + let replaced = Wtf8::new(subject).replace(w(""), w(insert)); + assert!( + Wtf8::from_bytes(replaced.as_bytes()).is_some(), + "{subject:?} with {insert:?} produced bytes that are not WTF-8" + ); + } + } + } + + #[test] + fn replace_non_empty_needle_is_unchanged() { + assert_eq!(w("ábç").replace(w("b"), w("#")), buf("á#ç")); + assert_eq!(w("ábç").replace(w("á"), w("#")), buf("#bç")); + assert_eq!(w("aaa").replacen(w("a"), w("b"), 2), buf("bba")); + } +} diff --git a/extra_tests/snippets/builtin_str.py b/extra_tests/snippets/builtin_str.py index 684bd66a1ff..859e8b7a7a8 100644 --- a/extra_tests/snippets/builtin_str.py +++ b/extra_tests/snippets/builtin_str.py @@ -915,3 +915,39 @@ def test_huge_width(): test_huge_width() + + +def test_replace_empty_pattern(): + # An empty pattern matches at every code point boundary, and only there. + # Matching it byte by byte instead put the replacement inside a multi-byte + # character, so what came back was no longer the text that went in. + assert "abc".replace("", "-") == "-a-b-c-" + assert "ábç".replace("", "#") == "#á#b#ç#" + assert "😀".replace("", "-") == "-😀-" + assert "".replace("", "-") == "-" + assert "abc".replace("", "") == "abc" + + # The count is a number of insertions, and the one after the last + # character only happens if the count reaches that far. + assert "abc".replace("", "-", 0) == "abc" + assert "abc".replace("", "-", 1) == "-abc" + assert "abc".replace("", "-", 3) == "-a-b-c" + assert "abc".replace("", "-", 4) == "-a-b-c-" + assert "abc".replace("", "-", 99) == "-a-b-c-" + assert "ábç".replace("", "#", 2) == "#á#bç" + + # The result has to stay readable as text afterwards. + spread = "á".replace("", "-") + assert len(spread) == 3 + assert list(spread) == ["-", "á", "-"] + assert spread[1] == "á" + assert spread.upper() == "-Á-" + assert spread.encode("utf-8") == b"-\xc3\xa1-" + + # A pattern that is not empty was already fine and stays that way. + assert "ábç".replace("b", "#") == "á#ç" + assert "ábç".replace("á", "#") == "#bç" + assert "aaa".replace("a", "b", 2) == "bba" + + +test_replace_empty_pattern() From c0773738d8b8b6420968622a3179c71224aff660 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:46:44 -0300 Subject: [PATCH 336/351] Drop the lint expectations clippy 1.98 no longer fulfils (#8564) `clippy::std_instead_of_core` stopped firing at the four places that carry an `#[expect]` for it. All four were added for the same false positive, on items whose `core` counterpart is still unstable. `unfulfilled_lint_expectations` is denied through `-D warnings`, so the three clippy jobs and the WASM check fail on current stable for every pull request, whatever it touches. Assisted-by: Claude Code:claude-opus-5 --- crates/host_env/src/fileutils.rs | 4 ---- crates/stdlib/src/pyexpat.rs | 3 --- crates/stdlib/src/ssl.rs | 3 --- crates/vm/src/stdlib/_io.rs | 4 ---- 4 files changed, 14 deletions(-) diff --git a/crates/host_env/src/fileutils.rs b/crates/host_env/src/fileutils.rs index 9d7e4430681..affb6d2205b 100644 --- a/crates/host_env/src/fileutils.rs +++ b/crates/host_env/src/fileutils.rs @@ -445,10 +445,6 @@ pub unsafe fn fclose(fp: *mut CFile) -> core::ffi::c_int { // _Py_fopen_obj in cpython (Python/fileutils.c:1757-1835) // Open a file using std::fs::File and convert to FILE* // Automatically handles path encoding and EINTR retries -#[expect( - clippy::std_instead_of_core, - reason = "false positive: core::io::ErrorKind is unstable (core_io)" -)] pub fn fopen(path: &std::path::Path, mode: &str) -> std::io::Result<*mut CFile> { use std::fs::File; diff --git a/crates/stdlib/src/pyexpat.rs b/crates/stdlib/src/pyexpat.rs index 9b55bbb22f3..500a8a686c8 100644 --- a/crates/stdlib/src/pyexpat.rs +++ b/crates/stdlib/src/pyexpat.rs @@ -1,8 +1,5 @@ //! Pyexpat builtin module -// false positive: core::io::Cursor is unstable (core_io), unusable on stable -#![expect(clippy::std_instead_of_core)] - // spell-checker: ignore libexpat pub(crate) use _pyexpat::module_def; diff --git a/crates/stdlib/src/ssl.rs b/crates/stdlib/src/ssl.rs index 262c3936e0a..c99ec024fa3 100644 --- a/crates/stdlib/src/ssl.rs +++ b/crates/stdlib/src/ssl.rs @@ -13,9 +13,6 @@ //! //! Warning: This library contains AI-generated code and comments. Do not trust any code or comment without verification. Please have a qualified expert review the code and remove this notice after review. -// false positive: core::io::{Cursor, ErrorKind} are unstable (core_io), unusable on stable -#![expect(clippy::std_instead_of_core)] - // OID (Object Identifier) management module mod oid; diff --git a/crates/vm/src/stdlib/_io.rs b/crates/vm/src/stdlib/_io.rs index 4db5fb760c1..08dd0e0ef80 100644 --- a/crates/vm/src/stdlib/_io.rs +++ b/crates/vm/src/stdlib/_io.rs @@ -113,10 +113,6 @@ impl std::os::fd::AsRawFd for Fildes { } #[pymodule] -#[expect( - clippy::std_instead_of_core, - reason = "false positive: core::io items (Cursor, etc.) are unstable (core_io)" -)] mod _io { use super::*; use crate::{ From 6079ad06e5bde79f3bfcd493017462c0acde3826 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Fri, 21 Aug 2026 05:13:29 -0300 Subject: [PATCH 337/351] Fix re.findall reporting a group that did not match (#8563) findall returns the matched text rather than a match object, so a group that took no part in the match is reported as an empty value. With one group it was reported as `None`: >>> re.findall(r"(a)?b", "b ab") [None, 'a'] # CPython: ['', 'a'] The branch for two or more groups was already right, since it passes `""` to `Match.groups` as the default, so the two halves of the same function disagreed with each other. That default is built as a `str` whatever the pattern is, so a `bytes` pattern came back with `str` mixed into it: >>> re.findall(rb"(a)|(b)", b"ab") [(b'a', ''), ('', b'b')] # CPython: [(b'a', b''), (b'', b'b')] The empty value is now built once from `isbytes` and both branches use it. `Match.groups` still reports `None`, which is what CPython does. Assisted-by: Claude Code:claude-opus-5 --- crates/vm/src/stdlib/_sre.rs | 16 ++++++++--- extra_tests/snippets/stdlib_re.py | 44 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/vm/src/stdlib/_sre.rs b/crates/vm/src/stdlib/_sre.rs index a9f98ca7015..4575901d3e8 100644 --- a/crates/vm/src/stdlib/_sre.rs +++ b/crates/vm/src/stdlib/_sre.rs @@ -452,15 +452,25 @@ mod _sre { let mut match_list: Vec = Vec::new(); let mut iter = SearchIter { req, state }; + // What a group that took no part in the match is reported as. + // `findall` hands back the matched text rather than a match + // object, so the stand-in has to be an empty value of the type + // the pattern works on. `Match.groups` still reports `None` and + // is not affected by this. + let empty: PyObjectRef = if zelf.isbytes { + vm.ctx.new_bytes(vec![]).into() + } else { + vm.ctx.new_str(ascii!("")).into() + }; + while iter.next().is_some() { let m = Match::new(&mut iter.state, zelf.clone(), string_args.string.clone()); let item = if zelf.groups == 0 || zelf.groups == 1 { m.get_slice(zelf.groups, s, vm) - .unwrap_or_else(|| vm.ctx.none()) + .unwrap_or_else(|| empty.clone()) } else { - m.groups(OptionalArg::Present(vm.ctx.new_str(ascii!("")).into()), vm)? - .into() + m.groups(OptionalArg::Present(empty.clone()), vm)?.into() }; match_list.push(item); diff --git a/extra_tests/snippets/stdlib_re.py b/extra_tests/snippets/stdlib_re.py index 8613ddd30fc..80006aaf3e8 100644 --- a/extra_tests/snippets/stdlib_re.py +++ b/extra_tests/snippets/stdlib_re.py @@ -82,3 +82,47 @@ # Combining characters; issue #7518 assert not re.match(r"\w", "\u0345"), r"\w should not match U+0345 (category Mn)" + + +def test_findall_group_that_did_not_participate(): + # findall returns the matched text, not a match object, so a group that + # took no part in the match stands in as an empty value of the type the + # pattern works on. One group used to come back as None, and a bytes + # pattern used to mix str into its results. + assert re.findall(r"(a)?b", "b ab") == ["", "a"] + assert re.findall(r"(x)?", "a") == ["", ""] + assert re.findall(r"(a|b)?c", "c ac bc") == ["", "a", "b"] + assert re.findall(r"(?Pa)?b", "b ab") == ["", "a"] + assert re.compile(r"(a)?b").findall("b ab") == ["", "a"] + + assert re.findall(rb"(a)?b", b"b ab") == [b"", b"a"] + assert re.findall(rb"(x)?", b"a") == [b"", b""] + + # Two or more groups give a tuple per match, with the same stand-in. + assert re.findall(r"(a)|(b)", "ab") == [("a", ""), ("", "b")] + assert re.findall(rb"(a)|(b)", b"ab") == [(b"a", b""), (b"", b"b")] + assert re.findall(rb"(a)(b)?", b"a ab") == [(b"a", b""), (b"a", b"b")] + + # The type is the pattern's, never the other one. + assert [type(x) for x in re.findall(r"(a)?b", "b ab")] == [str, str] + assert [type(x) for x in re.findall(rb"(a)?b", b"b ab")] == [bytes, bytes] + assert [type(y) for x in re.findall(rb"(a)|(b)", b"ab") for y in x] == [ + bytes, + bytes, + bytes, + bytes, + ] + + # A group that does participate, and no group at all, are unchanged. + assert re.findall(r"(a)", "aa") == ["a", "a"] + assert re.findall(r"a", "aa") == ["a", "a"] + assert re.findall(rb"a", b"aa") == [b"a", b"a"] + + # A match object still reports None, which is where the difference lies. + assert re.match(r"(a)?b", "b").groups() == (None,) + assert re.match(r"(a)?b", "b").group(1) is None + assert [m.groups() for m in re.finditer(r"(a)?b", "b ab")] == [(None,), ("a",)] + assert re.split(r"(a)|(b)", "xaybz") == ["x", "a", None, "y", None, "b", "z"] + + +test_findall_group_that_did_not_participate() From c420cc564c0d5f17d2a8644d7a2fcdfaa833c521 Mon Sep 17 00:00:00 2001 From: Yubin Kim <80163835+devyubin@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:16:40 +0900 Subject: [PATCH 338/351] Rebase range iterator `__reduce__` to match CPython (#8424) * Rebase range iterator __reduce__ to match CPython range_iterator.__reduce__ (and longrange_iterator) returned the original range plus the current index as pickle state; CPython returns the range rebased to the current position with a None state. Rebase start by index * step (clamped to the length) and emit None. __setstate__ is kept so pickles carrying an integer state still load. Assisted-by: Claude Code:claude-opus-4-8 * Add snippet test for range iterator __reduce__ Assert the rebased range and None state added in the previous commit. The snippet runner executes it under both CPython and RustPython, so it also cross-checks the representation against CPython. Assisted-by: Claude Code:claude-opus-4-8 * Update extra_tests/snippets/builtin_range.py --------- Co-authored-by: Jeong, YunWon <69878+youknowone@users.noreply.github.com> --- crates/vm/src/builtins/range.rs | 6 +++++- extra_tests/snippets/builtin_range.py | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/vm/src/builtins/range.rs b/crates/vm/src/builtins/range.rs index 928c67884a4..a315cef9d10 100644 --- a/crates/vm/src/builtins/range.rs +++ b/crates/vm/src/builtins/range.rs @@ -720,13 +720,17 @@ fn range_iter_reduce( vm: &VirtualMachine, ) -> PyTupleRef { let iter = builtins_iter(vm); + // CPython pickles the remaining range with a None state. next() increments + // the index unconditionally, so clamp it to length before rebasing start. + let index = BigInt::from(index).min(length.clone()); let stop = start.clone() + length * step.clone(); + let start = start + index * step.clone(); let range = PyRange { start: PyInt::from(start).into_ref(&vm.ctx), stop: PyInt::from(stop).into_ref(&vm.ctx), step: PyInt::from(step).into_ref(&vm.ctx), }; - vm.new_tuple((iter, (range,), index)) + vm.new_tuple((iter, (range,), vm.ctx.none())) } // Silently clips state (i.e index) in range [0, usize::MAX]. diff --git a/extra_tests/snippets/builtin_range.py b/extra_tests/snippets/builtin_range.py index 6bfb99f453d..4f91d4dce83 100644 --- a/extra_tests/snippets/builtin_range.py +++ b/extra_tests/snippets/builtin_range.py @@ -118,6 +118,17 @@ assert range(10, 1, -2).__reduce__()[0] == range assert range(10, 1, -2).__reduce__()[1] == (10, 1, -2) +# range iterator __reduce__ (state is None, range rebased to current position) +it = iter(range(10)) +next(it) +next(it) +next(it) +assert it.__reduce__()[0] is iter +assert it.__reduce__()[1] == (range(3, 10),) +assert it.__reduce__()[2] is None +assert iter(range(3)).__reduce__()[1:] == ((range(0, 3),), None) +assert reversed(range(3)).__reduce__()[1:] == ((range(2, -1, -1),), None) + # range retains the original int refs i = 2**64 assert range(i).stop is i From 728a61e98209f1131839d637b9a725d1f93ecce9 Mon Sep 17 00:00:00 2001 From: hyoinandout <68385607+hyoinandout@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:17:56 +0900 Subject: [PATCH 339/351] csv: validate dialect options (#8402) * csv: validate dialect options Resolve each dialect once and validate the merged options before constructing readers and writers. Handle Unicode character parsing consistently and enable the corresponding CPython CSV tests. Assisted-by: Tau:gpt-5.6-luna * cargo fmt * Validate csv dialect conflicts with line terminators - Reject dialect characters that overlap any lineterminator character - Validate Dialect construction consistently and allow non-ASCII terminators - Unskip csv invalid character coverage now that validation matches behavior * Remove stale CSV rejection snippet Removes a RustPython-only assertion that contradicts the CPython-compatible dialect validation added by this branch. Scope: test-only AI-Assisted-By: Codex * Revert "Remove stale CSV rejection snippet" This reverts commit 3a145dbba48470600150179b8d30278d69fe5c61. * Reapply "Remove stale CSV rejection snippet" This reverts commit 0754731a2e8c669dbf1b949cd361fc89ea087ba5. * fix(csv): complete dialect merge resolution Restore dialect validation and remove stale quoting logic. Assisted-by: Codex: GPT-5 --- Lib/test/test_csv.py | 3 - crates/stdlib/src/csv.rs | 269 +++++++++++++++-------------- extra_tests/snippets/stdlib_csv.py | 37 ---- 3 files changed, 144 insertions(+), 165 deletions(-) diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py index 65093dc70c1..0fbf026aee2 100644 --- a/Lib/test/test_csv.py +++ b/Lib/test/test_csv.py @@ -87,12 +87,10 @@ def _test_arg_valid(self, ctor, arg): self.assertRaises(ValueError, ctor, arg, quotechar='\x85', lineterminator='\x85') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_reader_arg_valid(self): self._test_arg_valid(csv.reader, []) self.assertRaises(OSError, csv.reader, BadIterable()) - @unittest.expectedFailure # TODO: RUSTPYTHON def test_writer_arg_valid(self): self._test_arg_valid(csv.writer, StringIO()) class BadWriter: @@ -1288,7 +1286,6 @@ class mydialect(csv.Dialect): self.assertEqual(str(cm.exception), '"lineterminator" must be a string, not NoneType') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_invalid_chars(self): def create_invalid(field_name, value, **kwargs): class mydialect(csv.Dialect): diff --git a/crates/stdlib/src/csv.rs b/crates/stdlib/src/csv.rs index 5697689ae7d..a7f5287645c 100644 --- a/crates/stdlib/src/csv.rs +++ b/crates/stdlib/src/csv.rs @@ -16,7 +16,7 @@ mod _csv { use itertools::Itertools; use parking_lot::Mutex; use rustpython_common::{lock::LazyLock, wtf8::Wtf8Buf}; - use rustpython_vm::{match_class, sliceable::SliceableSequenceOp}; + use rustpython_vm::match_class; use std::collections::HashMap; #[pyattr] @@ -100,8 +100,10 @@ mod _csv { impl Constructor for PyDialect { type Args = PyObjectRef; - fn py_new(_cls: &Py, ctx: Self::Args, vm: &VirtualMachine) -> PyResult { - Self::try_from_object(vm, ctx) + fn py_new(_cls: &Py, obj: Self::Args, vm: &VirtualMachine) -> PyResult { + let dialect = Self::try_from_object(vm, obj)?; + validate_dialect(vm, &dialect)?; + Ok(dialect) } } @@ -173,12 +175,11 @@ mod _csv { } else { match_class!(match obj.to_owned() { s @ PyStr => { - Ok(s.as_bytes().iter().copied().exactly_one().map_err(|_| { + parse_single_char(&s, |len| { vm.new_type_error(format!( - r#""delimiter" must be a unicode character, not a string of length {}"#, - s.len() + r#""delimiter" must be a unicode character, not a string of length {len}"# )) - })?) + }) } attr => { Err(vm.new_type_error(format!( @@ -193,8 +194,13 @@ mod _csv { fn parse_quotechar_from_obj(vm: &VirtualMachine, obj: &PyObject) -> PyResult> { match_class!(match obj.get_attr("quotechar", vm)? { s @ PyStr => { - Ok(Some(s.as_bytes().iter().copied().exactly_one().map_err(|_| { - new_csv_error(vm, format!(r#""quotechar" must be a unicode character or None, not a string of length {}"#, s.len())) + Ok(Some(parse_single_char(&s, |len| { + new_csv_error( + vm, + format!( + r#""quotechar" must be a unicode character or None, not a string of length {len}"# + ), + ) })?)) } _n @ PyNone => { @@ -215,10 +221,12 @@ mod _csv { fn parse_escapechar_from_obj(vm: &VirtualMachine, obj: &PyObject) -> PyResult> { match_class!(match obj.get_attr("escapechar", vm)? { s @ PyStr => { - Ok(Some(s.as_bytes().iter().copied().exactly_one().map_err(|_| { + Ok(Some(parse_single_char(&s, |len| { new_csv_error( vm, - format!(r#""escapechar" must be a unicode character or None, not a string of length {}"#, s.len()), + format!( + r#""escapechar" must be a unicode character or None, not a string of length {len}"# + ), ) })?)) } @@ -234,26 +242,7 @@ mod _csv { }) } - /// Validate that a line terminator is ASCII and return it as a `str`. - /// - /// The writer's quoting and escaping predicates compare raw bytes, so a - /// non-ASCII terminator would either quote a field that merely shares a - /// UTF-8 lead byte or splice an escape character into the middle of a - /// multi-byte sequence. Reject those here. - /// - /// The ASCII check must come before any UTF-8 conversion so that lone - /// surrogates are reported as this `csv.Error` too. - /// - /// TODO: RUSTPYTHON; handle non-ASCII terminators code-point-wise as part - /// of full Unicode dialect support. - fn ascii_lineterminator<'a>(vm: &VirtualMachine, s: &'a PyStr) -> PyResult<&'a str> { - if !s.as_wtf8().is_ascii() { - return Err(new_csv_error( - vm, - r#""lineterminator" must be an ASCII string"#, - )); - } - // An ASCII string is always valid UTF-8. + fn parse_lineterminator<'a>(vm: &VirtualMachine, s: &'a PyStr) -> PyResult<&'a str> { s.to_str() .ok_or_else(|| new_csv_error(vm, r#""lineterminator" must be a string"#)) } @@ -265,7 +254,7 @@ mod _csv { // arbitrary-length terminator; the manual writer paths emit it // verbatim and the csv-core writer path appends it after a // sentinel terminator (see `writerow`). - let value = ascii_lineterminator(vm, &s)?; + let value = parse_lineterminator(vm, &s)?; Ok(value.to_owned()) } attr => { @@ -277,6 +266,18 @@ mod _csv { }) } + fn parse_single_char( + s: &Py, + error: impl Fn(usize) -> PyBaseExceptionRef, + ) -> PyResult { + let ch = s + .as_wtf8() + .code_points() + .exactly_one() + .map_err(|_| error(s.char_len()))?; + u8::try_from(ch.to_u32()).map_err(|_| error(s.char_len())) + } + fn prase_quoting_from_obj(vm: &VirtualMachine, obj: &PyObject) -> PyResult { match_class!(match obj.get_attr("quoting", vm)? { i @ PyInt => { @@ -344,6 +345,7 @@ mod _csv { }; let dialect = opts.update_py_dialect(dialect); + validate_dialect(vm, &dialect)?; GLOBAL_HASHMAP .lock() .insert(name.as_str().to_owned(), dialect); @@ -441,13 +443,14 @@ mod _csv { _rest: FuncArgs, vm: &VirtualMachine, ) -> PyResult { + let dialect = options.result(vm)?; Ok(Reader { iter, state: PyMutex::new(ReadState { line_num: 0, generation: 0, }), - dialect: options.result(vm)?, + dialect, }) } @@ -466,14 +469,15 @@ mod _csv { return Err(vm.new_type_error(r#"argument 1 must have a "write" method"#)); } }; + let dialect = options.result(vm)?; Ok(Writer { write, state: PyMutex::new(WriteState { buffer: vec![0; 1024], - writer: options.to_writer(), + writer: FormatOptions::to_writer(&dialect), }), - dialect: options.result(vm)?, + dialect, }) } @@ -557,7 +561,7 @@ mod _csv { dialect: DialectItem, delimiter: Option, quotechar: Option>, - escapechar: Option, + escapechar: Option>, doublequote: Option, skipinitialspace: Option, lineterminator: Option, @@ -640,11 +644,15 @@ mod _csv { if let Some(escapechar) = args.kwargs.swap_remove("escapechar") { res.escapechar = match_class!(match escapechar { - s @ PyStr => - Some(s.as_bytes().iter().copied().exactly_one().map_err(|_| { - vm.new_type_error(r#""escapechar" must be a 1-character string"#) - })?), - _ => None, + s @ PyStr => Some(Some(parse_single_char(&s, |_| { + vm.new_type_error(r#""escapechar" must be a 1-character string"#) + })?)), + PyNone => Some(None), + _ => { + return Err(ArgumentError::Exception( + vm.new_type_error(r#""escapechar" must be a 1-character string"#), + )); + } }) }; @@ -655,7 +663,7 @@ mod _csv { lineterminator.class().name() )) })?; - let value = ascii_lineterminator(vm, s)?; + let value = parse_lineterminator(vm, s)?; res.lineterminator = Some(value.to_owned()); }; @@ -690,9 +698,9 @@ mod _csv { if let Some(quotechar) = args.kwargs.swap_remove("quotechar") { res.quotechar = match_class!(match quotechar { - s @ PyStr => Some(Some(s.as_bytes().iter().copied().exactly_one().map_err( - |_| { vm.new_type_error(r#""quotechar" must be a 1-character string"#) } - )?)), + s @ PyStr => Some(Some(parse_single_char(&s, |_| { + vm.new_type_error(r#""quotechar" must be a 1-character string"#) + })?)), PyNone => { if res .quoting @@ -735,6 +743,60 @@ mod _csv { } } + fn validate_dialect(vm: &VirtualMachine, dialect: &PyDialect) -> PyResult<()> { + let special = |name: &str, value: u8| { + if matches!(value, b'\r' | b'\n') { + Err(vm.new_value_error(format!( + "{name} must be a single character, not a line break" + ))) + } else { + Ok(()) + } + }; + + special("delimiter", dialect.delimiter)?; + if let Some(quotechar) = dialect.quotechar { + special("quotechar", quotechar)?; + } + if let Some(escapechar) = dialect.escapechar { + special("escapechar", escapechar)?; + } + + if dialect.skipinitialspace + && (matches!(dialect.escapechar, Some(b' ')) || matches!(dialect.quotechar, Some(b' '))) + { + return Err(vm.new_value_error( + "escapechar or quotechar cannot be a space when skipinitialspace is enabled", + )); + } + + let values: [(&str, Option); 3] = [ + ("delimiter", Some(dialect.delimiter)), + ("quotechar", dialect.quotechar), + ("escapechar", dialect.escapechar), + ]; + for (index, (left_name, left)) in values.iter().enumerate() { + for (right_name, right) in values.iter().skip(index + 1) { + if left.is_some() && left == right { + return Err(vm.new_value_error(format!( + "{left_name} and {right_name} cannot be the same" + ))); + } + } + if left.is_some_and(|value| { + dialect + .lineterminator + .chars() + .any(|character| character == value as char) + }) { + return Err(vm.new_value_error(format!( + "{left_name} and lineterminator cannot be the same" + ))); + } + } + Ok(()) + } + impl FormatOptions { fn update_py_dialect(&self, mut res: PyDialect) -> PyDialect { macro_rules! check_and_fill { @@ -752,7 +814,7 @@ mod _csv { check_and_fill!(res, skipinitialspace); if let Some(t) = self.escapechar { - res.escapechar = Some(t); + res.escapechar = t; }; if let Some(t) = self.quotechar { @@ -768,7 +830,7 @@ mod _csv { } fn result(&self, vm: &VirtualMachine) -> PyResult { - match &self.dialect { + let dialect = match &self.dialect { DialectItem::Str(name) => { let g = GLOBAL_HASHMAP.lock(); if let Some(dialect) = g.get(name) { @@ -789,84 +851,28 @@ mod _csv { quoting: QuoteStyle::Minimal, strict: false, })), - } - } - - fn get_quoting(&self) -> QuoteStyle { - let mut quoting = match &self.dialect { - DialectItem::Str(name) => { - let g = GLOBAL_HASHMAP.lock(); - if let Some(dialect) = g.get(name) { - dialect.quoting - } else { - QuoteStyle::Minimal - } - } - DialectItem::Obj(obj) => obj.quoting, - _ => QuoteStyle::Minimal, - }; - - if let Some(attr) = self.quoting { - quoting = attr - } - - quoting + }?; + validate_dialect(vm, &dialect)?; + Ok(dialect) } - fn to_writer(&self) -> csv_core::Writer { + fn to_writer(dialect: &PyDialect) -> csv_core::Writer { let mut builder = csv_core::WriterBuilder::new(); - let mut writer = match &self.dialect { - DialectItem::Str(name) => { - let g = GLOBAL_HASHMAP.lock(); - if let Some(dialect) = g.get(name) { - let mut builder = builder - .delimiter(dialect.delimiter) - .double_quote(dialect.doublequote); - - if let Some(t) = dialect.quotechar { - builder = builder.quote(t); - } - - builder - - // TODO: RUSTPYTHON; Perfecting the remaining attributes. - } else { - &mut builder - } - } - DialectItem::Obj(obj) => { - let mut builder = builder - .delimiter(obj.delimiter) - .double_quote(obj.doublequote); - - if let Some(t) = obj.quotechar { - builder = builder.quote(t); - } - - builder - } - _ => &mut builder, - }; + let mut writer = builder + .delimiter(dialect.delimiter) + .double_quote(dialect.doublequote); - if let Some(t) = self.delimiter { - writer = writer.delimiter(t); - } - - if let Some(Some(t)) = self.quotechar { + if let Some(t) = dialect.quotechar { writer = writer.quote(t); } - if let Some(t) = self.doublequote { - writer = writer.double_quote(t); - } - writer = writer.terminator(Terminator::Any(CSV_CORE_TERMINATOR_SENTINEL)); - if let Some(e) = self.escapechar { + if let Some(e) = dialect.escapechar { writer = writer.escape(e); } - writer = writer.quote_style(self.get_quoting().into()); + writer = writer.quote_style(dialect.quoting.into()); writer.build() } @@ -1282,39 +1288,52 @@ mod _csv { dialect: &PyDialect, vm: &VirtualMachine, ) -> PyResult<()> { - for &byte in data { - if field_needs_escape(byte, dialect) { + let mut data = data; + while let Some((&byte, rest)) = data.split_first() { + if field_needs_escape(data, dialect) { let escapechar = dialect .escapechar .ok_or_else(|| new_csv_error(vm, "need to escape, but no escapechar set"))?; output.push(escapechar); } output.push(byte); + data = rest; } Ok(()) } + fn data_contains_lineterminator_char(data: &[u8], dialect: &PyDialect) -> bool { + dialect.lineterminator.chars().any(|character| { + let mut encoded = [0; 4]; + let character = character.encode_utf8(&mut encoded).as_bytes(); + data.windows(character.len()) + .any(|window| window == character) + }) + } + + fn data_starts_with_lineterminator_char(data: &[u8], dialect: &PyDialect) -> bool { + dialect.lineterminator.chars().any(|character| { + let mut encoded = [0; 4]; + let character = character.encode_utf8(&mut encoded).as_bytes(); + data.starts_with(character) + }) + } + fn field_needs_quotes(data: &[u8], dialect: &PyDialect) -> bool { data.iter().any(|&byte| { byte == dialect.delimiter || dialect.quotechar == Some(byte) || matches!(byte, b'\r' | b'\n') - // CPython quotes a field containing any character of the line - // terminator. The terminator is ASCII-validated at parse time, so - // comparing raw bytes cannot match part of a multi-byte character. - // TODO: RUSTPYTHON; supporting non-ASCII terminators needs - // code-point-wise quoting and escaping as part of full - // Unicode dialect support. - || dialect.lineterminator.as_bytes().contains(&byte) - }) + }) || data_contains_lineterminator_char(data, dialect) } - fn field_needs_escape(byte: u8, dialect: &PyDialect) -> bool { + fn field_needs_escape(data: &[u8], dialect: &PyDialect) -> bool { + let byte = data[0]; byte == dialect.delimiter || dialect.quotechar == Some(byte) || dialect.escapechar == Some(byte) || matches!(byte, b'\r' | b'\n') - || dialect.lineterminator.as_bytes().contains(&byte) + || data_starts_with_lineterminator_char(data, dialect) } fn write_lineterminator(output: &mut Vec, terminator: &str) { diff --git a/extra_tests/snippets/stdlib_csv.py b/extra_tests/snippets/stdlib_csv.py index 3401cf64ace..bb5a7af1332 100644 --- a/extra_tests/snippets/stdlib_csv.py +++ b/extra_tests/snippets/stdlib_csv.py @@ -1,7 +1,6 @@ import _csv import csv import io -import sys from testutils import assert_raises @@ -264,42 +263,6 @@ def test_multichar_lineterminator(): test_multichar_lineterminator() -def test_reject_non_ascii_lineterminator(): - # CPython accepts non-ASCII line terminators; RustPython rejects them - # because the writer quotes and escapes byte by byte. Supporting them - # requires code-point-wise handling as part of full Unicode dialect support. - with assert_raises(csv.Error): - csv.writer(io.StringIO(), lineterminator="é") - - with assert_raises(csv.Error): - csv.writer(io.StringIO(), lineterminator="\x85") - - with assert_raises(csv.Error): - csv.writer(io.StringIO(), lineterminator="\ud800") - - with assert_raises(csv.Error): - csv.writer( - io.StringIO(), lineterminator="é", quoting=csv.QUOTE_NONE, escapechar="\\" - ) - - with assert_raises(csv.Error): - csv.register_dialect("non_ascii_lt", lineterminator="é") - - class NonAsciiDialect(csv.excel): - lineterminator = "é" - - with assert_raises(csv.Error): - NonAsciiDialect() - - buf = io.StringIO() - csv.writer(buf, lineterminator="!@#").writerow(["a", "b"]) - assert buf.getvalue() == "a,b!@#" - - -if sys.implementation.name == "rustpython": - test_reject_non_ascii_lineterminator() - - def test_empty_lineterminator(): class EmptyLineTerminator(csv.excel): lineterminator = "" From b917e963ddf2a9b6226e1819569dbb98eb0e2a20 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:28:42 -0300 Subject: [PATCH 340/351] Drop the last two lint expectations clippy 1.98 no longer fulfils (#8568) Assisted-by: Claude Code:claude-opus-5 --- crates/host_env/src/posix.rs | 4 ---- crates/vm/src/stdlib/_signal.rs | 4 ---- 2 files changed, 8 deletions(-) diff --git a/crates/host_env/src/posix.rs b/crates/host_env/src/posix.rs index 1e8d4cabe1e..5af8076613b 100644 --- a/crates/host_env/src/posix.rs +++ b/crates/host_env/src/posix.rs @@ -1406,10 +1406,6 @@ fn build_posix_spawn_attrs( target_os = "illumos", target_os = "hurd", )))] - #[expect( - clippy::std_instead_of_core, - reason = "false positive: core::io::ErrorKind is unstable (core_io); expect is co-gated with the usage so it is not left unfulfilled on platforms where this block is compiled out" - )] { return Err(std::io::Error::new( std::io::ErrorKind::Unsupported, diff --git a/crates/vm/src/stdlib/_signal.rs b/crates/vm/src/stdlib/_signal.rs index 5abfd327553..5f5010812cb 100644 --- a/crates/vm/src/stdlib/_signal.rs +++ b/crates/vm/src/stdlib/_signal.rs @@ -337,10 +337,6 @@ pub(crate) mod _signal { } #[cfg(windows)] - #[expect( - clippy::std_instead_of_core, - reason = "false positive: core::io::ErrorKind is unstable (core_io)" - )] let is_socket = if fd != INVALID_WAKEUP { host_signal::wakeup_fd_is_socket(fd).map_err(|err| { if err.kind() == std::io::ErrorKind::InvalidInput { From 6f542bf2858977c1c7382667870d49d3d013de52 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:29:22 -0300 Subject: [PATCH 341/351] Fix str.expandtabs aborting on a tab size of zero (#8562) `"a\tb".expandtabs(0)` panicked with a capacity overflow. CPython returns `'ab'`: with no width to advance to, the tabs come out and nothing else moves. `expandtabs(-1)` is the same call, since `ExpandTabsArgs::tabsize` sends every negative value to 0. `expandtabs` keeps the tab stop in `tab_size` and the current column in `col_count`, and on a tab it does `tab_size - col_count`. With a tab size of zero both start at 0, the first character makes `col_count` 1 while `tab_size` stays 0, and the subtraction underflows. The run of spaces asked for next is `usize::MAX`, and the allocation aborts the process. A tab has to follow something on the line to reach it: `"\ta".expandtabs(0)` subtracts 0 from 0 and comes out right by accident. `BytesInner::expandtabs` already returns early for this and filters the tabs out. The string version now does the same. Assisted-by: Claude Code:claude-opus-5 --- crates/common/src/str.rs | 33 +++++++++++++++++++++++++++++ extra_tests/snippets/builtin_str.py | 28 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/crates/common/src/str.rs b/crates/common/src/str.rs index c649c057de6..3e972dc7bbe 100644 --- a/crates/common/src/str.rs +++ b/crates/common/src/str.rs @@ -743,6 +743,15 @@ pub mod levenshtein { /// Replace all tabs in a string with spaces, using the given tab size. #[must_use] pub fn expandtabs(input: &str, tab_size: usize) -> String { + // A tab size of zero, which is also where a negative one lands, leaves no + // column for a tab to advance to: the tabs come out and nothing else moves. + // Going through the arithmetic anyway subtracts the current column from a + // tab stop of zero and underflows on the first tab, so the width asked for + // next is `usize::MAX`. The bytes version of this already returns here. + if tab_size == 0 { + return input.chars().filter(|ch| *ch != '\t').collect(); + } + let tab_stop = tab_size; let mut expanded_str = String::with_capacity(input.len()); let mut tab_size = tab_stop; @@ -905,4 +914,28 @@ mod tests { let s = "0😀😃😄😁😆😅😂🤣9"; assert_eq!(get_chars(s, 3..7), "😄😁😆😅"); } + + #[test] + fn expandtabs_with_zero_tab_size_drops_tabs() { + // A tab that follows a character used to subtract that column from a + // tab stop of zero, so the width of the run of spaces came out as + // `usize::MAX` and the allocation aborted the process. + assert_eq!(expandtabs("a\tb", 0), "ab"); + assert_eq!(expandtabs("ab\tcd\tef", 0), "abcdef"); + assert_eq!(expandtabs("a\nb\tc", 0), "a\nbc"); + assert_eq!(expandtabs("á\tb", 0), "áb"); + assert_eq!(expandtabs("\ta", 0), "a"); + assert_eq!(expandtabs("\t", 0), ""); + assert_eq!(expandtabs("", 0), ""); + assert_eq!(expandtabs("no tabs", 0), "no tabs"); + } + + #[test] + fn expandtabs_with_a_real_tab_size_is_unchanged() { + assert_eq!(expandtabs("a\tb", 8), "a b"); + assert_eq!(expandtabs("a\tb", 1), "a b"); + assert_eq!(expandtabs("abcd\te", 4), "abcd e"); + assert_eq!(expandtabs("a\nb\tc", 4), "a\nb c"); + assert_eq!(expandtabs("\ta", 4), " a"); + } } diff --git a/extra_tests/snippets/builtin_str.py b/extra_tests/snippets/builtin_str.py index 859e8b7a7a8..ccd2a03527c 100644 --- a/extra_tests/snippets/builtin_str.py +++ b/extra_tests/snippets/builtin_str.py @@ -951,3 +951,31 @@ def test_replace_empty_pattern(): test_replace_empty_pattern() + + +def test_expandtabs_zero_tabsize(): + # With no width to advance to, the tabs come out and nothing else moves. + # A tab that followed a character used to ask for a run of usize::MAX + # spaces and take the interpreter down with it. + for tabsize in (0, -1, -8): + assert "a\tb".expandtabs(tabsize) == "ab" + assert "ab\tcd\tef".expandtabs(tabsize) == "abcdef" + assert "a\nb\tc".expandtabs(tabsize) == "a\nbc" + assert "a\r\nb\tc".expandtabs(tabsize) == "a\r\nbc" + assert "á\tb".expandtabs(tabsize) == "áb" + assert "😀\tb".expandtabs(tabsize) == "😀b" + assert "\ta".expandtabs(tabsize) == "a" + assert "\t".expandtabs(tabsize) == "" + assert "".expandtabs(tabsize) == "" + assert "no tabs".expandtabs(tabsize) == "no tabs" + assert b"a\tb".expandtabs(tabsize) == b"ab" + assert bytearray(b"a\tb").expandtabs(tabsize) == bytearray(b"ab") + + # A tab size that is actually there keeps working. + assert "a\tb".expandtabs(8) == "a b" + assert "a\tb".expandtabs(1) == "a b" + assert "abcd\te".expandtabs(4) == "abcd e" + assert "a\nb\tc".expandtabs(4) == "a\nb c" + + +test_expandtabs_zero_tabsize() From 3d2ee6466d3cf9474dc7f9ae918c0de32c7973c2 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:17:07 -0300 Subject: [PATCH 342/351] Drop the narrow-build surrogate pairing from unicode_escape (#8569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unicodeescape_string is a port of CPython 2.7's PyUnicode_EncodeUnicodeEscape, where the branch mapping a character at or above 0x10000 to \U00xxxxxx sits under #ifdef Py_UNICODE_WIDE and the branch folding a surrogate pair sits under the #else. The port kept the "# ifdef Py_UNICODE_WIDE" and "# endif" comments around the first one but dropped the #else, so both branches run. The pairing branch reads the next character without checking that there is one, so a high surrogate at the end of the string walks off it: >>> "\ud800".encode("unicode_escape") IndexError: index out of range # CPython: b'\ud800' And when there is a next character it folds the two, which no wide build does: >>> "𐀀".encode("unicode_escape") b'\U00010000' # CPython: b'\ud800\udc00' With the branch gone a high surrogate falls through to the 16-bit case and comes out as \udXXX, which is where a low surrogate already went. test_incremental_surrogatepass in UnicodeEscapeTest carried an expectedFailure for the IndexError. It passes now, so the override goes with it. Assisted-by: Claude Code:claude-opus-5 --- Lib/_pycodecs.py | 14 -------------- Lib/test/test_codecs.py | 3 --- extra_tests/snippets/builtin_str_encode.py | 9 +++++++++ 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/Lib/_pycodecs.py b/Lib/_pycodecs.py index 98dec3c614d..2c043f0ec76 100644 --- a/Lib/_pycodecs.py +++ b/Lib/_pycodecs.py @@ -1102,20 +1102,6 @@ def unicodeescape_string(s, size, quotes): pos += 1 continue # endif - # /* Map UTF-16 surrogate pairs to Unicode \UXXXXXXXX escapes */ - elif ord(ch) >= 0xD800 and ord(ch) < 0xDC00: - pos += 1 - ch2 = s[pos] - - if ord(ch2) >= 0xDC00 and ord(ch2) <= 0xDFFF: - ucs = (((ord(ch) & 0x03FF) << 10) | (ord(ch2) & 0x03FF)) + 0x00010000 - p.append(b"\\U%08x" % ucs) - pos += 1 - continue - - # /* Fall through: isolated surrogates are copied as-is */ - pos -= 1 - # /* Map 16-bit characters to '\uxxxx' */ if ord(ch) >= 256: p.append(b"\\u%04x" % ord(ch)) diff --git a/Lib/test/test_codecs.py b/Lib/test/test_codecs.py index 58a7d6f3a38..a55057d91d7 100644 --- a/Lib/test/test_codecs.py +++ b/Lib/test/test_codecs.py @@ -2776,9 +2776,6 @@ def test_partial(self): ] ) - @unittest.expectedFailure # TODO: RUSTPYTHON; IndexError: index out of range - def test_incremental_surrogatepass(self): - return super().test_incremental_surrogatepass() class RawUnicodeEscapeTest(ReadTest, unittest.TestCase): encoding = "raw-unicode-escape" diff --git a/extra_tests/snippets/builtin_str_encode.py b/extra_tests/snippets/builtin_str_encode.py index fc72e6ad4d1..7e7cff57765 100644 --- a/extra_tests/snippets/builtin_str_encode.py +++ b/extra_tests/snippets/builtin_str_encode.py @@ -32,3 +32,12 @@ def round_trip(s, encoding="utf-8"): assert not b"\x80\x80".isupper() assert b"\x80cat\x80".islower() assert b"\x80CAT\x80".isupper() + +# A lone surrogate gets an escape of its own wherever it sits, and a high one +# followed by a low one stays two escapes rather than being folded into one. +assert "\ud800".encode("unicode_escape") == b"\\ud800" +assert "a\ud800".encode("unicode_escape") == b"a\\ud800" +assert "\ud800b".encode("unicode_escape") == b"\\ud800b" +assert "\ud800\ud800".encode("unicode_escape") == b"\\ud800\\ud800" +assert "\ud800\udc00".encode("unicode_escape") == b"\\ud800\\udc00" +assert "\U00010000".encode("unicode_escape") == b"\\U00010000" From f8de240febb244525054f83eebee175c7e312687 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:41:53 -0300 Subject: [PATCH 343/351] Insert the item rather than its key in bisect.insort (#8565) insort_left and insort_right rebound `x` to `key(x)` and then handed that same value to both the search and `a.insert`, so the object the caller passed in never reached the list: >>> words = ["a", "ccc"] >>> bisect.insort(words, "bb", key=len) >>> words ['a', 2, 'ccc'] The key now feeds the search only, and the insert keeps the original. Assisted-by: Claude Code:claude-opus-5 --- crates/stdlib/src/bisect.rs | 20 ++++---- extra_tests/snippets/stdlib_bisect.py | 73 +++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 10 deletions(-) create mode 100644 extra_tests/snippets/stdlib_bisect.py diff --git a/crates/stdlib/src/bisect.rs b/crates/stdlib/src/bisect.rs index 7cb965cd5f9..c9de629dbe5 100644 --- a/crates/stdlib/src/bisect.rs +++ b/crates/stdlib/src/bisect.rs @@ -106,15 +106,15 @@ mod _bisect { #[pyfunction] fn insort_left(BisectArgs { a, x, lo, hi, key }: BisectArgs, vm: &VirtualMachine) -> PyResult { - let x = if let Some(ref key) = key { - key.call((x,), vm)? - } else { - x + // The search runs on the key, the insert has to put back the item itself. + let needle = match key { + Some(ref key) => key.call((x.clone(),), vm)?, + None => x.clone(), }; let index = bisect_left( BisectArgs { a: a.clone(), - x: x.clone(), + x: needle, lo, hi, key, @@ -126,15 +126,15 @@ mod _bisect { #[pyfunction] fn insort_right(BisectArgs { a, x, lo, hi, key }: BisectArgs, vm: &VirtualMachine) -> PyResult { - let x = if let Some(ref key) = key { - key.call((x,), vm)? - } else { - x + // The search runs on the key, the insert has to put back the item itself. + let needle = match key { + Some(ref key) => key.call((x.clone(),), vm)?, + None => x.clone(), }; let index = bisect_right( BisectArgs { a: a.clone(), - x: x.clone(), + x: needle, lo, hi, key, diff --git a/extra_tests/snippets/stdlib_bisect.py b/extra_tests/snippets/stdlib_bisect.py new file mode 100644 index 00000000000..9aa6863491f --- /dev/null +++ b/extra_tests/snippets/stdlib_bisect.py @@ -0,0 +1,73 @@ +from bisect import bisect_left, bisect_right, insort, insort_left, insort_right + +# A key decides where the item goes, but the item is what gets stored. +for insort_fn in (insort, insort_left, insort_right): + words = ["a", "ccc"] + insort_fn(words, "bb", key=len) + assert words == ["a", "bb", "ccc"], (insort_fn.__name__, words) + + numbers = [1, 3] + insort_fn(numbers, -2, key=abs) + assert numbers == [1, -2, 3], (insort_fn.__name__, numbers) + + pairs = [(1, "a"), (3, "b")] + insort_fn(pairs, (2, "x"), key=lambda pair: pair[0]) + assert pairs == [(1, "a"), (2, "x"), (3, "b")], (insort_fn.__name__, pairs) + + +descending = [3, 1] +insort(descending, 2, key=lambda value: -value) +assert descending == [3, 2, 1], descending + + +class Tagged: + def __init__(self, size, tag): + self.size = size + self.tag = tag + + +def sizes_and_tags(items): + return [(item.size, item.tag) for item in items] + + +by_size = [Tagged(1, "old"), Tagged(2, "old")] + +# On a tie, left goes before the equal element and right goes after it. +left = list(by_size) +insort_left(left, Tagged(2, "new"), key=lambda item: item.size) +assert sizes_and_tags(left) == [(1, "old"), (2, "new"), (2, "old")] + +right = list(by_size) +insort_right(right, Tagged(2, "new"), key=lambda item: item.size) +assert sizes_and_tags(right) == [(1, "old"), (2, "old"), (2, "new")] + + +# The key runs once on the new item, whatever the search does afterwards. +seen = [] + + +def counting_key(value): + seen.append(value) + return value + + +data = [1, 3, 5, 7] +insort(data, 4, key=counting_key) +assert data == [1, 3, 4, 5, 7], data +assert seen.count(4) == 1, seen + + +# lo and hi bound the search and leave the inserted item alone. +bounded = [3, 1] +insort(bounded, 2, 0, 1, key=lambda value: -value) +assert bounded == [3, 2, 1], bounded + + +plain = [1, 3] +insort(plain, 2) +assert plain == [1, 2, 3], plain + +# The search takes the key value itself, so these two stay where they were. +sizes = ["a", "bb", "ccc"] +assert bisect_left(sizes, 2, key=len) == 1 +assert bisect_right(sizes, 2, key=len) == 2 From 4c448c470412590002a2560ff64971083f31990c Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:42:10 -0300 Subject: [PATCH 344/351] Apply the format spec to a bool instead of dropping it (#8566) format_bool answered "True" or "False" for every spec that carries no presentation type, so width, fill, alignment and sign were all discarded: >>> f"{True:>5}" 'True' CPython has no bool.__format__ of its own. It uses int's, where an empty spec on a subclass gives str(self) and everything else formats the integer. The empty spec keeps the spelled out answer, the rest now goes to format_int, which also brings back the errors an integer spec raises. Assisted-by: Claude Code:claude-opus-5 --- crates/common/src/format.rs | 71 +++++++++++++++++++++++--- extra_tests/snippets/builtin_format.py | 34 ++++++++++++ 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/crates/common/src/format.rs b/crates/common/src/format.rs index 1c5c0a9c9de..2c545de0702 100644 --- a/crates/common/src/format.rs +++ b/crates/common/src/format.rs @@ -829,6 +829,37 @@ impl FormatSpec { Ok(self.format_sign_and_align(&AsciiStr::new(&magnitude_str), "", FormatAlign::Right)) } + /// Whether the spec carries nothing at all, which is what `format(value, "")` + /// parses to. Written as a destructure so a new field cannot be forgotten here. + fn is_empty(&self) -> bool { + let Self { + conversion, + fill, + align, + align_specified, + sign, + no_neg_0, + alternate_form, + width, + grouping_option, + precision, + frac_grouping_option, + format_type, + } = self; + conversion.is_none() + && fill.is_none() + && align.is_none() + && !align_specified + && sign.is_none() + && !no_neg_0 + && !alternate_form + && width.is_none() + && grouping_option.is_none() + && precision.is_none() + && frac_grouping_option.is_none() + && format_type.is_none() + } + pub fn format_bool(&self, input: bool) -> Result { let x = u8::from(input); match &self.format_type { @@ -844,13 +875,11 @@ impl FormatSpec { Some(FormatType::Exponent(_) | FormatType::FixedPoint(_) | FormatType::Percentage) => { self.format_float(x as f64) } - None => { - if self.no_neg_0 { - return Err(FormatSpecError::NegativeZeroCoercionNotAllowed("integer")); - } - let first_letter = (input.to_string().as_bytes()[0] as char).to_uppercase(); - Ok(first_letter.collect::() + &input.to_string()[1..]) - } + // Only the empty spec spells the value out. Everything else without a + // presentation type, a width or an alignment included, formats `bool` + // the way it formats the `int` it is. + None if self.is_empty() => Ok(if input { "True" } else { "False" }.to_owned()), + None => self.format_int(&BigInt::from_u8(x).unwrap()), Some(format_type) => { let ch = char::from(format_type); Err(FormatSpecError::UnknownFormatCode(ch, "bool")) @@ -1769,6 +1798,34 @@ mod tests { assert_eq!(format_bool("%", false), Ok("0.000000%".to_owned())); } + #[test] + fn format_bool_without_a_presentation_type() { + // The bare spec is the only one that spells the value out. + assert_eq!(format_bool("", true), Ok("True".to_owned())); + assert_eq!(format_bool("", false), Ok("False".to_owned())); + + // Anything else formats the integer, the way `int.__format__` would. + assert_eq!(format_bool("5", true), Ok(" 1".to_owned())); + assert_eq!(format_bool("<5", true), Ok("1 ".to_owned())); + assert_eq!(format_bool(">5", false), Ok(" 0".to_owned())); + assert_eq!(format_bool("^5", true), Ok(" 1 ".to_owned())); + assert_eq!(format_bool("05", true), Ok("00001".to_owned())); + assert_eq!(format_bool("+", true), Ok("+1".to_owned())); + assert_eq!(format_bool(" ", false), Ok(" 0".to_owned())); + assert_eq!(format_bool(",", true), Ok("1".to_owned())); + assert_eq!(format_bool("<", true), Ok("1".to_owned())); + + // And it inherits the integer rules, precision included. + assert_eq!( + format_bool(".2", true), + Err(FormatSpecError::PrecisionNotAllowed) + ); + assert_eq!( + format_bool("z", true), + Err(FormatSpecError::NegativeZeroCoercionNotAllowed("integer")) + ); + } + #[test] fn format_string_zero_padding_uses_left_alignment() { let spec = FormatSpec::parse("08s").unwrap(); diff --git a/extra_tests/snippets/builtin_format.py b/extra_tests/snippets/builtin_format.py index 8ec8f3d5c2c..d92ddefe8ba 100644 --- a/extra_tests/snippets/builtin_format.py +++ b/extra_tests/snippets/builtin_format.py @@ -269,3 +269,37 @@ def test_zero_padding(): assert "{:.3g}".format(1234.5) == "1.23e+03" assert f"{float('nan'):.10f}" == "nan" assert f"{float('inf'):.10f}" == "inf" + +# bool has no __format__ of its own, so the int rules apply. Only the bare +# spec spells the value out. +assert format(True, "") == "True" +assert format(False, "") == "False" +assert f"{True}" == "True" +assert f"{True:}" == "True" + +assert format(True, "5") == " 1" +assert format(True, "<5") == "1 " +assert format(False, ">5") == " 0" +assert format(True, "^5") == " 1 " +assert format(True, "=5") == " 1" +assert format(True, "05") == "00001" +assert format(False, "05") == "00000" +assert format(True, "+") == "+1" +assert format(False, " ") == " 0" +assert format(True, ",") == "1" +assert format(True, "<") == "1" +assert "{:>6}|{:^6}".format(True, False) == " 1| 0 " + +# The presentation types were already right, and stay right. +assert format(True, "d") == "1" +assert format(True, "#b") == "0b1" +assert format(False, "x") == "0" +assert format(True, "c") == "\x01" +assert format(True, "e") == "1.000000e+00" +assert format(True, "%") == "100.000000%" + +# Precision belongs to no integer spec, bool included. +assert_raises(ValueError, format, True, ".2") +assert_raises(ValueError, format, True, "5.2") +assert_raises(ValueError, format, True, "z") +assert_raises(ValueError, format, True, "s") From f9e65c09952a6a035fb23cfa8f189eda95997953 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:42:40 -0300 Subject: [PATCH 345/351] Drop what a deque with maxlen of zero is handed (#8567) append and appendleft trimmed before pushing, and the test they used, maxlen == len, holds for an empty deque whose bound is zero. The pop then had nothing to remove and the item stayed: >>> d = deque(maxlen=0) >>> d.append(1) >>> list(d) [1] Both now push first and trim after, which is the order CPython uses, so a bound of zero drops what just arrived. extend, extendleft, insert, rotate, the operators and the constructor were already right. Assisted-by: Claude Code:claude-opus-5 --- crates/vm/src/stdlib/_collections.rs | 14 ++++-- .../snippets/stdlib_collections_deque.py | 43 +++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/crates/vm/src/stdlib/_collections.rs b/crates/vm/src/stdlib/_collections.rs index 4bd4aee25b3..3783d6fff9b 100644 --- a/crates/vm/src/stdlib/_collections.rs +++ b/crates/vm/src/stdlib/_collections.rs @@ -77,6 +77,10 @@ mod _collections { fn borrow_deque_mut(&self) -> PyRwLockWriteGuard<'_, VecDeque> { self.deque.write() } + + fn is_over_maxlen(&self, deque: &VecDeque) -> bool { + self.maxlen.is_some_and(|maxlen| deque.len() > maxlen) + } } #[pyclass( @@ -96,20 +100,22 @@ mod _collections { fn append(&self, obj: PyObjectRef) { self.state.fetch_add(1); let mut deque = self.borrow_deque_mut(); - if self.maxlen == Some(deque.len()) { + deque.push_back(obj); + // Trim after pushing, so that a `maxlen` of zero drops what just + // arrived instead of popping from an empty deque and keeping it. + if self.is_over_maxlen(&deque) { deque.pop_front(); } - deque.push_back(obj); } #[pymethod] fn appendleft(&self, obj: PyObjectRef) { self.state.fetch_add(1); let mut deque = self.borrow_deque_mut(); - if self.maxlen == Some(deque.len()) { + deque.push_front(obj); + if self.is_over_maxlen(&deque) { deque.pop_back(); } - deque.push_front(obj); } #[pymethod] diff --git a/extra_tests/snippets/stdlib_collections_deque.py b/extra_tests/snippets/stdlib_collections_deque.py index d4d3f25bc4f..0c280fd9b02 100644 --- a/extra_tests/snippets/stdlib_collections_deque.py +++ b/extra_tests/snippets/stdlib_collections_deque.py @@ -108,3 +108,46 @@ class D(deque): # until the allocator gives up, so it is left out of this check. with assert_raises(MemoryError): deque([0]) * sys.maxsize + + +# maxlen=0 keeps nothing, whichever end the item arrives at. +d = deque(maxlen=0) +d.append(1) +d.appendleft(2) +assert list(d) == [] +assert len(d) == 0 +assert d.maxlen == 0 + +d = deque(maxlen=0) +d.extend("abc") +d.extendleft("abc") +d += "abc" +assert list(d) == [] + +assert list(deque("abc", maxlen=0)) == [] +assert list(deque("ab", maxlen=0) * 3) == [] +assert list(deque("ab", maxlen=0) + deque("cd")) == [] + +d = deque("abc", maxlen=0) +d.rotate(1) +assert list(d) == [] + +assert_raises(IndexError, deque(maxlen=0).insert, 0, 1) + + +# A bounded deque still drops from the far end, and only once it is full. +d = deque(maxlen=1) +d.append(1) +assert list(d) == [1] +d.append(2) +assert list(d) == [2] +d.appendleft(3) +assert list(d) == [3] + +d = deque("ab", maxlen=3) +d.append("c") +assert list(d) == ["a", "b", "c"] +d.append("d") +assert list(d) == ["b", "c", "d"] +d.appendleft("z") +assert list(d) == ["z", "b", "c"] From 377c9dbc786aa9bd14dd8afe17684ec13967b82a Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:43:05 -0300 Subject: [PATCH 346/351] Follow CPython's padding rules in a2b_base64 (#8570) With strict_mode=True the decoder accepted padding CPython rejects: >>> binascii.a2b_base64(b'YWJj=', strict_mode=True) b'abc' It also dropped data in the default mode, because it returned at the first pad that completed a quad: >>> binascii.a2b_base64(b'abc=a') b'i\xb7' # CPython: b'i\xb7\x1a' The loop now counts pads and decides at the end, the way CPython does, so a stray pad is ignored outside strict mode and named as leading, excess or discontinuous inside it. DecodeError::InvalidPadding was dead in this file and now carries the excess padding message. test_base64_strict_mode and test_base64_excess_data were marked as expected failures and pass now. Assisted-by: Claude Code:claude-opus-5 --- Lib/test/test_binascii.py | 2 -- crates/stdlib/src/binascii.rs | 63 ++++++++++++++++++++++------------- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/Lib/test/test_binascii.py b/Lib/test/test_binascii.py index 48631cecec7..82eabf4bb06 100644 --- a/Lib/test/test_binascii.py +++ b/Lib/test/test_binascii.py @@ -117,7 +117,6 @@ def addnoise(line): # empty strings. TBD: shouldn't it raise an exception instead ? self.assertEqual(binascii.a2b_base64(self.type2test(fillers)), b'') - @unittest.expectedFailure # TODO: RUSTPYTHON def test_base64_strict_mode(self): # Test base64 with strict mode on def _assertRegexTemplate(assert_regex: str, data: bytes, non_strict_mode_expected_result: bytes): @@ -175,7 +174,6 @@ def assertExcessPadding(data, non_strict_mode_expected_result: bytes): assertExcessPadding(b'abcd====', b'i\xb7\x1d') assertExcessPadding(b'abcd=====', b'i\xb7\x1d') - @unittest.expectedFailure # TODO: RUSTPYTHON; AssertionError: b'i' != b'i\xb7' def test_base64_excess_data(self): # Test excess data exceptions def assertExcessData(data, expected): diff --git a/crates/stdlib/src/binascii.rs b/crates/stdlib/src/binascii.rs index d0cdc2148e7..8f73f262e40 100644 --- a/crates/stdlib/src/binascii.rs +++ b/crates/stdlib/src/binascii.rs @@ -291,31 +291,38 @@ mod decl { return Ok(vec![]); } - if strict_mode && b[0] == PAD { - return Err(base64::DecodeError::InvalidByte(0, 61)); - } - let mut decoded: Vec = vec![]; let mut quad_pos = 0; // position in the nibble let mut pads = 0; let mut left_char: u8 = 0; - let mut padding_started = false; for (i, &el) in b.iter().enumerate() { if el == PAD { - padding_started = true; - pads += 1; - if quad_pos >= 2 && quad_pos + pads >= 4 { - if strict_mode && i + 1 < b.len() { - // Represents excess data after padding error - return Err(base64::DecodeError::InvalidLastSymbol(i, PAD)); - } + // A pad that finishes the quad it belongs to is the expected one. + if quad_pos >= 2 && quad_pos + pads <= 4 { + continue; + } - return Ok(decoded); + // RFC 4648 section 3.3 allows a decoder to ignore a pad that + // shows up anywhere else, so only strict mode complains. + if !strict_mode { + continue; } - continue; + if quad_pos == 1 { + // A single data character cannot be padded into a quad, + // and the check after the loop already reports that. + break; + } + + return Err(if quad_pos == 0 && i == 0 { + // Represents leading padding error + base64::DecodeError::InvalidByte(0, PAD) + } else { + // Represents excess padding error + base64::DecodeError::InvalidPadding + }); } let binary_char = BASE64_TABLE[el as usize]; @@ -327,9 +334,14 @@ mod decl { continue; } - if strict_mode && padding_started { - // Represents discontinuous padding error - return Err(base64::DecodeError::InvalidByte(i, PAD)); + if pads > 0 && strict_mode { + return Err(if quad_pos + pads == 4 { + // Represents excess data after padding error + base64::DecodeError::InvalidLastSymbol(i, PAD) + } else { + // Represents discontinuous padding error + base64::DecodeError::InvalidByte(i, PAD) + }); } pads = 0; @@ -361,14 +373,19 @@ mod decl { } } - match quad_pos { - 0 => Ok(decoded), - 1 => Err(base64::DecodeError::InvalidLastSymbol( + if quad_pos == 1 { + // One data character too many: no input encodes to that length. + return Err(base64::DecodeError::InvalidLastSymbol( decoded.len() / 3 * 4 + 1, 0, - )), - _ => Err(base64::DecodeError::InvalidLength(quad_pos)), + )); } + + if quad_pos != 0 && quad_pos + pads < 4 { + return Err(base64::DecodeError::InvalidLength(quad_pos)); + } + + Ok(decoded) }) .map_err(|err| super::Base64DecodeError(err).to_pyexception(vm)) } @@ -864,7 +881,7 @@ impl ToPyException for Base64DecodeError { } // TODO: clean up errors DecodeError::InvalidLength(_) => "Incorrect padding".to_owned(), - DecodeError::InvalidPadding => "Incorrect padding".to_owned(), + DecodeError::InvalidPadding => "Excess padding not allowed".to_owned(), }; new_binascii_error(format!("error decoding base64: {message}"), vm) } From 30be3909436fbcddba675a114986e621997846a2 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:43:38 -0300 Subject: [PATCH 347/351] Reject the string format flags CPython refuses (#8571) A sign, a space or an alternate form parsed fine on a string spec and then had no effect: >>> format('ab', '+') 'ab' >>> f"{'ab':#5}" 'ab ' CPython raises ValueError for all three. format_string already refused z and an explicit '=' alignment with the same family of messages, so the three checks go next to those, in the order CPython reports them: sign, then z, then the alternate form, then the alignment. Assisted-by: Claude Code:claude-opus-5 --- crates/common/src/format.rs | 57 ++++++++++++++++++++++++++ crates/vm/src/format.rs | 4 ++ extra_tests/snippets/builtin_format.py | 24 +++++++++++ 3 files changed, 85 insertions(+) diff --git a/crates/common/src/format.rs b/crates/common/src/format.rs index 2c545de0702..7a5e3ac0fa1 100644 --- a/crates/common/src/format.rs +++ b/crates/common/src/format.rs @@ -1112,9 +1112,19 @@ impl FormatSpec { self.validate_format(FormatType::String)?; match self.format_type { Some(FormatType::String) | None => { + // CPython rejects these four in this order: sign, z, #, then '='. + if let Some(sign) = self.sign { + return Err(FormatSpecError::StringSpecNotAllowed(match sign { + FormatSign::MinusOrSpace => "Space", + FormatSign::Plus | FormatSign::Minus => "Sign", + })); + } if self.no_neg_0 { return Err(FormatSpecError::NegativeZeroCoercionNotAllowed("string")); } + if self.alternate_form { + return Err(FormatSpecError::StringSpecNotAllowed("Alternate form (#)")); + } if self.align == Some(FormatAlign::AfterSign) && self.align_specified { return Err(FormatSpecError::StringAlignmentFlag); } @@ -1363,6 +1373,7 @@ pub enum FormatSpecError { AlignmentFlag, NegativeZeroCoercionNotAllowed(&'static str), StringAlignmentFlag, + StringSpecNotAllowed(&'static str), NotImplemented(char, &'static str), } @@ -1845,6 +1856,52 @@ mod tests { ); } + #[test] + fn format_string_rejects_sign_space_and_alternate_form() { + let value = "result".to_owned(); + let cases = [ + ("+", "Sign"), + ("-", "Sign"), + ("+8s", "Sign"), + (" ", "Space"), + (" 8s", "Space"), + ("#", "Alternate form (#)"), + ("#8s", "Alternate form (#)"), + ]; + + for (text, flag) in cases { + let spec = FormatSpec::parse(text).unwrap(); + assert_eq!( + spec.format_string(&value), + Err(FormatSpecError::StringSpecNotAllowed(flag)), + "{text}" + ); + } + } + + #[test] + fn format_string_reports_the_flag_cpython_reports_first() { + let value = "result".to_owned(); + // Sign beats z, z beats the alternate form, and the alternate form + // beats an explicit '=' alignment. + let cases = [ + ("+z#5", FormatSpecError::StringSpecNotAllowed("Sign")), + ( + "x=z#5", + FormatSpecError::NegativeZeroCoercionNotAllowed("string"), + ), + ( + "x=#5", + FormatSpecError::StringSpecNotAllowed("Alternate form (#)"), + ), + ]; + + for (text, expected) in cases { + let spec = FormatSpec::parse(text).unwrap(); + assert_eq!(spec.format_string(&value), Err(expected), "{text}"); + } + } + #[test] fn format_complex_rejects_zero_padding_before_after_sign_alignment() { for text in [ diff --git a/crates/vm/src/format.rs b/crates/vm/src/format.rs index 4099cbd9b0f..75f5c32f0c3 100644 --- a/crates/vm/src/format.rs +++ b/crates/vm/src/format.rs @@ -86,6 +86,10 @@ impl IntoPyException for FormatSpecError { Self::StringAlignmentFlag => { vm.new_value_error("'=' alignment not allowed in string format specifier") } + Self::StringSpecNotAllowed(s) => { + let msg = format!("{s} not allowed in string format specifier"); + vm.new_value_error(msg) + } Self::NotImplemented(c, s) => { let msg = format!("Format code '{c}' for object of type '{s}' not implemented yet"); vm.new_value_error(msg) diff --git a/extra_tests/snippets/builtin_format.py b/extra_tests/snippets/builtin_format.py index d92ddefe8ba..6b7403da133 100644 --- a/extra_tests/snippets/builtin_format.py +++ b/extra_tests/snippets/builtin_format.py @@ -32,6 +32,30 @@ def test_zero_padding(): else: raise AssertionError("expected ValueError for '=8s' string format specifier") +# regression: a sign, a space or an alternate form used to be accepted and dropped. +for spec, flag in [ + ("+", "Sign"), + ("-", "Sign"), + ("+5", "Sign"), + (" ", "Space"), + (" 5", "Space"), + ("#", "Alternate form (#)"), + ("#5", "Alternate form (#)"), + ("+.2", "Sign"), +]: + try: + format("result", spec) + except ValueError as error: + expected = f"{flag} not allowed in string format specifier" + if str(error) != expected: + raise AssertionError( + f"{spec!r}: unexpected error message: {error}" + ) from error + else: + raise AssertionError( + f"expected ValueError for {spec!r} string format specifier" + ) + # regression: unknown conversion specifiers used to be silently ignored instead of raising. # The ValueError case itself is covered by test_str, but here we're testing the error message. try: From 17e1e49d7189ed67824c220d9ae84dfb88718dbd Mon Sep 17 00:00:00 2001 From: Shahar Naveh <50263213+ShaharNaveh@users.noreply.github.com> Date: Sat, 22 Aug 2026 07:24:07 +0300 Subject: [PATCH 348/351] Use `ruff_python_ast::name::Name` for symtable and compiler (#8344) * Use `ruff_python_ast::name::Name` for symtable and compiler * apply suggestion * fix tests --- crates/codegen/src/compile.rs | 240 ++++++++++--------- crates/codegen/src/symboltable.rs | 374 ++++++++++++++++-------------- crates/vm/src/stdlib/_symtable.rs | 4 +- 3 files changed, 328 insertions(+), 290 deletions(-) diff --git a/crates/codegen/src/compile.rs b/crates/codegen/src/compile.rs index bccb600f698..9d66eddefde 100644 --- a/crates/codegen/src/compile.rs +++ b/crates/codegen/src/compile.rs @@ -22,7 +22,7 @@ use core::{mem, slice}; use malachite_bigint::BigInt; use num_complex::Complex; use num_traits::{Num, ToPrimitive, Zero}; -use ruff_python_ast as ast; +use ruff_python_ast::{self as ast, name::Name}; use ruff_text_size::{Ranged, TextRange, TextSize}; use rustpython_compiler_core::{ Mode, OneIndexed, PositionEncoding, SourceFile, SourceLocation, @@ -675,7 +675,7 @@ fn compiler_unwrap_option(zelf: &Compiler<'_>, o: Option) -> T { #[derive(Clone)] pub struct PatternContext { /// A list of names captured by the pattern. - pub stores: Vec, + pub stores: Vec, /// If false, then any name captures against our subject will raise. pub allow_irrefutable: bool, /// A list of jump target labels used on pattern failure. @@ -1268,7 +1268,7 @@ impl<'warnings> Compiler<'warnings> { fn starunpack_helper_impl( &mut self, elts: &[ast::Expr], - injected_arg: Option<&str>, + injected_arg: Option<&Name>, pushed: u32, collection_type: CollectionType, ) -> CompileResult<()> { @@ -1713,7 +1713,7 @@ impl<'warnings> Compiler<'warnings> { // 5. "super" must be GlobalImplicit in the current scope. let table = self.current_symbol_table(); - if let Some(symbol) = table.lookup("super") + if let Some(symbol) = table.lookup(&"super".into()) && symbol.scope != SymbolScope::GlobalImplicit { return None; @@ -1721,7 +1721,7 @@ impl<'warnings> Compiler<'warnings> { // Then check the top-level scope and reject any statically // visible symbol for "super", not just local bindings. if let Some(top_table) = self.symbol_table_stack.first() - && top_table.lookup("super").is_some() + && top_table.lookup(&"super".into()).is_some() { return None; } @@ -1753,7 +1753,7 @@ impl<'warnings> Compiler<'warnings> { // Check if __class__ is available as a cell/free variable // The scope must be Free (from enclosing class) or have DEF_FREE_CLASS flag { - let symbol = table.lookup("__class__")?; + let symbol = table.lookup(&"__class__".into())?; if symbol.scope != SymbolScope::Free && !symbol.flags.contains(SymbolFlags::DEF_FREE_CLASS) { @@ -1777,7 +1777,7 @@ impl<'warnings> Compiler<'warnings> { ) -> CompileResult<()> { // 1. Load global super self.set_source_range(super_name_range); - self.compile_name("super", NameUsage::Load)?; + self.compile_name(&"super".into(), NameUsage::Load)?; match super_type { SuperCallType::TwoArg { @@ -1792,7 +1792,9 @@ impl<'warnings> Compiler<'warnings> { // 0-arg: load __class__ cell and first parameter // Load __class__ from cell/free variable self.set_source_range(super_call_range); - let scope = self.get_ref_type("__class__").map_err(|e| self.error(e))?; + let scope = self + .get_ref_type(&"__class__".into()) + .map_err(|e| self.error(e))?; let idx = match scope { SymbolScope::Cell => self.get_cell_var_index("__class__"), SymbolScope::Free => self.get_free_var_index("__class__"), @@ -1817,7 +1819,7 @@ impl<'warnings> Compiler<'warnings> { )) })?; self.set_source_range(super_call_range); - self.compile_name(&first_param, NameUsage::Load)?; + self.compile_name(&first_param.into(), NameUsage::Load)?; } } Ok(()) @@ -1861,7 +1863,7 @@ impl<'warnings> Compiler<'warnings> { }; // Use varnames from symbol table (already collected in definition order) - let varname_cache: IndexSet = ste.varnames.iter().cloned().collect(); + let varname_cache: IndexSet = ste.varnames.iter().cloned().collect(); let nparams = ste.varnames.len(); // Build cellvars using dictbytype (CELL scope or COMP_CELL flag, sorted) @@ -1876,7 +1878,7 @@ impl<'warnings> Compiler<'warnings> { .collect(); cell_names.sort(); for name in cell_names { - cellvar_cache.insert(name); + cellvar_cache.insert(name.into()); } // Handle implicit __class__ cell if needed @@ -1900,7 +1902,7 @@ impl<'warnings> Compiler<'warnings> { // Build freevars using dictbytype (FREE scope, offset by cellvars size) let mut freevar_cache = IndexSet::default(); - let annotation_free_names: IndexSet = ste + let annotation_free_names: IndexSet = ste .annotation_block .as_ref() .map(|annotation| { @@ -1936,7 +1938,7 @@ impl<'warnings> Compiler<'warnings> { .collect(); free_names.sort(); for name in free_names { - freevar_cache.insert(name); + freevar_cache.insert(name.into()); } // Initialize u_metadata fields @@ -2022,7 +2024,7 @@ impl<'warnings> Compiler<'warnings> { qualname: None, // Will be set below consts: Default::default(), names: IndexSet::default(), - varnames: varname_cache, + varnames: varname_cache.into_iter().map(Into::into).collect(), cellvars: cellvar_cache, freevars: freevar_cache, fast_hidden: IndexMap::default(), @@ -2515,10 +2517,10 @@ impl<'warnings> Compiler<'warnings> { self.emit_load_const(ConstantData::None); self.mark_unwind_no_location(*loc); self.set_unwind_source_range(*loc); - self.store_name(name)?; + self.store_name(&name.into())?; self.mark_unwind_no_location(*loc); self.set_unwind_source_range(*loc); - self.compile_name(name, NameUsage::Delete)?; + self.compile_name(&name.into(), NameUsage::Delete)?; self.mark_unwind_no_location(*loc); } } @@ -2610,11 +2612,12 @@ impl<'warnings> Compiler<'warnings> { name: &str, cache: impl FnOnce(&mut ir::CodeInfo) -> &mut IndexSet, ) -> u32 { - let name = self.mangle(name); + let target = name.into(); + let name = self.mangle(&target); let cache = cache(self.current_code_info()); cache - .get_index_of(name.as_ref()) - .unwrap_or_else(|| cache.insert_full(name.into_owned()).0) + .get_index_of(name.as_str()) + .unwrap_or_else(|| cache.insert_full(name.to_string()).0) .to_u32() } @@ -2701,14 +2704,15 @@ impl<'warnings> Compiler<'warnings> { // We might be in a situation where symbol table isn't pushed yet // In this case, check the parent symbol table if let Some(parent_table) = self.symbol_table_stack.last() - && let Some(symbol) = parent_table.lookup(¤t_obj_name) + && let Some(symbol) = parent_table.lookup(¤t_obj_name.clone().into()) && symbol.scope == SymbolScope::GlobalExplicit { force_global = true; } } else if let Some(_current_table) = self.symbol_table_stack.last() { // Mangle the name if necessary (for private names in classes) - let mangled_name = self.mangle(¤t_obj_name); + let target = ¤t_obj_name.clone().into(); + let mangled_name = self.mangle(target); // Look up in parent symbol table to check scope if self.symbol_table_stack.len() >= 2 { @@ -2815,7 +2819,7 @@ impl<'warnings> Compiler<'warnings> { if Self::scope_needs_conditional_annotations_cell(self.current_symbol_table()) { self.set_source_range(module_start_loc); emit!(self, Instruction::BuildSet { count: 0 }); - self.store_name("__conditional_annotations__")?; + self.store_name(&"__conditional_annotations__".into())?; } if self.future_annotations && annotations_used { @@ -2884,7 +2888,7 @@ impl<'warnings> Compiler<'warnings> { if self.current_symbol_table().has_conditional_annotations { self.set_source_range(module_start_loc); emit!(self, Instruction::BuildSet { count: 0 }); - self.store_name("__conditional_annotations__")?; + self.store_name(&"__conditional_annotations__".into())?; } if self.future_annotations && annotations_used { @@ -2936,7 +2940,7 @@ impl<'warnings> Compiler<'warnings> { }) => { validate_duplicate_params(parameters).map_err(|e| self.error(e))?; self.compile_function_def( - name.as_str(), + name.id(), parameters, body, decorator_list, @@ -2955,7 +2959,7 @@ impl<'warnings> Compiler<'warnings> { .. }) => { self.compile_class_def( - name.as_str(), + name.id(), body, decorator_list, type_params.as_deref(), @@ -3079,15 +3083,15 @@ impl<'warnings> Compiler<'warnings> { } } - fn load_name(&mut self, name: &str) -> CompileResult<()> { + fn load_name(&mut self, name: &Name) -> CompileResult<()> { self.compile_name(name, NameUsage::Load) } - fn store_name(&mut self, name: &str) -> CompileResult<()> { + fn store_name(&mut self, name: &Name) -> CompileResult<()> { self.compile_name(name, NameUsage::Store) } - fn emit_no_location_exception_name_cleanup(&mut self, name: &str) -> CompileResult<()> { + fn emit_no_location_exception_name_cleanup(&mut self, name: &Name) -> CompileResult<()> { // CPython codegen_try_except() emits `name = None; del name` // with NO_LOCATION for `except ... as name` cleanup. self.emit_load_const(ConstantData::None); @@ -3099,17 +3103,17 @@ impl<'warnings> Compiler<'warnings> { Ok(()) } - fn mangle<'a>(&self, name: &'a str) -> Cow<'a, str> { + fn mangle<'a>(&self, name: &'a Name) -> Cow<'a, Name> { // Use private from current code unit for name mangling let private = self .code_stack .last() - .and_then(|info| info.private.as_deref()); + .and_then(|info| info.private.as_ref()); let mangled_names = self.current_symbol_table().mangled_names.as_ref(); - symboltable::maybe_mangle_name(private, mangled_names, name) + symboltable::maybe_mangle_name(private.map(Name::from).as_ref(), mangled_names, name) } - fn module_name_declared_global_in_nested_scope(table: &SymbolTable, name: &str) -> bool { + fn module_name_declared_global_in_nested_scope(table: &SymbolTable, name: &Name) -> bool { table.sub_tables.iter().any(|subtable| { (!subtable.comp_inlined && subtable @@ -3120,7 +3124,7 @@ impl<'warnings> Compiler<'warnings> { } // = compiler_nameop - fn compile_name(&mut self, name: &str, usage: NameUsage) -> CompileResult<()> { + fn compile_name(&mut self, name: &Name, usage: NameUsage) -> CompileResult<()> { enum NameOp { Fast, Global, @@ -3132,7 +3136,7 @@ impl<'warnings> Compiler<'warnings> { let name = self.mangle(name); // Special handling for __debug__ - if NameUsage::Load == usage && name == "__debug__" { + if NameUsage::Load == usage && name.as_str() == "__debug__" { self.emit_load_const(ConstantData::Boolean { value: self.opts.optimize == 0, }); @@ -3154,14 +3158,14 @@ impl<'warnings> Compiler<'warnings> { let can_see_class = current_table.can_see_class_scope; // First try to find in current table - let symbol = current_table.lookup(name.as_ref()); + let symbol = current_table.lookup(&name); // If not found and we're in ast::TypeParams or Annotation scope, try parent scope let symbol = if symbol.is_none() && (is_typeparams || is_annotation) { self.symbol_table_stack .get(self.symbol_table_stack.len() - 2) // Try to get parent index .expect("Symbol has no parent! This is a compiler bug.") - .lookup(name.as_ref()) + .lookup(&name) } else { symbol }; @@ -3170,7 +3174,7 @@ impl<'warnings> Compiler<'warnings> { .iter() .rev() .find(|table| table.typ == CompilerScope::Class) - .and_then(|table| table.lookup(name.as_ref())) + .and_then(|table| table.lookup(&name)) .is_some_and(|symbol| symbol.flags.contains(SymbolFlags::DEF_GLOBAL)); ( @@ -3189,8 +3193,12 @@ impl<'warnings> Compiler<'warnings> { if current_table.typ == CompilerScope::Class && !self.current_code_info().in_inlined_comp && ((usage == NameUsage::Load - && (name == "__classdict__" || name == "__conditional_annotations__")) - || (name == "__conditional_annotations__" && usage == NameUsage::Store)) + && (matches!( + name.as_str(), + "__classdict__" | "__conditional_annotations__" + ))) + || (name.as_str() == "__conditional_annotations__" + && usage == NameUsage::Store)) { Some(SymbolScope::Cell) } else { @@ -3214,7 +3222,7 @@ impl<'warnings> Compiler<'warnings> { ) { SymbolScope::GlobalImplicit } else if matches!( - name.as_ref(), + name.as_str(), "__name__" | "__module__" | "__qualname__" @@ -3253,7 +3261,7 @@ impl<'warnings> Compiler<'warnings> { .current_code_info() .metadata .fast_hidden - .get(name.as_ref()) + .get(name.as_str()) .is_some_and(|&hidden| hidden) { NameOp::Fast @@ -3420,12 +3428,12 @@ impl<'warnings> Compiler<'warnings> { emit!(self, Instruction::PopTop); } } - self.store_name(alias.as_str())?; + self.store_name(alias.id())?; if !parts.is_empty() { emit!(self, Instruction::PopTop); } } else { - self.store_name(name.name.split('.').next().unwrap())? + self.store_name(&name.name.split('.').next().unwrap().into())? } } } @@ -3477,9 +3485,9 @@ impl<'warnings> Compiler<'warnings> { // Store module under proper name: if let Some(alias) = &name.asname { - self.store_name(alias.as_str())? + self.store_name(alias.id())? } else { - self.store_name(name.name.as_str())? + self.store_name(name.name.id())? } } @@ -3597,7 +3605,7 @@ impl<'warnings> Compiler<'warnings> { validate_duplicate_params(parameters).map_err(|e| self.error(e))?; self.compile_function_def( - name.as_str(), + name.id(), parameters, body, decorator_list, @@ -3615,7 +3623,7 @@ impl<'warnings> Compiler<'warnings> { arguments, .. }) => self.compile_class_def( - name.as_str(), + name.id(), body, decorator_list, type_params.as_deref(), @@ -3812,7 +3820,7 @@ impl<'warnings> Compiler<'warnings> { "type alias expect name".to_owned(), ))); }; - let name_string = name.id.to_string(); + let name_string = name.id(); if let Some(type_params) = type_params { self.set_source_range(*range); @@ -3832,10 +3840,10 @@ impl<'warnings> Compiler<'warnings> { self.set_source_range(*range); self.emit_load_const(ConstantData::Str { - value: name_string.clone().into(), + value: name_string.as_str().into(), }); self.compile_type_params(type_params)?; - self.compile_typealias_value_closure(&name_string, value, *range)?; + self.compile_typealias_value_closure(name_string, value, *range)?; self.set_source_range(*range); emit!(self, Instruction::BuildTuple { count: 3 }); emit!( @@ -3856,10 +3864,10 @@ impl<'warnings> Compiler<'warnings> { } else { self.set_source_range(*range); self.emit_load_const(ConstantData::Str { - value: name_string.clone().into(), + value: name_string.as_str().into(), }); self.emit_load_const(ConstantData::None); - self.compile_typealias_value_closure(&name_string, value, *range)?; + self.compile_typealias_value_closure(name_string, value, *range)?; self.set_source_range(*range); emit!(self, Instruction::BuildTuple { count: 3 }); emit!( @@ -3871,7 +3879,7 @@ impl<'warnings> Compiler<'warnings> { } self.set_source_range(*range); - self.store_name(&name_string)?; + self.store_name(name_string)?; } ast::Stmt::IpyEscapeCommand(stmt) => { return Err(self.error_ranged( @@ -3889,7 +3897,7 @@ impl<'warnings> Compiler<'warnings> { let result = (|| -> CompileResult<()> { match &expression { ast::Expr::Name(ast::ExprName { id, .. }) => { - self.compile_name(id.as_str(), NameUsage::Delete)? + self.compile_name(id, NameUsage::Delete)? } ast::Expr::Attribute(ast::ExprAttribute { value, attr, .. }) => { self.compile_expression(value)?; @@ -4138,7 +4146,7 @@ impl<'warnings> Compiler<'warnings> { self.set_source_range(*range); emit!(self, Instruction::Copy { i: 1 }); - self.store_name(name.as_ref())?; + self.store_name(name.id())?; } ast::TypeParam::ParamSpec(ast::TypeParamParamSpec { name, @@ -4182,7 +4190,7 @@ impl<'warnings> Compiler<'warnings> { self.set_source_range(*range); emit!(self, Instruction::Copy { i: 1 }); - self.store_name(name.as_ref())?; + self.store_name(name.id())?; } ast::TypeParam::TypeVarTuple(ast::TypeParamTypeVarTuple { name, @@ -4227,7 +4235,7 @@ impl<'warnings> Compiler<'warnings> { self.set_source_range(*range); emit!(self, Instruction::Copy { i: 1 }); - self.store_name(name.as_ref())?; + self.store_name(name.id())?; } }; } @@ -4423,7 +4431,7 @@ impl<'warnings> Compiler<'warnings> { let cleanup_end = self.new_block(); let cleanup_body = self.new_block(); - self.store_name(alias.as_str())?; + self.store_name(alias.id())?; emit!(self, PseudoInstruction::SetupCleanup { delta: cleanup_end }); self.use_cpython_label_block(cleanup_body); @@ -4445,7 +4453,7 @@ impl<'warnings> Compiler<'warnings> { emit!(self, Instruction::PopExcept); self.set_no_location(); - self.emit_no_location_exception_name_cleanup(alias.as_str())?; + self.emit_no_location_exception_name_cleanup(alias.id())?; emit!( self, @@ -4454,7 +4462,7 @@ impl<'warnings> Compiler<'warnings> { self.set_no_location(); self.use_cpython_label_block(cleanup_end); - self.emit_no_location_exception_name_cleanup(alias.as_str())?; + self.emit_no_location_exception_name_cleanup(alias.id())?; emit!(self, Instruction::Reraise { depth: 1 }); self.set_no_location(); } else { @@ -4714,7 +4722,7 @@ impl<'warnings> Compiler<'warnings> { // Store match to name or pop if let Some(alias) = name { - self.store_name(alias.as_str())?; + self.store_name(alias.id())?; } else { emit!(self, Instruction::PopTop); // pop match } @@ -4750,7 +4758,7 @@ impl<'warnings> Compiler<'warnings> { // Cleanup name binding if let Some(alias) = name { - self.emit_no_location_exception_name_cleanup(alias.as_str())?; + self.emit_no_location_exception_name_cleanup(alias.id())?; } emit!( @@ -4768,7 +4776,7 @@ impl<'warnings> Compiler<'warnings> { // Cleanup name binding if let Some(alias) = name { - self.emit_no_location_exception_name_cleanup(alias.as_str())?; + self.emit_no_location_exception_name_cleanup(alias.id())?; } // LIST_APPEND(3) - append raised_exc to list @@ -4959,7 +4967,7 @@ impl<'warnings> Compiler<'warnings> { for (arg, default) in &kw_with_defaults { self.set_source_range(loc); self.emit_load_const(ConstantData::Str { - value: self.mangle(arg.name.as_str()).into_owned().into(), + value: self.mangle(arg.name().id()).as_str().into(), }); self.compile_expression(default)?; } @@ -5134,7 +5142,7 @@ impl<'warnings> Compiler<'warnings> { if let Some(annotation) = ¶m.annotation { self.set_source_range(func_range); self.emit_load_const(ConstantData::Str { - value: self.mangle(param.name.as_str()).into_owned().into(), + value: self.mangle(param.name.id()).as_str().into(), }); self.compile_annotation(annotation)?; } @@ -5318,7 +5326,7 @@ impl<'warnings> Compiler<'warnings> { } = stmt; let simple_name = if *simple { match target.as_ref() { - ast::Expr::Name(ast::ExprName { id, .. }) => Some(id.as_str()), + ast::Expr::Name(ast::ExprName { id, .. }) => Some(id), _ => None, } } else { @@ -5368,7 +5376,7 @@ impl<'warnings> Compiler<'warnings> { self.set_source_range(*range); emit!(self, Instruction::Copy { i: 2 }); self.emit_load_const(ConstantData::Str { - value: self.mangle(name).into_owned().into(), + value: self.mangle(name).as_str().into(), }); self.set_source_range(loc); emit!(self, Instruction::StoreSubscr); @@ -5413,9 +5421,10 @@ impl<'warnings> Compiler<'warnings> { "__annotate_func__" } else { "__annotate__" - }; + } + .into(); self.set_source_range(loc); - self.store_name(name)?; + self.store_name(&name)?; Ok(true) } @@ -5424,7 +5433,7 @@ impl<'warnings> Compiler<'warnings> { #[expect(clippy::too_many_arguments, reason = "ignore warning for now")] fn compile_function_def( &mut self, - name: &str, + name: &Name, parameters: &ast::Parameters, body: &[ast::Stmt], decorator_list: &[ast::Decorator], @@ -5617,7 +5626,7 @@ impl<'warnings> Compiler<'warnings> { /// Determines if a variable should be CELL or FREE type // = get_ref_type - fn get_ref_type(&self, name: &str) -> Result { + fn get_ref_type(&self, name: &Name) -> Result { let table = self.symbol_table_stack.last().unwrap(); // Special handling for __class__, __classdict__, and __conditional_annotations__ in class scope @@ -5665,7 +5674,9 @@ impl<'warnings> Compiler<'warnings> { // well as by the normal name lookup logic. // Get reference type using our get_ref_type function - let ref_type = self.get_ref_type(var).map_err(|e| self.error(e))?; + let ref_type = self + .get_ref_type(&var.as_str().into()) + .map_err(|e| self.error(e))?; // Get parent code info let parent_code = self.code_stack.last().unwrap(); @@ -5828,26 +5839,26 @@ impl<'warnings> Compiler<'warnings> { self.set_source_range(class_body_prefix_range); // Load __name__ and store as __module__ - self.load_name("__name__")?; - self.store_name("__module__")?; + self.load_name(&"__name__".into())?; + self.store_name(&"__module__".into())?; // Store __qualname__ self.emit_load_const(ConstantData::Str { value: qualname.into(), }); - self.store_name("__qualname__")?; + self.store_name(&"__qualname__".into())?; // Store __firstlineno__ before __doc__ self.emit_load_const(ConstantData::Integer { value: BigInt::from(firstlineno), }); - self.store_name("__firstlineno__")?; + self.store_name(&"__firstlineno__".into())?; // Set __type_params__ from the enclosing type-params closure when // compiling a generic class body. if type_params.is_some() { - self.load_name(".type_params")?; - self.store_name("__type_params__")?; + self.load_name(&".type_params".into())?; + self.store_name(&"__type_params__".into())?; } // PEP 649: Initialize __classdict__ after synthetic generic-class @@ -5862,7 +5873,7 @@ impl<'warnings> Compiler<'warnings> { let annotations_used = self.current_symbol_table().annotations_used; if Self::scope_needs_conditional_annotations_cell(self.current_symbol_table()) { emit!(self, Instruction::BuildSet { count: 0 }); - self.store_name("__conditional_annotations__")?; + self.store_name(&"__conditional_annotations__".into())?; } if self.future_annotations && annotations_used { @@ -5874,7 +5885,7 @@ impl<'warnings> Compiler<'warnings> { let saved_range = self.current_source_range; self.set_source_range(range); self.emit_load_const(ConstantData::Str { value: doc.into() }); - self.store_name("__doc__")?; + self.store_name(&"__doc__".into())?; self.set_no_location(); self.set_source_range(saved_range); } @@ -5914,7 +5925,7 @@ impl<'warnings> Compiler<'warnings> { .collect(), }); self.set_no_location(); - self.store_name("__static_attributes__")?; + self.store_name(&"__static_attributes__".into())?; self.set_no_location(); } @@ -5923,7 +5934,7 @@ impl<'warnings> Compiler<'warnings> { let classdict_idx = u32::from(self.get_cell_var_index("__classdict__")); emit!(self, PseudoInstruction::LoadClosure { i: classdict_idx }); self.set_no_location(); - self.store_name("__classdictcell__")?; + self.store_name(&"__classdictcell__".into())?; self.set_no_location(); } @@ -5937,7 +5948,7 @@ impl<'warnings> Compiler<'warnings> { self.set_no_location(); emit!(self, Instruction::Copy { i: 1 }); self.set_no_location(); - self.store_name("__classcell__")?; + self.store_name(&"__classcell__".into())?; self.set_no_location(); } else { self.emit_load_const(ConstantData::None); @@ -5955,7 +5966,7 @@ impl<'warnings> Compiler<'warnings> { fn compile_class_def( &mut self, - name: &str, + name: &Name, body: &[ast::Stmt], decorator_list: &[ast::Decorator], type_params: Option<&ast::TypeParams>, @@ -6001,7 +6012,7 @@ impl<'warnings> Compiler<'warnings> { )?; // Set private name for name mangling - self.code_stack.last_mut().unwrap().private = Some(name.to_owned()); + self.code_stack.last_mut().unwrap().private = Some(name.as_str().to_owned()); // TypeParams scope is function-like self.ctx = CompileContext { @@ -6014,7 +6025,7 @@ impl<'warnings> Compiler<'warnings> { // generic class bodies close over. self.compile_type_params(type_params.unwrap())?; self.set_source_range(class_source_range); - self.store_name(".type_params")?; + self.store_name(&".type_params".into())?; } // Step 2: Compile class body (always done, whether generic or not) @@ -6040,12 +6051,14 @@ impl<'warnings> Compiler<'warnings> { // Create the class body function with the .type_params closure // captured through the class code object's freevars. self.make_closure(class_code, bytecode::MakeFunctionFlags::new())?; - self.emit_load_const(ConstantData::Str { value: name.into() }); + self.emit_load_const(ConstantData::Str { + value: name.as_str().into(), + }); // Create .generic_base after the class function and name are on the // stack so the remaining call shape matches CPython's ordering. self.set_source_range(class_source_range); - self.load_name(".type_params")?; + self.load_name(&".type_params".into())?; emit!( self, Instruction::CallIntrinsic1 { @@ -6053,7 +6066,7 @@ impl<'warnings> Compiler<'warnings> { } ); self.set_source_range(class_source_range); - self.store_name(".generic_base")?; + self.store_name(&".generic_base".into())?; let (bases, keywords) = arguments.map_or((&[][..], &[][..]), |args| { (&args.args[..], &args.keywords[..]) @@ -6064,7 +6077,7 @@ impl<'warnings> Compiler<'warnings> { keywords, class_source_range, None, - Some(".generic_base"), + Some(&".generic_base".into()), )?; // Return the created class @@ -6089,7 +6102,9 @@ impl<'warnings> Compiler<'warnings> { // Create class function with closure self.make_closure(class_code, bytecode::MakeFunctionFlags::new())?; - self.emit_load_const(ConstantData::Str { value: name.into() }); + self.emit_load_const(ConstantData::Str { + value: name.as_str().into(), + }); if let Some(arguments) = arguments { self.codegen_call_helper(2, arguments, class_source_range, None)?; @@ -6656,7 +6671,7 @@ impl<'warnings> Compiler<'warnings> { Some(name) => { // Ensure we don't store the same name twice. // TODO: maybe pc.stores should be a set? - if pc.stores.contains(&name.to_string()) { + if pc.stores.contains(name.id()) { return Err(self.error_ranged( CodegenErrorType::DuplicateStore(name.as_str().to_string()), loc, @@ -6668,7 +6683,7 @@ impl<'warnings> Compiler<'warnings> { self.pattern_helper_rotate(loc, rotations); // Append the name to the captured stores. - pc.stores.push(name.to_string()); + pc.stores.push(name.id().clone()); Ok(()) } } @@ -7430,7 +7445,7 @@ impl<'warnings> Compiler<'warnings> { let old_pc = pc.clone(); // Simulate Py_INCREF on pc.stores by cloning it. pc.stores = pc.stores.clone(); - let mut control: Option> = None; // Will hold the capture list of the first alternative. + let mut control: Option> = None; // Will hold the capture list of the first alternative. // Process each alternative. for (i, alt) in p.patterns.iter().enumerate() { @@ -8132,7 +8147,7 @@ impl<'warnings> Compiler<'warnings> { // Load the variable name self.set_source_range(loc); self.emit_load_const(ConstantData::Str { - value: self.mangle(id.as_str()).into_owned().into(), + value: self.mangle(id).as_str().into(), }); // Store: __annotations__[name] = annotation self.set_source_range(loc); @@ -8189,7 +8204,7 @@ impl<'warnings> Compiler<'warnings> { self.set_source_range(target.range()); let result = (|| -> CompileResult<()> { match &target { - ast::Expr::Name(ast::ExprName { id, .. }) => self.store_name(id.as_str())?, + ast::Expr::Name(ast::ExprName { id, .. }) => self.store_name(id)?, ast::Expr::Subscript(ast::ExprSubscript { value, slice, ctx, .. }) => { @@ -8279,7 +8294,7 @@ impl<'warnings> Compiler<'warnings> { let target_range = target.range(); enum AugAssignKind<'a> { Name { - id: &'a str, + id: &'a Name, }, Subscript { use_slice_opt: bool, @@ -8292,7 +8307,6 @@ impl<'warnings> Compiler<'warnings> { let kind = match &target { ast::Expr::Name(ast::ExprName { id, .. }) => { - let id = id.as_str(); self.set_source_range(target_range); self.compile_name(id, NameUsage::Load)?; AugAssignKind::Name { id } @@ -8917,7 +8931,7 @@ impl<'warnings> Compiler<'warnings> { self.emit_load_const(ConstantData::None); let _ = self.compile_yield_from_sequence(false); } - ast::Expr::Name(ast::ExprName { id, .. }) => self.load_name(id.as_str())?, + ast::Expr::Name(ast::ExprName { id, .. }) => self.load_name(id)?, ast::Expr::Lambda(ast::ExprLambda { parameters, body, @@ -8962,7 +8976,7 @@ impl<'warnings> Compiler<'warnings> { for (arg, default) in &kw_with_defaults { self.set_source_range(*range); self.emit_load_const(ConstantData::Str { - value: self.mangle(arg.name.as_str()).into_owned().into(), + value: self.mangle(arg.name().id()).as_str().into(), }); self.compile_expression(default)?; } @@ -9179,10 +9193,12 @@ impl<'warnings> Compiler<'warnings> { if self.current_code_info().in_inlined_comp && let ast::Expr::Name(ast::ExprName { id, .. }) = target.as_ref() { - let name = self.mangle(id.as_str()); + let name = self.mangle(id); let info = self.code_stack.last_mut().unwrap(); info.metadata.fast_hidden.insert(name.to_string(), false); - info.metadata.fast_hidden_final.swap_remove(name.as_ref()); + info.metadata + .fast_hidden_final + .swap_remove(name.into_owned().as_str()); } self.compile_expression(value)?; self.set_source_range(*range); @@ -9695,7 +9711,7 @@ impl<'warnings> Compiler<'warnings> { keywords: &[ast::Keyword], call_range: TextRange, kw_names_range: Option, - injected_arg: Option<&str>, + injected_arg: Option<&Name>, ) -> CompileResult<()> { self.validate_keywords(keywords)?; @@ -10891,7 +10907,7 @@ impl<'warnings> Compiler<'warnings> { }; self.current_code_info().in_inlined_comp = true; - let mut temp_symbols: IndexMap = IndexMap::default(); + let mut temp_symbols: IndexMap = IndexMap::default(); let mut changed_fast_hidden = Vec::new(); let result = (|| { @@ -10911,8 +10927,8 @@ impl<'warnings> Compiler<'warnings> { current_table.sub_tables.insert(insert_pos + i, st.clone()); } } - let mut pushed_locals: Vec = Vec::new(); - let mut fast_hidden_locals: Vec = Vec::new(); + let mut pushed_locals: Vec = Vec::new(); + let mut fast_hidden_locals: Vec = Vec::new(); for (name, sym) in &comp_table.symbols { if sym.flags.contains(SymbolFlags::DEF_PARAM) { continue; // skip .0 @@ -10964,11 +10980,11 @@ impl<'warnings> Compiler<'warnings> { self.current_code_info() .metadata .fast_hidden - .insert(name.clone(), true); + .insert(name.clone().into(), true); self.current_code_info() .metadata .fast_hidden_final - .insert(name.clone()); + .insert(name.clone().into()); changed_fast_hidden.push(name.clone()); } } @@ -11241,7 +11257,7 @@ impl<'warnings> Compiler<'warnings> { self.current_code_info() .metadata .fast_hidden - .insert(name, false); + .insert(name.into(), false); } self.current_code_info().in_inlined_comp = was_in_inlined_comp; @@ -14181,7 +14197,7 @@ mod tests { .unwrap(); assert!( - table.lookup("frozenset").is_none(), + table.lookup(&"frozenset".into()).is_none(), "CPython symtable Constant_kind does not visit the lowered frozenset() expression" ); } @@ -33382,7 +33398,7 @@ deoptmap = { for name in ["base", "family", "specialized"] { let symbol = symbol_table - .lookup(name) + .lookup(&name.into()) .unwrap_or_else(|| panic!("missing module symbol {name}")); assert_eq!( symbol.scope, @@ -33398,7 +33414,7 @@ deoptmap = { assert!(comp.comp_inlined, "expected comprehension to be inlined"); for name in ["base", "family", "specialized"] { let symbol = comp - .lookup(name) + .lookup(&name.into()) .unwrap_or_else(|| panic!("missing comprehension symbol {name}")); assert_eq!( symbol.scope, diff --git a/crates/codegen/src/symboltable.rs b/crates/codegen/src/symboltable.rs index a09deb0bb3a..0adfab497f0 100644 --- a/crates/codegen/src/symboltable.rs +++ b/crates/codegen/src/symboltable.rs @@ -13,7 +13,7 @@ use crate::{ }; use alloc::{borrow::Cow, fmt}; use bitflags::bitflags; -use ruff_python_ast as ast; +use ruff_python_ast::{self as ast, name::Name}; use ruff_text_size::{Ranged, TextRange}; use rustpython_compiler_core::{PositionEncoding, SourceFile, SourceLocation}; @@ -24,7 +24,7 @@ const RECURSION_ERROR: &str = "maximum recursion depth exceeded during compilati #[derive(Clone)] pub struct SymbolTable { /// The name of this symbol table. Often the name of the class or function. - pub name: String, + pub name: Name, /// The type of symbol table pub typ: CompilerScope, @@ -42,7 +42,7 @@ pub struct SymbolTable { pub is_method: bool, /// A set of symbols present on this scope level. - pub symbols: IndexMap, + pub symbols: IndexMap, /// A list of sub-scopes in the order as found in the /// AST nodes. @@ -66,7 +66,7 @@ pub struct SymbolTable { pub next_sub_table: usize, /// Variable names in definition order (parameters first, then locals) - pub varnames: Vec, + pub varnames: Vec, /// Whether this class scope needs an implicit __class__ cell pub needs_class_closure: bool, @@ -119,12 +119,12 @@ pub struct SymbolTable { /// Names of type parameters that should still be mangled in type param scopes. /// When Some, only names in this set are mangled; other names are left unmangled. /// Set on type param blocks for generic classes; inherited by non-class child scopes. - pub mangled_names: Option>, + pub mangled_names: Option>, } impl SymbolTable { fn new( - name: String, + name: Name, typ: CompilerScope, line_number: u32, is_nested: bool, @@ -164,16 +164,16 @@ impl SymbolTable { } fn add_format_parameter(&mut self) { - let name = ".format"; + let name = Name::new_static(".format"); let symbol = self .symbols - .entry(name.to_owned()) - .or_insert_with(|| Symbol::new(name)); + .entry(name.clone()) + .or_insert_with(|| Symbol::new(name.clone())); symbol .flags .insert(SymbolFlags::DEF_PARAM | SymbolFlags::USE); - if !self.varnames.iter().any(|varname| varname == name) { - self.varnames.push(name.to_owned()); + if !self.varnames.contains(&name) { + self.varnames.push(name); } } @@ -223,7 +223,7 @@ impl SymbolTable { } #[must_use] - pub fn lookup(&self, name: &str) -> Option<&Symbol> { + pub fn lookup(&self, name: &Name) -> Option<&Symbol> { self.symbols.get(name) } } @@ -331,16 +331,16 @@ bitflags! { /// of the symbol, and also the various uses of the symbol. #[derive(Debug, Clone)] pub struct Symbol { - pub name: String, + pub name: Name, pub scope: SymbolScope, pub flags: SymbolFlags, pub location: Option, } impl Symbol { - fn new(name: &str) -> Self { + fn new(name: Name) -> Self { Self { - name: name.to_owned(), + name, // table, scope: SymbolScope::Unknown, flags: SymbolFlags::empty(), @@ -421,7 +421,7 @@ fn analyze_symbol_table(symbol_table: &mut SymbolTable) -> SymbolTableResult { `newfree` set (which contains free variables collected from all child scopes) and sets the corresponding flags on the class's symbol table entry. */ -fn drop_class_free(symbol_table: &mut SymbolTable, newfree: &mut IndexSet) { +fn drop_class_free(symbol_table: &mut SymbolTable, newfree: &mut IndexSet) { // Check if __class__ is in the free variables collected from children // If found, it means a child scope (method) references __class__ if newfree.shift_remove("__class__") { @@ -445,10 +445,10 @@ fn drop_class_free(symbol_table: &mut SymbolTable, newfree: &mut IndexSet, - inlined_cells: &mut IndexSet, + comp_free: &mut IndexSet, + inlined_cells: &mut IndexSet, parent_type: CompilerScope, -) -> IndexSet { +) -> IndexSet { let mut removed_class_implicits = IndexSet::default(); for (name, sub_symbol) in &comp.symbols { // Skip the .0 parameter @@ -512,7 +512,7 @@ fn inline_comprehension( removed_class_implicits } -type SymbolMap = IndexMap; +type SymbolMap = IndexMap; mod stack { use alloc::vec::Vec; @@ -595,7 +595,7 @@ impl SymbolTableAnalyzer { &mut self, symbol_table: &mut SymbolTable, class_entry: Option<&SymbolMap>, - ) -> SymbolTableResult> { + ) -> SymbolTableResult> { let symbols = core::mem::take(&mut symbol_table.symbols); let sub_tables = &mut *symbol_table.sub_tables; @@ -623,8 +623,8 @@ impl SymbolTableAnalyzer { // Collect (child_free, is_inlined) pairs from child scopes. // We need to process inlined comprehensions after the closure // when we have access to symbol_table.symbols. - let mut child_frees: Vec<(IndexSet, bool)> = Vec::new(); - let mut annotation_free: Option> = None; + let mut child_frees: Vec<(IndexSet, bool)> = Vec::new(); + let mut annotation_free: Option> = None; let mut info = ( symbols, @@ -663,7 +663,7 @@ impl SymbolTableAnalyzer { // PEP 709: Process inlined comprehensions. // Merge symbols from inlined comps into parent scope without bail-out. - let mut inlined_cells: IndexSet = IndexSet::default(); + let mut inlined_cells: IndexSet = IndexSet::default(); let mut newfree = IndexSet::default(); for (idx, (mut child_free, is_inlined)) in child_frees.into_iter().enumerate() { if is_inlined { @@ -889,7 +889,7 @@ impl SymbolTableAnalyzer { fn found_in_outer_scope( &mut self, - name: &str, + name: &Name, st_typ: CompilerScope, skip_enclosing_function_scope: bool, ) -> Option { @@ -960,7 +960,7 @@ impl SymbolTableAnalyzer { if let Some(free_class) = table.get_mut(name) { free_class.flags.insert(SymbolFlags::DEF_FREE_CLASS) } else { - let mut symbol = Symbol::new(name); + let mut symbol = Symbol::new(name.clone()); symbol.flags.insert(SymbolFlags::DEF_FREE_CLASS); symbol.scope = SymbolScope::Free; table.insert(name.to_owned(), symbol); @@ -976,7 +976,7 @@ impl SymbolTableAnalyzer { // Skip: don't add __classdict__/__conditional_annotations__ // as free vars in regular functions — only annotation/type scopes need them } else if !table.contains_key(name) { - let mut symbol = Symbol::new(name); + let mut symbol = Symbol::new(name.clone()); symbol.scope = SymbolScope::Free; table.insert(name.to_owned(), symbol); } @@ -989,7 +989,7 @@ impl SymbolTableAnalyzer { fn found_in_inner_scope( &self, sub_tables: &[SymbolTable], - name: &str, + name: &Name, st_typ: CompilerScope, ) -> Option { sub_tables.iter().find_map(|st| { @@ -1034,16 +1034,16 @@ enum SymbolUsage { } struct SymbolTableBuilder { - class_name: Option, + class_name: Option, // Scope stack. tables: Vec, future_annotations: bool, allow_top_level_await: bool, source_file: SourceFile, // Current scope's varnames being collected (temporary storage) - current_varnames: Vec, + current_varnames: Vec, // Stack to preserve parent varnames when entering nested scopes - varnames_stack: Vec>, + varnames_stack: Vec>, // Track if we're inside an iterable definition expression (for nested comprehensions) in_iter_def_exp: bool, // yield/yield from inside comprehension scopes is rejected with a @@ -1087,7 +1087,7 @@ impl SymbolTableBuilder { recursion_limit: DEFAULT_RECURSION_LIMIT, next_block_index: 0, }; - this.enter_scope("top", CompilerScope::Module, 0); + this.enter_scope(&"top".into(), CompilerScope::Module, 0); this } @@ -1146,7 +1146,7 @@ impl SymbolTableBuilder { Ok(symbol_table) } - fn enter_scope(&mut self, name: &str, typ: CompilerScope, line_number: u32) { + fn enter_scope(&mut self, name: &Name, typ: CompilerScope, line_number: u32) { let parent = self.tables.last(); let is_nested = parent.is_some_and(|table| table.is_nested || Self::is_function_like_scope(table.typ)); @@ -1180,7 +1180,7 @@ impl SymbolTableBuilder { fn enter_type_param_block( &mut self, - name: &str, + name: &Name, range: TextRange, for_class: bool, has_defaults: bool, @@ -1210,22 +1210,22 @@ impl SymbolTableBuilder { // Add __classdict__ as a USE symbol in type param scope if in class if in_class { - self.register_name("__classdict__", SymbolUsage::Used, range)?; + self.register_name(&"__classdict__".into(), SymbolUsage::Used, range)?; } if for_class { // It gets set when we create the type params tuple and used when // we build up the bases. - self.register_name(".type_params", SymbolUsage::Assigned, range)?; - self.register_name(".type_params", SymbolUsage::Used, range)?; - self.register_name(".generic_base", SymbolUsage::Assigned, range)?; - self.register_name(".generic_base", SymbolUsage::Used, range)?; + self.register_name(&".type_params".into(), SymbolUsage::Assigned, range)?; + self.register_name(&".type_params".into(), SymbolUsage::Used, range)?; + self.register_name(&".generic_base".into(), SymbolUsage::Assigned, range)?; + self.register_name(&".generic_base".into(), SymbolUsage::Used, range)?; } if has_defaults { - self.register_name(".defaults", SymbolUsage::Parameter, range)?; + self.register_name(&".defaults".into(), SymbolUsage::Parameter, range)?; } if has_kwdefaults { - self.register_name(".kwdefaults", SymbolUsage::Parameter, range)?; + self.register_name(&".kwdefaults".into(), SymbolUsage::Parameter, range)?; } Ok(()) @@ -1283,7 +1283,7 @@ impl SymbolTableBuilder { let block_index = self.next_block_index; self.next_block_index += 1; let mut annotation_table = SymbolTable::new( - "__annotate__".to_owned(), + Name::new_static("__annotate__"), CompilerScope::Annotation, line_number, is_nested, @@ -1337,10 +1337,10 @@ impl SymbolTableBuilder { fn add_classdict_freevar(&mut self) { let table = self.tables.last_mut().unwrap(); - let name = "__classdict__"; + let name = Name::new_static("__classdict__"); let symbol = table .symbols - .entry(name.to_owned()) + .entry(name.clone()) .or_insert_with(|| Symbol::new(name)); symbol.scope = SymbolScope::Free; symbol @@ -1350,10 +1350,10 @@ impl SymbolTableBuilder { fn add_conditional_annotations_freevar(&mut self) { let table = self.tables.last_mut().unwrap(); - let name = "__conditional_annotations__"; + let name = Name::new_static("__conditional_annotations__"); let symbol = table .symbols - .entry(name.to_owned()) + .entry(name.clone()) .or_insert_with(|| Symbol::new(name)); symbol.scope = SymbolScope::Free; symbol @@ -1364,7 +1364,7 @@ impl SymbolTableBuilder { fn add_format_parameter(&mut self) { self.tables.last_mut().unwrap().add_format_parameter(); if !self.current_varnames.iter().any(|name| name == ".format") { - self.current_varnames.push(".format".to_owned()); + self.current_varnames.push(".format".into()); } } @@ -1459,11 +1459,19 @@ impl SymbolTableBuilder { let current = self.tables.last().unwrap(); let can_see_class_scope = current.typ == CompilerScope::Class || current.can_see_class_scope; - self.enter_scope("__annotate__", CompilerScope::Annotation, line_number); + self.enter_scope( + &"__annotate__".into(), + CompilerScope::Annotation, + line_number, + ); self.tables.last_mut().unwrap().can_see_class_scope = can_see_class_scope; self.add_format_parameter(); if can_see_class_scope { - self.register_name("__classdict__", SymbolUsage::Used, TextRange::default())?; + self.register_name( + &"__classdict__".into(), + SymbolUsage::Used, + TextRange::default(), + )?; } let was_in_unevaluated_annotation = self.tables.last().unwrap().in_unevaluated_annotation; @@ -1553,7 +1561,7 @@ impl SymbolTableBuilder { if should_register_conditional_annotations { self.register_name( - "__conditional_annotations__", + &"__conditional_annotations__".into(), SymbolUsage::Used, annotation.range(), )?; @@ -1588,16 +1596,12 @@ impl SymbolTableBuilder { match &statement { Stmt::Global(StmtGlobal { names, .. }) => { for name in names { - self.register_name(name.as_str(), SymbolUsage::Global, statement.range())?; + self.register_name(name.id(), SymbolUsage::Global, statement.range())?; } } Stmt::Nonlocal(StmtNonlocal { names, .. }) => { for name in names { - self.register_name( - name.as_str(), - SymbolUsage::Nonlocal, - statement.range(), - )?; + self.register_name(name.id(), SymbolUsage::Nonlocal, statement.range())?; } } Stmt::FunctionDef(StmtFunctionDef { @@ -1611,7 +1615,7 @@ impl SymbolTableBuilder { is_async, .. }) => { - self.register_name(name.as_str(), SymbolUsage::Assigned, *range)?; + self.register_name(name.id(), SymbolUsage::Assigned, *range)?; self.scan_parameter_defaults(parameters)?; self.scan_decorators(decorator_list, ExpressionContext::Load)?; @@ -1620,7 +1624,7 @@ impl SymbolTableBuilder { // annotation scopes are nested inside and can see type parameters. if let Some(type_params) = type_params { self.enter_type_param_block( - name.as_str(), + name.id(), *range, false, Self::has_positional_defaults(parameters), @@ -1629,7 +1633,7 @@ impl SymbolTableBuilder { self.scan_type_params(type_params)?; } self.enter_scope_with_parameters( - name.as_str(), + name.id(), parameters, self.line_index_start(*range), returns.as_deref(), @@ -1660,19 +1664,19 @@ impl SymbolTableBuilder { .. }) => { let prev_class = self.class_name.clone(); - self.register_name(name.as_str(), SymbolUsage::Assigned, *range)?; + self.register_name(name.id(), SymbolUsage::Assigned, *range)?; self.scan_decorators(decorator_list, ExpressionContext::Load)?; if let Some(type_params) = type_params { self.enter_type_param_block( - name.as_str(), + name.id(), *range, true, // for_class: enable selective mangling false, false, )?; // Set class_name for mangling in type param scope - self.class_name = Some(name.to_string()); + self.class_name = Some(name.id().clone()); self.scan_type_params(type_params)?; } @@ -1684,11 +1688,7 @@ impl SymbolTableBuilder { self.scan_expressions(&arguments.args, ExpressionContext::Load)?; for keyword in &arguments.keywords { if let Some(arg) = &keyword.arg { - self.check_name( - arg.as_str(), - ExpressionContext::Store, - keyword.range, - )?; + self.check_name(arg.id(), ExpressionContext::Store, keyword.range)?; } } for keyword in &arguments.keywords { @@ -1697,17 +1697,21 @@ impl SymbolTableBuilder { } self.enter_scope( - name.as_str(), + name.id(), CompilerScope::Class, self.line_index_start(*range), ); // Reset in_conditional_block for new class scope let saved_in_conditional = self.in_conditional_block; self.in_conditional_block = false; - self.class_name = Some(name.to_string()); + self.class_name = Some(name.id().clone()); if type_params.is_some() { - self.register_name("__type_params__", SymbolUsage::Assigned, *range)?; - self.register_name(".type_params", SymbolUsage::Used, *range)?; + self.register_name( + &"__type_params__".into(), + SymbolUsage::Assigned, + *range, + )?; + self.register_name(&".type_params".into(), SymbolUsage::Used, *range)?; } self.scan_statements(body)?; self.leave_scope(); @@ -1788,11 +1792,7 @@ impl SymbolTableBuilder { for name in names { if let Some(alias) = &name.asname { // `import my_module as my_alias` - self.register_name( - alias.as_str(), - SymbolUsage::Imported, - name.name.range, - )?; + self.register_name(alias.id(), SymbolUsage::Imported, name.name.range)?; } else if name.name.as_str() == "*" { // Star imports are only allowed at module level if self.tables.last().unwrap().typ != CompilerScope::Module { @@ -1809,14 +1809,14 @@ impl SymbolTableBuilder { // Don't register star imports as symbols } else { // `import module` or `from x import name` - let imported_name = name.name.split('.').next().unwrap(); + let imported_name = name.name.split('.').next().unwrap().into(); self.check_name( - imported_name, + &imported_name, ExpressionContext::Store, name.name.range, )?; self.register_name( - imported_name, + &imported_name, SymbolUsage::Imported, name.name.range, )?; @@ -1862,14 +1862,12 @@ impl SymbolTableBuilder { range: target_range, .. }) => { - let id_str = id.as_str(); - if *simple { let existing_flags = self.tables.last().and_then(|table| { let name = maybe_mangle_name( - self.class_name.as_deref(), + self.class_name.as_ref(), table.mangled_names.as_ref(), - id_str, + id, ); table.symbols.get(name.as_ref()).map(|symbol| symbol.flags) }); @@ -1888,9 +1886,7 @@ impl SymbolTableBuilder { "nonlocal" }; return Err(SymbolTableError { - error: format!( - "annotated name '{id_str}' can't be {usage}" - ), + error: format!("annotated name '{id}' can't be {usage}"), location: Some( self.source_file.to_source_code().source_location( range.start(), @@ -1901,12 +1897,12 @@ impl SymbolTableBuilder { } self.register_name( - id_str, + id, SymbolUsage::AnnotationAssigned, *target_range, )?; } else if value.is_some() { - self.register_name(id_str, SymbolUsage::Assigned, *target_range)?; + self.register_name(id, SymbolUsage::Assigned, *target_range)?; } } _ => { @@ -1970,11 +1966,7 @@ impl SymbolTableBuilder { self.scan_expression(expression, ExpressionContext::Load)?; } if let Some(name) = name { - self.register_name( - name.as_str(), - SymbolUsage::Assigned, - handler.range(), - )?; + self.register_name(name.id(), SymbolUsage::Assigned, handler.range())?; } self.scan_statements(body)?; } @@ -2021,7 +2013,7 @@ impl SymbolTableBuilder { ), }); }; - let alias_name = name_expr.id.to_string(); + let alias_name = name_expr.id(); self.scan_expression(name, ExpressionContext::Store)?; // Check before entering any sub-scopes let in_class = self @@ -2030,23 +2022,27 @@ impl SymbolTableBuilder { .is_some_and(|t| t.typ == CompilerScope::Class); let is_generic = type_params.is_some(); if let Some(type_params) = type_params { - self.enter_type_param_block(&alias_name, *range, false, false, false)?; + self.enter_type_param_block(alias_name, *range, false, false, false)?; self.scan_type_params(type_params)?; } // Value scope for lazy evaluation self.enter_scope( - &alias_name, + alias_name, CompilerScope::TypeAlias, self.line_index_start(*range), ); // Evaluator takes a format parameter - self.register_name(".format", SymbolUsage::Parameter, *range)?; - self.register_name(".format", SymbolUsage::Used, *range)?; + self.register_name(&".format".into(), SymbolUsage::Parameter, *range)?; + self.register_name(&".format".into(), SymbolUsage::Used, *range)?; if in_class { if let Some(table) = self.tables.last_mut() { table.can_see_class_scope = true; } - self.register_name("__classdict__", SymbolUsage::Used, value.range())?; + self.register_name( + &"__classdict__".into(), + SymbolUsage::Used, + value.range(), + )?; } self.scan_expression(value, ExpressionContext::Load)?; self.leave_scope(); @@ -2174,7 +2170,7 @@ impl SymbolTableBuilder { Expr::Attribute(ExprAttribute { value, attr, range, .. }) => { - self.check_name(attr.as_str(), context, *range)?; + self.check_name(attr.id(), context, *range)?; self.scan_expression(value, ExpressionContext::Load)?; } Expr::Dict(ExprDict { items, .. }) => { @@ -2288,7 +2284,14 @@ impl SymbolTableBuilder { self.in_iter_def_exp = true; } // Generator expression - is_generator = true - self.scan_comprehension("", elt, None, generators, *range, true)?; + self.scan_comprehension( + &"".into(), + elt, + None, + generators, + *range, + true, + )?; self.in_iter_def_exp = was_in_iter_def_exp; } Expr::ListComp(ExprListComp { @@ -2302,7 +2305,14 @@ impl SymbolTableBuilder { self.in_iter_def_exp = true; } // List comprehension - is_generator = false (can be inlined) - self.scan_comprehension("", elt, None, generators, *range, false)?; + self.scan_comprehension( + &"".into(), + elt, + None, + generators, + *range, + false, + )?; self.in_iter_def_exp = was_in_iter_def_exp; } Expr::SetComp(ExprSetComp { @@ -2316,7 +2326,14 @@ impl SymbolTableBuilder { self.in_iter_def_exp = true; } // Set comprehension - is_generator = false (can be inlined) - self.scan_comprehension("", elt, None, generators, *range, false)?; + self.scan_comprehension( + &"".into(), + elt, + None, + generators, + *range, + false, + )?; self.in_iter_def_exp = was_in_iter_def_exp; } Expr::DictComp(ExprDictComp { @@ -2333,7 +2350,7 @@ impl SymbolTableBuilder { // Dict comprehension - is_generator = false (can be inlined) let key = key.as_ref(); self.scan_comprehension( - "", + &"".into(), key, Some(value), generators, @@ -2357,7 +2374,7 @@ impl SymbolTableBuilder { self.scan_expressions(&arguments.args, ExpressionContext::Load)?; for keyword in &arguments.keywords { if let Some(arg) = &keyword.arg { - self.check_name(arg.as_str(), ExpressionContext::Store, keyword.range)?; + self.check_name(arg.id(), ExpressionContext::Store, keyword.range)?; } } for keyword in &arguments.keywords { @@ -2365,8 +2382,6 @@ impl SymbolTableBuilder { } } Expr::Name(ExprName { id, range, .. }) => { - let id = id.as_str(); - self.check_name(id, context, *range)?; if !self @@ -2395,7 +2410,7 @@ impl SymbolTableBuilder { && Self::is_function_like_scope(self.tables.last().unwrap().typ) && id == "super" { - self.register_name("__class__", SymbolUsage::Used, *range)?; + self.register_name(&"__class__".into(), SymbolUsage::Used, *range)?; } } } @@ -2408,7 +2423,7 @@ impl SymbolTableBuilder { self.scan_parameter_defaults(parameters)?; } self.enter_scope_with_parameters( - "lambda", + &"lambda".into(), parameters, self.line_index_start(expression.range()), None, // lambdas have no return annotation @@ -2418,7 +2433,7 @@ impl SymbolTableBuilder { )?; } else { self.enter_scope( - "lambda", + &"lambda".into(), CompilerScope::Lambda, self.line_index_start(expression.range()), ); @@ -2527,7 +2542,6 @@ impl SymbolTableBuilder { .. }) = &**target { - let id = id.as_str(); self.check_name(id, ExpressionContext::Store, *target_range)?; let table = self.tables.last().unwrap(); if table.typ == CompilerScope::Comprehension { @@ -2555,7 +2569,7 @@ impl SymbolTableBuilder { fn scan_comprehension( &mut self, - scope_name: &str, + scope_name: &Name, elt1: &ast::Expr, elt2: Option<&ast::Expr>, generators: &[ast::Comprehension], @@ -2595,10 +2609,10 @@ impl SymbolTableBuilder { } // Register the passed argument to the generator function as the name ".0" - self.register_name(".0", SymbolUsage::Parameter, range)?; + self.register_name(&".0".into(), SymbolUsage::Parameter, range)?; let saved_comprehension_yield_context = self.comprehension_yield_context; - self.comprehension_yield_context = Some(match scope_name { + self.comprehension_yield_context = Some(match scope_name.as_ref() { "" => "list comprehension", "" => "set comprehension", "" => "dict comprehension", @@ -2666,7 +2680,7 @@ impl SymbolTableBuilder { fn scan_type_param_bound_or_default( &mut self, expr: &ast::Expr, - scope_name: &str, + scope_name: &Name, scope_info: &'static str, ) -> SymbolTableResult { // Bounds/defaults are compiled as annotation scopes. @@ -2674,14 +2688,14 @@ impl SymbolTableBuilder { let line_number = self.line_index_start(expr.range()); self.enter_scope(scope_name, CompilerScope::TypeVariable, line_number); // Evaluator takes a format parameter - self.register_name(".format", SymbolUsage::Parameter, expr.range())?; - self.register_name(".format", SymbolUsage::Used, expr.range())?; + self.register_name(&".format".into(), SymbolUsage::Parameter, expr.range())?; + self.register_name(&".format".into(), SymbolUsage::Used, expr.range())?; if in_class { if let Some(table) = self.tables.last_mut() { table.can_see_class_scope = true; } - self.register_name("__classdict__", SymbolUsage::Used, expr.range())?; + self.register_name(&"__classdict__".into(), SymbolUsage::Used, expr.range())?; } self.tables.last_mut().unwrap().scope_info = Some(scope_info); @@ -2713,7 +2727,7 @@ impl SymbolTableBuilder { default, .. }) => { - self.register_name(name.as_str(), SymbolUsage::TypeParam, *type_var_range)?; + self.register_name(name.id(), SymbolUsage::TypeParam, *type_var_range)?; if name.as_str() == "__classdict__" { return Err(SymbolTableError { error: format!( @@ -2734,18 +2748,14 @@ impl SymbolTableBuilder { } else { "a TypeVar bound" }; - self.scan_type_param_bound_or_default( - binding, - name.as_str(), - scope_info, - )?; + self.scan_type_param_bound_or_default(binding, name.id(), scope_info)?; } // Process default in a separate scope if let Some(default_value) = default { self.scan_type_param_bound_or_default( default_value, - name.as_str(), + name.id(), "a TypeVar default", )?; } @@ -2756,7 +2766,7 @@ impl SymbolTableBuilder { default, .. }) => { - self.register_name(name, SymbolUsage::TypeParam, *param_spec_range)?; + self.register_name(name.id(), SymbolUsage::TypeParam, *param_spec_range)?; if name == "__classdict__" { return Err(SymbolTableError { error: format!( @@ -2773,7 +2783,7 @@ impl SymbolTableBuilder { if let Some(default_value) = default { self.scan_type_param_bound_or_default( default_value, - name, + name.id(), "a ParamSpec default", )?; } @@ -2784,7 +2794,11 @@ impl SymbolTableBuilder { default, .. }) => { - self.register_name(name, SymbolUsage::TypeParam, *type_var_tuple_range)?; + self.register_name( + name.id(), + SymbolUsage::TypeParam, + *type_var_tuple_range, + )?; if name == "__classdict__" { return Err(SymbolTableError { error: format!( @@ -2801,7 +2815,7 @@ impl SymbolTableBuilder { if let Some(default_value) = default { self.scan_type_param_bound_or_default( default_value, - name, + name.id(), "a TypeVarTuple default", )?; } @@ -2863,7 +2877,7 @@ impl SymbolTableBuilder { ), }); } - self.register_name(rest.as_str(), SymbolUsage::Assigned, pattern.range())?; + self.register_name(rest.id(), SymbolUsage::Assigned, pattern.range())?; } } MatchClass(ast::PatternMatchClass { cls, arguments, .. }) => { @@ -2871,7 +2885,7 @@ impl SymbolTableBuilder { self.scan_patterns(&arguments.patterns)?; for kw in &arguments.keywords { self.check_name( - kw.attr.as_str(), + kw.attr.id(), ExpressionContext::Store, kw.pattern.range(), )?; @@ -2882,7 +2896,7 @@ impl SymbolTableBuilder { } MatchStar(ast::PatternMatchStar { name, .. }) => { if let Some(name) = name { - self.register_name(name.as_str(), SymbolUsage::Assigned, pattern.range())?; + self.register_name(name.id(), SymbolUsage::Assigned, pattern.range())?; } } MatchAs(ast::PatternMatchAs { @@ -2894,7 +2908,7 @@ impl SymbolTableBuilder { self.scan_pattern(as_pattern)?; } if let Some(name) = name { - self.register_name(name.as_str(), SymbolUsage::Assigned, pattern.range())?; + self.register_name(name.id(), SymbolUsage::Assigned, pattern.range())?; } } MatchOr(ast::PatternMatchOr { patterns, .. }) => self.scan_patterns(patterns)?, @@ -2940,7 +2954,7 @@ impl SymbolTableBuilder { )] fn enter_scope_with_parameters( &mut self, - name: &str, + name: &Name, parameters: &ast::Parameters, line_number: u32, returns: Option<&ast::Expr>, @@ -2977,12 +2991,12 @@ impl SymbolTableBuilder { } fn register_ident(&mut self, ident: &ast::Identifier, role: SymbolUsage) -> SymbolTableResult { - self.register_name(ident.as_str(), role, ident.range) + self.register_name(ident.id(), role, ident.range) } fn check_name( &self, - name: &str, + name: &Name, context: ExpressionContext, range: TextRange, ) -> SymbolTableResult { @@ -3014,7 +3028,7 @@ impl SymbolTableBuilder { // Mirrors symtable_extend_namedexpr_scope(): assignment expressions // inside comprehensions bind in the nearest function/module-like scope, not // in the synthetic comprehension scope itself. - fn extend_namedexpr_scope(&mut self, name: &str, range: TextRange) -> SymbolTableResult { + fn extend_namedexpr_scope(&mut self, name: &Name, range: TextRange) -> SymbolTableResult { let location = Some( self.source_file .to_source_code() @@ -3024,7 +3038,7 @@ impl SymbolTableBuilder { for table_idx in (0..self.tables.len()).rev() { let table_type = self.tables[table_idx].typ; let mangled = maybe_mangle_name( - self.class_name.as_deref(), + self.class_name.as_ref(), self.tables[table_idx].mangled_names.as_ref(), name, ) @@ -3060,7 +3074,7 @@ impl SymbolTableBuilder { let current_symbol = current .symbols .entry(mangled.clone()) - .or_insert_with(|| Symbol::new(mangled.as_str())); + .or_insert_with(|| Symbol::new(mangled.clone())); if parent_is_global { current_symbol.flags.insert(SymbolFlags::DEF_GLOBAL); current_symbol.scope = SymbolScope::GlobalExplicit; @@ -3072,7 +3086,7 @@ impl SymbolTableBuilder { let symbol = self.tables[table_idx] .symbols .entry(mangled.clone()) - .or_insert_with(|| Symbol::new(mangled.as_str())); + .or_insert_with(|| Symbol::new(mangled.clone())); symbol.flags.insert(SymbolFlags::DEF_LOCAL); return Ok(()); } @@ -3081,14 +3095,14 @@ impl SymbolTableBuilder { let current_symbol = current .symbols .entry(mangled.clone()) - .or_insert_with(|| Symbol::new(mangled.as_str())); + .or_insert_with(|| Symbol::new(mangled.clone())); current_symbol.flags.insert(SymbolFlags::DEF_GLOBAL); current_symbol.scope = SymbolScope::GlobalExplicit; let symbol = self.tables[table_idx] .symbols .entry(mangled.clone()) - .or_insert_with(|| Symbol::new(mangled.as_str())); + .or_insert_with(|| Symbol::new(mangled.clone())); symbol.flags.insert(SymbolFlags::DEF_GLOBAL); symbol.scope = SymbolScope::GlobalExplicit; return Ok(()); @@ -3131,7 +3145,7 @@ impl SymbolTableBuilder { fn register_name( &mut self, - name: &str, + name: &Name, role: SymbolUsage, range: TextRange, ) -> SymbolTableResult { @@ -3170,11 +3184,7 @@ impl SymbolTableBuilder { } let original_name = name; - let name = maybe_mangle_name( - self.class_name.as_deref(), - table.mangled_names.as_ref(), - name, - ); + let name = &maybe_mangle_name(self.class_name.as_ref(), table.mangled_names.as_ref(), name); // Some checks for the symbol that present on this scope level: let symbol = if let Some(symbol) = table.symbols.get_mut(name.as_ref()) { let flags = &symbol.flags; @@ -3305,8 +3315,11 @@ impl SymbolTableBuilder { } } // Insert symbol when required: - let symbol = Symbol::new(name.as_ref()); - table.symbols.entry(name.into_owned()).or_insert(symbol) + let symbol = Symbol::new(name.clone().into_owned()); + table + .symbols + .entry(name.clone().into_owned()) + .or_insert(symbol) }; if matches!(role, SymbolUsage::Global | SymbolUsage::Nonlocal) { @@ -3375,38 +3388,38 @@ fn is_docstring_expr(expr: &ast::Expr) -> bool { ) } -pub(crate) fn mangle_name<'a>(class_name: Option<&str>, name: &'a str) -> Cow<'a, str> { - let class_name = match class_name { - Some(n) => n, - None => return name.into(), +pub(crate) fn mangle_name<'a>(class_name: Option<&Name>, name: &'a Name) -> Cow<'a, Name> { + let Some(class_name) = class_name else { + return Cow::Borrowed(name); }; + if !name.starts_with("__") || name.ends_with("__") || name.contains('.') { - return name.into(); + return Cow::Borrowed(name); } // Strip leading underscores from class name let class_name = class_name.trim_start_matches('_'); if class_name.is_empty() { - return name.into(); + return Cow::Borrowed(name); } let mut ret = String::with_capacity(1 + class_name.len() + name.len()); ret.push('_'); ret.push_str(class_name); ret.push_str(name); - ret.into() + Cow::Owned(ret.into()) } /// Selective mangling for type parameter scopes around generic classes. /// If `mangled_names` is Some, only mangle names that are in the set; /// other names are left unmangled. pub(crate) fn maybe_mangle_name<'a>( - class_name: Option<&str>, - mangled_names: Option<&IndexSet>, - name: &'a str, -) -> Cow<'a, str> { + class_name: Option<&Name>, + mangled_names: Option<&IndexSet>, + name: &'a Name, +) -> Cow<'a, Name> { if let Some(set) = mangled_names && !set.contains(name) { - return name.into(); + return Cow::Borrowed(name); } mangle_name(class_name, name) } @@ -3437,15 +3450,24 @@ mod tests { #[test] fn mangle_name_leaves_private_name_in_underscore_only_class() { - assert_eq!(mangle_name(Some("_"), "__a"), "__a"); - assert_eq!(mangle_name(Some("__"), "__a"), "__a"); - assert_eq!(mangle_name(Some("___"), "__a"), "__a"); + assert_eq!(mangle_name(Some(&"_".into()), &"__a".into()), "__a".into()); + assert_eq!(mangle_name(Some(&"__".into()), &"__a".into()), "__a".into()); + assert_eq!( + mangle_name(Some(&"___".into()), &"__a".into()), + "__a".into() + ); } #[test] fn mangle_name_strips_leading_class_underscores() { - assert_eq!(mangle_name(Some("_a"), "__a"), "_a__a"); - assert_eq!(mangle_name(Some("__a"), "__a"), "_a__a"); + assert_eq!( + mangle_name(Some(&"_a".into()), &"__a".into()), + "_a__a".into() + ); + assert_eq!( + mangle_name(Some(&"__a".into()), &"__a".into()), + "_a__a".into() + ); } #[test] @@ -3474,7 +3496,7 @@ mod tests { .expect("missing lambda scope"); assert!( - lambda.lookup("__class__").is_some(), + lambda.lookup(&"__class__".into()).is_some(), "CPython symtable Name_kind treats super as a __class__ use in any function-like scope" ); } @@ -3488,7 +3510,7 @@ mod tests { .find(|table| table.typ == CompilerScope::Comprehension) .expect("missing comprehension scope"); let symbol = comprehension - .lookup("i") + .lookup(&"i".into()) .expect("missing comprehension iteration target"); assert!( @@ -3536,7 +3558,7 @@ mod tests { .expect("CPython still creates an AnnotationBlock for future annotations"); assert!( - annotation_block.lookup("T").is_some(), + annotation_block.lookup(&"T".into()).is_some(), "CPython symtable_visit_annotation still visits the annotation expression with future annotations" ); } @@ -3550,7 +3572,7 @@ mod tests { .find(|table| table.typ == CompilerScope::Annotation) .expect("missing function annotation block"); let format = annotation_block - .lookup(".format") + .lookup(&".format".into()) .expect("missing annotation .format parameter"); assert_eq!(annotation_block.varnames, [".format"]); assert!( @@ -3567,7 +3589,7 @@ mod tests { .find(|table| table.typ == CompilerScope::TypeAlias) .expect("missing type alias scope"); let format = alias - .lookup(".format") + .lookup(&".format".into()) .expect("missing type alias .format parameter"); assert!( format @@ -3588,7 +3610,7 @@ mod tests { .find(|table| table.typ == CompilerScope::TypeVariable) .expect("missing type variable scope"); let format = type_variable - .lookup(".format") + .lookup(&".format".into()) .expect("missing type variable .format parameter"); assert!( format @@ -3601,7 +3623,7 @@ mod tests { #[test] fn deferred_annotation_store_names_are_not_public_symbols() { let module = scan_source("x: int\n"); - assert!(module.lookup("__annotate__").is_none()); + assert!(module.lookup(&"__annotate__".into()).is_none()); assert!(module.annotation_block.is_some()); let module = scan_source("class C:\n y: str\n"); @@ -3610,7 +3632,7 @@ mod tests { .iter() .find(|table| table.typ == CompilerScope::Class) .expect("missing class scope"); - assert!(class.lookup("__annotate_func__").is_none()); + assert!(class.lookup(&"__annotate_func__".into()).is_none()); assert!(class.annotation_block.is_some()); } @@ -3628,7 +3650,7 @@ mod tests { .find(|table| table.typ == CompilerScope::Class) .expect("missing generic class scope"); assert_eq!( - class.symbols.keys().map(String::as_str).collect::>(), + class.symbols.keys().collect::>(), ["__type_params__", ".type_params", "q", "range", "i"] ); } diff --git a/crates/vm/src/stdlib/_symtable.rs b/crates/vm/src/stdlib/_symtable.rs index cf3fa8bafc8..f772da62146 100644 --- a/crates/vm/src/stdlib/_symtable.rs +++ b/crates/vm/src/stdlib/_symtable.rs @@ -182,7 +182,7 @@ mod _symtable { let packed_flags = i32::from(symbol.flags.bits()) | (symbol.scope.as_i32() << SCOPE_OFFSET); symbols - .set_item(name, vm.new_pyobj(packed_flags), vm) + .set_item(name.as_str(), vm.new_pyobj(packed_flags), vm) .unwrap(); } let varnames = vm.ctx.new_list( @@ -221,7 +221,7 @@ mod _symtable { impl PySymbolTable { #[pygetset] fn name(&self) -> String { - self.symtable.name.clone() + self.symtable.name.to_string() } #[pygetset(name = "type")] From 9edd97ec4668784b4c5bece264ae4709046e03d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:08:28 +0900 Subject: [PATCH 349/351] build(deps): bump github/gh-aw/actions/setup from 0.84.3 to 0.85.4 (#8547) Bumps [github/gh-aw/actions/setup](https://github.com/github/gh-aw) from 0.84.3 to 0.85.4. - [Release notes](https://github.com/github/gh-aw/releases) - [Changelog](https://github.com/github/gh-aw/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/gh-aw/compare/53258938b59e0797fefeed05ec0c681514b2a827...53843da968225dc56e1590978a7ed6407a8438ac) --- updated-dependencies: - dependency-name: github/gh-aw/actions/setup dependency-version: 0.85.4 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/upgrade-pylib.lock.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/upgrade-pylib.lock.yml b/.github/workflows/upgrade-pylib.lock.yml index 3ac44585943..045c29e8457 100644 --- a/.github/workflows/upgrade-pylib.lock.yml +++ b/.github/workflows/upgrade-pylib.lock.yml @@ -58,7 +58,7 @@ jobs: comment_repo: "" steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 + uses: github/gh-aw/actions/setup@53843da968225dc56e1590978a7ed6407a8438ac # v0.85.4 with: destination: /opt/gh-aw/actions - name: Check workflow file timestamps @@ -99,7 +99,7 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 + uses: github/gh-aw/actions/setup@53843da968225dc56e1590978a7ed6407a8438ac # v0.85.4 with: destination: /opt/gh-aw/actions - name: Checkout repository @@ -806,7 +806,7 @@ jobs: total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 + uses: github/gh-aw/actions/setup@53843da968225dc56e1590978a7ed6407a8438ac # v0.85.4 with: destination: /opt/gh-aw/actions - name: Download agent output artifact @@ -927,7 +927,7 @@ jobs: success: ${{ steps.parse_results.outputs.success }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 + uses: github/gh-aw/actions/setup@53843da968225dc56e1590978a7ed6407a8438ac # v0.85.4 with: destination: /opt/gh-aw/actions - name: Download agent artifacts @@ -1039,7 +1039,7 @@ jobs: process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Setup Scripts - uses: github/gh-aw/actions/setup@53258938b59e0797fefeed05ec0c681514b2a827 # v0.84.3 + uses: github/gh-aw/actions/setup@53843da968225dc56e1590978a7ed6407a8438ac # v0.85.4 with: destination: /opt/gh-aw/actions - name: Download agent output artifact From dfbc9b6d1b43160325f1d9d8dd72a09c36d26191 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:34:11 +0900 Subject: [PATCH 350/351] replace wasmer --dir -> --volume (#8572) --- .github/workflows/ci.yaml | 6 +++--- README.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 354902f9571..06da9564e36 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -819,10 +819,10 @@ jobs: run: cargo build --profile wasm-release --target wasm32-wasip1 --no-default-features --features freeze-stdlib,stdlib,stdio,importlib,host_env --verbose - name: run snippets run: | - wasmer run --dir "$(pwd)" target/wasm32-wasip1/wasm-release/rustpython.wasm -- "$(pwd)/extra_tests/snippets/stdlib_random.py" - wasmer run --dir "$(pwd)" target/wasm32-wasip1/wasm-release/rustpython.wasm -- "$(pwd)/extra_tests/snippets/stdlib_time.py" + wasmer run --volume "$(pwd)" target/wasm32-wasip1/wasm-release/rustpython.wasm -- "$(pwd)/extra_tests/snippets/stdlib_random.py" + wasmer run --volume "$(pwd)" target/wasm32-wasip1/wasm-release/rustpython.wasm -- "$(pwd)/extra_tests/snippets/stdlib_time.py" - name: run cpython unittest - run: wasmer run --dir "$(pwd)" target/wasm32-wasip1/wasm-release/rustpython.wasm -- "$(pwd)/Lib/test/test_int.py" + run: wasmer run --volume "$(pwd)" target/wasm32-wasip1/wasm-release/rustpython.wasm -- "$(pwd)/Lib/test/test_int.py" cargo_doc: needs: diff --git a/README.md b/README.md index cb086687d8f..9da54c4b4ee 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ cargo build --target wasm32-wasip1 --no-default-features --features freeze-stdli Run by wasmer ```bash -wasmer run --dir `pwd` -- target/wasm32-wasip1/release/rustpython.wasm `pwd`/extra_tests/snippets/stdlib_random.py +wasmer run --volume `pwd` -- target/wasm32-wasip1/release/rustpython.wasm `pwd`/extra_tests/snippets/stdlib_random.py ``` Run by wapm From 344bf8f4d6663b30fadf1b246f267ebc0063db7d Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:42:12 +0900 Subject: [PATCH 351/351] Give the strong count the bits an unused weak field held (#8574) * Give the strong count the bits an unused weak field held RefCount packs its state into one usize and splits the non-flag bits evenly between a strong and a weak count, so a 32-bit target left the strong count 15 bits. An object reaching 32 767 references then called refcount_overflow, which aborts the process; on wasm that surfaces as an unreachable trap with no Python-level error. Two ordinary module imports pass that total. The weak half is never read and never written. WEAK_COUNT appears once outside its own definition, in RefCount::new, and nothing in the repository observes those bits: weak references live in the object's WeakRefList, which Py::weak_count walks. The strong count now takes every bit the three flags leave, which is 29 on a 32-bit target and 61 on a 64-bit one. The tests run on wasm32-wasip2, where the old layout traps and the new one passes. A 64-bit host passes either way, since 31 bits already covered them. Fixes #8469 Assisted-by: Claude Code:claude-opus-5 * Return the strong count as usize so a 64-bit target cannot truncate it The count is 61 bits wide on a 64-bit target once it takes the bits the weak field held, so a u32 return truncates above 4 294 967 295 references: inc would read a wrapped 0 and re-increment, and dec would read a wrapped 1 and free a live object. A 32-bit target is unaffected, where 29 bits fit u32. Assisted-by: Claude Code:claude-opus-5 * Cover the refcount ceiling through inc and dec inc_by writes its own overflow check, so a test built on it leaves inc and dec unexercised. The reported aborts came through inc, one reference at a time. Assisted-by: Claude Code:claude-opus-5 --------- Co-authored-by: Jean-Marc Le Roux --- crates/common/src/refcount.rs | 84 ++++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 16 deletions(-) diff --git a/crates/common/src/refcount.rs b/crates/common/src/refcount.rs index 4d52e1382e6..6cfe6470ea1 100644 --- a/crates/common/src/refcount.rs +++ b/crates/common/src/refcount.rs @@ -1,8 +1,13 @@ use crate::atomic::{Ordering, PyAtomic, Radium}; // State layout (usize): -// [1 bit: destructed] [1 bit: published] [1 bit: leaked] [N bits: weak_count] [M bits: strong_count] -// 64-bit: N=30, M=31. 32-bit: N=14, M=15. +// [1 bit: destructed] [1 bit: published] [1 bit: leaked] [M bits: strong_count] +// 64-bit: M=61. 32-bit: M=29. +// +// Weak references live in the object's `WeakRefList`, not in this word, so the +// strong count takes every bit the flags leave. A 32-bit target reaches its +// ceiling at 536 870 911 references rather than the 32 767 that half the word +// would allow — a number two ordinary module imports pass on `wasm32`. const FLAG_BITS: u32 = 3; const DESTRUCTED: usize = 1 << (usize::BITS - 1); /// Object was published to a lock-free cache; memory reclamation is @@ -10,12 +15,9 @@ const DESTRUCTED: usize = 1 << (usize::BITS - 1); /// freed memory. Sticky once set. const PUBLISHED: usize = 1 << (usize::BITS - 2); const LEAKED: usize = 1 << (usize::BITS - 3); -const TOTAL_COUNT_WIDTH: u32 = usize::BITS - FLAG_BITS; -const WEAK_WIDTH: u32 = TOTAL_COUNT_WIDTH / 2; -const STRONG_WIDTH: u32 = TOTAL_COUNT_WIDTH - WEAK_WIDTH; +const STRONG_WIDTH: u32 = usize::BITS - FLAG_BITS; const STRONG: usize = (1 << STRONG_WIDTH) - 1; const COUNT: usize = 1; -const WEAK_COUNT: usize = 1 << STRONG_WIDTH; #[inline(never)] #[cold] @@ -48,8 +50,8 @@ impl State { } #[inline] - fn strong(self) -> u32 { - ((self.inner & STRONG) / COUNT) as u32 + fn strong(self) -> usize { + (self.inner & STRONG) / COUNT } #[inline] @@ -76,8 +78,8 @@ impl State { /// Reference count using state layout with LEAKED support. /// /// State layout (usize): -/// 64-bit: [1 bit: destructed] [1 bit: published] [1 bit: leaked] [30 bits: weak_count] [31 bits: strong_count] -/// 32-bit: [1 bit: destructed] [1 bit: published] [1 bit: leaked] [14 bits: weak_count] [15 bits: strong_count] +/// 64-bit: [1 bit: destructed] [1 bit: published] [1 bit: leaked] [61 bits: strong_count] +/// 32-bit: [1 bit: destructed] [1 bit: published] [1 bit: leaked] [29 bits: strong_count] pub struct RefCount { state: PyAtomic, } @@ -92,23 +94,22 @@ impl RefCount { /// Create a new RefCount with strong count = 1 #[must_use] pub fn new() -> Self { - // Initial state: strong=1, weak=1 (implicit weak for strong refs) Self { - state: Radium::new(COUNT + WEAK_COUNT), + state: Radium::new(COUNT), } } /// Get current strong count #[inline] pub fn get(&self) -> usize { - State::from_raw(self.state.load(Ordering::Relaxed)).strong() as usize + State::from_raw(self.state.load(Ordering::Relaxed)).strong() } /// Increment strong count #[inline] pub fn inc(&self) { let val = State::from_raw(self.state.fetch_add(COUNT, Ordering::Relaxed)); - if val.destructed() || (val.strong() as usize) > STRONG - 1 { + if val.destructed() || val.strong() > STRONG - 1 { refcount_overflow(); } if val.strong() == 0 { @@ -121,7 +122,7 @@ impl RefCount { pub fn inc_by(&self, n: usize) { debug_assert!(n <= STRONG); let val = State::from_raw(self.state.fetch_add(n * COUNT, Ordering::Relaxed)); - if val.destructed() || (val.strong() as usize) > STRONG - n { + if val.destructed() || val.strong() > STRONG - n { refcount_overflow(); } } @@ -135,7 +136,7 @@ impl RefCount { if old.destructed() || old.strong() == 0 { return false; } - if (old.strong() as usize) >= STRONG { + if old.strong() >= STRONG { refcount_overflow(); } let new_state = old.add_strong(1); @@ -299,6 +300,57 @@ pub fn flush_deferred_drops() { mod tests { use super::*; + /// The strong count reaches far past a 16-bit ceiling on every target. + /// + /// The count shares its word with the flag bits, so its width follows the + /// pointer width. A 32-bit target is the one this guards: a second counter + /// packed beside the strong count once left it 15 bits, and `wasm32` + /// aborted at 32 767 references — a total two ordinary module imports + /// pass. The check is a no-op on a 64-bit host, where 31 bits already + /// covered this; run the crate's tests against a 32-bit target to exercise + /// it. + #[test] + fn strong_count_reaches_past_a_16_bit_ceiling() { + const REFERENCES: usize = 1 << 20; + + let rc = RefCount::new(); + rc.inc_by(REFERENCES); + assert_eq!(rc.get(), REFERENCES + 1); + } + + /// `inc` and `dec` reach the same ceiling as `inc_by`. + /// + /// The aborts reported against this layout came one reference at a time + /// through `inc`, whose overflow check is written separately from + /// `inc_by`'s, and the count has to come back down through `dec` without + /// reporting the object collectable before the last reference goes. + #[test] + fn inc_and_dec_reach_past_a_16_bit_ceiling() { + const REFERENCES: usize = 1 << 20; + + let rc = RefCount::new(); // strong = 1 + for _ in 1..REFERENCES { + rc.inc(); + } + assert_eq!(rc.get(), REFERENCES); + for _ in 1..REFERENCES { + assert!(!rc.dec()); + } + assert_eq!(rc.get(), 1); + assert!(rc.dec()); + } + + /// A fresh count holds exactly one strong reference and no stray bits. + /// + /// `get` masks the flags away, so a spare field left in the word would not + /// show up there. Reading the raw state keeps the layout honest. + #[test] + fn a_new_refcount_holds_one_strong_reference_and_nothing_else() { + let rc = RefCount::new(); + assert_eq!(rc.get(), 1); + assert_eq!(rc.state.load(Ordering::Relaxed), COUNT); + } + #[test] fn published_bit_survives_refcount_traffic() { let rc = RefCount::new(); // strong = 1